Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
5 changed files with 84 additions and 3 deletions
Showing only changes of commit e3036580ce - Show all commits

View file

@ -1 +1 @@
0.3.94-beta 0.3.95-beta

View 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 $$;

View file

@ -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;

View file

@ -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');
await pool.query('DELETE FROM users WHERE id = $1', [u.id]); try {
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 };
}); });

View file

@ -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,