Files
TX/artifacts/api-server/tests/group-permission-audit.test.mjs
T
riyadhafraa 2bf2f3cd3a Task #147: Add structured permission-change audit (users, groups, apps)
Mirrors the existing role-permission audit pattern with a unified
`permission_audit` table capturing actor, target, prev/new id sets, and
timestamp written in the same transaction as the change.

Schema & API
- New `permission_audit` table (target_kind, target_id, change_kind,
  actor_user_id, previous_ids[], new_ids[], created_at) with index on
  (target_kind, target_id, created_at).
- Transactional audit writes in routes/users.ts (POST/DELETE roles,
  PATCH groupIds), routes/groups.ts (PATCH + add/remove members for
  users/roles/apps), routes/apps.ts (POST/DELETE permissions).
- Cross-entity mirroring: when group membership changes via a group
  endpoint, a user.groups row is also written for each affected user
  (and vice versa via PATCH /users), so each entity's history is
  exhaustive regardless of which editor was used.
- Admin-only GET /users/:id/audit, /groups/:id/audit, /apps/:id/audit
  with limit/offset/actorUserId/from/to filters and the same response
  shape as role audit.
- OpenAPI types + codegen regenerated.

UI
- Reusable PermissionAuditHistory component in admin.tsx wired into
  UserGroupsEditor, GroupDetailEditor (new "history" tab), and the
  editing-app dialog. App history correctly resolves permission ids
  (NOT roles) via useListPermissions.
- Bilingual i18n keys added under admin.{users,groups,apps}.history*
  in en.json + ar.json.

Tests
- New backend tests: user-permission-audit, group-permission-audit,
  app-permission-audit (14 cases — transactional capture, GET filters
  & pagination, admin-only, 404 on unknown id, plus 2 new mirror
  tests covering cross-entity audit visibility). All pass; 35
  adjacent role/groups/users/audit-coverage tests still pass.

Notes
- replit.md updated to list `permission_audit` table.
- Restored opengraph.jpg (an unrelated stray binary diff).
- Code-review comments addressed: cross-entity asymmetry fixed via
  mirroring; opengraph.jpg restored.
- Follow-ups proposed: timeline UI improvements, cascade audit on
  delete/bulk paths, e2e UI test for History sections.

Replit-Task-Id: 4ba8c8de-8dfd-42c7-a3bc-964a1ed3a1a3
2026-04-30 11:58:18 +00:00

