Audit log: filter by actor (#197)

Admins can now filter the audit log by who acted, not just by what was
acted on.

User-facing changes
- Replaced the flat actor <select> with an autocomplete combobox
  (Popover + cmdk Command) that searches by username AND display name,
  in English or Arabic.
- Added an `actorId` URL hash param that survives full reload — picks
  up alongside the existing target filter on initial mount and is
  cleared when the admin navigates to a different section.
- Each audit row's actor avatar + name is now clickable, mirroring the
  existing target chip pivot, so admins can jump from "this row" to
  "everything by this person" in one click. Rows with a null actor
  (system / deleted user) render the same avatar/name without the
  click affordance, since the API can only filter by a concrete id.
- Active actor pill renders in sky (target pill remains emerald) and
  has a clear button.
- CSV export already accepted `actorUserId` on the backend; the
  frontend export call now forwards the filter and the OpenAPI
  description has been updated to document it.

Files
- artifacts/tx-os/src/pages/admin.tsx
  - New AuditActorPicker component, hash helpers
    (parseAuditHashActor / syncAuditHashActor), pivotToActor /
    clearActorFilter handlers, sky-colored active pill, clickable
    actor in AuditLogRow.
- artifacts/api-server/tests/audit-logs-actor-filter.test.mjs (new)
  - 6 tests covering: filter narrows results, excludes other actors
    on the same target_type, combines with target filter, invalid
    /zero/negative actorUserId → 400, CSV export honors filter.
- artifacts/tx-os/src/locales/{en,ar}.json
  - actorSearch / actorEmpty / actorWithName / actorWithId /
    clearActorFilter / actorPivotAria.
- lib/api-spec/openapi.yaml
  - Audit export endpoint description now mentions
    targetType / targetId / actorUserId.

Verification
- pnpm --filter @workspace/tx-os run typecheck → clean.
- All 6 new actor-filter tests + all 7 existing target-filter tests
  pass against a live API server.
- E2E run via runTest() succeeded: opened the picker, selected an
  actor, verified the pill + reload-safe hash, cleared, pivoted via
  a row click, and confirmed CSV export honored the filter.

Notes / drift
- URL key is `actorId` (per task spec), but the React state and API
  param remain `actorUserId` to match the existing backend.
- The broader `test` workflow has pre-existing failures in
  executive-meetings.test.mjs and service-orders.test.mjs unrelated
  to this change (admin 401 / HTML-404 fallback). Captured as
  follow-up #291.

Replit-Task-Id: 0f02b232-eda3-46db-8235-98ecce2ebdb7
This commit is contained in:
riyadhafraa
2026-05-01 15:26:36 +00:00
parent ab5ec2e2e2
commit 6876a83bbb
8 changed files with 432 additions and 118 deletions
@@ -1,7 +1,7 @@
// #197: Coverage for the actorUserId filter on GET /admin/audit-logs and the
// CSV export. Two distinct actor rows are seeded, then we assert the filter
// narrows to a single actor and that the export endpoint honours the same
// param. Tagging metadata so we ignore unrelated rows in the same database.
// #197: end-to-end coverage for the audit log's actorUserId filter and its
// CSV-export counterpart. The buildWhere clause already supported the
// filter; these tests pin down the wiring (param parsing, filter
// composition, and CSV export honouring it).
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
@@ -21,12 +21,18 @@ const pool = new pg.Pool({ connectionString: DATABASE_URL });
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const TEST_TAG = `actor_filter_test_${STAMP}`;
let adminId;
let adminCookie;
let actorAId;
let actorBId;
let adminAId;
let adminAUsername;
let adminACookie;
let adminBId;
let adminBUsername;
const insertedAuditIds = [];
const TARGET_ID_A = 800400001;
const TARGET_ID_B = 800400002;
const TARGET_ID_C = 800400003;
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
@@ -41,16 +47,16 @@ async function loginAndGetCookie(username, password) {
.find((c) => c.startsWith("connect.sid="));
}
async function insertAuditRow({ actorUserId, action, targetType, targetId }) {
async function insertAuditRow({ actorId, 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`,
[
actorUserId,
actorId,
action,
targetType,
targetId,
JSON.stringify({ testTag: TEST_TAG }),
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
@@ -58,118 +64,165 @@ async function insertAuditRow({ actorUserId, action, targetType, targetId }) {
}
before(async () => {
const adminUsername = `audit_actor_admin_${STAMP}`;
adminAUsername = `audit_actor_admin_a_${STAMP}`;
adminBUsername = `audit_actor_admin_b_${STAMP}`;
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
VALUES ($1, $2, $3, 'Audit Actor A', 'en', true) RETURNING id`,
[adminAUsername, `${adminAUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
adminAId = a.rows[0].id;
const b = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Audit Actor B', 'en', true) RETURNING id`,
[adminBUsername, `${adminBUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminBId = b.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
[adminAId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// Two distinct actors who will each leave audit rows.
const aa = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor A', 'en', true) RETURNING id`,
[`actor_a_${STAMP}`, `actor_a_${STAMP}@example.com`, TEST_PASSWORD_HASH],
);
actorAId = aa.rows[0].id;
const bb = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor B', 'en', true) RETURNING id`,
[`actor_b_${STAMP}`, `actor_b_${STAMP}@example.com`, TEST_PASSWORD_HASH],
);
actorBId = bb.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminBId],
);
adminACookie = await loginAndGetCookie(adminAUsername, TEST_PASSWORD);
// Two rows by Admin A.
await insertAuditRow({
actorUserId: actorAId,
action: "settings.update",
targetType: "settings",
targetId: 1,
actorId: adminAId,
action: "service.create",
targetType: "service",
targetId: TARGET_ID_A,
metadata: { nameEn: "ASvc1" },
});
await insertAuditRow({
actorUserId: actorAId,
action: "settings.update",
targetType: "settings",
targetId: 1,
actorId: adminAId,
action: "service.update",
targetType: "service",
targetId: TARGET_ID_B,
metadata: { changes: 3 },
});
// One row by Admin B on a third target so we can prove the filter
// narrows by *who acted*, not by what was acted on.
await insertAuditRow({
actorUserId: actorBId,
action: "settings.update",
targetType: "settings",
targetId: 1,
actorId: adminBId,
action: "service.create",
targetType: "service",
targetId: TARGET_ID_C,
metadata: { nameEn: "BSvc" },
});
});
after(async () => {
if (insertedAuditIds.length > 0) {
if (insertedAuditIds.length) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
[adminId, actorAId, actorBId].filter(Boolean),
]);
for (const id of [adminAId, adminBId]) {
if (id !== undefined) {
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [id]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [id]);
await pool.query(`DELETE FROM users WHERE id = $1`, [id]);
}
}
await pool.end();
});
function rowMatchesTag(entry) {
return entry.metadata && entry.metadata.testTag === TEST_TAG;
function ownEntries(body) {
return body.entries.filter(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
e.metadata.testTag === TEST_TAG,
);
}
test("actorUserId filter narrows results to the chosen actor", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorAId}&limit=200`,
{ headers: { Cookie: adminCookie } },
);
async function listWithParams(qs) {
const res = await fetch(`${API_BASE}/api/admin/audit-logs?${qs}`, {
headers: { Cookie: adminACookie },
});
assert.equal(res.status, 200);
const body = await res.json();
const tagged = body.entries.filter(rowMatchesTag);
assert.equal(tagged.length, 2);
for (const entry of tagged) {
assert.equal(entry.actor && entry.actor.id, actorAId);
return res.json();
}
test("actorUserId filter narrows results to that acting user", async () => {
const body = await listWithParams(`actorUserId=${adminAId}&limit=200`);
const ours = ownEntries(body);
assert.equal(ours.length, 2, "expected exactly the two seeded admin-A rows");
for (const e of ours) {
assert.equal(
e.actor?.id,
adminAId,
`unexpected actor ${e.actor?.id ?? "null"}`,
);
}
const targetIds = ours.map((e) => e.targetId).sort();
assert.deepEqual(targetIds, [TARGET_ID_A, TARGET_ID_B]);
});
test("actorUserId filter excludes other actors' rows", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorBId}&limit=200`,
{ headers: { Cookie: adminCookie } },
test("actorUserId filter excludes other actors on the same target_type", async () => {
const body = await listWithParams(`actorUserId=${adminAId}&limit=200`);
const ours = ownEntries(body);
assert.ok(
!ours.some((e) => e.actor?.id === adminBId),
"admin B's row must not appear in admin A's filtered list",
);
assert.equal(res.status, 200);
const body = await res.json();
const tagged = body.entries.filter(rowMatchesTag);
assert.equal(tagged.length, 1);
assert.equal(tagged[0].actor.id, actorBId);
assert.ok(!ours.some((e) => e.targetId === TARGET_ID_C));
});
test("actorUserId combines with targetId to scope to one (actor, target)", async () => {
const body = await listWithParams(
`actorUserId=${adminAId}&targetType=service&targetId=${TARGET_ID_A}&limit=200`,
);
const ours = ownEntries(body);
assert.equal(ours.length, 1);
assert.equal(ours[0].actor?.id, adminAId);
assert.equal(ours[0].targetId, TARGET_ID_A);
});
test("invalid actorUserId returns 400", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`,
{ headers: { Cookie: adminCookie } },
{ headers: { Cookie: adminACookie } },
);
assert.equal(res.status, 400);
const body = await res.json();
assert.match(body.error ?? "", /actorUserId/);
});
test("CSV export honours actorUserId filter", async () => {
test("zero / negative actorUserId returns 400", async () => {
for (const bad of ["0", "-1"]) {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${bad}`,
{ headers: { Cookie: adminACookie } },
);
assert.equal(res.status, 400, `expected 400 for actorUserId=${bad}`);
}
});
test("CSV export honors the actorUserId filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${actorAId}`,
{ headers: { Cookie: adminCookie } },
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${adminAId}`,
{ headers: { Cookie: adminACookie, Accept: "text/csv" } },
);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") ?? "",
/text\/csv/i,
);
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
const text = await res.text();
// Filter to rows with our test tag in the metadata column. Two rows from
// Actor A, none from Actor B.
const taggedLines = text
const ourLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.equal(taggedLines.length, 2);
assert.equal(ourLines.length, 2, "expected exactly the two admin-A rows");
for (const line of ourLines) {
assert.match(
line,
new RegExp(`,${adminAUsername},`),
`expected every CSV row to be authored by admin A: ${line}`,
);
assert.ok(
!line.includes(adminBUsername),
`admin B must not appear in the filtered export: ${line}`,
);
}
});