'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: '' }], }; 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(' { const spec = { specVersion: 1, meta: { title: 'Граф-уровень' }, viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 }, params: [{ name: 'a', min: -1, max: 2, step: 0.05, value: 0.5 }], objects: [ { id: 'curve', type: 'plot', expr: 'a*x', var: 'x', range: [0, 10], runner: { duration: 5 } }, { id: 'ball', type: 'point', x: 'curve.runX', y: 'curve.runY', r: 7 }, { type: 'zone', id: 'pit', kind: 'forbidden', shape: 'rect', x: 5, y: 0, w: 4, h: 2, track: 'ball', label: 'яма' }, { type: 'zone', id: 'gate', kind: 'target', shape: 'circle', x: 10, y: 5, r: 1, track: 'ball' }, ], goal: { when: 'gate.hit', fail: 'pit.hit', stars: [{ when: 'gate.hit' }] }, }; const res = await inject('POST', '/api/custom-sims', { spec }, teacherToken); assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`); const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacherToken); const objs = get.body.sim.spec.objects; assert.ok(objs.find(o => o.type === 'zone' && o.id === 'pit'), 'zone object preserved'); assert.ok(objs.find(o => o.type === 'plot' && o.runner), 'runner block preserved'); }); it('rejects unknown object type even with zone allowed (400)', async () => { const bad = { ...VALID_SPEC, objects: [{ type: 'zoney_fake', x: 0, y: 0 }] }; const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken); assert.equal(res.status, 400, `got ${res.status}`); }); 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'); }); });