teach/test/schools.test.js
Ramon e3036580ce
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s
fix: gebruiker verwijderen werkt weer (herstel foreign-key cascade) (v0.3.95-beta)
- Verwijderen gaf op de live-database een 500 "serverfout". Oorzaak: op databases die zijn ontstaan vóór alle foreign keys ON DELETE CASCADE waren, houdt een tabel de oude constraint (CREATE TABLE IF NOT EXISTS werkt een bestaande constraint nooit bij), waardoor DELETE FROM users een foreign-key-fout geeft
- Migratie 015: zoekt elke FK naar users(id) die niet cascade en niet set-null is en zet die alsnog op ON DELETE CASCADE (bewuste SET NULL-constraints blijven ongemoeid). Draait bij de volgende deploy en is idempotent
- Verwijderroute vangt een resterende FK-fout (23503) op als een begrijpelijke 409 i.p.v. een kale serverfout
- Geverifieerd: alle INSERT INTO users-paden vangen al 23505 op (gebruikersnaam uniek per school); dezelfde naam in verschillende scholen is bewust toegestaan (inloggen kiest de school) en staat sinds 0.3.94 per school gegroepeerd
- 3 nieuwe servertests (FK-fout -> 409, normale verwijdering, migratie-inhoud); 97 tests groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 21:39:29 +02:00

