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>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 12:39:08 +03:00
parent edb98895df
commit 1bbddc00c8
5 changed files with 126 additions and 4 deletions
+104
View File
@@ -0,0 +1,104 @@
'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);
});
});