teach/test/shared-library.test.js
Ramon 939c4ee68b v0.3.26-beta: server-API voor de gedeelde bibliotheek
- Nieuwe migratie db/010: shared_library-tabel (school_id NULL =
  systeembreed, anders per school; kind board/anchor; folder als pad)
- Routes: GET /shared/:kind (lijst, gefilterd op systeembreed + eigen
  school), GET /shared/:kind/:id (met data, voor kopiëren), POST (publiceren;
  scope global vereist systeemmanager, scope school staf met school),
  PATCH/DELETE per item en PATCH /shared/:kind/folder (prefix-rewrite,
  alleen als álle items onder het pad beheerd mogen worden)
- Beheerregel: systeemmanager altijd, eigenaar altijd, schoolbeheerder voor
  items van de eigen school; leerlingen hebben geen toegang
- Nieuwe permissies shared.global.manage / shared.school.manage (beide
  permissions.js); zes nieuwe servertests
2026-07-14 22:27:16 +02:00

111 lines
5.1 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
// App met een instelbare ingelogde gebruiker en vastgelegde queries; de
// shared_library-select levert een vaste mix van systeembrede en school-items.
function makeApp(user, sharedRows = []) {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.includes('FROM shared_library')) return { rows: sharedRows };
if (sql.includes('INSERT INTO shared_library') && sql.includes('RETURNING id')) return { rows: [{ id: 42 }] };
return { rows: [] };
},
};
return (async () => {
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
return { app, calls };
})();
}
const cookies = { teach_session: 'x'.repeat(64) };
const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 };
const teacher = { id: 7, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const pupil = { id: 9, username: 'kind', role: 'pupil', school_id: 2, class_id: 5, data: {}, data_rev: 0 };
test('GET /shared/:kind filtert op systeembreed + eigen school voor staf', async () => {
const { app, calls } = await makeApp(teacher, [
{ id: 1, school_id: null, owner_id: 1, folder: 'Rekenen', name: 'Breuken', owner_name: 'SM' },
]);
const res = await app.inject({ method: 'GET', url: '/api/shared/board', cookies });
assert.equal(res.statusCode, 200, res.body);
const items = res.json().items;
assert.equal(items[0].scope, 'global');
assert.equal(items[0].canManage, false);
const q = calls.find((c) => c.sql.includes('FROM shared_library'));
assert.ok(q.sql.includes('school_id IS NULL OR s.school_id = $2'));
assert.equal(q.params[1], 2);
await app.close();
});
test('leerlingen hebben geen toegang tot de gedeelde bibliotheek', async () => {
const { app } = await makeApp(pupil);
const res = await app.inject({ method: 'GET', url: '/api/shared/board', cookies });
assert.equal(res.statusCode, 403);
await app.close();
});
test('publiceren met scope global vereist systeemmanager', async () => {
const { app } = await makeApp(teacher);
const res = await app.inject({ method: 'POST', url: '/api/shared/board', cookies,
payload: { name: 'Breuken', folder: 'Rekenen', data: { widgets: [] }, scope: 'global' } });
assert.equal(res.statusCode, 403);
const { app: superApp, calls } = await makeApp(superUser);
const ok = await superApp.inject({ method: 'POST', url: '/api/shared/board', cookies,
payload: { name: 'Breuken', folder: 'Rekenen', data: { widgets: [] }, scope: 'global' } });
assert.equal(ok.statusCode, 200, ok.body);
const ins = calls.find((c) => c.sql.includes('INSERT INTO shared_library'));
assert.equal(ins.params[0], null); // school_id NULL = systeembreed
await superApp.close();
await app.close();
});
test('publiceren met scope school gebruikt de eigen school', async () => {
const { app, calls } = await makeApp(teacher);
const res = await app.inject({ method: 'POST', url: '/api/shared/anchor', cookies,
payload: { name: 'water', data: { cells: {} }, scope: 'school' } });
assert.equal(res.statusCode, 200, res.body);
const ins = calls.find((c) => c.sql.includes('INSERT INTO shared_library'));
assert.equal(ins.params[0], 2);
assert.equal(ins.params[2], 'anchor');
await app.close();
});
test('beheren mag alleen door eigenaar, schoolbeheerder of systeemmanager', async () => {
// teacher (id 7) probeert andermans school-item te verwijderen
const foreignItem = { id: 5, school_id: 2, owner_id: 8, kind: 'board', folder: '', name: 'X', data: {} };
const { app } = await makeApp(teacher, [foreignItem]);
const res = await app.inject({ method: 'DELETE', url: '/api/shared/board/5', cookies });
assert.equal(res.statusCode, 403);
// eigenaar mag wel
const ownItem = { ...foreignItem, owner_id: 7 };
const { app: app2 } = await makeApp(teacher, [ownItem]);
const res2 = await app2.inject({ method: 'DELETE', url: '/api/shared/board/5', cookies });
assert.equal(res2.statusCode, 200, res2.body);
await app2.close();
await app.close();
});
test('map-rewrite weigert als niet alle items beheerd mogen worden', async () => {
const rows = [
{ id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {} },
{ id: 6, school_id: 2, owner_id: 8, kind: 'board', folder: 'Taal/Sub', name: 'B', data: {} },
];
const { app } = await makeApp(teacher, rows);
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/folder', cookies,
payload: { scope: 'school', from: 'Taal', to: 'Lezen' } });
assert.equal(res.statusCode, 403);
await app.close();
});