245 lines
12 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';
import { readFile } from 'node:fs/promises';
function makeApp(user, targetUser) {
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.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] };
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 100, school_id: 2, name: 'Groep 4' }] };
if (sql.startsWith('SELECT * FROM users WHERE id') && targetUser) return { rows: [targetUser] };
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 };
})();
}
/* variant waarin de scholen-existentiecheck slaagt */
function makeAppWithSchool(user, targetUser) {
return (async () => {
const { app, calls } = await makeApp(user, targetUser);
const pool = app.pg;
const orig = pool.query.bind(pool);
pool.query = async (sql, params = []) => {
if (sql.startsWith('SELECT 1 FROM schools')) { calls.push({ sql, params }); return { rows: [{ 1: 1 }] }; }
return orig(sql, params);
};
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 admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
test('systeemmanager kan een school hernoemen', async () => {
const { app, calls } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: 'De Nieuwe Naam' } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.json().school.name, 'De Nieuwe Naam');
const upd = calls.find((c) => c.sql.startsWith('UPDATE schools SET name'));
assert.deepEqual(upd.params, ['De Nieuwe Naam', '5']);
await app.close();
});
test('schoolbeheerder mag geen scholen hernoemen', async () => {
const { app } = await makeApp(admin);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: 'Hack' } });
assert.equal(res.statusCode, 403);
await app.close();
});
test('schoolbeheerder en systeemmanager kunnen aan een klas gekoppeld worden', async () => {
// schoolbeheerder van dezelfde school
const { app, calls } = await makeApp(superUser, { id: 3, role: 'admin', school_id: 2 });
const res = await app.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 3 } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(calls.some((c) => c.sql.includes('INSERT INTO class_teachers')));
await app.close();
// systeemmanager (geen school) mag ook
const { app: app2 } = await makeApp(superUser, { id: 1, role: 'super', school_id: null });
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 1 } });
assert.equal(res2.statusCode, 200, res2.body);
await app2.close();
// leerlingen niet, en staf van een andere school niet
const { app: app3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2 });
const res3 = await app3.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 9 } });
assert.equal(res3.statusCode, 400);
await app3.close();
const { app: app4 } = await makeApp(superUser, { id: 4, role: 'admin', school_id: 3 });
const res4 = await app4.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 4 } });
assert.equal(res4.statusCode, 400);
await app4.close();
});
test('ouder-achtige rol krijgt geen promotie en geen klaskoppeling', async () => {
// rolwijziging: alleen staf → staf; een parent-account mag nooit promoveren
const { app, calls } = await makeApp(superUser, { id: 7, role: 'parent', school_id: null });
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
payload: { role: 'admin' } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET role')),
'rolwijziging mag voor een ouder-account niet uitgevoerd worden');
await app.close();
// klaskoppeling: parent weigeren, net als pupil
const { app: app2, calls: calls2 } = await makeApp(superUser, { id: 7, role: 'parent', school_id: null });
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 7 } });
assert.equal(res2.statusCode, 400);
assert.ok(!calls2.some((c) => c.sql.includes('INSERT INTO class_teachers')));
await app2.close();
});
test('systeemmanager koppelt een gebruiker aan een school en maakt hem weer los', async () => {
// koppelen: school bestaat -> update + klas-/toewijzing-opruiming
const { app, calls } = await makeApp(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' });
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
payload: { schoolId: 2 } });
assert.equal(res.statusCode, 404, res.body); // mock geeft geen schools-rij terug
const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 7, role: 'teacher', school_id: null, username: 'juf' });
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies,
payload: { schoolId: 2 } });
assert.equal(res2.statusCode, 200, res2.body);
const upd = calls2.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
assert.deepEqual(upd.params, [2, null, 7]);
assert.ok(calls2.some((c) => c.sql.includes('DELETE FROM class_teachers')));
await app.close(); await app2.close();
// losmaken: schoolId null, geen schools-check nodig; leerling verliest toewijzing
const { app: app3, calls: calls3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2, class_id: 5, username: 'lena' });
const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/users/9', cookies,
payload: { schoolId: null } });
assert.equal(res3.statusCode, 200, res3.body);
const upd3 = calls3.find((c) => c.sql.startsWith('UPDATE users SET school_id'));
assert.deepEqual(upd3.params, [null, null, 9]);
assert.ok(calls3.some((c) => c.sql.includes('DELETE FROM assignments WHERE pupil_id')));
await app3.close();
});
test('school koppelen kan niet door een schoolbeheerder en niet op super/parent', async () => {
const { app, calls } = await makeAppWithSchool(admin, { id: 7, role: 'teacher', school_id: 2, username: 'juf' });
await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 3 } });
assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'admin mag geen school wijzigen');
await app.close();
const { app: app2, calls: calls2 } = await makeAppWithSchool(superUser, { id: 20, role: 'parent', school_id: null, username: 'ouder' });
await app2.inject({ method: 'PATCH', url: '/api/admin/users/20', cookies, payload: { schoolId: 2 } });
assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'parent blijft schoolloos');
await app2.close();
});
test('school koppelen meldt een naamconflict netjes als 409', async () => {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [superUser] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 7, role: 'teacher', school_id: null, username: 'juf' }] };
if (sql.startsWith('SELECT 1 FROM schools')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('UPDATE users SET school_id')) { const e = new Error('dup'); e.code = '23505'; throw e; }
return { rows: [] };
},
};
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();
const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: 2 } });
assert.equal(res.statusCode, 409);
assert.match(res.json().error, /bestaat al/);
await app.close();
});
test('systeemmanager verplaatst een klas naar een andere school; losmaken kan niet', async () => {
const { app, calls } = await makeAppWithSchool(superUser, null);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: 3 } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.startsWith('UPDATE classes SET school_id'));
assert.deepEqual(upd.params, [3, 100]);
// een klas hoort altijd bij een school: geen/null wordt geweigerd
const { app: app2 } = await makeAppWithSchool(superUser, null);
const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: null } });
assert.equal(res2.statusCode, 400);
// schoolbeheerder mag dit niet (schools.manage = super)
const { app: app3, calls: calls3 } = await makeAppWithSchool(admin, null);
const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/classes/100', cookies,
payload: { schoolId: 3 } });
assert.equal(res3.statusCode, 403);
assert.ok(!calls3.some((c) => c.sql.startsWith('UPDATE classes SET school_id')));
await app.close(); await app2.close(); await app3.close();
});
test('gebruiker verwijderen: FK-fout wordt een nette 409 i.p.v. serverfout', async () => {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [superUser] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [{ id: 9, role: 'teacher', school_id: 2, username: 'juf' }] };
if (sql.startsWith('DELETE FROM users')) { const e = new Error('fk'); e.code = '23503'; throw e; }
return { rows: [] };
},
};
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();
const res = await app.inject({ method: 'DELETE', url: '/api/admin/users/9', cookies });
assert.equal(res.statusCode, 409, res.body);
assert.match(res.json().error, /gekoppelde gegevens/);
await app.close();
});
test('gebruiker verwijderen slaagt normaal (DELETE uitgevoerd)', async () => {
const { app, calls } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2, username: 'lena' });
const res = await app.inject({ method: 'DELETE', url: '/api/admin/users/9', cookies });
assert.equal(res.statusCode, 200, res.body);
assert.ok(calls.some((c) => c.sql.startsWith('DELETE FROM users')));
await app.close();
});
test('migratie 015 herstelt ON DELETE CASCADE voor user-foreign-keys', async () => {
const sql = await readFile('db/015_user_fk_cascade.sql', 'utf8');
assert.match(sql, /confrelid = 'users'::regclass/);
assert.match(sql, /ON DELETE CASCADE/);
assert.match(sql, /confdeltype NOT IN \('c', 'n'\)/);
});
test('hernoemen weigert een lege of te lange naam', async () => {
const { app } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: ' ' } });
assert.equal(res.statusCode, 400);
const res2 = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: 'x'.repeat(90) } });
assert.equal(res2.statusCode, 400);
await app.close();
});