786419ce01
Миниатюры: 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>
243 lines
13 KiB
JavaScript
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');
|
|
});
|
|
});
|