Files
Learn_System/backend/tests/custom-sims.test.js
T

213 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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');
});
});