From 1bbddc00c842f80c532d999f9dce88c6a142b3b3 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 12:39:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=A4=D0=B0=D0=B7=D0=B0=200=20?= =?UTF-8?q?=E2=80=94=20=D1=86=D0=B5=D0=BB=D0=BE=D1=81=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20+=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=B0=D1=81=D1=81=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/src/controllers/adminController.js | 5 +- backend/src/controllers/classController.js | 5 +- backend/src/services/contentAccess.js | 14 +++ backend/tests/content-access.test.js | 104 +++++++++++++++++++++ frontend/js/admin/sections/access.js | 2 + 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 backend/tests/content-access.test.js diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index fb63689..ed6b3d6 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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); }); diff --git a/backend/src/controllers/classController.js b/backend/src/controllers/classController.js index 370516c..566f759 100644 --- a/backend/src/controllers/classController.js +++ b/backend/src/controllers/classController.js @@ -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 }); } diff --git a/backend/src/services/contentAccess.js b/backend/src/services/contentAccess.js index c8a4e28..c9e4f21 100644 --- a/backend/src/services/contentAccess.js +++ b/backend/src/services/contentAccess.js @@ -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, }; diff --git a/backend/tests/content-access.test.js b/backend/tests/content-access.test.js new file mode 100644 index 0000000..a49ec92 --- /dev/null +++ b/backend/tests/content-access.test.js @@ -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); + }); +}); diff --git a/frontend/js/admin/sections/access.js b/frontend/js/admin/sections/access.js index 5677dc3..7188f2d 100644 --- a/frontend/js/admin/sections/access.js +++ b/frontend/js/admin/sections/access.js @@ -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 {