feat(sim-builder): фаза 3 — БД custom_sims + CRUD API с валидацией спеки и проверкой владения

This commit is contained in:
Maxim Dolgolyov
2026-06-13 12:10:02 +03:00
parent 572d479f12
commit 014c96db1e
10 changed files with 697 additions and 24 deletions
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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);
+23
View File
@@ -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;
+1
View File
@@ -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) ── */
+212
View File
@@ -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('&lt;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');
});
});