353 lines
12 KiB
JavaScript

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 nonAdminCookie;
const createdUserIds = [];
const createdGroupIds = [];
const createdRoleIds = [];
const createdAppIds = [];
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="));
}
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random()
.toString(36)
.slice(2, 6)}`;
}
async function createGroup() {
const res = await fetch(`${API_BASE}/api/groups`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name: uniqueName("grp_audit") }),
});
assert.equal(res.status, 201);
const body = await res.json();
createdGroupIds.push(body.id);
return body;
}
async function createRole() {
const res = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name: uniqueName("grp_audit_role") }),
});
assert.equal(res.status, 201);
const body = await res.json();
createdRoleIds.push(body.id);
return body;
}
async function createUser() {
const username = uniqueName("grp_audit_user");
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Member', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const id = r.rows[0].id;
createdUserIds.push(id);
return { id, username };
}
async function createApp() {
const slug = uniqueName("grp_audit_app");
const r = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, icon_name, color, route)
VALUES ($1, 'تطبيق', 'App', 'Grid', '#6366f1', '/x') RETURNING id`,
[slug],
);
const id = r.rows[0].id;
createdAppIds.push(id);
return { id, slug };
}
before(async () => {
adminUsername = uniqueName("grp_audit_admin");
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Group Audit Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
createdUserIds.push(adminId);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
ON CONFLICT DO NOTHING`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
const naUsername = uniqueName("grp_audit_nonadmin");
const na = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
);
createdUserIds.push(na.rows[0].id);
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
});
after(async () => {
if (createdGroupIds.length) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'group' AND target_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
createdGroupIds,
]);
}
if (createdAppIds.length) {
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
createdAppIds,
]);
}
if (createdRoleIds.length) {
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
createdRoleIds,
]);
}
for (const uid of createdUserIds) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = $1`,
[uid],
);
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
}
await pool.end();
});
test("POST /api/groups/:id/users/:targetId writes a group.users permission_audit row transactionally", async () => {
const grp = await createGroup();
const u = await createUser();
const before = await pool.query(
`SELECT COUNT(*)::int AS c FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1`,
[grp.id],
);
assert.equal(before.rows[0].c, 0);
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const rows = await pool.query(
`SELECT actor_user_id, change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1`,
[grp.id],
);
assert.equal(rows.rowCount, 1);
const row = rows.rows[0];
assert.equal(row.actor_user_id, adminId);
assert.equal(row.change_kind, "group.users");
assert.deepEqual(row.previous_ids, []);
assert.deepEqual(row.new_ids, [u.id]);
});
test("Group user-membership changes mirror onto the affected user's history", async () => {
const grp = await createGroup();
const u = await createUser();
// Add user via group endpoint → expect group.users AND mirrored user.groups.
let res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
let userRows = await pool.query(
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`,
[u.id],
);
assert.equal(userRows.rowCount, 1);
assert.equal(userRows.rows[0].change_kind, "user.groups");
assert.deepEqual(userRows.rows[0].previous_ids, []);
assert.deepEqual(userRows.rows[0].new_ids, [grp.id]);
// Remove via group endpoint → another mirrored user.groups row.
res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
userRows = await pool.query(
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`,
[u.id],
);
assert.equal(userRows.rowCount, 2);
assert.equal(userRows.rows[1].change_kind, "user.groups");
assert.deepEqual(userRows.rows[1].previous_ids, [grp.id]);
assert.deepEqual(userRows.rows[1].new_ids, []);
});
test("DELETE /api/groups/:id/roles/:targetId records previous/new ids", async () => {
const grp = await createGroup();
const r1 = await createRole();
const r2 = await createRole();
// Add two roles → audit rows captured by POST.
for (const r of [r1, r2]) {
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
await new Promise((r) => setTimeout(r, 5));
}
// Remove r1 → expect a delete audit row with prev=[r1,r2], new=[r2].
const del = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r1.id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(del.status, 204);
const rows = await pool.query(
`SELECT change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1
ORDER BY id DESC LIMIT 1`,
[grp.id],
);
assert.equal(rows.rowCount, 1);
const sortedIds = (a) => [...a].sort((x, y) => x - y);
assert.equal(rows.rows[0].change_kind, "group.roles");
assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([r1.id, r2.id]));
assert.deepEqual(rows.rows[0].new_ids, [r2.id]);
});
test("GET /api/groups/:id/audit returns mixed-kind history with pagination and date filters", async () => {
const grp = await createGroup();
const u = await createUser();
const role = await createRole();
const app = await createApp();
// 1: add user
let r = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(r.status, 204);
await new Promise((r) => setTimeout(r, 10));
// 2: add role
r = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${role.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(r.status, 204);
await new Promise((r) => setTimeout(r, 10));
// 3: add app
r = await fetch(`${API_BASE}/api/groups/${grp.id}/apps/${app.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(r.status, 204);
const allRes = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(allRes.status, 200);
const all = await allRes.json();
assert.equal(all.totalCount, 3);
assert.equal(all.entries.length, 3);
// Newest first → app, role, user.
assert.equal(all.entries[0].changeKind, "group.apps");
assert.deepEqual(all.entries[0].addedIds, [app.id]);
assert.equal(all.entries[1].changeKind, "group.roles");
assert.equal(all.entries[2].changeKind, "group.users");
assert.ok(all.entries[0].actor);
assert.equal(all.entries[0].actor.id, adminId);
const pageRes = await fetch(
`${API_BASE}/api/groups/${grp.id}/audit?limit=2&offset=0`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(pageRes.status, 200);
const page = await pageRes.json();
assert.equal(page.entries.length, 2);
assert.equal(page.nextOffset, 2);
const today = new Date().toISOString().slice(0, 10);
const inRange = await fetch(
`${API_BASE}/api/groups/${grp.id}/audit?from=${today}&to=${today}`,
{ headers: { Cookie: adminCookie } },
);
const inRangeBody = await inRange.json();
assert.equal(inRangeBody.totalCount, 3);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const past = await fetch(
`${API_BASE}/api/groups/${grp.id}/audit?from=${yesterday}&to=${yesterday}`,
{ headers: { Cookie: adminCookie } },
);
const pastBody = await past.json();
assert.equal(pastBody.totalCount, 0);
});
test("GET /api/groups/:id/audit is admin-only and 404s on unknown group", async () => {
const grp = await createGroup();
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, {
headers: { Cookie: nonAdminCookie },
});
assert.equal(res.status, 403);
const missing = await fetch(`${API_BASE}/api/groups/99999999/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(missing.status, 404);
});