Make forced-delete dependency chips clickable to pivot the audit log

Task #134: Admins investigating a forced deletion can now click any
dependency chip on a force_delete row to jump to a pre-filtered audit log
view of the related history.

Backend (artifacts/api-server, lib/api-spec):
- Added targetType + targetId query params to GET /api/admin/audit-logs
  and its CSV export. targetId is validated as a positive integer (400
  on bad input). Codegen regenerated for the React API client.

Frontend (artifacts/tx-os/src/pages/admin.tsx):
- Dependency chips on forced-delete rows are now real <button> elements
  with aria-labels and keyboard focus. Non-deletion rows are unchanged.
- Chip → pivot mapping: groupCount→targetType=group,
  memberCount→targetType=user, appCount→targetType=app,
  roleCount→targetType=role; cascade chips (orderCount, messageCount,
  noteCount, etc.) pivot to the parent (targetType+targetId).
- Active filter is reflected in the URL hash (deep-linkable + reload
  safe) and shown as a dismissible pill in the panel; the pill includes
  a Clear button. Switching audit sub-section drops the filter.
- Section sync logic preserves hash params and uses a one-shot
  skipNextSectionSync ref so initial deep-linked hashes aren't clobbered.
- New i18n keys in en.json and ar.json for filter labels and chip
  aria-labels.

Tests:
- New backend tests in artifacts/api-server/tests/audit-logs-target-filter.test.mjs
  cover targetType narrowing, targetType+targetId narrowing, invalid
  targetId rejection, combination with forcedOnly, and CSV export
  honoring the new filters (7 tests, all passing).
- Verified end-to-end via the browser testing skill: chip click,
  filter pill, clear, deep-link reload all behave correctly.

Pre-existing unrelated failures (not touched): two PDF archive tests in
executive-meetings.test.mjs and the matching typecheck errors in
executive-meetings.ts.

Replit-Task-Id: 46f972ef-2874-4fc3-95c5-53d0ff0732e9
This commit is contained in:
riyadhafraa
2026-04-30 07:25:16 +00:00
parent d72c5cc851
commit 058cb8b817
8 changed files with 560 additions and 20 deletions
@@ -0,0 +1,243 @@
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 insertedAuditIds = [];
const TEST_TAG = `target_filter_test_${STAMP}`;
// A handful of unique parent IDs so we can verify targetId filtering.
const PARENT_SERVICE_ID = 800100001;
const PARENT_SERVICE_ID_OTHER = 800100002;
const PARENT_GROUP_ID = 800200001;
const PARENT_APP_ID = 800300001;
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="));
}
async function insertAuditRow({ action, targetType, targetId, metadata }) {
const r = await pool.query(
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
[
adminId,
action,
targetType,
targetId,
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
return r.rows[0].id;
}
before(async () => {
adminUsername = `audit_target_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 Target Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// History for the parent service that will be force-deleted.
await insertAuditRow({
action: "service.create",
targetType: "service",
targetId: PARENT_SERVICE_ID,
metadata: { nameEn: "ParentSvc" },
});
await insertAuditRow({
action: "service.update",
targetType: "service",
targetId: PARENT_SERVICE_ID,
metadata: { changes: 2 },
});
await insertAuditRow({
action: "service.force_delete",
targetType: "service",
targetId: PARENT_SERVICE_ID,
metadata: { nameEn: "ParentSvc", orderCount: 7, messageCount: 3 },
});
// A different service so we can prove targetId narrows correctly.
await insertAuditRow({
action: "service.create",
targetType: "service",
targetId: PARENT_SERVICE_ID_OTHER,
metadata: { nameEn: "OtherSvc" },
});
// Group and app rows so we can prove targetType-only filtering works.
await insertAuditRow({
action: "group.create",
targetType: "group",
targetId: PARENT_GROUP_ID,
metadata: { name: "Grp" },
});
await insertAuditRow({
action: "group.force_delete",
targetType: "group",
targetId: PARENT_GROUP_ID,
metadata: { name: "Grp", memberCount: 4 },
});
await insertAuditRow({
action: "app.create",
targetType: "app",
targetId: PARENT_APP_ID,
metadata: { nameEn: "AppX" },
});
});
after(async () => {
if (insertedAuditIds.length) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
if (adminId !== undefined) {
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [
adminId,
]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
}
await pool.end();
});
function ownEntries(body) {
return body.entries.filter(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
e.metadata.testTag === TEST_TAG,
);
}
async function listWithParams(qs) {
const res = await fetch(`${API_BASE}/api/admin/audit-logs?${qs}`, {
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 200);
return res.json();
}
test("targetType filter narrows results to that target_type", async () => {
const body = await listWithParams("targetType=group&limit=200");
const ours = ownEntries(body);
assert.ok(ours.length >= 2, "expected at least the seeded group rows");
for (const e of ours) {
assert.equal(e.targetType, "group", `unexpected targetType ${e.targetType}`);
}
});
test("targetType=app excludes service and group rows", async () => {
const body = await listWithParams("targetType=app&limit=200");
const ours = ownEntries(body);
assert.ok(ours.some((e) => e.targetId === PARENT_APP_ID));
assert.ok(!ours.some((e) => e.targetType === "service"));
assert.ok(!ours.some((e) => e.targetType === "group"));
});
test("targetType + targetId returns only that single entity's history", async () => {
const body = await listWithParams(
`targetType=service&targetId=${PARENT_SERVICE_ID}&limit=200`,
);
const ours = ownEntries(body);
// 3 seeded rows for that service: create, update, force_delete.
assert.equal(ours.length, 3);
for (const e of ours) {
assert.equal(e.targetType, "service");
assert.equal(e.targetId, PARENT_SERVICE_ID);
}
const actions = ours.map((e) => e.action).sort();
assert.deepEqual(actions, [
"service.create",
"service.force_delete",
"service.update",
]);
});
test("targetType + targetId excludes other entities of the same type", async () => {
const body = await listWithParams(
`targetType=service&targetId=${PARENT_SERVICE_ID}&limit=200`,
);
const ours = ownEntries(body);
assert.ok(
!ours.some((e) => e.targetId === PARENT_SERVICE_ID_OTHER),
"other service rows must not appear",
);
});
test("invalid targetId returns 400", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?targetType=service&targetId=not-a-number`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error ?? "", /targetId/);
});
test("targetType combines with forcedOnly filter", async () => {
const body = await listWithParams(
"targetType=service&forcedOnly=true&limit=200",
);
const ours = ownEntries(body);
assert.ok(ours.some((e) => e.action === "service.force_delete"));
// Non-force rows must be excluded by forcedOnly.
assert.ok(!ours.some((e) => e.action === "service.create"));
assert.ok(!ours.some((e) => e.action === "service.update"));
});
test("CSV export honors targetType + targetId filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?targetType=service&targetId=${PARENT_SERVICE_ID}`,
{ headers: { Cookie: adminCookie, Accept: "text/csv" } },
);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
const text = await res.text();
const ourLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.ok(ourLines.length >= 3);
for (const line of ourLines) {
assert.match(
line,
new RegExp(`,service,${PARENT_SERVICE_ID},`),
`expected every CSV row to be the targeted service: ${line}`,
);
}
});