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:
@@ -1,6 +1,7 @@
|
||||
const db = require('../db/db');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { audit } = require('../utils/audit');
|
||||
const { purgeAccessFor } = require('../services/contentAccess');
|
||||
|
||||
/* ── Prepared statements ──────────────────────────────────────────────── */
|
||||
const stmts = {
|
||||
@@ -480,8 +481,8 @@ const _deleteUserTx = db.transaction((uid) => {
|
||||
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
|
||||
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
|
||||
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
|
||||
// Персональные правила доступа к контенту (нет FK — чистим вручную):
|
||||
db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?").run(uid);
|
||||
// Персональные правила доступа к контенту (нет FK — единая чистка):
|
||||
purgeAccessFor('student', uid);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const crypto = require('crypto');
|
||||
const { onClassJoined } = require('./gamificationController');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { purgeAccessFor } = require('../services/contentAccess');
|
||||
|
||||
function genCode() {
|
||||
return crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||
@@ -324,8 +325,8 @@ function deleteClass(req, res) {
|
||||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
stmts.deleteClass.run(req.params.id);
|
||||
// Правила доступа к контенту для этого класса (нет FK — чистим вручную):
|
||||
db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?").run(req.params.id);
|
||||
// Правила доступа к контенту для этого класса (нет FK — единая чистка):
|
||||
purgeAccessFor('class', req.params.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,19 @@ function filterTextbooks(user, rows) {
|
||||
return rows.filter(r => allow.has(r.slug));
|
||||
}
|
||||
|
||||
/* ── Очистка правил (единая точка; у content_access нет FK) ───────────────
|
||||
* Вызывать из ВСЕХ путей удаления цели, чтобы не оставлять осиротевших правил:
|
||||
* purgeAccessFor('class', classId) — при удалении класса
|
||||
* purgeAccessFor('student', userId) — при удалении пользователя
|
||||
* Возвращает число удалённых строк. */
|
||||
const _purgeClass = db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?");
|
||||
const _purgeStudent = db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?");
|
||||
function purgeAccessFor(scope, id) {
|
||||
if (scope === 'class') return _purgeClass.run(id).changes;
|
||||
if (scope === 'student') return _purgeStudent.run(id).changes;
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRIVILEGED,
|
||||
textbookAccessKey,
|
||||
@@ -92,4 +105,5 @@ module.exports = {
|
||||
canAccessExam,
|
||||
allowedRefs,
|
||||
filterTextbooks,
|
||||
purgeAccessFor,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -223,6 +223,7 @@
|
||||
}
|
||||
|
||||
async function bulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return;
|
||||
const classes = _targets.classes || [];
|
||||
try {
|
||||
await Promise.all(classes.map(c =>
|
||||
@@ -310,6 +311,7 @@
|
||||
}
|
||||
|
||||
async function classBulk(allow) {
|
||||
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
||||
const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]),
|
||||
...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])];
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user