'use strict'; /** * contentAccess — резолвинг доступа (allowlist) + целостность чистки правил. * Модель: по умолчанию закрыто; правило ученика важнее правила класса; глава * наследует доступ хаба; admin/teacher проходят всегда. purgeAccessFor — единая * точка очистки; deleteClass обязан её вызывать (нет FK у content_access). */ const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { db, getToken, inject, cleanup } = require('./setup'); const access = require('../src/services/contentAccess'); after(() => cleanup()); describe('contentAccess', () => { let teacher, student, classId; const HUB = 'acc-test-hub', CH = 'acc-test-hub-ch1'; before(async () => { teacher = await getToken('teacher'); student = await getToken('student'); const ins = db.prepare(`INSERT INTO textbooks (slug,subject,grade,title,author,description,html_path,para_count,color,sort_order,is_active,parent_slug) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`); ins.run(HUB, 'math', 5, 'Acc Test Hub', '', '', 'acc_hub.html', 2, 'indigo', 99, 1, null); ins.run(CH, 'math', 5, 'Acc Test Ch1', '', '', 'acc_ch1.html', 2, 'indigo', 1, 1, HUB); const r = await inject('POST', '/api/classes', { name: 'AccTest Class' }, teacher.token); assert.ok(r.status < 300, 'класс создан: ' + JSON.stringify(r.body)); classId = db.prepare('SELECT id FROM classes WHERE name = ?').get('AccTest Class').id; const m = await inject('POST', `/api/classes/${classId}/members`, { user_id: student.userId }, teacher.token); assert.ok(m.status < 300, 'ученик добавлен: ' + JSON.stringify(m.body)); }); const stu = () => ({ id: student.userId, role: 'student' }); function setRule(scope, target, allow) { db.prepare(`INSERT INTO content_access (content_type,content_ref,scope,target_id,allow) VALUES ('textbook',?,?,?,?) ON CONFLICT(content_type,content_ref,scope,target_id) DO UPDATE SET allow=excluded.allow`) .run(HUB, scope, target, allow); } const clearHub = () => db.prepare("DELETE FROM content_access WHERE content_ref=?").run(HUB); it('default deny — без правил доступа нет', () => { clearHub(); assert.equal(access.canAccessTextbook(stu(), HUB), false); }); it('правило класса allow=1 открывает хаб и главу (наследование)', () => { setRule('class', classId, 1); assert.equal(access.canAccessTextbook(stu(), HUB), true); assert.equal(access.canAccessTextbook(stu(), CH), true); }); it('правило ученика allow=0 перекрывает разрешение класса', () => { setRule('class', classId, 1); setRule('student', student.userId, 0); assert.equal(access.canAccessTextbook(stu(), HUB), false); }); it('правило ученика allow=1 даёт доступ без класса', () => { clearHub(); setRule('student', student.userId, 1); assert.equal(access.canAccessTextbook(stu(), HUB), true); }); it('admin и teacher проходят всегда', () => { clearHub(); assert.equal(access.canAccessTextbook({ id: teacher.userId, role: 'teacher' }, HUB), true); assert.equal(access.canAccessTextbook({ role: 'admin' }, HUB), true); }); it('allowedRefs / filterTextbooks учитывают правила', () => { clearHub(); setRule('class', classId, 1); assert.ok(access.allowedRefs(student.userId, 'textbook').has(HUB)); const filtered = access.filterTextbooks(stu(), [{ slug: HUB }, { slug: 'nope' }]); assert.deepEqual(filtered.map(r => r.slug), [HUB]); }); it('purgeAccessFor(class) удаляет правила класса', () => { clearHub(); setRule('class', classId, 1); const n = access.purgeAccessFor('class', classId); assert.ok(n >= 1, 'удалено хотя бы одно'); assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='class' AND target_id=?").get(classId).c, 0); }); it('purgeAccessFor(student) удаляет персональные правила', () => { clearHub(); setRule('student', student.userId, 1); access.purgeAccessFor('student', student.userId); assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='student' AND target_id=?").get(student.userId).c, 0); }); it('GET /api/access/matrix — учитель видит свои классы и открытый контент', async () => { clearHub(); setRule('class', classId, 1); const r = await inject('GET', '/api/access/matrix', null, teacher.token); assert.equal(r.status, 200, JSON.stringify(r.body)); const cls = (r.body.classes || []).find(c => c.id === classId); assert.ok(cls, 'класс учителя в матрице'); assert.ok((r.body.open[classId].textbook || []).includes(HUB), 'открытый учебник в матрице'); }); it('GET /api/access/matrix — ученику 403', async () => { const r = await inject('GET', '/api/access/matrix', null, student.token); assert.equal(r.status, 403); }); it('GET /api/access/catalog включает симуляции', async () => { const r = await inject('GET', '/api/access/catalog', null, teacher.token); assert.equal(r.status, 200); assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции'); }); it('GET /api/access/log — история (admin видит запись; учителю 403)', async () => { const admin = await getToken('admin'); const p = await inject('POST', '/api/access/rules', { content_type: 'textbook', content_ref: HUB, scope: 'class', target_id: classId, allow: 1 }, admin.token); assert.ok(p.status < 300, JSON.stringify(p.body)); const log = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, admin.token); assert.equal(log.status, 200); assert.ok(Array.isArray(log.body) && log.body.length >= 1, 'есть запись истории'); assert.equal(log.body[0].action, 'grant'); const t = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, teacher.token); assert.equal(t.status, 403, 'учителю недоступно'); }); it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => { setRule('class', classId, 1); const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token); assert.ok(del.status < 300, 'класс удалён: ' + JSON.stringify(del.body)); assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='class' AND target_id=?").get(classId).c, 0); }); });