1bbddc00c8
- 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>
105 lines
4.9 KiB
JavaScript
105 lines
4.9 KiB
JavaScript
'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);
|
||
});
|
||
});
|