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 body = await get.json(); const { entries } = body; assert.equal(entries.length, 2); assert.equal(body.totalCount, 2); assert.equal(body.offset, 0); assert.equal(body.nextOffset, null); // 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 body = await get.json(); assert.ok(Array.isArray(body.entries)); assert.equal(body.entries.length, 2); assert.equal(body.totalCount, 2); assert.equal(body.offset, 0); assert.equal(body.limit, 10); assert.equal(body.nextOffset, null); const [latest, earlier] = body.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 body = await limited.json(); assert.equal(body.entries.length, 2); assert.equal(body.totalCount, 4); assert.equal(body.limit, 2); assert.equal(body.offset, 0); // Two more rows beyond the page → nextOffset should point at row 2. assert.equal(body.nextOffset, 2); }); test("GET /api/roles/:id/audit paginates with offset and stops at the end", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); // 4 distinct permission sets → 4 audit rows, deterministic ordering thanks // to the small inter-write delay. 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 page1Res = await fetch( `${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=0`, { headers: { Cookie: adminCookie } }, ); assert.equal(page1Res.status, 200); const page1 = await page1Res.json(); assert.equal(page1.entries.length, 2); assert.equal(page1.totalCount, 4); assert.equal(page1.nextOffset, 2); const page2Res = await fetch( `${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=2`, { headers: { Cookie: adminCookie } }, ); assert.equal(page2Res.status, 200); const page2 = await page2Res.json(); assert.equal(page2.entries.length, 2); assert.equal(page2.totalCount, 4); // Reached the end of the table — there is nothing more to page through. assert.equal(page2.nextOffset, null); // Pages must not overlap. const page1Ids = new Set(page1.entries.map((e) => e.id)); for (const e of page2.entries) { assert.equal(page1Ids.has(e.id), false, "page1/page2 overlap"); } }); test("GET /api/roles/:id/audit filters by actorUserId", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); // Create a SECOND admin so we can attribute audit rows to two different // actors and then verify that filtering returns only one of them. const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const otherUsername = `role_audit_admin_other_${stamp}`; const otherRow = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Role Audit Admin Other', 'en', true) RETURNING id`, [otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH], ); const otherId = otherRow.rows[0].id; createdUserIds.push(otherId); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [otherId], ); const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD); // Two writes by adminCookie + one by otherCookie. for (const [cookie, set] of [ [adminCookie, [p1]], [otherCookie, [p1, p2]], [adminCookie, [p2]], ]) { const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ permissionIds: set }), }); assert.equal(r.status, 200); await new Promise((r) => setTimeout(r, 5)); } const filteredRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit?actorUserId=${otherId}`, { headers: { Cookie: adminCookie } }, ); assert.equal(filteredRes.status, 200); const filtered = await filteredRes.json(); assert.equal(filtered.totalCount, 1); assert.equal(filtered.entries.length, 1); assert.equal(filtered.entries[0].actor.id, otherId); // actorUserId=0 means "no filter" — should match all 3 rows. const allRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit?actorUserId=0`, { headers: { Cookie: adminCookie } }, ); assert.equal(allRes.status, 200); const all = await allRes.json(); assert.equal(all.totalCount, 3); }); test("GET /api/roles/:id/audit filters by date range", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); // Make a couple of writes "today" (UTC). for (const set of [[p1], [p2]]) { 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, 5)); } const today = new Date().toISOString().slice(0, 10); const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); // Today is in range → both rows should match. const inRangeRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit?from=${today}&to=${today}`, { headers: { Cookie: adminCookie } }, ); assert.equal(inRangeRes.status, 200); const inRange = await inRangeRes.json(); assert.equal(inRange.totalCount, 2); // Yesterday-only window → no rows. const pastRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit?from=${yesterday}&to=${yesterday}`, { headers: { Cookie: adminCookie } }, ); assert.equal(pastRes.status, 200); const past = await pastRes.json(); assert.equal(past.totalCount, 0); assert.equal(past.entries.length, 0); // Inverted range → 400. const badRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit?from=${tomorrow}&to=${yesterday}`, { headers: { Cookie: adminCookie } }, ); assert.equal(badRes.status, 400); // Garbage date → 400. const garbageRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit?from=not-a-date`, { headers: { Cookie: adminCookie } }, ); assert.equal(garbageRes.status, 400); // Impossible calendar days are rejected too — without strict validation // these would silently roll forward into a different real date. for (const bad of ["2024-02-31", "2025-13-01", "2025-00-15", "2025-02-30"]) { const r = await fetch( `${API_BASE}/api/roles/${role.id}/audit?from=${bad}`, { headers: { Cookie: adminCookie } }, ); assert.equal(r.status, 400, `expected 400 for from=${bad}`); } }); // --- GET /api/roles/:id/audit.csv ------------------------------------------- // CSV export endpoint added for #205. The download is admin-only, returns // `text/csv` with an attachment Content-Disposition, prepends a UTF-8 BOM // for spreadsheet apps, and resolves permission IDs to human-readable // names so the file is useful in Excel without a separate join. // Parse a single CSV line into fields, honouring RFC 4180 double-quote // escaping. Tests build comma-separated rows by hand on the server side // so we need a small parser that knows about quoted fields. Returns null // inputs as undefined. function parseCsvLine(line) { const fields = []; let cur = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = false; } } else { cur += ch; } } else if (ch === ",") { fields.push(cur); cur = ""; } else if (ch === '"') { inQuotes = true; } else { cur += ch; } } fields.push(cur); return fields; } test("GET /api/roles/:id/audit.csv exports CSV with headers and resolved permission names", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); // Look up the permission names so the assertions below don't depend // on whatever the seeded data calls them. const permRows = await pool.query( `SELECT id, name FROM permissions WHERE id = ANY($1::int[])`, [[p1, p2]], ); const nameById = new Map(permRows.rows.map((r) => [r.id, r.name])); // Two writes: first adds p1, second swaps p1 → p2 (so the second // row should have one added and one removed permission). for (const set of [[p1], [p2]]) { 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, 5)); } const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, { headers: { Cookie: adminCookie }, }); assert.equal(res.status, 200); const ctype = res.headers.get("content-type") ?? ""; assert.match(ctype, /^text\/csv/i, `unexpected Content-Type: ${ctype}`); const dispo = res.headers.get("content-disposition") ?? ""; assert.match(dispo, /attachment;\s*filename="role-/i); assert.match(dispo, /\.csv"?$/i); // Read raw bytes so we can verify the UTF-8 BOM is present — the // standard `Response.text()` decoder strips a leading BOM, so we'd // otherwise miss a regression where the server forgot to emit it. // The BOM is required for Excel to render Arabic permission names // correctly when an admin opens the file. const buf = new Uint8Array(await res.arrayBuffer()); assert.deepEqual( [buf[0], buf[1], buf[2]], [0xef, 0xbb, 0xbf], "missing UTF-8 BOM", ); const body = new TextDecoder("utf-8").decode(buf.subarray(3)); const lines = body.split(/\r\n|\n/).filter((l) => l.length > 0); assert.deepEqual(parseCsvLine(lines[0]), [ "timestamp", "actor_username", "added_permissions", "removed_permissions", ]); // Two writes → two data rows. assert.equal(lines.length - 1, 2, `expected 2 data rows, got ${lines.length - 1}`); // Newest first: row index 1 is the p1→p2 swap, row index 2 is the // initial add of p1. const swap = parseCsvLine(lines[1]); const initial = parseCsvLine(lines[2]); assert.equal(swap[1], adminUsername); assert.equal(swap[2], nameById.get(p2)); assert.equal(swap[3], nameById.get(p1)); assert.equal(initial[1], adminUsername); assert.equal(initial[2], nameById.get(p1)); assert.equal(initial[3], ""); }); test("GET /api/roles/:id/audit.csv honours actorUserId and date filters", async () => { const role = await createRole(); const [p1, p2] = await getTwoPermissionIds(); // Spin up a second admin actor so we can prove the actor filter // narrows the export to one row out of three. const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const otherUsername = `role_audit_csv_other_${stamp}`; const otherRow = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Role Audit CSV Other', 'en', true) RETURNING id`, [otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH], ); const otherId = otherRow.rows[0].id; createdUserIds.push(otherId); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [otherId], ); const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD); for (const [cookie, set] of [ [adminCookie, [p1]], [otherCookie, [p1, p2]], [adminCookie, [p2]], ]) { const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ permissionIds: set }), }); assert.equal(r.status, 200); await new Promise((r) => setTimeout(r, 5)); } // actorUserId narrows export to one row. const filteredRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit.csv?actorUserId=${otherId}`, { headers: { Cookie: adminCookie } }, ); assert.equal(filteredRes.status, 200); const filtered = (await filteredRes.text()).slice(1); // strip BOM const filteredLines = filtered.split(/\r\n|\n/).filter((l) => l.length > 0); assert.equal(filteredLines.length, 2, "header + 1 data row expected"); assert.equal(parseCsvLine(filteredLines[1])[1], otherUsername); // Yesterday-only window → no data rows but still returns a header. const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); const pastRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit.csv?from=${yesterday}&to=${yesterday}`, { headers: { Cookie: adminCookie } }, ); assert.equal(pastRes.status, 200); const past = (await pastRes.text()).slice(1); const pastLines = past.split(/\r\n|\n/).filter((l) => l.length > 0); assert.equal(pastLines.length, 1, "header only when no rows match"); // Bad filter → 400, same as the JSON endpoint. const badRes = await fetch( `${API_BASE}/api/roles/${role.id}/audit.csv?from=not-a-date`, { headers: { Cookie: adminCookie } }, ); assert.equal(badRes.status, 400); }); test("GET /api/roles/:id/audit.csv returns 404 for unknown role", async () => { const res = await fetch(`${API_BASE}/api/roles/999999999/audit.csv`, { headers: { Cookie: adminCookie }, }); assert.equal(res.status, 404); }); test("GET /api/roles/:id/audit.csv requires admin", async () => { const role = await createRole(); // Brand-new user with no admin role assignment. They should be // blocked at the requireAdmin middleware just like the JSON endpoint. const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const username = `role_audit_csv_nonadmin_${stamp}`; const row = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'CSV Non-Admin', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); createdUserIds.push(row.rows[0].id); const cookie = await loginAndGetCookie(username, TEST_PASSWORD); const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, { headers: { Cookie: cookie }, }); // requireAdmin returns 403 for authenticated-but-not-admin users. assert.equal(res.status, 403); });