diff --git a/backend/src/db/migrations/051_content_access_types.sql b/backend/src/db/migrations/051_content_access_types.sql new file mode 100644 index 0000000..ce4ac53 --- /dev/null +++ b/backend/src/db/migrations/051_content_access_types.sql @@ -0,0 +1,40 @@ +-- 051_content_access_types.sql +-- Расширяем content_access.content_type на 'course' и 'sim' (добавочная модель +-- видимости контента по классам/ученикам — см. plans/access-redesign/PLAN.md). +-- +-- SQLite не поддерживает ALTER TABLE ... DROP/ALTER CONSTRAINT, поэтому +-- пересобираем таблицу с расширенным CHECK, сохраняя данные, UNIQUE и индексы. +-- Затем мост: открываем все ВКЛЮЧЁННЫЕ симуляции всем существующим классам +-- (как миграция 040 сделала для учебников) — текущее поведение не меняется. +-- +-- В этой миграции wired только тип 'sim' (фильтр в /api/lab/sims). Тип 'course' +-- уже разрешён CHECK-ом на будущее, но мост/гейт курсов добавим отдельным шагом +-- (у курсов своя логика публикации и class_courses). + +CREATE TABLE content_access_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_type TEXT NOT NULL CHECK (content_type IN ('textbook','exam','course','sim')), + content_ref TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('class','student')), + target_id INTEGER NOT NULL, + allow INTEGER NOT NULL DEFAULT 1 CHECK (allow IN (0,1)), + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (content_type, content_ref, scope, target_id) +); + +INSERT INTO content_access_new (id, content_type, content_ref, scope, target_id, allow, created_by, created_at) + SELECT id, content_type, content_ref, scope, target_id, allow, created_by, created_at + FROM content_access; + +DROP TABLE content_access; +ALTER TABLE content_access_new RENAME TO content_access; + +CREATE INDEX IF NOT EXISTS idx_content_access_lookup ON content_access (content_type, content_ref); +CREATE INDEX IF NOT EXISTS idx_content_access_target ON content_access (content_type, scope, target_id); + +-- Мост: все включённые симуляции — всем существующим классам (allowlist-переход). +INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow) +SELECT 'sim', s.id, 'class', c.id, 1 + FROM lab_sims s CROSS JOIN classes c + WHERE s.enabled = 1; diff --git a/backend/src/routes/lab.js b/backend/src/routes/lab.js index 429083e..2ea9201 100644 --- a/backend/src/routes/lab.js +++ b/backend/src/routes/lab.js @@ -16,6 +16,7 @@ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); +const access = require('../services/contentAccess'); const CATS = ['math', 'phys', 'chem', 'bio', 'game']; const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question']; @@ -48,7 +49,7 @@ function rowToSim(r) { } /* ── GET /api/lab/sims ─────────────────────────────────────────────────── */ -router.get('/sims', (_req, res) => { +router.get('/sims', (req, res) => { let rows; try { rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all(); @@ -60,12 +61,19 @@ router.get('/sims', (_req, res) => { return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true }); } const legacyDisabled = readLegacyDisabledIds(); - const sims = rows.map(r => { + let sims = rows.map(r => { const s = rowToSim(r); // Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке. s.enabled = s.enabled && !legacyDisabled.has(r.id); return s; }); + // Видимость по классам (добавочная модель): ролевой simulations.access решает, + // включён ли модуль вообще (проверяется на фронте/при действиях); здесь ученик + // видит только разрешённые его классу/лично симуляции. admin/teacher — все. + if (req.user && !access.PRIVILEGED.has(req.user.role)) { + const allowed = access.allowedRefs(req.user.id, 'sim'); + sims = sims.filter(s => allowed.has(s.id)); + } res.json({ module_disabled: readModuleDisabled(), sims }); }); diff --git a/backend/tests/lab-access.test.js b/backend/tests/lab-access.test.js new file mode 100644 index 0000000..ae70d6b --- /dev/null +++ b/backend/tests/lab-access.test.js @@ -0,0 +1,58 @@ +'use strict'; +/** + * Фаза 1 (добавочная модель): видимость симуляций по классам через content_access. + * Ролевой simulations.access решает «включён ли модуль» (тут не проверяется — + * это делает фронт/действия); GET /api/lab/sims фильтрует список так, что ученик + * видит только разрешённые его классу/лично симуляции, а admin/teacher — все. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { db, getToken, inject, cleanup } = require('./setup'); + +after(() => cleanup()); + +describe('lab sim access (per-class)', () => { + let teacher, student, classId, simA, simB; + + before(async () => { + teacher = await getToken('teacher'); + student = await getToken('student'); + const sims = db.prepare('SELECT id FROM lab_sims WHERE enabled = 1 ORDER BY id LIMIT 2').all(); + assert.ok(sims.length >= 2, 'в seed есть хотя бы 2 симуляции'); + simA = sims[0].id; simB = sims[1].id; + + const r = await inject('POST', '/api/classes', { name: 'LabAcc Class' }, teacher.token); + assert.ok(r.status < 300, JSON.stringify(r.body)); + classId = db.prepare('SELECT id FROM classes WHERE name = ?').get('LabAcc Class').id; + await inject('POST', `/api/classes/${classId}/members`, { user_id: student.userId }, teacher.token); + }); + + const ids = (body) => (body.sims || []).map(s => s.id); + const rule = (ref, scope, target) => + db.prepare(`INSERT OR IGNORE INTO content_access (content_type,content_ref,scope,target_id,allow) + VALUES ('sim',?,?,?,1)`).run(ref, scope, target); + + it('ученик без правил не видит симуляций (allowlist)', async () => { + const r = await inject('GET', '/api/lab/sims', null, student.token); + assert.equal(r.status, 200); + assert.equal(ids(r.body).length, 0); + }); + + it('teacher видит все симуляции (privileged, без фильтра)', async () => { + const r = await inject('GET', '/api/lab/sims', null, teacher.token); + assert.ok(ids(r.body).length >= 2); + }); + + it('симуляция, открытая классу, видна ученику (и только она)', async () => { + rule(simA, 'class', classId); + const got = ids((await inject('GET', '/api/lab/sims', null, student.token)).body); + assert.ok(got.includes(simA), 'видит открытую'); + assert.ok(!got.includes(simB), 'не видит неоткрытую'); + }); + + it('личное правило ученика добавляет симуляцию', async () => { + rule(simB, 'student', student.userId); + const got = ids((await inject('GET', '/api/lab/sims', null, student.token)).body); + assert.ok(got.includes(simA) && got.includes(simB)); + }); +}); diff --git a/backend/tests/lab-sims.test.js b/backend/tests/lab-sims.test.js index 803578d..232decf 100644 --- a/backend/tests/lab-sims.test.js +++ b/backend/tests/lab-sims.test.js @@ -6,10 +6,7 @@ */ const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); -const { app, db, inject, getToken, cleanup } = require('./setup'); - -// Mount /api/lab on the shared test app (setup builds its own app without it). -app.use('/api/lab', require('../src/routes/lab')); +const { db, inject, getToken, cleanup } = require('./setup'); after(() => cleanup()); @@ -26,8 +23,10 @@ describe('/api/lab/sims', () => { assert.equal(res.status, 401, `got ${res.status}`); }); - it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => { - const res = await inject('GET', '/api/lab/sims', null, studentToken); + /* Полный каталог проверяем под админом (privileged видит все). Видимость + по классам для ученика — в lab-access.test.js (allowlist). */ + it('GET /api/lab/sims returns seeded catalog (40 sims) for admin', async () => { + const res = await inject('GET', '/api/lab/sims', null, adminToken); assert.equal(res.status, 200, `got ${res.status}`); assert.equal(res.body.module_disabled, false); assert.ok(Array.isArray(res.body.sims), 'sims is array'); @@ -40,7 +39,7 @@ describe('/api/lab/sims', () => { }); it('catalog is ordered by sort_order (graph first, angrybirds last)', async () => { - const res = await inject('GET', '/api/lab/sims', null, studentToken); + const res = await inject('GET', '/api/lab/sims', null, adminToken); assert.equal(res.body.sims[0].id, 'graph'); assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds'); }); diff --git a/backend/tests/setup.js b/backend/tests/setup.js index 34064df..b31c3de 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -45,6 +45,7 @@ app.use('/api/questions', require('../src/routes/questions')); // Additional routes for integration tests app.use('/api/permissions', require('../src/routes/permissions')); app.use('/api/access', require('../src/routes/access')); +app.use('/api/lab', require('../src/routes/lab')); // Feature-gated routes (requireFeature checks app_settings in DB) const { requireFeature } = require('../src/middleware/features');