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 { hashPassword } from '../src/auth.js'; import { readFile } from 'node:fs/promises'; function makeApp(user, targetUser) { const calls = []; const query = async (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: [] }; }; const pool = { query, // pool.connect() voor transacties (o.a. gebruiker verwijderen) async connect() { return { query, release() {} }; }, }; 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('een bestaande groepsleiding of schoolbeheerder kan via rolwijziging nooit systeemmanager worden', async () => { // zelfs een systeemmanager mag dit niet via de gewone rolwijziging - alleen // via POST /admin/users (met de eigen-wachtwoord-bevestiging) mag een // nieuwe systeemmanager ontstaan const { app, calls } = await makeApp(superUser, { id: 7, role: 'teacher', school_id: 2 }); const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { role: 'super' } }); assert.equal(res.statusCode, 200, res.body); assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET role')), 'promotie naar systeemmanager via rolwijziging moet genegeerd worden'); await app.close(); const { app: app2, calls: calls2 } = await makeApp(superUser, { id: 8, role: 'admin', school_id: 2 }); const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/users/8', cookies, payload: { role: 'super' } }); assert.equal(res2.statusCode, 200, res2.body); assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE users SET role'))); await app2.close(); // ook niet via het extra-rollen-mechanisme const { app: app3, calls: calls3 } = await makeApp(superUser, { id: 8, role: 'admin', school_id: 2 }); const res3 = await app3.inject({ method: 'PATCH', url: '/api/admin/users/8', cookies, payload: { extraRoles: ['super', 'teacher'] } }); assert.equal(res3.statusCode, 200, res3.body); assert.deepEqual(res3.json().extraRoles, ['teacher']); assert.ok(!calls3.some((c) => c.sql.startsWith('INSERT INTO user_roles') && c.params.includes('super'))); await app3.close(); }); test('groepsleiding mag zelf geen rol wijzigen en geen school koppelen/losmaken', async () => { // rolwijziging: users.role.change is alleen 'super'. Een groepsleiding die // dit probeert op een niet-leerling-account wordt zelfs al eerder // geweigerd (teacherOnly-check: groepsleiding beheert uitsluitend // leerlingen), dus 403 - niet stilzwijgend genegeerd. const teacherActor = { id: 5, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; const { app, calls } = await makeApp(teacherActor, { id: 7, role: 'teacher', school_id: 2 }); const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { role: 'admin' } }); assert.equal(res.statusCode, 403, res.body); assert.ok(!calls.some((c) => c.sql.startsWith('UPDATE users SET role')), 'groepsleiding mag geen rol van een ander account wijzigen'); await app.close(); // schoolId koppelen/losmaken: alleen systeemmanager (req.user.allRoles.includes('super')). // Op een eigen leerling (waar een groepsleiding verder wél bij mag) blijft // dit alsnog verboden, dus hier specifiek getest met een leerling-doel. const { app: app2, calls: calls2 } = await makeApp(teacherActor, { id: 7, role: 'pupil', school_id: 2, class_id: 40 }); const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/users/7', cookies, payload: { schoolId: null } }); assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE users SET school_id')), 'groepsleiding mag een account niet van school koppelen/losmaken'); await app2.close(); // klassen aanmaken/koppelen: classes.manage is alleen super/admin const { app: app3, calls: calls3 } = await makeApp(teacherActor, null); const res3 = await app3.inject({ method: 'POST', url: '/api/admin/classes', cookies, payload: { name: 'Nieuwe klas' } }); assert.equal(res3.statusCode, 403, res3.body); assert.ok(!calls3.some((c) => c.sql.startsWith('INSERT INTO classes'))); await app3.close(); // scholen beheren: schools.manage is alleen super const { app: app4 } = await makeApp(teacherActor); const res4 = await app4.inject({ method: 'PATCH', url: '/api/admin/schools/2', cookies, payload: { name: 'Andere naam' } }); assert.equal(res4.statusCode, 403, res4.body); await app4.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('leerling verplaatsen naar een andere klas (uitwisselen) werkt', async () => { const { app, calls } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2, class_id: 50, username: 'lena' }); const res = await app.inject({ method: 'PATCH', url: '/api/admin/users/9', cookies, payload: { classId: 100 } }); assert.equal(res.statusCode, 200, res.body); const upd = calls.find((c) => c.sql === 'UPDATE users SET class_id = $1 WHERE id = $2'); assert.deepEqual(upd.params, [100, 9]); await app.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('klas verwijderen werkt en meldt bij een fout de echte reden i.p.v. kale serverfout', async () => { const { app, calls } = await makeApp(superUser, null); const res = await app.inject({ method: 'DELETE', url: '/api/admin/classes/100', cookies }); assert.equal(res.statusCode, 200, res.body); assert.ok(calls.some((c) => c.sql === 'DELETE FROM classes WHERE id = $1')); const { app: app2 } = await makeApp(superUser, null); const pool2 = app2.pg; const orig = pool2.query.bind(pool2); pool2.query = async (sql, params) => { if (sql === 'DELETE FROM classes WHERE id = $1') { const e = new Error('boom'); e.detail = 'Key is still referenced from table "iets".'; throw e; } return orig(sql, params); }; const res2 = await app2.inject({ method: 'DELETE', url: '/api/admin/classes/100', cookies }); assert.equal(res2.statusCode, 409, res2.body); assert.match(res2.json().error, /still referenced/); await app.close(); await app2.close(); }); test('gebruiker verwijderen ruimt eerst alle afhankelijke rijen op (geen serverfout)', async () => { const { app, calls } = await makeApp(superUser, { id: 9, role: 'teacher', school_id: 2, username: 'juf' }); const res = await app.inject({ method: 'DELETE', url: '/api/admin/users/9', cookies }); assert.equal(res.statusCode, 200, res.body); // in een transactie, afhankelijke tabellen vóór users assert.ok(calls.some((c) => c.sql === 'BEGIN')); assert.ok(calls.some((c) => c.sql.includes('DELETE FROM class_teachers WHERE user_id'))); assert.ok(calls.some((c) => c.sql.includes('DELETE FROM assignments WHERE pupil_id'))); assert.ok(calls.some((c) => c.sql.includes('DELETE FROM boards WHERE owner_id'))); const usersIdx = calls.findIndex((c) => c.sql === 'DELETE FROM users WHERE id = $1'); const ctIdx = calls.findIndex((c) => c.sql.includes('DELETE FROM class_teachers')); assert.ok(usersIdx > ctIdx, 'gebruiker wordt pas ná de afhankelijke rijen verwijderd'); assert.ok(calls.some((c) => c.sql === 'COMMIT')); await app.close(); }); test('als verwijderen tóch faalt, komt de echte reden terug (geen kale serverfout)', async () => { const { app } = await makeApp(superUser, { id: 9, role: 'teacher', school_id: 2, username: 'juf' }); const pool = app.pg; const orig = pool.connect.bind(pool); pool.connect = async () => { const client = await orig(); const q = client.query; client.query = async (sql, params) => { if (sql === 'DELETE FROM users WHERE id = $1') { const e = new Error('boom'); e.detail = 'Key is still referenced from table "iets".'; throw e; } return q(sql, params); }; return client; }; 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, /still referenced/); await app.close(); }); test('faalt de opzoekquery vóór deleteUserFully, dan ook de echte reden i.p.v. kale serverfout', async () => { const { app } = await makeApp(superUser, { id: 9, role: 'teacher', school_id: 2, username: 'juf' }); const pool = app.pg; const orig = pool.query.bind(pool); pool.query = async (sql, params) => { if (sql.startsWith('SELECT * FROM users WHERE id')) { const e = new Error('connection terminated'); throw e; } return orig(sql, params); }; 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, /connection terminated/); 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('migratie 016 hernoemt bestaande dubbele klasnamen vóórdat de unieke index wordt gezet', async () => { const sql = await readFile('db/016_class_name_unique.sql', 'utf8'); // eerst hernoemen (niets verwijderen), dan pas de constraint - anders faalt // de migratie zelf op een database die deze bug al heeft laten ontstaan const renameIdx = sql.indexOf('ROW_NUMBER()'); const indexIdx = sql.indexOf('CREATE UNIQUE INDEX'); assert.ok(renameIdx > -1 && indexIdx > -1 && renameIdx < indexIdx, 'hernoemen van dubbele namen moet vóór het aanmaken van de unieke index staan'); assert.match(sql, /PARTITION BY school_id, lower\(name\)/); assert.doesNotMatch(sql, /DELETE FROM classes/); }); test('migratie 017 laat schoolbeheerder en systeemmanager ook echt via de database toe aan een klas', async () => { // regressie: de trigger uit 006 stond alleen 'teacher' toe en blokkeerde // daarmee (met een verwarrende foutmelding) wat de applicatielaag // (POST /admin/classes/:id/teachers) allang toestond voor admin/super - // een mismatch die de gemockte-pool-tests principieel nooit konden vangen. const sql = await readFile('db/017_class_teacher_roles.sql', 'utf8'); assert.match(sql, /u\.role = 'super'/); assert.match(sql, /u\.role IN \('teacher', 'admin'\)/); assert.match(sql, /CREATE OR REPLACE FUNCTION teach_validate_class_teacher/); }); test('klas aanmaken weigert een dubbele naam binnen dezelfde school (409)', async () => { const { app } = await makeAppWithSchool(superUser, null); const pool = app.pg; const orig = pool.query.bind(pool); pool.query = async (sql, params) => { if (sql.startsWith('INSERT INTO classes')) { const e = new Error('dup'); e.code = '23505'; throw e; } return orig(sql, params); }; const res = await app.inject({ method: 'POST', url: '/api/admin/classes', cookies, payload: { name: 'B1', school: 2 } }); assert.equal(res.statusCode, 409, res.body); assert.match(res.json().error, /bestaat al/); await app.close(); }); 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(); }); test('systeemmanager aanmaken vereist het eigen wachtwoord van de aanmaker ter bevestiging', async () => { const ownHash = await hashPassword('correcthorse1'); const superWithPw = { ...superUser, password_hash: ownHash }; // fout eigen wachtwoord: geweigerd, geen insert const { app, calls } = await makeApp(superWithPw); const res = await app.inject({ method: 'POST', url: '/api/admin/users', cookies, payload: { role: 'super', username: 'nieuwesm', confirmPassword: 'helemaal-fout' } }); assert.equal(res.statusCode, 403, res.body); assert.match(res.json().error, /wachtwoord onjuist/); assert.ok(!calls.some((c) => c.sql.startsWith('INSERT INTO users'))); await app.close(); // geen eigen wachtwoord meegestuurd: ook geweigerd const { app: app2, calls: calls2 } = await makeApp(superWithPw); const res2 = await app2.inject({ method: 'POST', url: '/api/admin/users', cookies, payload: { role: 'super', username: 'nieuwesm' } }); assert.equal(res2.statusCode, 403, res2.body); assert.ok(!calls2.some((c) => c.sql.startsWith('INSERT INTO users'))); await app2.close(); // correct eigen wachtwoord: mag door naar de insert const { app: app3, calls: calls3 } = await makeApp(superWithPw); const res3 = await app3.inject({ method: 'POST', url: '/api/admin/users', cookies, payload: { role: 'super', username: 'nieuwesm', confirmPassword: 'correcthorse1' } }); assert.equal(res3.statusCode, 200, res3.body); assert.ok(calls3.some((c) => c.sql.startsWith('INSERT INTO users'))); await app3.close(); // een schoolbeheerder mag sowieso geen systeemmanager aanmaken (creatableRoles), // ongeacht wat er als confirmPassword meegestuurd wordt const adminHash = await hashPassword('adminwachtwoord1'); const { app: app4, calls: calls4 } = await makeApp({ ...admin, password_hash: adminHash }); const res4 = await app4.inject({ method: 'POST', url: '/api/admin/users', cookies, payload: { role: 'super', username: 'nieuwesm', confirmPassword: 'adminwachtwoord1' } }); assert.equal(res4.statusCode, 403, res4.body); assert.match(res4.json().error, /geen rechten voor deze rol/); assert.ok(!calls4.some((c) => c.sql.startsWith('INSERT INTO users'))); await app4.close(); });