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