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; let nonAdminCookie; let nonAdminId; const createdUserIds = []; const createdGroupIds = []; const createdRoleIds = []; 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=")); } function uniqueName(prefix) { return `${prefix}_${Date.now().toString(36)}${Math.random() .toString(36) .slice(2, 6)}`; } async function createSubjectUser() { const username = uniqueName("user_audit_subj"); const r = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Subject', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const id = r.rows[0].id; createdUserIds.push(id); return { id, username }; } async function createGroup() { const name = uniqueName("user_audit_grp"); const res = await fetch(`${API_BASE}/api/groups`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name }), }); assert.equal(res.status, 201); const body = await res.json(); createdGroupIds.push(body.id); return body; } async function createRole() { const name = uniqueName("user_audit_role"); const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name }), }); assert.equal(res.status, 201); const body = await res.json(); createdRoleIds.push(body.id); return body; } before(async () => { adminUsername = uniqueName("user_audit_admin"); const a = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'User 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); // A non-admin user used to assert /audit is admin-only. const naUsername = uniqueName("user_audit_nonadmin"); const na = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`, [naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH], ); nonAdminId = na.rows[0].id; createdUserIds.push(nonAdminId); nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD); }); after(async () => { if (createdUserIds.length) { await pool.query( `DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = ANY($1::int[])`, [createdUserIds], ); } if (createdGroupIds.length) { await pool.query( `DELETE FROM permission_audit WHERE target_kind = 'group' AND target_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query( `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ createdGroupIds, ]); } if (createdRoleIds.length) { await pool.query( `DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ createdRoleIds, ]); } for (const uid of createdUserIds) { 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(); }); test("POST /api/users/:id/roles writes a user.roles permission_audit row transactionally", async () => { const subject = await createSubjectUser(); const role = await createRole(); const before = await pool.query( `SELECT COUNT(*)::int AS c FROM permission_audit WHERE target_kind = 'user' AND target_id = $1`, [subject.id], ); assert.equal(before.rows[0].c, 0); const res = await fetch(`${API_BASE}/api/users/${subject.id}/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ roleName: role.name }), }); assert.equal(res.status, 200); const rows = await pool.query( `SELECT actor_user_id, change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'user' AND target_id = $1`, [subject.id], ); assert.equal(rows.rowCount, 1); const row = rows.rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.change_kind, "user.roles"); assert.deepEqual(row.previous_ids, []); assert.deepEqual(row.new_ids, [role.id]); }); test("PATCH /api/users/:id with groupIds writes a user.groups permission_audit row", async () => { const subject = await createSubjectUser(); const g1 = await createGroup(); const g2 = await createGroup(); // First write: empty → [g1, g2] const r1 = await fetch(`${API_BASE}/api/users/${subject.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ groupIds: [g1.id, g2.id] }), }); assert.equal(r1.status, 200); await new Promise((r) => setTimeout(r, 25)); // Second write: [g1, g2] → [g2] const r2 = await fetch(`${API_BASE}/api/users/${subject.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ groupIds: [g2.id] }), }); assert.equal(r2.status, 200); const rows = await pool.query( `SELECT change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`, [subject.id], ); assert.equal(rows.rowCount, 2); for (const row of rows.rows) { assert.equal(row.change_kind, "user.groups"); } const sortedIds = (a) => [...a].sort((x, y) => x - y); assert.deepEqual(rows.rows[0].previous_ids, []); assert.deepEqual(sortedIds(rows.rows[0].new_ids), sortedIds([g1.id, g2.id])); assert.deepEqual(sortedIds(rows.rows[1].previous_ids), sortedIds([g1.id, g2.id])); assert.deepEqual(rows.rows[1].new_ids, [g2.id]); }); test("PATCH /api/users/:id with groupIds mirrors onto each affected group's history", async () => { const subject = await createSubjectUser(); const g1 = await createGroup(); const g2 = await createGroup(); // Add user to [g1, g2] → expect mirrored group.users rows on g1 AND g2. let res = await fetch(`${API_BASE}/api/users/${subject.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ groupIds: [g1.id, g2.id] }), }); assert.equal(res.status, 200); for (const g of [g1, g2]) { const rows = await pool.query( `SELECT change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'group' AND target_id = $1 ORDER BY id`, [g.id], ); assert.equal(rows.rowCount, 1); assert.equal(rows.rows[0].change_kind, "group.users"); assert.deepEqual(rows.rows[0].previous_ids, []); assert.deepEqual(rows.rows[0].new_ids, [subject.id]); } // Now drop g1 → expect a mirrored group.users row only on g1. res = await fetch(`${API_BASE}/api/users/${subject.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ groupIds: [g2.id] }), }); assert.equal(res.status, 200); const g1Rows = await pool.query( `SELECT change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'group' AND target_id = $1 ORDER BY id`, [g1.id], ); assert.equal(g1Rows.rowCount, 2); assert.deepEqual(g1Rows.rows[1].previous_ids, [subject.id]); assert.deepEqual(g1Rows.rows[1].new_ids, []); const g2Rows = await pool.query( `SELECT COUNT(*)::int AS c FROM permission_audit WHERE target_kind = 'group' AND target_id = $1`, [g2.id], ); assert.equal(g2Rows.rows[0].c, 1); }); test("GET /api/users/:id/audit returns entries newest first with diff fields, actor and pagination", async () => { const subject = await createSubjectUser(); const g1 = await createGroup(); const g2 = await createGroup(); const g3 = await createGroup(); const role = await createRole(); const sets = [[g1.id], [g1.id, g2.id], [g3.id]]; for (const s of sets) { const r = await fetch(`${API_BASE}/api/users/${subject.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ groupIds: s }), }); assert.equal(r.status, 200); await new Promise((r) => setTimeout(r, 10)); } // One role mutation too — to exercise filtering by changeKind via the // cross-kind history. const ar = await fetch(`${API_BASE}/api/users/${subject.id}/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ roleName: role.name }), }); assert.equal(ar.status, 200); const allRes = await fetch(`${API_BASE}/api/users/${subject.id}/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(allRes.status, 200); const all = await allRes.json(); assert.equal(all.totalCount, 4); assert.equal(all.entries.length, 4); // Latest entry is the role addition. const latest = all.entries[0]; assert.equal(latest.changeKind, "user.roles"); assert.deepEqual(latest.addedIds, [role.id]); assert.deepEqual(latest.removedIds, []); assert.ok(latest.actor); assert.equal(latest.actor.id, adminId); assert.equal(latest.actor.username, adminUsername); // Pagination: limit=2 should yield nextOffset=2. const pageRes = await fetch( `${API_BASE}/api/users/${subject.id}/audit?limit=2`, { headers: { Cookie: adminCookie } }, ); assert.equal(pageRes.status, 200); const page = await pageRes.json(); assert.equal(page.entries.length, 2); assert.equal(page.totalCount, 4); assert.equal(page.nextOffset, 2); // Date range: today must include all 4. const today = new Date().toISOString().slice(0, 10); const rangeRes = await fetch( `${API_BASE}/api/users/${subject.id}/audit?from=${today}&to=${today}`, { headers: { Cookie: adminCookie } }, ); assert.equal(rangeRes.status, 200); const range = await rangeRes.json(); assert.equal(range.totalCount, 4); // Inverted range → 400. const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10); const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); const badRes = await fetch( `${API_BASE}/api/users/${subject.id}/audit?from=${tomorrow}&to=${yesterday}`, { headers: { Cookie: adminCookie } }, ); assert.equal(badRes.status, 400); }); test("GET /api/users/:id/audit is admin-only and 404s on unknown user", async () => { const subject = await createSubjectUser(); const res = await fetch(`${API_BASE}/api/users/${subject.id}/audit`, { headers: { Cookie: nonAdminCookie }, }); assert.equal(res.status, 403); const missing = await fetch(`${API_BASE}/api/users/99999999/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(missing.status, 404); });