Files
Learn_System/backend/tests/content-access.test.js
Maxim Dolgolyov b702b04ed2 feat(access): Фаза 2c — история правил + пресет «копировать доступ из класса»
История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил
правило для контента (из admin_audit_log, имена классов/учеников резолвятся).
Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений».
Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет
текущие правила открытыми правилами класса-источника). Тест: история (admin
видит запись, учителю 403). content-access 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:55:02 +03:00

139 lines
6.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
});
});