Files
Learn_System/backend/tests/content-access.test.js
T
Maxim Dolgolyov 1bbddc00c8 feat(access): Фаза 0 — целостность правил доступа + подтверждение массового закрытия
- contentAccess.purgeAccessFor(scope,id) — единая точка очистки content_access
  (нет FK). deleteClass и _deleteUserTx переведены на неё (убрано дублирование).
- Админ-UI: confirm() перед «Закрыть у всех / Закрыть весь» (необратимая массовая
  операция больше не срабатывает мгновенно).
- Новый тест content-access.test.js (9/9): allowlist, ученик>класс, наследование
  главой хаба, admin/teacher bypass, allowedRefs/filterTextbooks, purgeAccessFor,
  чистка правил при DELETE класса. Полный backend-набор: 203/206 (3 — baseline Auth).

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

105 lines
4.9 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('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);
});
});