feat(sim-builder): фаза 3 — БД custom_sims + CRUD API с валидацией спеки и проверкой владения
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
'use strict';
|
||||
/* Custom simulations ("Конструктор симуляций" / SimForge), Фаза 3.
|
||||
*
|
||||
* Учитель/админ сохраняет интерактивную 2D-симуляцию как ДАННЫЕ (JSON-спека).
|
||||
* CRUD под авторизацией с проверкой владения; спека валидируется на входе
|
||||
* через validateSpec — БЕЗ исполнения (спека шарится между людьми, server
|
||||
* не запускает движок выражений). draft видит только владелец; published —
|
||||
* публичная (каталог /lab, Фаза 5).
|
||||
*
|
||||
* Стиль следует studentMaterialsController: node:sqlite db.prepare,
|
||||
* per-row ownership на каждой мутации, статусы 400/403/404.
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
|
||||
/* ── Лимиты валидации спеки ──────────────────────────────────────────── */
|
||||
const MAX_SPEC_BYTES = 200 * 1024; // 200 KB сериализованного JSON
|
||||
const MAX_PARAMS = 50;
|
||||
const MAX_OBJECTS = 200;
|
||||
const MAX_WALLS = 20;
|
||||
const MAX_SPRINGS = 50;
|
||||
const MAX_EXPR_LEN = 500; // длина строки-выражения (x/y/expr/…)
|
||||
const MAX_DEPTH = 8; // глубина вложенности JSON (анти-bomb)
|
||||
const MAX_TEXT_LEN = 300; // подписи/заголовки/единицы
|
||||
const MAX_POINTS = 1000; // точек в polyline/path/points
|
||||
|
||||
// Типы объектов из whitelist (см. формат спеки v1 в _sim_engine.js).
|
||||
const OBJECT_TYPES = new Set([
|
||||
'point', 'segment', 'vector', 'circle', 'rect',
|
||||
'polyline', 'path', 'label', 'plot', 'readout',
|
||||
]);
|
||||
|
||||
const STATUSES = new Set(['draft', 'published']);
|
||||
const CATS = new Set(['math', 'phys', 'chem', 'bio', 'game']);
|
||||
|
||||
/* Экранирование подписей как ТЕКСТА (не HTML): спека рендерится в KaTeX/canvas,
|
||||
но мы режем угловые скобки/амперсанд, чтобы исключить инъекцию при возможном
|
||||
попадании строки в HTML-контекст. Также обрезаем по длине. */
|
||||
function sanitizeText(v, max = MAX_TEXT_LEN) {
|
||||
if (v === null || v === undefined) return v;
|
||||
let s = String(v).slice(0, max);
|
||||
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Строка-выражение: число оставляем числом; строку обрезаем по длине, но НЕ
|
||||
парсим/исполняем (это делает безопасный SimExpr на клиенте). Отклоняем
|
||||
только превышение длины. */
|
||||
function checkExpr(v, label, errs) {
|
||||
if (typeof v === 'string' && v.length > MAX_EXPR_LEN) {
|
||||
errs.push(`${label}: выражение длиннее ${MAX_EXPR_LEN} символов`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Глубина вложенности — простая защита от «бомбы» из вложенных структур. */
|
||||
function depthOK(node, depth) {
|
||||
if (depth > MAX_DEPTH) return false;
|
||||
if (Array.isArray(node)) {
|
||||
for (const x of node) if (!depthOK(x, depth + 1)) return false;
|
||||
} else if (node && typeof node === 'object') {
|
||||
for (const k of Object.keys(node)) if (!depthOK(node[k], depth + 1)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* validateSpec(spec) — серверная валидация спеки БЕЗ исполнения.
|
||||
* Возвращает { ok:true, clean } с очищенной (санитизированной) спекой,
|
||||
* либо { ok:false, error } для ответа 400.
|
||||
*/
|
||||
function validateSpec(spec) {
|
||||
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
||||
return { ok: false, error: 'spec должна быть объектом' };
|
||||
}
|
||||
|
||||
// Размер сериализованного JSON.
|
||||
let json;
|
||||
try { json = JSON.stringify(spec); }
|
||||
catch { return { ok: false, error: 'spec не сериализуется в JSON' }; }
|
||||
if (Buffer.byteLength(json, 'utf8') > MAX_SPEC_BYTES) {
|
||||
return { ok: false, error: `spec превышает ${Math.round(MAX_SPEC_BYTES / 1024)} KB` };
|
||||
}
|
||||
|
||||
// Глубина вложенности.
|
||||
if (!depthOK(spec, 0)) return { ok: false, error: 'слишком глубокая вложенность spec' };
|
||||
|
||||
// specVersion.
|
||||
if (spec.specVersion !== undefined && spec.specVersion !== 1) {
|
||||
return { ok: false, error: 'неподдерживаемая specVersion (ожидается 1)' };
|
||||
}
|
||||
|
||||
const errs = [];
|
||||
const clean = {};
|
||||
clean.specVersion = 1;
|
||||
|
||||
// meta: title/desc — текст.
|
||||
if (spec.meta && typeof spec.meta === 'object') {
|
||||
clean.meta = {};
|
||||
if (spec.meta.title !== undefined) clean.meta.title = sanitizeText(spec.meta.title);
|
||||
if (spec.meta.desc !== undefined) clean.meta.desc = sanitizeText(spec.meta.desc, 1000);
|
||||
}
|
||||
|
||||
// viewport — числовые границы пропускаем как есть (числа/строки).
|
||||
if (spec.viewport && typeof spec.viewport === 'object' && !Array.isArray(spec.viewport)) {
|
||||
clean.viewport = spec.viewport;
|
||||
}
|
||||
|
||||
// time — конфиг t-цикла (autoplay/loop/duration/speed).
|
||||
if (spec.time && typeof spec.time === 'object' && !Array.isArray(spec.time)) {
|
||||
clean.time = spec.time;
|
||||
}
|
||||
|
||||
// params[] — слайдеры.
|
||||
const params = Array.isArray(spec.params) ? spec.params : [];
|
||||
if (params.length > MAX_PARAMS) return { ok: false, error: `params > ${MAX_PARAMS}` };
|
||||
clean.params = params.map((p, i) => {
|
||||
if (!p || typeof p !== 'object') { errs.push(`params[${i}]: не объект`); return {}; }
|
||||
const out = { ...p };
|
||||
if (p.label !== undefined) out.label = sanitizeText(p.label, 120);
|
||||
if (p.unit !== undefined) out.unit = sanitizeText(p.unit, 40);
|
||||
if (p.name !== undefined) out.name = sanitizeText(p.name, 60);
|
||||
return out;
|
||||
});
|
||||
|
||||
// objects[] — фигуры/подписи/графики/телá.
|
||||
const objects = Array.isArray(spec.objects) ? spec.objects : [];
|
||||
if (objects.length > MAX_OBJECTS) return { ok: false, error: `objects > ${MAX_OBJECTS}` };
|
||||
clean.objects = objects.map((o, i) => {
|
||||
if (!o || typeof o !== 'object') { errs.push(`objects[${i}]: не объект`); return {}; }
|
||||
const type = String(o.type || '');
|
||||
if (!OBJECT_TYPES.has(type)) errs.push(`objects[${i}]: недопустимый type "${type}"`);
|
||||
|
||||
const out = { ...o };
|
||||
// Текстовые поля.
|
||||
if (o.text !== undefined) out.text = sanitizeText(o.text, 1000);
|
||||
if (o.label !== undefined) out.label = sanitizeText(o.label, 120);
|
||||
if (o.unit !== undefined) out.unit = sanitizeText(o.unit, 40);
|
||||
if (o.id !== undefined) out.id = sanitizeText(o.id, 60);
|
||||
|
||||
// Строки-выражения: координаты/радиусы/выражения/диапазоны.
|
||||
for (const k of ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'r', 'w', 'h', 'dx', 'dy', 'expr', 'size', 'width', 'precision', 'samples']) {
|
||||
if (o[k] !== undefined) checkExpr(o[k], `objects[${i}].${k}`, errs);
|
||||
}
|
||||
|
||||
// points[] (polyline/path) — ограничиваем число точек.
|
||||
if (Array.isArray(o.points) && o.points.length > MAX_POINTS) {
|
||||
errs.push(`objects[${i}].points > ${MAX_POINTS}`);
|
||||
}
|
||||
|
||||
// body{} — физическое тело (mass/vx/vy/fixed). mass>0.
|
||||
if (o.body && typeof o.body === 'object' && !Array.isArray(o.body)) {
|
||||
const b = o.body;
|
||||
for (const k of ['mass', 'vx', 'vy']) if (b[k] !== undefined) checkExpr(b[k], `objects[${i}].body.${k}`, errs);
|
||||
if (typeof b.mass === 'number' && !(b.mass > 0)) errs.push(`objects[${i}].body.mass должна быть > 0`);
|
||||
}
|
||||
|
||||
// drag{} — параметр-привязка.
|
||||
if (o.drag && typeof o.drag === 'object' && o.drag.param !== undefined) {
|
||||
out.drag = { ...o.drag };
|
||||
out.drag.param = sanitizeText(o.drag.param, 60);
|
||||
if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// physics{} — глобальный блок сил/мира.
|
||||
if (spec.physics && typeof spec.physics === 'object' && !Array.isArray(spec.physics)) {
|
||||
const ph = spec.physics;
|
||||
const cph = { ...ph };
|
||||
|
||||
// gravity: {x,y} — числа/выражения.
|
||||
if (ph.gravity && typeof ph.gravity === 'object') {
|
||||
checkExpr(ph.gravity.x, 'physics.gravity.x', errs);
|
||||
checkExpr(ph.gravity.y, 'physics.gravity.y', errs);
|
||||
}
|
||||
// friction/restitution/dt — числа/выражения + границы для числовых.
|
||||
for (const k of ['friction', 'restitution', 'dt']) if (ph[k] !== undefined) checkExpr(ph[k], `physics.${k}`, errs);
|
||||
if (typeof ph.restitution === 'number' && (ph.restitution < 0 || ph.restitution > 1)) {
|
||||
errs.push('physics.restitution вне диапазона 0..1');
|
||||
}
|
||||
if (typeof ph.dt === 'number' && (ph.dt < 1 / 2000 || ph.dt > 1 / 30)) {
|
||||
errs.push('physics.dt вне диапазона 1/2000..1/30');
|
||||
}
|
||||
|
||||
// walls[] — лимит.
|
||||
if (Array.isArray(ph.walls)) {
|
||||
if (ph.walls.length > MAX_WALLS) return { ok: false, error: `physics.walls > ${MAX_WALLS}` };
|
||||
for (let i = 0; i < ph.walls.length; i++) {
|
||||
const wl = ph.walls[i];
|
||||
if (wl && typeof wl === 'object') {
|
||||
for (const k of ['x1', 'y1', 'x2', 'y2']) if (wl[k] !== undefined) checkExpr(wl[k], `physics.walls[${i}].${k}`, errs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// springs[] — лимит + поля.
|
||||
if (Array.isArray(ph.springs)) {
|
||||
if (ph.springs.length > MAX_SPRINGS) return { ok: false, error: `physics.springs > ${MAX_SPRINGS}` };
|
||||
for (let i = 0; i < ph.springs.length; i++) {
|
||||
const sp = ph.springs[i];
|
||||
if (sp && typeof sp === 'object') {
|
||||
for (const k of ['k', 'length', 'damping']) if (sp[k] !== undefined) checkExpr(sp[k], `physics.springs[${i}].${k}`, errs);
|
||||
}
|
||||
}
|
||||
}
|
||||
clean.physics = cph;
|
||||
}
|
||||
|
||||
if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') };
|
||||
return { ok: true, clean };
|
||||
}
|
||||
|
||||
/* ── Сериализация строки БД → ответ API ──────────────────────────────── */
|
||||
function rowToSim(row) {
|
||||
if (!row) return null;
|
||||
let spec = null;
|
||||
try { spec = JSON.parse(row.spec_json); } catch { spec = null; }
|
||||
return {
|
||||
id: row.id,
|
||||
owner_id: row.owner_id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
subject: row.subject,
|
||||
grade: row.grade,
|
||||
cat: row.cat,
|
||||
status: row.status,
|
||||
version: row.version,
|
||||
spec,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/* Метаданные из body — общая нормализация для create/update. */
|
||||
function readMeta(b) {
|
||||
return {
|
||||
title: b.title !== undefined ? sanitizeText(b.title) : undefined,
|
||||
description: b.description !== undefined ? sanitizeText(b.description, 2000) : undefined,
|
||||
subject: b.subject !== undefined ? (b.subject != null ? String(b.subject).slice(0, 60) : null) : undefined,
|
||||
grade: b.grade !== undefined ? (Number.isFinite(Number(b.grade)) ? Number(b.grade) : null) : undefined,
|
||||
cat: b.cat !== undefined ? (CATS.has(String(b.cat)) ? String(b.cat) : null) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/* GET /api/custom-sims — свои (любой статус) + чужие published.
|
||||
Без выдачи spec_json в списке (тяжело); spec приходит в GET /:id. */
|
||||
function list(req, res) {
|
||||
const uid = req.user.id;
|
||||
const rows = db.prepare(`
|
||||
SELECT id, owner_id, title, description, subject, grade, cat, status, version, created_at, updated_at
|
||||
FROM custom_sims
|
||||
WHERE owner_id = ? OR status = 'published'
|
||||
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||
`).all(uid);
|
||||
res.json({ sims: rows });
|
||||
}
|
||||
|
||||
/* GET /api/custom-sims/:id — свой (любой статус) ИЛИ чужой published. */
|
||||
function get(req, res) {
|
||||
const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.owner_id !== req.user.id && row.status !== 'published') {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
res.json({ sim: rowToSim(row) });
|
||||
}
|
||||
|
||||
/* POST /api/custom-sims — создать (teacher/admin). Body: { title?, description?,
|
||||
subject?, grade?, cat?, status?, spec }. */
|
||||
function create(req, res) {
|
||||
const b = req.body || {};
|
||||
const v = validateSpec(b.spec);
|
||||
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||
|
||||
const m = readMeta(b);
|
||||
const status = STATUSES.has(String(b.status)) ? String(b.status) : 'draft';
|
||||
const r = db.prepare(`
|
||||
INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(
|
||||
req.user.id,
|
||||
m.title ?? null,
|
||||
m.description ?? null,
|
||||
m.subject ?? null,
|
||||
m.grade ?? null,
|
||||
m.cat ?? null,
|
||||
JSON.stringify(v.clean),
|
||||
status,
|
||||
);
|
||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||
}
|
||||
|
||||
/* PUT /api/custom-sims/:id — обновить (владелец/admin). Любое поле опционально;
|
||||
spec, если передан, валидируется заново и поднимает version. */
|
||||
function update(req, res) {
|
||||
const row = db.prepare('SELECT owner_id, version FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
|
||||
const b = req.body || {};
|
||||
const fields = [], args = [];
|
||||
|
||||
if (b.spec !== undefined) {
|
||||
const v = validateSpec(b.spec);
|
||||
if (!v.ok) return res.status(400).json({ error: v.error });
|
||||
fields.push('spec_json = ?'); args.push(JSON.stringify(v.clean));
|
||||
fields.push('version = ?'); args.push(row.version + 1);
|
||||
}
|
||||
|
||||
const m = readMeta(b);
|
||||
if (m.title !== undefined) { fields.push('title = ?'); args.push(m.title); }
|
||||
if (m.description !== undefined) { fields.push('description = ?'); args.push(m.description); }
|
||||
if (m.subject !== undefined) { fields.push('subject = ?'); args.push(m.subject); }
|
||||
if (m.grade !== undefined) { fields.push('grade = ?'); args.push(m.grade); }
|
||||
if (m.cat !== undefined) { fields.push('cat = ?'); args.push(m.cat); }
|
||||
if (b.status !== undefined && STATUSES.has(String(b.status))) { fields.push('status = ?'); args.push(String(b.status)); }
|
||||
|
||||
if (!fields.length) return res.json({ ok: true });
|
||||
fields.push("updated_at = datetime('now')");
|
||||
args.push(req.params.id);
|
||||
db.prepare(`UPDATE custom_sims SET ${fields.join(', ')} WHERE id = ?`).run(...args);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* DELETE /api/custom-sims/:id — удалить (владелец/admin). */
|
||||
function remove(req, res) {
|
||||
const row = db.prepare('SELECT owner_id FROM custom_sims WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
db.prepare('DELETE FROM custom_sims WHERE id = ?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { list, get, create, update, remove, validateSpec };
|
||||
@@ -0,0 +1,29 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 071: Custom simulations (Конструктор симуляций / SimForge), Фаза 3.
|
||||
--
|
||||
-- Учитель/админ собирает интерактивную 2D-симуляцию из ДАННЫХ (JSON-спека:
|
||||
-- params[], objects[], physics{}, …) и сохраняет её здесь. Спека хранится как
|
||||
-- TEXT(JSON) в spec_json; её схема/лимиты валидируются на входе сервером
|
||||
-- (validateSpec), БЕЗ исполнения. status='draft' видит только владелец;
|
||||
-- status='published' — публичная (видна всем в каталоге /lab, Фаза 5).
|
||||
--
|
||||
-- owner_id ON DELETE CASCADE — спеки удаляются вместе с автором.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_sims (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
subject TEXT, -- курикулум (напр. 'physics')
|
||||
grade INTEGER, -- класс
|
||||
cat TEXT, -- категория каталога (math|phys|chem|bio|game)
|
||||
spec_json TEXT NOT NULL, -- JSON-спека (данные, не код)
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
|
||||
version INTEGER NOT NULL DEFAULT 1, -- ++ на каждом update
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_sims_owner ON custom_sims (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_sims_status ON custom_sims (status);
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
/* /api/custom-sims — CRUD спек-симуляций «Конструктора симуляций» (Фаза 3).
|
||||
* Read-роуты — auth-only (видимость своих + published проверяет контроллер).
|
||||
* Мутации — inline requireRole('teacher','admin') + per-row ownership в хендлере.
|
||||
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const c = require('../controllers/customSimController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', c.list);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id', c.get);
|
||||
|
||||
router.post('/', requireRole('teacher', 'admin'), c.create);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.put('/:id', requireRole('teacher', 'admin'), c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id', requireRole('teacher', 'admin'), c.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -196,6 +196,7 @@ app.use('/api/access', accessRoutes);
|
||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||
app.use('/api/lab', labRoutes);
|
||||
app.use('/api/materials', require('./routes/materials'));
|
||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
|
||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/custom-sims — CRUD спек-симуляций (Фаза 3).
|
||||
* Covers: auth, role-gating (POST teacher/admin), CRUD happy-path, ownership
|
||||
* (чужой PUT/DELETE → 403), видимость (own draft / others published), serverная
|
||||
* валидация спеки (кривая/огромная → 400), version-bump на update.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
// Mount /api/custom-sims on the shared test app (setup.js не монтирует его).
|
||||
app.use('/api/custom-sims', require('../src/routes/customSims'));
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
// Минимальная валидная спека (формат v1).
|
||||
const VALID_SPEC = {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Бросок' },
|
||||
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
|
||||
params: [{ name: 'v', label: 'Скорость', min: 0, max: 30, step: 0.5, value: 18, unit: 'м/с' }],
|
||||
objects: [
|
||||
{ id: 'p', type: 'point', x: 'v*t', y: '-4.9*t*t', r: 6, color: '#06D6E0' },
|
||||
{ type: 'segment', x1: 0, y1: 0, x2: 'p.x', y2: 'p.y', color: '#fff', width: 2 },
|
||||
],
|
||||
physics: { enabled: true, gravity: { x: 0, y: -9.8 }, restitution: 0.9, dt: 1 / 240 },
|
||||
};
|
||||
|
||||
describe('/api/custom-sims', () => {
|
||||
let teacherToken, teacherId, otherTeacherToken, studentToken;
|
||||
|
||||
before(async () => {
|
||||
const t = await getToken('teacher');
|
||||
teacherToken = t.token; teacherId = t.userId;
|
||||
otherTeacherToken = (await getToken('teacher')).token;
|
||||
studentToken = (await getToken('student')).token;
|
||||
});
|
||||
|
||||
it('GET /api/custom-sims requires auth (401 without token)', async () => {
|
||||
const res = await inject('GET', '/api/custom-sims', null, null);
|
||||
assert.equal(res.status, 401, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('POST is role-gated: student → 403', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: VALID_SPEC }, studentToken);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
let simId;
|
||||
it('teacher can create a sim (201) with valid spec', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims',
|
||||
{ title: 'Бросок тела', subject: 'physics', grade: 9, cat: 'phys', spec: VALID_SPEC }, teacherToken);
|
||||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
assert.ok(Number.isFinite(res.body.id), 'returns numeric id');
|
||||
simId = res.body.id;
|
||||
});
|
||||
|
||||
it('GET /:id returns own sim with parsed spec + metadata + version 1', async () => {
|
||||
const res = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
const s = res.body.sim;
|
||||
assert.equal(s.id, simId);
|
||||
assert.equal(s.owner_id, teacherId);
|
||||
assert.equal(s.title, 'Бросок тела');
|
||||
assert.equal(s.subject, 'physics');
|
||||
assert.equal(s.grade, 9);
|
||||
assert.equal(s.cat, 'phys');
|
||||
assert.equal(s.status, 'draft');
|
||||
assert.equal(s.version, 1);
|
||||
assert.equal(s.spec.specVersion, 1);
|
||||
assert.equal(s.spec.objects.length, 2);
|
||||
});
|
||||
|
||||
it('GET list returns own draft', async () => {
|
||||
const res = await inject('GET', '/api/custom-sims', null, teacherToken);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.ok(Array.isArray(res.body.sims));
|
||||
assert.ok(res.body.sims.find(s => s.id === simId), 'own draft present');
|
||||
});
|
||||
|
||||
it("other teacher CANNOT see someone's draft in list, and GET draft → 403", async () => {
|
||||
const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken);
|
||||
assert.ok(!list.body.sims.find(s => s.id === simId), 'draft not in other user list');
|
||||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, otherTeacherToken);
|
||||
assert.equal(get.status, 403, `got ${get.status}`);
|
||||
});
|
||||
|
||||
it('owner PUT updates metadata + spec and bumps version', async () => {
|
||||
const newSpec = { ...VALID_SPEC, meta: { title: 'Изменено' } };
|
||||
const res = await inject('PUT', `/api/custom-sims/${simId}`,
|
||||
{ title: 'Новое имя', status: 'published', spec: newSpec }, teacherToken);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||
assert.equal(get.body.sim.title, 'Новое имя');
|
||||
assert.equal(get.body.sim.status, 'published');
|
||||
assert.equal(get.body.sim.version, 2, 'version bumped');
|
||||
assert.equal(get.body.sim.spec.meta.title, 'Изменено');
|
||||
});
|
||||
|
||||
it('published sim is visible to other users (list + GET)', async () => {
|
||||
const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken);
|
||||
assert.ok(list.body.sims.find(s => s.id === simId), 'published in other user list');
|
||||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, studentToken);
|
||||
assert.equal(get.status, 200, 'student can read published');
|
||||
assert.equal(get.body.sim.id, simId);
|
||||
});
|
||||
|
||||
it("other teacher CANNOT PUT/DELETE someone else's sim (403)", async () => {
|
||||
const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'хак' }, otherTeacherToken);
|
||||
assert.equal(put.status, 403, `PUT got ${put.status}`);
|
||||
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, otherTeacherToken);
|
||||
assert.equal(del.status, 403, `DELETE got ${del.status}`);
|
||||
});
|
||||
|
||||
it('admin can update/delete any sim', async () => {
|
||||
const adminToken = (await getToken('admin')).token;
|
||||
const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'admin edit' }, adminToken);
|
||||
assert.equal(put.status, 200, `admin PUT got ${put.status}`);
|
||||
});
|
||||
|
||||
it('PUT/GET unknown id → 404', async () => {
|
||||
assert.equal((await inject('GET', '/api/custom-sims/999999', null, teacherToken)).status, 404);
|
||||
assert.equal((await inject('PUT', '/api/custom-sims/999999', { title: 'x' }, teacherToken)).status, 404);
|
||||
assert.equal((await inject('DELETE', '/api/custom-sims/999999', null, teacherToken)).status, 404);
|
||||
});
|
||||
|
||||
/* ── validateSpec: отклонение кривых/огромных спек (400) ── */
|
||||
it('rejects missing spec (400)', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims', { title: 'нет спеки' }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects non-object spec (400)', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: 'just a string' }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects wrong specVersion (400)', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, specVersion: 99 } }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects disallowed object type (400)', async () => {
|
||||
const bad = { ...VALID_SPEC, objects: [{ type: 'eval_me', x: 1, y: 1 }] };
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects too many objects (400)', async () => {
|
||||
const objs = Array.from({ length: 201 }, () => ({ type: 'point', x: 1, y: 1 }));
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, objects: objs } }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects too many params (400)', async () => {
|
||||
const ps = Array.from({ length: 51 }, (_, i) => ({ name: 'p' + i, min: 0, max: 1, value: 0 }));
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, params: ps } }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects over-long expression string (400)', async () => {
|
||||
const bad = { ...VALID_SPEC, objects: [{ type: 'point', x: 'a+'.repeat(300) + '1', y: 0 }] };
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects physics.restitution out of range (400)', async () => {
|
||||
const bad = { ...VALID_SPEC, physics: { enabled: true, restitution: 5 } };
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects body.mass <= 0 (400)', async () => {
|
||||
const bad = { ...VALID_SPEC, objects: [{ type: 'circle', x: 0, y: 0, r: 1, body: { mass: 0 } }] };
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects too many springs (400)', async () => {
|
||||
const springs = Array.from({ length: 51 }, () => ({ a: [0, 0], b: [1, 1], k: 40, length: 1 }));
|
||||
const res = await inject('POST', '/api/custom-sims',
|
||||
{ spec: { ...VALID_SPEC, physics: { enabled: true, springs } } }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('rejects huge spec (>200KB) (400)', async () => {
|
||||
const huge = { ...VALID_SPEC, meta: { title: 'x', desc: 'a'.repeat(300000) } };
|
||||
const res = await inject('POST', '/api/custom-sims', { spec: huge }, teacherToken);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('sanitizes label/text fields (escapes angle brackets)', async () => {
|
||||
const spec = {
|
||||
...VALID_SPEC,
|
||||
objects: [{ type: 'label', x: 0, y: 0, text: '<img src=x onerror=alert(1)>' }],
|
||||
};
|
||||
const create = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
|
||||
assert.equal(create.status, 201, `got ${create.status}`);
|
||||
const get = await inject('GET', `/api/custom-sims/${create.body.id}`, null, teacherToken);
|
||||
const txt = get.body.sim.spec.objects[0].text;
|
||||
assert.ok(!txt.includes('<img'), 'angle brackets escaped');
|
||||
assert.ok(txt.includes('<img'), 'escaped form present');
|
||||
});
|
||||
|
||||
it('owner can DELETE own sim (then 404)', async () => {
|
||||
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||
assert.equal(del.status, 200, `got ${del.status}`);
|
||||
const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||
assert.equal(get.status, 404, 'gone after delete');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user