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:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user