teach/test/shared-library.test.js
Ramon 24180beec1 v0.3.28-beta: alleen-lezen-vlag voor gedeelde items (server)
- Nieuwe migratie db/011: shared_library.readonly (default false)
- Beheerregel aangescherpt: een vergrendeld school-item is ook voor de
  eigenaar alleen-lezen; alleen schoolbeheerder en systeemmanager kunnen
  het nog wijzigen (systeem-items waren al alleen-lezen voor iedereen
  behalve de systeemmanager) - kopiëren blijft altijd werken
- De vlag zetten/weghalen kan per item (PATCH readonly) of per map
  (PATCH /shared/:kind/folder met {from, readonly}), uitsluitend door de
  schoolbeheerder van die school of de systeemmanager
- Lijst/detail geven readonly + canLock terug voor de UI; drie nieuwe tests
2026-07-14 22:49:17 +02:00

159 lines
7.6 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('een vergrendeld school-item is ook voor de eigenaar alleen-lezen', async () => {
const lockedOwn = { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: '', name: 'X', data: {}, readonly: true };
const { app } = await makeApp(teacher, [lockedOwn]);
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies,
payload: { name: 'Nieuwe naam' } });
assert.equal(res.statusCode, 403);
// kopiëren (GET met data) blijft gewoon werken
const get = await app.inject({ method: 'GET', url: '/api/shared/board/5', cookies });
assert.equal(get.statusCode, 200);
assert.equal(get.json().item.readonly, true);
assert.equal(get.json().item.canManage, false);
await app.close();
});
test('alleen schoolbeheerder/systeemmanager mag de alleen-lezen-vlag zetten', async () => {
const ownItem = { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: '', name: 'X', data: {}, readonly: false };
// eigenaar-leerkracht mag de vlag NIET zetten
const { app } = await makeApp(teacher, [ownItem]);
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies,
payload: { readonly: true } });
assert.equal(res.statusCode, 403);
await app.close();
// schoolbeheerder van dezelfde school wel
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const { app: app2, calls } = await makeApp(admin, [ownItem]);
const res2 = await app2.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies,
payload: { readonly: true } });
assert.equal(res2.statusCode, 200, res2.body);
const upd = calls.find((c) => c.sql.includes('SET readonly'));
assert.deepEqual(upd.params, [true, 5]);
await app2.close();
});
test('map vergrendelen zet de vlag op alle items onder het pad', async () => {
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
const rows = [
{ id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {}, readonly: false },
{ id: 6, school_id: 2, owner_id: 8, kind: 'board', folder: 'Taal/Sub', name: 'B', data: {}, readonly: false },
];
const { app, calls } = await makeApp(admin, rows);
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/folder', cookies,
payload: { scope: 'school', from: 'Taal', readonly: true } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.includes('SET readonly'));
assert.deepEqual(upd.params, [true, [5, 6]]);
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();
});