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; const createdUserIds = []; const createdGroupIds = []; const createdRoleIds = []; const createdAppIds = []; 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 createGroup() { const res = await fetch(`${API_BASE}/api/groups`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name: uniqueName("grp_audit") }), }); assert.equal(res.status, 201); const body = await res.json(); createdGroupIds.push(body.id); return body; } async function createRole() { const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name: uniqueName("grp_audit_role") }), }); assert.equal(res.status, 201); const body = await res.json(); createdRoleIds.push(body.id); return body; } async function createUser() { const username = uniqueName("grp_audit_user"); const r = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Member', '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 createApp() { const slug = uniqueName("grp_audit_app"); const r = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, icon_name, color, route) VALUES ($1, 'تطبيق', 'App', 'Grid', '#6366f1', '/x') RETURNING id`, [slug], ); const id = r.rows[0].id; createdAppIds.push(id); return { id, slug }; } before(async () => { adminUsername = uniqueName("grp_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, 'Group 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); const naUsername = uniqueName("grp_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], ); createdUserIds.push(na.rows[0].id); nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD); }); after(async () => { 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 group_roles WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query( `DELETE FROM group_apps WHERE group_id = ANY($1::int[])`, [createdGroupIds], ); await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ createdGroupIds, ]); } if (createdAppIds.length) { await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [ createdAppIds, ]); } if (createdRoleIds.length) { await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ createdRoleIds, ]); } for (const uid of createdUserIds) { await pool.query( `DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = $1`, [uid], ); 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/groups/:id/users/:targetId writes a group.users permission_audit row transactionally", async () => { const grp = await createGroup(); const u = await createUser(); const before = await pool.query( `SELECT COUNT(*)::int AS c FROM permission_audit WHERE target_kind = 'group' AND target_id = $1`, [grp.id], ); assert.equal(before.rows[0].c, 0); const res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await pool.query( `SELECT actor_user_id, change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'group' AND target_id = $1`, [grp.id], ); assert.equal(rows.rowCount, 1); const row = rows.rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.change_kind, "group.users"); assert.deepEqual(row.previous_ids, []); assert.deepEqual(row.new_ids, [u.id]); }); test("Group user-membership changes mirror onto the affected user's history", async () => { const grp = await createGroup(); const u = await createUser(); // Add user via group endpoint → expect group.users AND mirrored user.groups. let res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); let userRows = await pool.query( `SELECT change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`, [u.id], ); assert.equal(userRows.rowCount, 1); assert.equal(userRows.rows[0].change_kind, "user.groups"); assert.deepEqual(userRows.rows[0].previous_ids, []); assert.deepEqual(userRows.rows[0].new_ids, [grp.id]); // Remove via group endpoint → another mirrored user.groups row. res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); userRows = await pool.query( `SELECT change_kind, previous_ids, new_ids FROM permission_audit WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`, [u.id], ); assert.equal(userRows.rowCount, 2); assert.equal(userRows.rows[1].change_kind, "user.groups"); assert.deepEqual(userRows.rows[1].previous_ids, [grp.id]); assert.deepEqual(userRows.rows[1].new_ids, []); }); test("DELETE /api/groups/:id/roles/:targetId records previous/new ids", async () => { const grp = await createGroup(); const r1 = await createRole(); const r2 = await createRole(); // Add two roles → audit rows captured by POST. for (const r of [r1, r2]) { const res = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); await new Promise((r) => setTimeout(r, 5)); } // Remove r1 → expect a delete audit row with prev=[r1,r2], new=[r2]. const del = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r1.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(del.status, 204); 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 DESC LIMIT 1`, [grp.id], ); assert.equal(rows.rowCount, 1); const sortedIds = (a) => [...a].sort((x, y) => x - y); assert.equal(rows.rows[0].change_kind, "group.roles"); assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([r1.id, r2.id])); assert.deepEqual(rows.rows[0].new_ids, [r2.id]); }); test("GET /api/groups/:id/audit returns mixed-kind history with pagination and date filters", async () => { const grp = await createGroup(); const u = await createUser(); const role = await createRole(); const app = await createApp(); // 1: add user let r = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(r.status, 204); await new Promise((r) => setTimeout(r, 10)); // 2: add role r = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${role.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(r.status, 204); await new Promise((r) => setTimeout(r, 10)); // 3: add app r = await fetch(`${API_BASE}/api/groups/${grp.id}/apps/${app.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(r.status, 204); const allRes = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(allRes.status, 200); const all = await allRes.json(); assert.equal(all.totalCount, 3); assert.equal(all.entries.length, 3); // Newest first → app, role, user. assert.equal(all.entries[0].changeKind, "group.apps"); assert.deepEqual(all.entries[0].addedIds, [app.id]); assert.equal(all.entries[1].changeKind, "group.roles"); assert.equal(all.entries[2].changeKind, "group.users"); assert.ok(all.entries[0].actor); assert.equal(all.entries[0].actor.id, adminId); const pageRes = await fetch( `${API_BASE}/api/groups/${grp.id}/audit?limit=2&offset=0`, { headers: { Cookie: adminCookie } }, ); assert.equal(pageRes.status, 200); const page = await pageRes.json(); assert.equal(page.entries.length, 2); assert.equal(page.nextOffset, 2); const today = new Date().toISOString().slice(0, 10); const inRange = await fetch( `${API_BASE}/api/groups/${grp.id}/audit?from=${today}&to=${today}`, { headers: { Cookie: adminCookie } }, ); const inRangeBody = await inRange.json(); assert.equal(inRangeBody.totalCount, 3); const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); const past = await fetch( `${API_BASE}/api/groups/${grp.id}/audit?from=${yesterday}&to=${yesterday}`, { headers: { Cookie: adminCookie } }, ); const pastBody = await past.json(); assert.equal(pastBody.totalCount, 0); }); test("GET /api/groups/:id/audit is admin-only and 404s on unknown group", async () => { const grp = await createGroup(); const res = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, { headers: { Cookie: nonAdminCookie }, }); assert.equal(res.status, 403); const missing = await fetch(`${API_BASE}/api/groups/99999999/audit`, { headers: { Cookie: adminCookie }, }); assert.equal(missing.status, 404); });