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'; 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 koppelt een losse klas terug aan een school', async () => { const { app, calls } = await makeAppWithSchool(superUser, null); const origQuery = app.pg.query.bind(app.pg); app.pg.query = async (sql, params = []) => { if (sql.startsWith('SELECT * FROM classes WHERE id')) { calls.push({ sql, params }); return { rows: [{ id: 9, name: 'Groep 4', school_id: null }] }; } return origQuery(sql, params); }; const res = await app.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies, payload: { schoolId: 2 } }); assert.equal(res.statusCode, 200, res.body); const upd = calls.find((c) => c.sql.startsWith('UPDATE classes SET school_id')); assert.deepEqual(upd.params, [2, 9]); // schoolbeheerder mag dit niet (schools.manage = super) const { app: app2, calls: calls2 } = await makeAppWithSchool(admin, null); const res2 = await app2.inject({ method: 'PATCH', url: '/api/admin/classes/9', cookies, payload: { schoolId: 2 } }); assert.equal(res2.statusCode, 403); assert.ok(!calls2.some((c) => c.sql.startsWith('UPDATE classes SET school_id'))); await app.close(); await app2.close(); }); test('losse klassen zijn opvraagbaar met school=none (alleen super)', async () => { const { app, calls } = await makeApp(superUser); const res = await app.inject({ url: '/api/admin/classes?school=none', cookies }); assert.equal(res.statusCode, 200, res.body); const q = calls.find((c) => c.sql.includes('FROM classes c')); assert.ok(q.sql.includes('school_id IS NULL'), 'filtert op klassen zonder school'); 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(); });