From d1d6f27cb7b1b453b2345662c9824dfc157de28d Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 19:16:52 +0000 Subject: [PATCH] Add automated tests for the expanded audit log coverage (Task #115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds artifacts/api-server/tests/audit-log-coverage.test.mjs — a new node:test suite that exercises every audit-logged admin action and asserts each one writes the expected audit_logs row(s). Coverage (26 tests): - user.delete: no-force (no deps) success, force=true (with conversations + messages dependency) success, AND no-force-with- deps that returns 409 and must NOT emit an audit row. Verifies metadata.force and the presence/absence of the dependency counts. - role.create / role.update / role.delete; plus a no-op PATCH that must NOT emit a role.update row. - group.create with size counts. - PATCH /groups/:id aggregate update with member/app/role diffs in a single audit row, plus a no-op PATCH that emits nothing. - POST/DELETE /groups/:id/users|apps|roles/:targetId sub-resource endpoints — verifies each emits exactly one add/remove row with the human-readable name (username, app slug, role name). Includes an explicit group.user.remove case (added per code review). - group.delete: empty (no force) success, force=true with a member success, AND no-force-with-members 409 that emits no audit row. - app.create / app.update (with from→to changes); a no-op PATCH that emits nothing; app.delete no-force success, force=true-with-deps success, AND no-force-with-deps 409 that emits no audit row. - auth.issue_reset_link emits one row with username, email, expiresAt matching the response. - settings.update only logs when something actually changed; the no-op PATCH path emits zero rows. Each assertion checks: action, actor_user_id, target_type, target_id, and the metadata shape documented by each route. Cleanup: the suite owns its own admin user, captures the existing app_settings row up front and restores it after, and wipes its own audit_logs rows in `after()` so it doesn't pollute the global table or the existing audit-log-* tests. No production code changes. Replit-Task-Id: 6e9fd065-a14b-4aeb-887a-96b7fe6170fe --- .../tests/audit-log-coverage.test.mjs | 1012 +++++++++++++++++ 1 file changed, 1012 insertions(+) create mode 100644 artifacts/api-server/tests/audit-log-coverage.test.mjs diff --git a/artifacts/api-server/tests/audit-log-coverage.test.mjs b/artifacts/api-server/tests/audit-log-coverage.test.mjs new file mode 100644 index 00000000..c62b9620 --- /dev/null +++ b/artifacts/api-server/tests/audit-log-coverage.test.mjs @@ -0,0 +1,1012 @@ +// 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 = []; + +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 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 (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.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.name, 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.slug, app.slug); + 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); +}); + +// ---------- 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", + ); +});