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
+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');
});
});