import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); let adminId; let adminUsername; let adminCookie; const createdRoleIds = []; const createdUserIds = []; async function loginAndGetCookie(username, password) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200, `login expected 200, got ${res.status}`); const setCookie = res.headers.get("set-cookie"); return setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")); } before(async () => { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; adminUsername = `role_audit_admin_${stamp}`; const a = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Role Audit Admin', 'en', true) RETURNING id`, [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], ); adminId = a.rows[0].id; createdUserIds.push(adminId); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [adminId], ); await pool.query( `INSERT INTO user_groups (user_id, group_id) SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins') ON CONFLICT DO NOTHING`, [adminId], ); adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); }); after(async () => { if (createdRoleIds.length) { await pool.query( `DELETE FROM role_permission_audit WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query( `DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ createdRoleIds, ]); } for (const uid of createdUserIds) { if (uid !== undefined) { await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]); await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]); await pool.query(`DELETE FROM users WHERE id = $1`, [uid]); } } await pool.end(); }); function uniqueRoleName(prefix) { const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; return `${prefix}_${stamp}`; } async function createRole() { const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name: uniqueRoleName("role_audit") }), }); assert.equal(res.status, 201); const body = await res.json(); createdRoleIds.push(body.id); return body; } async function getTwoPermissionIds() { const rows = await pool.query( `SELECT id FROM permissions ORDER BY id LIMIT 2`, ); assert.ok(rows.rowCount >= 2, "need at least 2 seeded permissions"); return [rows.rows[0].id, rows.rows[1].id]; } test("PUT /api/roles/:id/permissions writes a role_permission_audit row", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); const before = await pool.query( `SELECT COUNT(*)::int AS c FROM role_permission_audit WHERE role_id = $1`, [role.id], ); assert.equal(before.rows[0].c, 0); const put = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: [p1, p2] }), }); assert.equal(put.status, 200); const dbRows = await pool.query( `SELECT actor_user_id, role_id, previous_permission_ids, new_permission_ids FROM role_permission_audit WHERE role_id = $1`, [role.id], ); assert.equal(dbRows.rowCount, 1); const row = dbRows.rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.role_id, role.id); assert.deepEqual(row.previous_permission_ids, []); assert.deepEqual( [...row.new_permission_ids].sort((a, b) => a - b), [p1, p2].sort((a, b) => a - b), ); }); test("PUT /api/roles/:id/permissions writes an audit row on every call (including no-op replaces)", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); const first = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: [p1, p2] }), }); assert.equal(first.status, 200); // Re-submit the same set in a different order — the resulting permission // set is identical, but the spec requires recording every PUT call. const second = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: [p2, p1] }), }); assert.equal(second.status, 200); const dbRows = await pool.query( `SELECT previous_permission_ids, new_permission_ids FROM role_permission_audit WHERE role_id = $1 ORDER BY id`, [role.id], ); assert.equal( dbRows.rowCount, 2, "every PUT call must append an audit row, including no-op saves", ); const sorted = [p1, p2].sort((a, b) => a - b); // Second row's previous and new arrays must be identical to the first // row's "new" array, capturing the no-op save. assert.deepEqual([...dbRows.rows[1].previous_permission_ids].sort((a, b) => a - b), sorted); assert.deepEqual([...dbRows.rows[1].new_permission_ids].sort((a, b) => a - b), sorted); }); test("GET /api/roles/:id/audit reports no-op saves with empty added/removed", async () => { const role = await createRole(); const [p1] = await getTwoPermissionIds(); for (const set of [[p1], [p1]]) { const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: set }), }); assert.equal(r.status, 200); await new Promise((r) => setTimeout(r, 10)); } const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(get.status, 200); const entries = await get.json(); assert.equal(entries.length, 2); // Latest entry is the no-op save: same set in/out, empty diffs. assert.deepEqual(entries[0].previousPermissionIds, [p1]); assert.deepEqual(entries[0].newPermissionIds, [p1]); assert.deepEqual(entries[0].addedPermissionIds, []); assert.deepEqual(entries[0].removedPermissionIds, []); }); test("GET /api/roles/:id/audit returns entries newest first with diff fields and actor", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); // First change: add p1 const r1 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: [p1] }), }); assert.equal(r1.status, 200); // Ensure the second change has a strictly later created_at so ordering is // deterministic regardless of clock resolution. await new Promise((r) => setTimeout(r, 25)); // Second change: swap p1 -> p2 const r2 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: [p2] }), }); assert.equal(r2.status, 200); const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(get.status, 200); const entries = await get.json(); assert.ok(Array.isArray(entries)); assert.equal(entries.length, 2); const [latest, earlier] = entries; assert.deepEqual(latest.previousPermissionIds, [p1]); assert.deepEqual(latest.newPermissionIds, [p2]); assert.deepEqual(latest.addedPermissionIds, [p2]); assert.deepEqual(latest.removedPermissionIds, [p1]); assert.ok(latest.actor); assert.equal(latest.actor.id, adminId); assert.equal(latest.actor.username, adminUsername); assert.deepEqual(earlier.previousPermissionIds, []); assert.deepEqual(earlier.newPermissionIds, [p1]); assert.deepEqual(earlier.addedPermissionIds, [p1]); assert.deepEqual(earlier.removedPermissionIds, []); }); test("GET /api/roles/:id/audit returns 404 for an unknown role", async () => { const res = await fetch(`${API_BASE}/api/roles/99999999/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(res.status, 404); }); test("GET /api/roles/:id/audit honours the limit parameter", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); const sets = [[p1], [p2], [p1, p2], []]; for (const s of sets) { const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: s }), }); assert.equal(r.status, 200); await new Promise((r) => setTimeout(r, 5)); } const limited = await fetch( `${API_BASE}/api/roles/${role.id}/audit?limit=2`, { headers: { Cookie: adminCookie } }, ); assert.equal(limited.status, 200); const entries = await limited.json(); assert.equal(entries.length, 2); });