Task #243: Admin audit log — focused readability + actor-filter subset
Landed a tight subset of the 13-item umbrella, mirroring the proven narrow-then-defer pattern from #242: - #195 — Plain DELETE /api/services/:id now writes a `service.delete` audit row carrying nameEn + nameAr (force-with-deps still uses the dedicated `service.force_delete`). Added matching `service.delete` formatter case + EN/AR i18n keys, and surfaced nameAr on the existing `service.force_delete` summary. - #197 — `actorUserId` filter for `/admin/audit-logs` and CSV export. openapi.yaml updated, codegen regenerated, server filter wired through parseFilters/buildWhere with 400-on-invalid handling, AuditLogPanel UI got an actor dropdown wired into params + export URL + reset, and a new audit-logs-actor-filter API test (4 cases) covers list narrowing, exclusion, invalid input, and CSV export. - #178 — Formatter unit tests for user.delete (id-only, EN/AR display name resolution, force flag, force + name) and the new service.delete (id-only, EN/AR), 11 new cases (33/33 pass). Skipped #194 — already implemented; users.ts DELETE persists displayName fields and audit-summary already renders user.deleteWithName/forceDeleteWithName. Deferred via follow-ups (no duplicate of existing #182/#183/#184): - F1: #196 recent-activity endpoint + 5 admin panels - F2: #205+#206+#208 permission history CSV/name resolution/timeline - F3: #209+#210 cascade/bulk audit rows + e2e UI spec for History tabs Validation: tx-os typecheck clean; pre-existing executive-meetings.ts errors not regressed; all targeted server tests pass (delete-force-warnings 10, audit-logs target-filter 7, forced-only 6, audit-log-coverage 27, new actor-filter 4, broader audit/services sweep 40); e2e test verified actor dropdown rendering, filter behavior, readable Arabic service.delete summary, and CSV export honoring the filter.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// #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.
|
||||
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)}`;
|
||||
const TEST_TAG = `actor_filter_test_${STAMP}`;
|
||||
|
||||
let adminId;
|
||||
let adminCookie;
|
||||
let actorAId;
|
||||
let actorBId;
|
||||
const insertedAuditIds = [];
|
||||
|
||||
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({ actorUserId, action, targetType, targetId }) {
|
||||
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,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
JSON.stringify({ testTag: TEST_TAG }),
|
||||
],
|
||||
);
|
||||
insertedAuditIds.push(r.rows[0].id);
|
||||
return r.rows[0].id;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const adminUsername = `audit_actor_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, 'Actor 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);
|
||||
|
||||
// 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 insertAuditRow({
|
||||
actorUserId: actorAId,
|
||||
action: "settings.update",
|
||||
targetType: "settings",
|
||||
targetId: 1,
|
||||
});
|
||||
await insertAuditRow({
|
||||
actorUserId: actorAId,
|
||||
action: "settings.update",
|
||||
targetType: "settings",
|
||||
targetId: 1,
|
||||
});
|
||||
await insertAuditRow({
|
||||
actorUserId: actorBId,
|
||||
action: "settings.update",
|
||||
targetType: "settings",
|
||||
targetId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (insertedAuditIds.length > 0) {
|
||||
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),
|
||||
]);
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
function rowMatchesTag(entry) {
|
||||
return entry.metadata && entry.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 } },
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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 } },
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
test("invalid actorUserId returns 400", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("CSV export honours actorUserId filter", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${actorAId}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(
|
||||
res.headers.get("content-type") ?? "",
|
||||
/text\/csv/i,
|
||||
);
|
||||
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
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes(TEST_TAG));
|
||||
assert.equal(taggedLines.length, 2);
|
||||
});
|
||||
@@ -355,8 +355,8 @@ test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async (
|
||||
|
||||
test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => {
|
||||
const s = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', $1, 1.00, true) RETURNING id`,
|
||||
[`${SVC_PREFIX}Clean`],
|
||||
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.00, true) RETURNING id`,
|
||||
[`${SVC_PREFIX}CleanAr`, `${SVC_PREFIX}Clean`],
|
||||
);
|
||||
const id = s.rows[0].id;
|
||||
createdServiceIds.push(id);
|
||||
@@ -366,6 +366,43 @@ test("DELETE /api/services/:id without dependents succeeds (no force needed)", a
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
// #195: plain (no-deps) deletes now write a `service.delete` audit row
|
||||
// carrying both nameEn and nameAr so the audit log can render the service
|
||||
// name in either locale even after the row is gone.
|
||||
const audit = await pool.query(
|
||||
`SELECT action, metadata FROM audit_logs WHERE target_type = 'service' AND target_id = $1`,
|
||||
[String(id)],
|
||||
);
|
||||
assert.equal(audit.rowCount, 1);
|
||||
assert.equal(audit.rows[0].action, "service.delete");
|
||||
assert.equal(audit.rows[0].metadata.nameEn, `${SVC_PREFIX}Clean`);
|
||||
assert.equal(audit.rows[0].metadata.nameAr, `${SVC_PREFIX}CleanAr`);
|
||||
});
|
||||
|
||||
test("DELETE /api/services/:id?force=true with no dependents writes service.delete (not service.force_delete)", async () => {
|
||||
// #195: the `force` query has no effect when there are no dependents, so
|
||||
// the plain `service.delete` row is correct here. `service.force_delete`
|
||||
// is reserved for the path that actually had to clear dependent orders.
|
||||
const s = await pool.query(
|
||||
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.50, true) RETURNING id`,
|
||||
[`${SVC_PREFIX}ForceCleanAr`, `${SVC_PREFIX}ForceClean`],
|
||||
);
|
||||
const id = s.rows[0].id;
|
||||
createdServiceIds.push(id);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/services/${id}?force=true`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const audit = await pool.query(
|
||||
`SELECT action FROM audit_logs WHERE target_type = 'service' AND target_id = $1`,
|
||||
[String(id)],
|
||||
);
|
||||
assert.equal(audit.rowCount, 1);
|
||||
assert.equal(audit.rows[0].action, "service.delete");
|
||||
});
|
||||
|
||||
test("DELETE /api/services/:id with orders returns 409 with counts", async () => {
|
||||
|
||||
Reference in New Issue
Block a user