Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
5 changed files with 84 additions and 3 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.94-beta
|
0.3.95-beta
|
||||||
|
|
|
||||||
33
db/015_user_fk_cascade.sql
Normal file
33
db/015_user_fk_cascade.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- v0.3.95-beta: een gebruiker moet altijd te verwijderen zijn.
|
||||||
|
--
|
||||||
|
-- Alle foreign keys die naar users(id) verwijzen horen ON DELETE CASCADE te
|
||||||
|
-- zijn (of bewust SET NULL, zoals site_content.updated_by). Databases die zijn
|
||||||
|
-- ontstaan vóór een tabel z'n cascade kreeg houden echter de oude constraint:
|
||||||
|
-- `CREATE TABLE IF NOT EXISTS` werkt een bestaande tabel nooit bij. Op zo'n
|
||||||
|
-- database geeft het verwijderen van een gebruiker een foreign-key-fout (500
|
||||||
|
-- "serverfout"). Deze migratie zoekt elke FK naar users die NIET cascade en
|
||||||
|
-- NIET set-null is, en zet die alsnog op ON DELETE CASCADE - de bewuste
|
||||||
|
-- SET NULL-constraints blijven ongemoeid.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT con.conname AS name,
|
||||||
|
con.conrelid::regclass::text AS tbl,
|
||||||
|
att.attname AS col
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_attribute att
|
||||||
|
ON att.attrelid = con.conrelid AND att.attnum = con.conkey[1]
|
||||||
|
WHERE con.contype = 'f'
|
||||||
|
AND con.confrelid = 'users'::regclass
|
||||||
|
AND array_length(con.conkey, 1) = 1 -- alle user-FK's zijn 1 kolom
|
||||||
|
AND con.confdeltype NOT IN ('c', 'n') -- niet cascade, niet set null
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.name);
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES users(id) ON DELETE CASCADE',
|
||||||
|
r.tbl, r.name, r.col);
|
||||||
|
RAISE NOTICE 'FK % op %(%): ON DELETE CASCADE hersteld', r.name, r.tbl, r.col;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
||||||
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
||||||
const VERSION = "0.3.94-beta";
|
const VERSION = "0.3.95-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
|
||||||
|
|
@ -662,7 +662,15 @@ export default async function api(app) {
|
||||||
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
|
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
|
||||||
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
|
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
|
||||||
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
|
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
|
||||||
|
try {
|
||||||
await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
|
await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
|
||||||
|
} catch (e) {
|
||||||
|
// 23503 = foreign_key_violation: een gekoppelde tabel is (nog) niet
|
||||||
|
// ON DELETE CASCADE. Migratie 015 herstelt dat; tot die tijd een
|
||||||
|
// begrijpelijke melding i.p.v. een kale "serverfout".
|
||||||
|
if (e.code === '23503') return fail(reply, 409, 'verwijderen mislukt: er hangen nog gekoppelde gegevens aan dit account');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Fastify from 'fastify';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import api from '../src/api.js';
|
import api from '../src/api.js';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
function makeApp(user, targetUser) {
|
function makeApp(user, targetUser) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|
@ -193,6 +194,45 @@ test('systeemmanager verplaatst een klas naar een andere school; losmaken kan ni
|
||||||
await app.close(); await app2.close(); await app3.close();
|
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 () => {
|
test('hernoemen weigert een lege of te lange naam', async () => {
|
||||||
const { app } = await makeApp(superUser);
|
const { app } = await makeApp(superUser);
|
||||||
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue