e816f136bd
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.
290 lines
9.5 KiB
JavaScript
290 lines
9.5 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 createdAppIds = [];
|
|
const createdPermissionIds = [];
|
|
|
|
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 createApp() {
|
|
const slug = uniqueName("app_audit");
|
|
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 };
|
|
}
|
|
|
|
async function createPermission() {
|
|
const name = uniqueName("app_audit_perm");
|
|
const r = await pool.query(
|
|
`INSERT INTO permissions (name, description_en) VALUES ($1::varchar, $1::text) RETURNING id`,
|
|
[name],
|
|
);
|
|
const id = r.rows[0].id;
|
|
createdPermissionIds.push(id);
|
|
return { id, name };
|
|
}
|
|
|
|
before(async () => {
|
|
adminUsername = uniqueName("app_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, 'App 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("app_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 (createdAppIds.length) {
|
|
await pool.query(
|
|
`DELETE FROM permission_audit WHERE target_kind = 'app' AND target_id = ANY($1::int[])`,
|
|
[createdAppIds],
|
|
);
|
|
await pool.query(
|
|
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
|
[createdAppIds],
|
|
);
|
|
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
|
|
createdAppIds,
|
|
]);
|
|
}
|
|
if (createdPermissionIds.length) {
|
|
await pool.query(
|
|
`DELETE FROM permissions WHERE id = ANY($1::int[])`,
|
|
[createdPermissionIds],
|
|
);
|
|
}
|
|
for (const uid of createdUserIds) {
|
|
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/apps/:id/permissions writes an app.permissions audit row transactionally", async () => {
|
|
const app = await createApp();
|
|
const p1 = await createPermission();
|
|
|
|
const before = await pool.query(
|
|
`SELECT COUNT(*)::int AS c FROM permission_audit
|
|
WHERE target_kind = 'app' AND target_id = $1`,
|
|
[app.id],
|
|
);
|
|
assert.equal(before.rows[0].c, 0);
|
|
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionId: p1.id }),
|
|
});
|
|
assert.equal(res.status, 201);
|
|
|
|
const rows = await pool.query(
|
|
`SELECT actor_user_id, change_kind, previous_ids, new_ids
|
|
FROM permission_audit
|
|
WHERE target_kind = 'app' AND target_id = $1`,
|
|
[app.id],
|
|
);
|
|
assert.equal(rows.rowCount, 1);
|
|
const row = rows.rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.change_kind, "app.permissions");
|
|
assert.deepEqual(row.previous_ids, []);
|
|
assert.deepEqual(row.new_ids, [p1.id]);
|
|
|
|
// Idempotent re-add (same permissionId) → no new audit row.
|
|
const dup = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionId: p1.id }),
|
|
});
|
|
assert.equal(dup.status, 201);
|
|
const after = await pool.query(
|
|
`SELECT COUNT(*)::int AS c FROM permission_audit
|
|
WHERE target_kind = 'app' AND target_id = $1`,
|
|
[app.id],
|
|
);
|
|
assert.equal(after.rows[0].c, 1);
|
|
});
|
|
|
|
test("DELETE /api/apps/:id/permissions/:permissionId records previous/new ids", async () => {
|
|
const app = await createApp();
|
|
const p1 = await createPermission();
|
|
const p2 = await createPermission();
|
|
|
|
for (const p of [p1, p2]) {
|
|
const r = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionId: p.id }),
|
|
});
|
|
assert.equal(r.status, 201);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
const del = await fetch(
|
|
`${API_BASE}/api/apps/${app.id}/permissions/${p1.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 = 'app' AND target_id = $1
|
|
ORDER BY id DESC LIMIT 1`,
|
|
[app.id],
|
|
);
|
|
assert.equal(rows.rowCount, 1);
|
|
const sortedIds = (a) => [...a].sort((x, y) => x - y);
|
|
assert.equal(rows.rows[0].change_kind, "app.permissions");
|
|
assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([p1.id, p2.id]));
|
|
assert.deepEqual(rows.rows[0].new_ids, [p2.id]);
|
|
});
|
|
|
|
test("GET /api/apps/:id/audit returns entries with diff fields, actor, pagination and date filters", async () => {
|
|
const app = await createApp();
|
|
const p1 = await createPermission();
|
|
const p2 = await createPermission();
|
|
const p3 = await createPermission();
|
|
|
|
for (const p of [p1, p2, p3]) {
|
|
const r = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionId: p.id }),
|
|
});
|
|
assert.equal(r.status, 201);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
|
|
const allRes = await fetch(`${API_BASE}/api/apps/${app.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: p3 added.
|
|
const latest = all.entries[0];
|
|
assert.equal(latest.changeKind, "app.permissions");
|
|
assert.deepEqual(latest.addedIds, [p3.id]);
|
|
assert.deepEqual(latest.removedIds, []);
|
|
assert.ok(latest.actor);
|
|
assert.equal(latest.actor.id, adminId);
|
|
assert.equal(latest.actor.username, adminUsername);
|
|
|
|
const pageRes = await fetch(
|
|
`${API_BASE}/api/apps/${app.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.totalCount, 3);
|
|
assert.equal(page.nextOffset, 2);
|
|
|
|
// actorUserId filter — admin authored every row.
|
|
const filteredRes = await fetch(
|
|
`${API_BASE}/api/apps/${app.id}/audit?actorUserId=${adminId}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
const filtered = await filteredRes.json();
|
|
assert.equal(filtered.totalCount, 3);
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
const past = await fetch(
|
|
`${API_BASE}/api/apps/${app.id}/audit?from=${yesterday}&to=${yesterday}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
const pastBody = await past.json();
|
|
assert.equal(pastBody.totalCount, 0);
|
|
|
|
const inRange = await fetch(
|
|
`${API_BASE}/api/apps/${app.id}/audit?from=${today}&to=${today}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
const inRangeBody = await inRange.json();
|
|
assert.equal(inRangeBody.totalCount, 3);
|
|
});
|
|
|
|
test("GET /api/apps/:id/audit is admin-only and 404s on unknown app", async () => {
|
|
const app = await createApp();
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}/audit`, {
|
|
headers: { Cookie: nonAdminCookie },
|
|
});
|
|
assert.equal(res.status, 403);
|
|
|
|
const missing = await fetch(`${API_BASE}/api/apps/99999999/audit`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(missing.status, 404);
|
|
});
|