// Verifies that every sensitive admin action that the API claims to audit // actually writes the expected `audit_logs` row. Each test exercises a // single endpoint, then queries the table and asserts: // - exactly one matching row exists // - action, actor_user_id, target_type, target_id are correct // - the metadata blob has the documented shape // // The suite owns its own admin user, so `actor_user_id = adminId` is enough // to tell our rows apart from other rows produced by parallel suites. 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 }); const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; let adminId; let adminUsername; let adminCookie; const createdUserIds = []; const createdGroupIds = []; const createdRoleIds = []; const createdAppIds = []; const createdServiceIds = []; let originalSettings = null; 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}_${STAMP}_${Math.random().toString(36).slice(2, 8)}`; } async function insertUser(prefix) { const username = uniqueName(prefix).toLowerCase(); const r = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, 'Audit Coverage', 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH], ); const id = r.rows[0].id; createdUserIds.push(id); // Always membership in Everyone so the seed-default trigger / FK behavior matches prod. await pool.query( `INSERT INTO user_groups (user_id, group_id) SELECT $1, id FROM groups WHERE name = 'Everyone' ON CONFLICT DO NOTHING`, [id], ); return { id, username }; } async function insertApp(slugPrefix) { const slug = uniqueName(slugPrefix).toLowerCase(); const r = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, route, sort_order) VALUES ($1, 'تطبيق تدقيق', 'Audit App', '/x', 999) RETURNING id`, [slug], ); const id = r.rows[0].id; createdAppIds.push(id); return { id, slug }; } async function insertGroup(prefix) { const name = uniqueName(prefix); const r = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, 'audit coverage group') RETURNING id`, [name], ); const id = r.rows[0].id; createdGroupIds.push(id); return { id, name }; } async function insertService(prefix) { const nameEn = uniqueName(prefix); const nameAr = `${nameEn}_ar`; const r = await pool.query( `INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.00, true) RETURNING id`, [nameAr, nameEn], ); const id = r.rows[0].id; createdServiceIds.push(id); return { id, nameEn, nameAr }; } async function insertRole(prefix) { const name = uniqueName(prefix).toLowerCase(); const r = await pool.query( `INSERT INTO roles (name, description_en) VALUES ($1, 'audit coverage role') RETURNING id`, [name], ); const id = r.rows[0].id; createdRoleIds.push(id); return { id, name }; } async function fetchAuditRows({ action, targetId, targetType }) { const rows = await pool.query( `SELECT id, actor_user_id, action, target_type, target_id, metadata, created_at FROM audit_logs WHERE actor_user_id = $1 AND action = $2 AND target_id = $3 AND target_type = $4 ORDER BY id`, [adminId, action, targetId, targetType], ); return rows.rows; } before(async () => { adminUsername = `audit_cov_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, 'Audit Coverage 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], ); // Capture the current settings row so we can restore exactly what was // there before this suite mutated it (settings.update is global state, // shared with the rest of the app). const s = await pool.query(`SELECT * FROM app_settings WHERE id = 1`); if (s.rowCount === 1) { originalSettings = s.rows[0]; } adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); }); after(async () => { // Wipe audit rows we created so they don't contaminate the global table. await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [ adminId, ]); if (createdRoleIds.length) { await pool.query(`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [ createdRoleIds, ]); await pool.query(`DELETE FROM group_roles 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 role_permission_audit WHERE role_id = ANY($1::int[])`, [createdRoleIds], ); await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ createdRoleIds, ]); } if (createdGroupIds.length) { await pool.query(`DELETE FROM user_groups 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 group_roles WHERE group_id = ANY($1::int[])`, [ createdGroupIds, ]); await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ createdGroupIds, ]); } if (createdServiceIds.length) { await pool.query( `DELETE FROM service_orders WHERE service_id = ANY($1::int[])`, [createdServiceIds], ); await pool.query(`DELETE FROM services WHERE id = ANY($1::int[])`, [ createdServiceIds, ]); } if (createdAppIds.length) { await pool.query(`DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [ createdAppIds, ]); await pool.query( `DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query(`DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [ createdAppIds, ]); await pool.query( `DELETE FROM user_app_orders WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [ createdAppIds, ]); } for (const uid of createdUserIds) { if (uid !== undefined) { await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [ uid, ]); // Drop messages and conversations the user created/sent — the 409 // user-delete test deliberately seeds these and the FK to users // is RESTRICT, so they must go before the user row can be removed. await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]); await pool.query( `DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE created_by = $1)`, [uid], ); await pool.query(`DELETE FROM conversations WHERE created_by = $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]); } } // Restore settings exactly as captured (clears any change rows we wrote). if (originalSettings) { await pool.query( `UPDATE app_settings SET site_name_ar = $1, site_name_en = $2, registration_open = $3, footer_text_ar = $4, footer_text_en = $5 WHERE id = 1`, [ originalSettings.site_name_ar, originalSettings.site_name_en, originalSettings.registration_open, originalSettings.footer_text_ar, originalSettings.footer_text_en, ], ); } await pool.end(); }); // ---------- users ---------- test("DELETE /api/users/:id (no-force, no deps) writes one user.delete row with force=false", async () => { const { id, username } = await insertUser("u_nf"); const email = `${username}@example.com`; const res = await fetch(`${API_BASE}/api/users/${id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "user.delete", targetId: id, targetType: "user", }); assert.equal(rows.length, 1, "expected exactly one user.delete audit row"); const row = rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.metadata.username, username); assert.equal(row.metadata.email, email); assert.equal(row.metadata.displayNameEn, "Audit Coverage"); assert.equal(row.metadata.displayNameAr, null); assert.equal(row.metadata.force, false); // No-deps path must NOT include the dependency counts. assert.equal(row.metadata.noteCount, undefined); assert.equal(row.metadata.orderCount, undefined); assert.equal(row.metadata.conversationCount, undefined); assert.equal(row.metadata.messageCount, undefined); }); test("DELETE /api/users/:id without force on a user with deps returns 409 and writes NO audit row", async () => { const { id } = await insertUser("u_409"); // Give the user a dependency that blocks delete. const conv = await pool.query( `INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`, [id], ); await pool.query( `INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`, [conv.rows[0].id, id], ); const res = await fetch(`${API_BASE}/api/users/${id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [ id, ]); assert.equal(stillThere.rowCount, 1, "user must still exist after 409"); const rows = await fetchAuditRows({ action: "user.delete", targetId: id, targetType: "user", }); assert.equal( rows.length, 0, "denied delete (409) must NOT emit a user.delete audit row", ); }); test("DELETE /api/users/:id?force=true writes one user.delete row with force=true and dep counts", async () => { const { id, username } = await insertUser("u_f"); // Add a dependency so the force branch is taken meaningfully. const conv = await pool.query( `INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`, [id], ); await pool.query( `INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`, [conv.rows[0].id, id], ); const res = await fetch(`${API_BASE}/api/users/${id}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "user.delete", targetId: id, targetType: "user", }); assert.equal(rows.length, 1); const row = rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.metadata.username, username); assert.equal(row.metadata.force, true); // Force-with-deps path records the counts. assert.equal(typeof row.metadata.conversationCount, "number"); assert.equal(typeof row.metadata.messageCount, "number"); assert.ok(row.metadata.conversationCount >= 1); assert.ok(row.metadata.messageCount >= 1); }); // ---------- roles ---------- test("POST /api/roles writes one role.create row", async () => { const name = uniqueName("aud_role_c").toLowerCase(); const res = await fetch(`${API_BASE}/api/roles`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name, descriptionEn: "audit role create", descriptionAr: "تدقيق إنشاء الدور", }), }); assert.equal(res.status, 201); const created = await res.json(); createdRoleIds.push(created.id); const rows = await fetchAuditRows({ action: "role.create", targetId: created.id, targetType: "role", }); assert.equal(rows.length, 1); assert.equal(rows[0].actor_user_id, adminId); assert.equal(rows[0].metadata.name, name); assert.equal(rows[0].metadata.descriptionEn, "audit role create"); assert.equal(rows[0].metadata.descriptionAr, "تدقيق إنشاء الدور"); }); test("PATCH /api/roles/:id (rename) writes one role.update row marked renamed", async () => { const role = await insertRole("aud_role_u"); const newName = uniqueName("aud_role_u_new").toLowerCase(); const res = await fetch(`${API_BASE}/api/roles/${role.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name: newName, descriptionEn: "renamed" }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "role.update", targetId: role.id, targetType: "role", }); assert.equal(rows.length, 1); const row = rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.metadata.previousName, role.name); assert.equal(row.metadata.name, newName); assert.equal(row.metadata.renamed, true); assert.equal(row.metadata.renamedFrom, role.name); assert.equal(row.metadata.renamedTo, newName); assert.ok(row.metadata.changes, "metadata.changes must be present"); assert.equal(row.metadata.changes.name, newName); assert.equal(row.metadata.changes.descriptionEn, "renamed"); }); test("PATCH /api/roles/:id with no effective changes does NOT write an audit row", async () => { const role = await insertRole("aud_role_noop"); const res = await fetch(`${API_BASE}/api/roles/${role.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name: role.name }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "role.update", targetId: role.id, targetType: "role", }); assert.equal(rows.length, 0, "no-op patch must not emit role.update"); }); test("DELETE /api/roles/:id (no users/groups) writes one role.delete row", async () => { const role = await insertRole("aud_role_d"); const res = await fetch(`${API_BASE}/api/roles/${role.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "role.delete", targetId: role.id, targetType: "role", }); assert.equal(rows.length, 1); assert.equal(rows[0].actor_user_id, adminId); assert.equal(rows[0].metadata.roleName, role.name); }); // ---------- groups ---------- test("POST /api/groups writes one group.create row with size counts", async () => { const name = uniqueName("aud_grp_c"); const member = await insertUser("aud_grp_member"); const res = await fetch(`${API_BASE}/api/groups`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ name, descriptionEn: "audit group create", userIds: [member.id], appIds: [], roleIds: [], }), }); assert.equal(res.status, 201); const created = await res.json(); createdGroupIds.push(created.id); const rows = await fetchAuditRows({ action: "group.create", targetId: created.id, targetType: "group", }); assert.equal(rows.length, 1); assert.equal(rows[0].actor_user_id, adminId); assert.equal(rows[0].metadata.name, name); assert.equal(rows[0].metadata.memberCount, 1); assert.equal(rows[0].metadata.appCount, 0); assert.equal(rows[0].metadata.roleCount, 0); }); test("PATCH /api/groups/:id (members + apps + roles) writes one aggregated group.update row with diffs", async () => { const group = await insertGroup("aud_grp_u"); const userOld = await insertUser("aud_grp_uOld"); const userNew = await insertUser("aud_grp_uNew"); const appOld = await insertApp("aud_grp_aOld"); const appNew = await insertApp("aud_grp_aNew"); const roleOld = await insertRole("aud_grp_rOld"); const roleNew = await insertRole("aud_grp_rNew"); // Seed the "previous" associations directly so we can predict the diff. await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [userOld.id, group.id], ); await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [group.id, appOld.id], ); await pool.query( `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, [group.id, roleOld.id], ); const res = await fetch(`${API_BASE}/api/groups/${group.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ userIds: [userNew.id], appIds: [appNew.id], roleIds: [roleNew.id], }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "group.update", targetId: group.id, targetType: "group", }); assert.equal(rows.length, 1, "aggregate PATCH must emit a single group.update row"); const meta = rows[0].metadata; assert.equal(meta.name, group.name); // Each diff is { added: number[], removed: number[] } assert.deepEqual(meta.members.added, [userNew.id]); assert.deepEqual(meta.members.removed, [userOld.id]); assert.deepEqual(meta.apps.added, [appNew.id]); assert.deepEqual(meta.apps.removed, [appOld.id]); assert.deepEqual(meta.roles.added, [roleNew.id]); assert.deepEqual(meta.roles.removed, [roleOld.id]); }); test("PATCH /api/groups/:id with no diffs does NOT write group.update", async () => { const group = await insertGroup("aud_grp_noop"); const res = await fetch(`${API_BASE}/api/groups/${group.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({}), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "group.update", targetId: group.id, targetType: "group", }); assert.equal(rows.length, 0); }); test("POST /api/groups/:id/users/:targetId writes one group.user.add row with username", async () => { const group = await insertGroup("aud_sub_uadd"); const user = await insertUser("aud_sub_uadd_user"); const res = await fetch( `${API_BASE}/api/groups/${group.id}/users/${user.id}`, { method: "POST", headers: { Cookie: adminCookie } }, ); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "group.user.add", targetId: group.id, targetType: "group", }); assert.equal(rows.length, 1); assert.equal(rows[0].metadata.groupName, group.name); assert.equal(rows[0].metadata.userId, user.id); assert.equal(rows[0].metadata.username, user.username); }); test("POST then DELETE /api/groups/:id/users/:targetId writes one add row and one group.user.remove row", async () => { const group = await insertGroup("aud_sub_uremove"); const user = await insertUser("aud_sub_uremove_user"); const add = await fetch( `${API_BASE}/api/groups/${group.id}/users/${user.id}`, { method: "POST", headers: { Cookie: adminCookie } }, ); assert.equal(add.status, 204); const addRows = await fetchAuditRows({ action: "group.user.add", targetId: group.id, targetType: "group", }); assert.equal(addRows.length, 1); const rm = await fetch( `${API_BASE}/api/groups/${group.id}/users/${user.id}`, { method: "DELETE", headers: { Cookie: adminCookie } }, ); assert.equal(rm.status, 204); const rmRows = await fetchAuditRows({ action: "group.user.remove", targetId: group.id, targetType: "group", }); assert.equal(rmRows.length, 1); assert.equal(rmRows[0].actor_user_id, adminId); assert.equal(rmRows[0].target_type, "group"); assert.equal(rmRows[0].target_id, group.id); assert.equal(rmRows[0].metadata.groupName, group.name); assert.equal(rmRows[0].metadata.userId, user.id); assert.equal(rmRows[0].metadata.username, user.username); }); test("POST then DELETE /api/groups/:id/apps/:targetId writes one add row and one remove row", async () => { const group = await insertGroup("aud_sub_app"); const app = await insertApp("aud_sub_app_app"); const add = await fetch(`${API_BASE}/api/groups/${group.id}/apps/${app.id}`, { method: "POST", headers: { Cookie: adminCookie }, }); assert.equal(add.status, 204); const addRows = await fetchAuditRows({ action: "group.app.add", targetId: group.id, targetType: "group", }); assert.equal(addRows.length, 1); assert.equal(addRows[0].metadata.groupName, group.name); assert.equal(addRows[0].metadata.appId, app.id); assert.equal(addRows[0].metadata.appSlug, app.slug); const rm = await fetch(`${API_BASE}/api/groups/${group.id}/apps/${app.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(rm.status, 204); const rmRows = await fetchAuditRows({ action: "group.app.remove", targetId: group.id, targetType: "group", }); assert.equal(rmRows.length, 1); assert.equal(rmRows[0].metadata.appId, app.id); assert.equal(rmRows[0].metadata.appSlug, app.slug); }); test("POST then DELETE /api/groups/:id/roles/:targetId writes one add row and one remove row", async () => { const group = await insertGroup("aud_sub_role"); const role = await insertRole("aud_sub_role_role"); const add = await fetch( `${API_BASE}/api/groups/${group.id}/roles/${role.id}`, { method: "POST", headers: { Cookie: adminCookie } }, ); assert.equal(add.status, 204); const addRows = await fetchAuditRows({ action: "group.role.add", targetId: group.id, targetType: "group", }); assert.equal(addRows.length, 1); assert.equal(addRows[0].metadata.roleId, role.id); assert.equal(addRows[0].metadata.roleName, role.name); const rm = await fetch( `${API_BASE}/api/groups/${group.id}/roles/${role.id}`, { method: "DELETE", headers: { Cookie: adminCookie } }, ); assert.equal(rm.status, 204); const rmRows = await fetchAuditRows({ action: "group.role.remove", targetId: group.id, targetType: "group", }); assert.equal(rmRows.length, 1); assert.equal(rmRows[0].metadata.roleName, role.name); }); test("DELETE /api/groups/:id (empty group, no force) writes group.delete with force=false and zero counts", async () => { const group = await insertGroup("aud_grp_d_nf"); const res = await fetch(`${API_BASE}/api/groups/${group.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "group.delete", targetId: group.id, targetType: "group", }); assert.equal(rows.length, 1); assert.equal(rows[0].actor_user_id, adminId); assert.equal(rows[0].metadata.name, group.name); assert.equal(rows[0].metadata.force, false); assert.equal(rows[0].metadata.memberCount, 0); assert.equal(rows[0].metadata.appCount, 0); assert.equal(rows[0].metadata.roleCount, 0); }); test("DELETE /api/groups/:id without force on a non-empty group returns 409 and writes NO audit row", async () => { const group = await insertGroup("aud_grp_409"); const member = await insertUser("aud_grp_409_member"); await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [member.id, group.id], ); const res = await fetch(`${API_BASE}/api/groups/${group.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const stillThere = await pool.query(`SELECT id FROM groups WHERE id = $1`, [ group.id, ]); assert.equal(stillThere.rowCount, 1, "group must still exist after 409"); const rows = await fetchAuditRows({ action: "group.delete", targetId: group.id, targetType: "group", }); assert.equal( rows.length, 0, "denied delete (409) must NOT emit a group.delete audit row", ); }); test("DELETE /api/groups/:id?force=true (with members) writes one group.delete with force=true and counts", async () => { const group = await insertGroup("aud_grp_d_f"); const member = await insertUser("aud_grp_d_f_member"); await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [member.id, group.id], ); const res = await fetch(`${API_BASE}/api/groups/${group.id}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "group.delete", targetId: group.id, targetType: "group", }); assert.equal(rows.length, 1); assert.equal(rows[0].metadata.force, true); assert.equal(rows[0].metadata.memberCount, 1); }); // ---------- apps ---------- test("POST /api/apps writes one app.create row", async () => { const slug = uniqueName("aud_app_c").toLowerCase(); const res = await fetch(`${API_BASE}/api/apps`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ slug, nameAr: "تطبيق", nameEn: "Audit Create App", iconName: "Box", route: "/aud-c", color: "#fff", }), }); assert.equal(res.status, 201); const created = await res.json(); createdAppIds.push(created.id); const rows = await fetchAuditRows({ action: "app.create", targetId: created.id, targetType: "app", }); assert.equal(rows.length, 1); assert.equal(rows[0].metadata.slug, slug); assert.equal(rows[0].metadata.nameEn, "Audit Create App"); assert.equal(rows[0].metadata.route, "/aud-c"); }); test("PATCH /api/apps/:id (real change) writes one app.update row with from/to changes", async () => { const app = await insertApp("aud_app_u"); const res = await fetch(`${API_BASE}/api/apps/${app.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ nameEn: "Audit App Renamed" }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "app.update", targetId: app.id, targetType: "app", }); assert.equal(rows.length, 1); assert.equal(rows[0].metadata.slug, app.slug); assert.equal(rows[0].metadata.changes.nameEn.from, "Audit App"); assert.equal(rows[0].metadata.changes.nameEn.to, "Audit App Renamed"); }); test("PATCH /api/apps/:id with same value does NOT write app.update", async () => { const app = await insertApp("aud_app_noop"); const res = await fetch(`${API_BASE}/api/apps/${app.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ nameEn: "Audit App" }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "app.update", targetId: app.id, targetType: "app", }); assert.equal(rows.length, 0); }); test("DELETE /api/apps/:id (no deps) writes app.delete with force=false and zero counts", async () => { const app = await insertApp("aud_app_d_nf"); const res = await fetch(`${API_BASE}/api/apps/${app.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "app.delete", targetId: app.id, targetType: "app", }); assert.equal(rows.length, 1); assert.equal(rows[0].metadata.appSlug, app.slug); assert.equal(rows[0].metadata.appNameEn, "Audit App"); assert.equal(rows[0].metadata.appNameAr, "تطبيق تدقيق"); assert.equal(rows[0].metadata.force, false); assert.equal(rows[0].metadata.groupCount, 0); assert.equal(rows[0].metadata.restrictionCount, 0); assert.equal(rows[0].metadata.openCount, 0); }); test("DELETE /api/apps/:id without force on an app with deps returns 409 and writes NO audit row", async () => { const app = await insertApp("aud_app_409"); const group = await insertGroup("aud_app_409_grp"); await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [group.id, app.id], ); const res = await fetch(`${API_BASE}/api/apps/${app.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const stillThere = await pool.query(`SELECT id FROM apps WHERE id = $1`, [ app.id, ]); assert.equal(stillThere.rowCount, 1, "app must still exist after 409"); const rows = await fetchAuditRows({ action: "app.delete", targetId: app.id, targetType: "app", }); assert.equal( rows.length, 0, "denied delete (409) must NOT emit an app.delete audit row", ); }); test("DELETE /api/apps/:id?force=true (with deps) writes app.delete with force=true and dep counts", async () => { const app = await insertApp("aud_app_d_f"); const group = await insertGroup("aud_app_d_f_grp"); await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [group.id, app.id], ); await pool.query(`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`, [ adminId, app.id, ]); const res = await fetch(`${API_BASE}/api/apps/${app.id}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "app.delete", targetId: app.id, targetType: "app", }); assert.equal(rows.length, 1); assert.equal(rows[0].metadata.force, true); assert.equal(rows[0].metadata.groupCount, 1); assert.equal(rows[0].metadata.openCount, 1); }); // ---------- services ---------- test("DELETE /api/services/:id (no deps, no force) writes one service.delete row with nameEn and nameAr", async () => { // #195: a clean delete (no orders) used to leave no audit trail at all. // It must now write a `service.delete` row carrying both names so the // audit log can render the service name in either locale after deletion. const svc = await insertService("aud_svc_d_nf"); const res = await fetch(`${API_BASE}/api/services/${svc.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "service.delete", targetId: svc.id, targetType: "service", }); assert.equal(rows.length, 1, "expected exactly one service.delete audit row"); const row = rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.metadata.nameEn, svc.nameEn); assert.equal(row.metadata.nameAr, svc.nameAr); // No-deps path must NOT include the dependency counts that // `service.force_delete` carries. assert.equal(row.metadata.orderCount, undefined); // The forced action must not be emitted on the clean path. const forced = await fetchAuditRows({ action: "service.force_delete", targetId: svc.id, targetType: "service", }); assert.equal(forced.length, 0); }); test("DELETE /api/services/:id without force on a service with deps returns 409 and writes NO audit row", async () => { const svc = await insertService("aud_svc_409"); await pool.query( `INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending')`, [adminId, svc.id], ); const res = await fetch(`${API_BASE}/api/services/${svc.id}`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 409); const stillThere = await pool.query( `SELECT id FROM services WHERE id = $1`, [svc.id], ); assert.equal(stillThere.rowCount, 1, "service must still exist after 409"); const deleteRows = await fetchAuditRows({ action: "service.delete", targetId: svc.id, targetType: "service", }); const forceRows = await fetchAuditRows({ action: "service.force_delete", targetId: svc.id, targetType: "service", }); assert.equal( deleteRows.length + forceRows.length, 0, "denied delete (409) must NOT emit any service audit row", ); }); test("DELETE /api/services/:id?force=true (with deps) writes one service.force_delete row with orderCount", async () => { const svc = await insertService("aud_svc_d_f"); await pool.query( `INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending'), ($1, $2, 'completed')`, [adminId, svc.id], ); const res = await fetch(`${API_BASE}/api/services/${svc.id}?force=true`, { method: "DELETE", headers: { Cookie: adminCookie }, }); assert.equal(res.status, 204); const rows = await fetchAuditRows({ action: "service.force_delete", targetId: svc.id, targetType: "service", }); assert.equal(rows.length, 1); const row = rows[0]; assert.equal(row.actor_user_id, adminId); assert.equal(row.metadata.nameEn, svc.nameEn); assert.equal(row.metadata.nameAr, svc.nameAr); assert.equal(row.metadata.orderCount, 2); // Forced path must not also emit the plain `service.delete` row. const plain = await fetchAuditRows({ action: "service.delete", targetId: svc.id, targetType: "service", }); assert.equal(plain.length, 0); }); // ---------- auth: password reset link ---------- test("POST /api/auth/admin/users/:id/issue-reset-link writes one auth.issue_reset_link row", async () => { const target = await insertUser("aud_reset"); const res = await fetch( `${API_BASE}/api/auth/admin/users/${target.id}/issue-reset-link`, { method: "POST", headers: { Cookie: adminCookie } }, ); assert.equal(res.status, 200); const body = await res.json(); assert.ok(body.resetUrl); assert.ok(body.expiresAt); const rows = await fetchAuditRows({ action: "auth.issue_reset_link", targetId: target.id, targetType: "user", }); assert.equal(rows.length, 1); assert.equal(rows[0].actor_user_id, adminId); assert.equal(rows[0].metadata.username, target.username); assert.equal(rows[0].metadata.email, `${target.username}@example.com`); assert.equal(rows[0].metadata.expiresAt, body.expiresAt); }); // ---------- settings ---------- test("PATCH /api/settings with a real change writes one settings.update row", async () => { // Read current value, change it, observe the audit row, then restore. const before = await pool.query( `SELECT site_name_en FROM app_settings WHERE id = 1`, ); const previous = before.rows[0]?.site_name_en ?? "Tx OS"; const next = `Audit ${STAMP}`; const res = await fetch(`${API_BASE}/api/settings`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ siteNameEn: next }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "settings.update", targetId: 1, targetType: "settings", }); assert.equal(rows.length, 1, "exactly one settings.update row for a real change"); assert.equal(rows[0].actor_user_id, adminId); assert.equal(rows[0].metadata.changes.siteNameEn.from, previous); assert.equal(rows[0].metadata.changes.siteNameEn.to, next); // Clean up the audit row produced by this test so the next test starts // from zero settings.update rows for our admin actor. await pool.query( `DELETE FROM audit_logs WHERE actor_user_id = $1 AND action = 'settings.update'`, [adminId], ); // Restore the original value via the API; that emits another audit row, // which we also clean up so a later run is unaffected. const restore = await fetch(`${API_BASE}/api/settings`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ siteNameEn: previous }), }); assert.equal(restore.status, 200); await pool.query( `DELETE FROM audit_logs WHERE actor_user_id = $1 AND action = 'settings.update'`, [adminId], ); }); test("PATCH /api/settings with no effective change does NOT write settings.update", async () => { const before = await pool.query( `SELECT site_name_en FROM app_settings WHERE id = 1`, ); const current = before.rows[0]?.site_name_en ?? "Tx OS"; // PATCHing with the value that's already there must be a no-op. const res = await fetch(`${API_BASE}/api/settings`, { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ siteNameEn: current }), }); assert.equal(res.status, 200); const rows = await fetchAuditRows({ action: "settings.update", targetId: 1, targetType: "settings", }); assert.equal( rows.length, 0, "no-op settings PATCH must not emit a settings.update audit row", ); });