Files
Learn_System/backend/tests/materials.test.js
T
Maxim Dolgolyov 786419ce01 feat(materials): серверные миниатюры (sharp) + пагинация рендера списка
Миниатюры: uploadPersonalFile генерирует webp ≤480px (sharp), возвращает {url, thumbUrl}; колонка thumb_url (мигр.074); грид рисует <img> на миниатюре, просмотр/скачивание/аннотация — полный url. Ссылочная чистка матчит url И thumb_url; share копирует thumb; квота учитывает файл+миниатюру. Сейверы board-clip/material-save/textbook-clip/draw пробрасывают thumbUrl.

Пагинация: клиент рендерит PAGE_SIZE=60 карточек + «Показать ещё» (сброс на смену фильтра), сохраняя клиентский поиск/сортировку над полным списком.

Тесты: materials.test.js 16→19. План V2 выполнен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:40:23 +03:00

243 lines
13 KiB
JavaScript

'use strict';
/**
* Integration tests: /api/materials — «Мои материалы» (v2 hardening).
* Covers: auth, CRUD happy-path, ownership (чужой PATCH/DELETE → 403, 404),
* collections (create / move / delete keeps material), share-копия (роль + owner
* + привязка ученика), URL-allowlist (javascript: → 400), лимит числа материалов,
* и ссылочно-подсчётную чистку файла (releaseFileForUrl на временном файле).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { app, db, inject, getToken, cleanup } = require('./setup');
// setup.js не монтирует /api/materials — монтируем на общий тест-app.
app.use('/api/materials', require('../src/routes/materials'));
const ctrl = require('../src/controllers/studentMaterialsController');
after(() => cleanup());
describe('/api/materials', () => {
let studentToken, studentId, otherToken, teacherToken, teacherId;
before(async () => {
const s = await getToken('student'); studentToken = s.token; studentId = s.userId;
otherToken = (await getToken('student')).token;
const t = await getToken('teacher'); teacherToken = t.token; teacherId = t.userId;
});
it('GET requires auth (401 without token)', async () => {
const res = await inject('GET', '/api/materials', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
let noteId;
it('student can create a note (201) and list it back', async () => {
const c = await inject('POST', '/api/materials', { kind: 'note', title: 'Закон Ома', body: 'U=IR' }, studentToken);
assert.equal(c.status, 201, JSON.stringify(c.body));
noteId = c.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.status, 200);
assert.ok(l.body.materials.some(m => m.id === noteId && m.title === 'Закон Ома'));
});
it('accepts http(s) and app-relative link urls', async () => {
const a = await inject('POST', '/api/materials', { kind: 'link', url: 'https://example.com/x' }, studentToken);
assert.equal(a.status, 201, JSON.stringify(a.body));
const b = await inject('POST', '/api/materials', { kind: 'link', url: '/textbook/phys7#sec-1' }, studentToken);
assert.equal(b.status, 201, JSON.stringify(b.body));
});
it('rejects a link with javascript: scheme (400) — stored-XSS guard', async () => {
const res = await inject('POST', '/api/materials', { kind: 'link', url: 'javascript:alert(1)' }, studentToken);
assert.equal(res.status, 400, JSON.stringify(res.body));
});
it('rejects a protocol-relative url (400)', async () => {
const res = await inject('POST', '/api/materials', { kind: 'link', url: '//evil.example.com' }, studentToken);
assert.equal(res.status, 400, JSON.stringify(res.body));
});
it('PATCH cannot smuggle a javascript: url (400)', async () => {
const res = await inject('PATCH', `/api/materials/${noteId}`, { url: 'javascript:alert(1)' }, studentToken);
assert.equal(res.status, 400, JSON.stringify(res.body));
});
it('owner can rename; others get 403; missing → 404', async () => {
const ok = await inject('PATCH', `/api/materials/${noteId}`, { title: 'Ом' }, studentToken);
assert.equal(ok.status, 200);
const forbidden = await inject('PATCH', `/api/materials/${noteId}`, { title: 'hack' }, otherToken);
assert.equal(forbidden.status, 403);
const missing = await inject('PATCH', '/api/materials/999999', { title: 'x' }, studentToken);
assert.equal(missing.status, 404);
});
it('list returns a 1000-char body preview; GET /:id returns the full body (owner only)', async () => {
const big = 'x'.repeat(1500);
const c = await inject('POST', '/api/materials', { kind: 'note', body: big }, studentToken);
const id = c.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
const row = l.body.materials.find(m => m.id === id);
assert.equal(row.body.length, 1000, 'preview trimmed');
assert.equal(row.body_trunc, 1, 'truncation flagged');
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
assert.equal(one.status, 200);
assert.equal(one.body.body.length, 1500, 'full body returned');
assert.equal(one.body.user_id, undefined, 'user_id not leaked');
const forbidden = await inject('GET', `/api/materials/${id}`, null, otherToken);
assert.equal(forbidden.status, 403);
const missing = await inject('GET', '/api/materials/999999', null, studentToken);
assert.equal(missing.status, 404);
});
it('collections: create, move material in, delete keeps material (uncategorised)', async () => {
const col = await inject('POST', '/api/materials/collections', { name: 'Физика' }, studentToken);
assert.equal(col.status, 201, JSON.stringify(col.body));
const cid = col.body.id;
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: cid }, studentToken);
assert.equal(mv.status, 200);
let l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, cid);
assert.equal(l.body.collections.find(c => c.id === cid).count, 1);
const del = await inject('DELETE', `/api/materials/collections/${cid}`, null, studentToken);
assert.equal(del.status, 200);
l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null, 'material survives folder delete');
});
it('moving into another user\'s collection is ignored (collection_id stays null)', async () => {
const col = await inject('POST', '/api/materials/collections', { name: 'Чужая' }, otherToken);
const foreignCid = col.body.id;
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: foreignCid }, studentToken);
assert.equal(mv.status, 200);
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null);
});
it('share: student is role-gated (403)', async () => {
const res = await inject('POST', `/api/materials/${noteId}/share`, { userId: studentId }, studentToken);
assert.equal(res.status, 403, JSON.stringify(res.body));
});
it('share: teacher → linked student copies the material; unlinked → 403', async () => {
const tNote = await inject('POST', '/api/materials', { kind: 'note', title: 'Раздатка', body: 'привет' }, teacherToken);
const tId = tNote.body.id;
// not linked yet
const denied = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
assert.equal(denied.status, 403, JSON.stringify(denied.body));
// link teacher → student, then share
db.prepare('INSERT INTO teacher_students (teacher_id, student_id) VALUES (?, ?)').run(teacherId, studentId);
const ok = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.sent, 1);
const l = await inject('GET', '/api/materials', null, studentToken);
assert.ok(l.body.materials.some(m => m.title === 'Раздатка' && /Раздатка:/.test(m.source_title || '')), 'student received a copy');
});
it('enforces the per-user item cap (413)', async () => {
const q = await getToken('student');
const prev = process.env.MATERIALS_MAX_ITEMS;
process.env.MATERIALS_MAX_ITEMS = '3';
try {
for (let i = 0; i < 3; i++) {
const r = await inject('POST', '/api/materials', { kind: 'note', body: 'n' + i }, q.token);
assert.equal(r.status, 201, `create #${i}: ${JSON.stringify(r.body)}`);
}
const over = await inject('POST', '/api/materials', { kind: 'note', body: 'overflow' }, q.token);
assert.equal(over.status, 413, JSON.stringify(over.body));
} finally {
if (prev === undefined) delete process.env.MATERIALS_MAX_ITEMS; else process.env.MATERIALS_MAX_ITEMS = prev;
}
});
it('delete removes the row; owner only', async () => {
const m = await inject('POST', '/api/materials', { kind: 'note', body: 'temp' }, studentToken);
const id = m.body.id;
const forbidden = await inject('DELETE', `/api/materials/${id}`, null, otherToken);
assert.equal(forbidden.status, 403);
const ok = await inject('DELETE', `/api/materials/${id}`, null, studentToken);
assert.equal(ok.status, 200);
const l = await inject('GET', '/api/materials', null, studentToken);
assert.ok(!l.body.materials.some(x => x.id === id));
});
it('releaseFileForUrl: unlinks the file only when no material references it', () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const fname = 'test_' + Date.now() + '.png';
const fpath = path.join(dir, fname);
const url = '/uploads/materials/' + fname;
fs.writeFileSync(fpath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const ins = db.prepare('INSERT INTO student_materials (user_id, kind, url) VALUES (?, ?, ?)');
const r1 = ins.run(studentId, 'image', url).lastInsertRowid;
const r2 = ins.run(studentId, 'image', url).lastInsertRowid; // aliasing copy (как при share)
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'file kept while two rows reference it');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r1);
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'file kept while one row still references it');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r2);
ctrl.releaseFileForUrl(url);
assert.ok(!fs.existsSync(fpath), 'file unlinked once orphaned');
});
it('safeUrl / measureBytes behave as documented', () => {
assert.equal(ctrl.safeUrl('https://a.b/c'), 'https://a.b/c');
assert.equal(ctrl.safeUrl('/textbook/x'), '/textbook/x');
assert.equal(ctrl.safeUrl(''), '');
assert.equal(ctrl.safeUrl('javascript:x'), undefined);
assert.equal(ctrl.safeUrl('//host'), undefined);
assert.equal(ctrl.measureBytes('note', null, 'abc'), 3);
assert.equal(ctrl.measureBytes('link', 'https://x', null), 0);
});
it('thumb_url: create stores it, list/getOne return it; bad scheme → 400', async () => {
const ok = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/a.png', thumbUrl: '/uploads/materials/a_thumb.webp' }, studentToken);
assert.equal(ok.status, 201, JSON.stringify(ok.body));
const id = ok.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === id).thumb_url, '/uploads/materials/a_thumb.webp');
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
assert.equal(one.body.thumb_url, '/uploads/materials/a_thumb.webp');
const bad = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/b.png', thumbUrl: 'javascript:alert(1)' }, studentToken);
assert.equal(bad.status, 400, JSON.stringify(bad.body));
});
it('releaseFileForUrl ref-counts files referenced as a thumbnail (thumb_url column)', () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const fname = 'th_' + Date.now() + '.webp';
const fpath = path.join(dir, fname);
const url = '/uploads/materials/' + fname;
fs.writeFileSync(fpath, Buffer.from([0x52, 0x49, 0x46, 0x46]));
const rid = db.prepare('INSERT INTO student_materials (user_id, kind, thumb_url) VALUES (?, ?, ?)').run(studentId, 'image', url).lastInsertRowid;
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'kept while a row references it as thumb_url');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(rid);
ctrl.releaseFileForUrl(url);
assert.ok(!fs.existsSync(fpath), 'unlinked once orphaned');
});
it('DELETE removes the material\'s full image AND thumbnail files', async () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const base = 'del_' + Date.now();
const fFull = path.join(dir, base + '.png'), fThumb = path.join(dir, base + '_thumb.webp');
fs.writeFileSync(fFull, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
fs.writeFileSync(fThumb, Buffer.from([0x52, 0x49, 0x46, 0x46]));
const c = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/' + base + '.png', thumbUrl: '/uploads/materials/' + base + '_thumb.webp' }, studentToken);
assert.equal(c.status, 201);
const d = await inject('DELETE', `/api/materials/${c.body.id}`, null, studentToken);
assert.equal(d.status, 200);
assert.ok(!fs.existsSync(fFull), 'full image unlinked');
assert.ok(!fs.existsSync(fThumb), 'thumbnail unlinked');
});
});