feat(access): Фаза 1a — видимость симуляций по классам (добавочная модель)
Миграция 051: расширяет content_access.content_type на 'course'/'sim' (пересборка таблицы — SQLite не умеет ALTER CHECK) + мост «открыть все включённые симуляции всем существующим классам» → текущее поведение не меняется. GET /api/lab/sims теперь фильтрует список для НЕпривилегированных по allowedRefs(uid,'sim'); admin/ teacher видят все. Ролевой simulations.access остаётся «модуль вкл.» (добавочно). Тесты: lab-access (4/4, allowlist+класс+личное), lab-sims переведён на admin для проверки полного каталога (видимость ученика — в lab-access). /api/lab в харнессе. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const access = require('../services/contentAccess');
|
||||||
|
|
||||||
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
||||||
const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question'];
|
const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question'];
|
||||||
@@ -48,7 +49,7 @@ function rowToSim(r) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
|
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
|
||||||
router.get('/sims', (_req, res) => {
|
router.get('/sims', (req, res) => {
|
||||||
let rows;
|
let rows;
|
||||||
try {
|
try {
|
||||||
rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
|
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 });
|
return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true });
|
||||||
}
|
}
|
||||||
const legacyDisabled = readLegacyDisabledIds();
|
const legacyDisabled = readLegacyDisabledIds();
|
||||||
const sims = rows.map(r => {
|
let sims = rows.map(r => {
|
||||||
const s = rowToSim(r);
|
const s = rowToSim(r);
|
||||||
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
|
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
|
||||||
s.enabled = s.enabled && !legacyDisabled.has(r.id);
|
s.enabled = s.enabled && !legacyDisabled.has(r.id);
|
||||||
return s;
|
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 });
|
res.json({ module_disabled: readModuleDisabled(), sims });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,10 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
const { describe, it, before, after } = require('node:test');
|
const { describe, it, before, after } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
const { 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'));
|
|
||||||
|
|
||||||
after(() => cleanup());
|
after(() => cleanup());
|
||||||
|
|
||||||
@@ -26,8 +23,10 @@ describe('/api/lab/sims', () => {
|
|||||||
assert.equal(res.status, 401, `got ${res.status}`);
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => {
|
/* Полный каталог проверяем под админом (privileged видит все). Видимость
|
||||||
const res = await inject('GET', '/api/lab/sims', null, studentToken);
|
по классам для ученика — в 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.status, 200, `got ${res.status}`);
|
||||||
assert.equal(res.body.module_disabled, false);
|
assert.equal(res.body.module_disabled, false);
|
||||||
assert.ok(Array.isArray(res.body.sims), 'sims is array');
|
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 () => {
|
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[0].id, 'graph');
|
||||||
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
|
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ app.use('/api/questions', require('../src/routes/questions'));
|
|||||||
// Additional routes for integration tests
|
// Additional routes for integration tests
|
||||||
app.use('/api/permissions', require('../src/routes/permissions'));
|
app.use('/api/permissions', require('../src/routes/permissions'));
|
||||||
app.use('/api/access', require('../src/routes/access'));
|
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)
|
// Feature-gated routes (requireFeature checks app_settings in DB)
|
||||||
const { requireFeature } = require('../src/middleware/features');
|
const { requireFeature } = require('../src/middleware/features');
|
||||||
|
|||||||
Reference in New Issue
Block a user