All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 52s
- PATCH /admin/users/:id ondersteunt schoolId (alleen systeemmanager, rollen teacher/admin/pupil): null = losmaken, anders koppelen met bestaanscheck (404); bij wissel worden klas, klaskoppelingen en (voor leerlingen) oude toewijzingen opgeruimd; naamconflicten in doelschool of school-loze naamruimte geven een nette 409 - Detailpaneel toont voor de systeemmanager een School-veld bij deze rollen - losse accounts (ooit aan een school gekoppeld) zijn hiermee terug te zetten en bewust los te maken; werkt ook in de beheerder-subrijen op het Scholen-tabblad - Gebruikers-tab (alle scholen): losse accounts staan in een eigen inklapbare groep "Zonder school (n)" i.p.v. onopvallend tussen de rolgroepen; de oudergroep is nu ook inklapbaar - 3 nieuwe servertests (koppelen incl. opruiming, losmaken, 404, geen rechten/no-op voor admin en parent, 409 bij naamconflict) + 10 Playwright-checks voor de volledige flow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
184 lines
9.1 KiB
JavaScript
184 lines
9.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';
|
|
|
|
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, 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, 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('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();
|
|
});
|