Files
Learn_System/backend/tests/content-access.test.js
T
Maxim Dolgolyov 67a70c672d feat(access): Фаза 2a — режим «Матрица» класс × контент в админке
GET /api/access/matrix (классы + карта открытого контента одним запросом,
скоуп учителя). Клиент LS.accessMatrix. Третий режим вкладки «Доступ»:
таблица контент × классы с чекбоксами (правка в один клик) + поиск по
названию (обновляет только tbody — фокус ввода сохраняется), залипающие
заголовки. Тест /api/access смонтирован в харнесс; content-access.test 11/11
(+матрица: учитель видит свои классы и открытый контент, ученику 403).

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

120 lines
5.7 KiB
JavaScript
Raw 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('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);
});
});