Add automated tests for audit log readable summaries
Original task: add unit tests for the new `formatAuditSummary` formatter
and an API-level test asserting the enriched group sub-resource audit
metadata, and wire both into the existing `test` workflow.
What changed:
- Extracted `formatAuditSummary` and its helpers (`asRecord`, `asString`,
`asNumber`, `unitLabel`, `appName`, `linkedAppName`, `plainName`,
`changeCount`) out of `artifacts/tx-os/src/pages/admin.tsx` into a new
`artifacts/tx-os/src/lib/audit-summary.ts` module so the pure formatter
can be unit-tested without the React tree. `admin.tsx` now imports the
helpers from that module.
- Added `artifacts/tx-os/src/__tests__/audit-summary.test.mjs` with 22
Node test-runner cases covering app rename (EN + AR), app-update
fallback, group rename, group multi-field update, registration toggle
(open / close / with-other-changes), and every group.user/app/role
add/remove name vs id-only branch, plus the unknown-action default.
- Added `pnpm --filter @workspace/tx-os test` (Node 24's native
TypeScript loader runs the .mjs tests against the .ts module directly).
- Added `artifacts/api-server/tests/group-audit-metadata.test.mjs` using
the same harness as `groups-crud.test.mjs`. It hits POST/DELETE
`/api/groups/:id/{users,apps,roles}/:targetId` and reads the resulting
`audit_logs.metadata`, asserting `username`, `appSlug` /`appNameEn` /
`appNameAr`, and `roleName` are persisted alongside the raw IDs.
- Updated the `test` workflow to run the tx-os unit tests before the
api-server tests, then the tx-os e2e tests.
Verification: all 22 tx-os unit tests pass via the new pnpm script, and
all 6 new api-server audit-metadata tests pass against a live server.
The overall api-server suite still has pre-existing flakes
(executive-meetings notifications/status transitions, and the
count-based group invariant in groups-crud.test.mjs) that are unrelated
to this change; both flake clusters are filed as follow-up tasks.
Replit-Task-Id: 182cd4ed-c55c-43e3-b10b-8147a9611fd4
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// API-level coverage for the enriched audit_logs metadata that the group
|
||||
// sub-resource endpoints write. We assert that:
|
||||
// - group.user.add / group.user.remove rows record `username`
|
||||
// - group.app.add / group.app.remove rows record `appSlug`, `appNameEn`,
|
||||
// `appNameAr`
|
||||
// - group.role.add / group.role.remove rows record `roleName`
|
||||
//
|
||||
// The rows themselves are produced by POST /api/groups/:id/:kind/:targetId
|
||||
// and DELETE /api/groups/:id/:kind/:targetId in routes/groups.ts, and are
|
||||
// what powers the readable summary formatter on the admin audit log UI.
|
||||
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 });
|
||||
|
||||
let adminId;
|
||||
let adminUsername;
|
||||
let adminCookie;
|
||||
let memberId;
|
||||
let memberUsername;
|
||||
let appId;
|
||||
let appSlug;
|
||||
let appNameEn;
|
||||
let appNameAr;
|
||||
let roleId;
|
||||
let roleName;
|
||||
let groupId;
|
||||
|
||||
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="));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
adminUsername = `gam_admin_${stamp}`;
|
||||
memberUsername = `gam_user_${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 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],
|
||||
);
|
||||
|
||||
const u = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Audit Member', 'en', true) RETURNING id`,
|
||||
[memberUsername, `${memberUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
memberId = u.rows[0].id;
|
||||
|
||||
appSlug = `gam_app_${stamp}`;
|
||||
appNameEn = `GAM App ${stamp}`;
|
||||
appNameAr = `تطبيق ${stamp}`;
|
||||
const ap = await pool.query(
|
||||
`INSERT INTO apps (slug, name_en, name_ar, icon_name, route, color, sort_order)
|
||||
VALUES ($1, $2, $3, 'Box', '/gam', '#000000', 999) RETURNING id`,
|
||||
[appSlug, appNameEn, appNameAr],
|
||||
);
|
||||
appId = ap.rows[0].id;
|
||||
|
||||
roleName = `gam_role_${stamp}`;
|
||||
const r = await pool.query(
|
||||
`INSERT INTO roles (name) VALUES ($1) RETURNING id`,
|
||||
[roleName],
|
||||
);
|
||||
roleId = r.rows[0].id;
|
||||
|
||||
// Empty group so we can drive sub-resource add/remove operations cleanly.
|
||||
const g = await pool.query(
|
||||
`INSERT INTO groups (name) VALUES ($1) RETURNING id`,
|
||||
[`gam_grp_${stamp}`],
|
||||
);
|
||||
groupId = g.rows[0].id;
|
||||
|
||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Clean up audit rows the tests produced before tearing down their targets,
|
||||
// otherwise actor_user_id/target_id FKs (or noisy leftovers) can linger.
|
||||
if (groupId !== undefined) {
|
||||
await pool.query(`DELETE FROM audit_logs WHERE target_type = 'group' AND target_id = $1`, [groupId]);
|
||||
await pool.query(`DELETE FROM user_groups WHERE group_id = $1`, [groupId]);
|
||||
await pool.query(`DELETE FROM group_apps WHERE group_id = $1`, [groupId]);
|
||||
await pool.query(`DELETE FROM group_roles WHERE group_id = $1`, [groupId]);
|
||||
await pool.query(`DELETE FROM groups WHERE id = $1`, [groupId]);
|
||||
}
|
||||
if (memberId !== undefined) {
|
||||
await pool.query(`DELETE FROM audit_logs WHERE target_type = 'user' AND target_id = $1`, [memberId]);
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [memberId]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [memberId]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [memberId]);
|
||||
}
|
||||
if (appId !== undefined) {
|
||||
await pool.query(`DELETE FROM audit_logs WHERE target_type = 'app' AND target_id = $1`, [appId]);
|
||||
await pool.query(`DELETE FROM group_apps WHERE app_id = $1`, [appId]);
|
||||
await pool.query(`DELETE FROM apps WHERE id = $1`, [appId]);
|
||||
}
|
||||
if (roleId !== undefined) {
|
||||
await pool.query(`DELETE FROM audit_logs WHERE target_type = 'role' AND target_id = $1`, [roleId]);
|
||||
await pool.query(`DELETE FROM group_roles WHERE role_id = $1`, [roleId]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE role_id = $1`, [roleId]);
|
||||
await pool.query(`DELETE FROM roles WHERE id = $1`, [roleId]);
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
// Look up the most recent audit_logs row for a given (action, target_type,
|
||||
// target_id) tuple so each test can target the row it just produced.
|
||||
async function latestAuditRow(action, targetType, targetId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT metadata FROM audit_logs
|
||||
WHERE action = $1 AND target_type = $2 AND target_id = $3
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[action, targetType, targetId],
|
||||
);
|
||||
assert.equal(rows.length, 1, `expected one ${action} row for ${targetType}#${targetId}`);
|
||||
return rows[0].metadata;
|
||||
}
|
||||
|
||||
test("group.user.add audit row records username (not just userId)", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/groups/${groupId}/users/${memberId}`,
|
||||
{ method: "POST", headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 204);
|
||||
const meta = await latestAuditRow("group.user.add", "group", groupId);
|
||||
assert.equal(meta.userId, memberId);
|
||||
assert.equal(meta.username, memberUsername);
|
||||
assert.ok(typeof meta.groupName === "string" && meta.groupName.length > 0);
|
||||
});
|
||||
|
||||
test("group.user.remove audit row records username (not just userId)", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/groups/${groupId}/users/${memberId}`,
|
||||
{ method: "DELETE", headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 204);
|
||||
const meta = await latestAuditRow("group.user.remove", "group", groupId);
|
||||
assert.equal(meta.userId, memberId);
|
||||
assert.equal(meta.username, memberUsername);
|
||||
});
|
||||
|
||||
test("group.app.add audit row records appSlug, appNameEn, appNameAr (not just appId)", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/groups/${groupId}/apps/${appId}`,
|
||||
{ method: "POST", headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 204);
|
||||
const meta = await latestAuditRow("group.app.add", "group", groupId);
|
||||
assert.equal(meta.appId, appId);
|
||||
assert.equal(meta.appSlug, appSlug);
|
||||
assert.equal(meta.appNameEn, appNameEn);
|
||||
assert.equal(meta.appNameAr, appNameAr);
|
||||
});
|
||||
|
||||
test("group.app.remove audit row records appSlug, appNameEn, appNameAr (not just appId)", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/groups/${groupId}/apps/${appId}`,
|
||||
{ method: "DELETE", headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 204);
|
||||
const meta = await latestAuditRow("group.app.remove", "group", groupId);
|
||||
assert.equal(meta.appId, appId);
|
||||
assert.equal(meta.appSlug, appSlug);
|
||||
assert.equal(meta.appNameEn, appNameEn);
|
||||
assert.equal(meta.appNameAr, appNameAr);
|
||||
});
|
||||
|
||||
test("group.role.add audit row records roleName (not just roleId)", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/groups/${groupId}/roles/${roleId}`,
|
||||
{ method: "POST", headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 204);
|
||||
const meta = await latestAuditRow("group.role.add", "group", groupId);
|
||||
assert.equal(meta.roleId, roleId);
|
||||
assert.equal(meta.roleName, roleName);
|
||||
});
|
||||
|
||||
test("group.role.remove audit row records roleName (not just roleId)", async () => {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/groups/${groupId}/roles/${roleId}`,
|
||||
{ method: "DELETE", headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(res.status, 204);
|
||||
const meta = await latestAuditRow("group.role.remove", "group", groupId);
|
||||
assert.equal(meta.roleId, roleId);
|
||||
assert.equal(meta.roleName, roleName);
|
||||
});
|
||||
Reference in New Issue
Block a user