Files
TX/artifacts/api-server/tests/roles-crud.test.mjs
T
riyadhafraa a14b589006 Audit role permission changes (Task #100)
Record an audit trail every time a role's permissions change and surface
recent history inside the role edit dialog.

Schema (lib/db):
- New `role_permission_audit` table: id, roleId (FK roles), actorUserId
  (FK users, nullable on delete), previousPermissionIds int[],
  newPermissionIds int[], createdAt. Created via raw SQL because
  `drizzle push` currently fails on a pre-existing app_permissions
  duplicate (followup #148 tracks fixing this).

API (artifacts/api-server):
- PUT /api/roles/:id/permissions now wraps the permission delete/insert,
  the legacy audit_logs row, and the new role_permission_audit row in
  a *single* transaction so the permission set and its audit trail
  always commit (or roll back) together. Previous IDs are also read
  inside the transaction to avoid races.
- A role_permission_audit row is written on EVERY PUT call (per task
  spec), including no-op saves; the GET handler computes
  addedPermissionIds/removedPermissionIds so the History UI can
  distinguish meaningful changes from no-op saves.
- New GET /api/roles/:id/audit (admin-only, ?limit=1..50, default 10)
  returns recent entries newest-first with computed
  added/removedPermissionIds and joined actor info.
- New tests in tests/role-permission-audit.test.mjs cover write,
  every-call write (incl. no-op), GET ordering+diff+actor, no-op diff
  reporting, 404, and limit.

Drive-by fixes:
- Fixed pre-existing syntax corruption in tests/apps-open.test.mjs
  (stray `ccaPassa,` line from a prior bad merge that prevented the
  whole api-server test workflow from running).
- Made tests/roles-crud.test.mjs "rejects an invalid name" robust to
  parallel test execution by scoping the leak check to the bad name
  instead of relying on a global row count.

API spec / codegen (lib/api-spec, lib/api-client-react):
- Added `getRolePermissionAudit` operation and
  `RolePermissionAuditEntry` schema; ran orval codegen.

Frontend (artifacts/tx-os):
- New RolePermissionHistory component renders inside the role edit
  dialog (between permissions and Save/Cancel) using
  useGetRolePermissionAudit. Shows timestamp, actor, added/removed
  permission names (resolved via permissionsById), and total count.
- Save invalidates the audit query so the new entry appears immediately
  on the next open.
- Bilingual i18n strings (en + ar with full Arabic plural variants:
  zero/one/two/few/many/other) under admin.roles.history*.

Verification:
- tx-os typecheck passes.
- All 5 new audit tests + 13 existing roles tests pass.
- E2E (testing skill) verified the full flow: empty state on a fresh
  role, save adds a permission, reopened dialog shows the new entry
  with actor name in Arabic.

Docs:
- replit.md updated to list `role_permission_audit` in Database Tables.

Follow-ups proposed: #146 paginate/filter history, #147 audit other
admin permission changes, #148 fix drizzle push duplicate.

Replit-Task-Id: 9b8886a2-6e76-4072-b345-a772421fa161
2026-04-29 13:16:02 +00:00

308 lines
10 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 consumerUserId;
let consumerGroupId;
const createdRoleIds = [];
const createdGroupIds = [];
const createdUserIds = [];
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 = `roles_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, 'Roles 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],
);
const consumer = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Roles Consumer', 'en', true) RETURNING id`,
[
`roles_consumer_${stamp}`,
`roles_consumer_${stamp}@example.com`,
TEST_PASSWORD_HASH,
],
);
consumerUserId = consumer.rows[0].id;
createdUserIds.push(consumerUserId);
const grp = await pool.query(
`INSERT INTO groups (name, description_en) VALUES ($1, 'roles test group') RETURNING id`,
[`RolesGrp_${stamp}`],
);
consumerGroupId = grp.rows[0].id;
createdGroupIds.push(consumerGroupId);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
});
after(async () => {
if (createdRoleIds.length) {
await pool.query(
`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`,
[createdRoleIds],
);
await pool.query(
`DELETE FROM group_roles WHERE role_id = ANY($1::int[])`,
[createdRoleIds],
);
await pool.query(
`DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`,
[createdRoleIds],
);
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
createdRoleIds,
]);
}
if (createdGroupIds.length) {
await pool.query(
`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
createdGroupIds,
]);
}
for (const uid of createdUserIds) {
if (uid !== undefined) {
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();
});
function uniqueRoleName(prefix) {
const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return `${prefix}_${stamp}`;
}
test("POST /api/roles creates a role and returns 201", async () => {
const name = uniqueRoleName("role_create");
const res = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({
name,
descriptionEn: "Created by automated test",
descriptionAr: "تم إنشاؤه بواسطة الاختبار",
}),
});
assert.equal(res.status, 201);
const created = await res.json();
createdRoleIds.push(created.id);
assert.equal(created.name, name);
assert.equal(created.descriptionEn, "Created by automated test");
assert.equal(created.descriptionAr, "تم إنشاؤه بواسطة الاختبار");
assert.equal(created.isSystem, false);
const dbRow = await pool.query(`SELECT id, name FROM roles WHERE id = $1`, [
created.id,
]);
assert.equal(dbRow.rowCount, 1);
assert.equal(dbRow.rows[0].name, name);
});
test("POST /api/roles with a duplicate name returns 409", async () => {
const name = uniqueRoleName("role_dup");
const first = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name }),
});
assert.equal(first.status, 201);
const firstBody = await first.json();
createdRoleIds.push(firstBody.id);
const dup = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name }),
});
assert.equal(dup.status, 409);
const body = await dup.json();
assert.match(String(body.error || ""), /already taken/i);
const dbRows = await pool.query(`SELECT id FROM roles WHERE name = $1`, [
name,
]);
assert.equal(dbRows.rowCount, 1, "duplicate must not insert a second row");
});
test("POST /api/roles rejects an invalid name with 400", async () => {
const badName = "1bad name!";
const res = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name: badName }),
});
assert.equal(res.status, 400);
// Scope the leak check to the specific bad name so this test is robust
// when run in parallel with other suites that legitimately create roles.
const leak = await pool.query(`SELECT id FROM roles WHERE name = $1`, [
badName,
]);
assert.equal(leak.rowCount, 0, "no role row should leak");
});
test("PATCH /api/roles/:id updates description without touching the name", async () => {
const name = uniqueRoleName("role_patch");
const create = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name, descriptionEn: "before" }),
});
assert.equal(create.status, 201);
const created = await create.json();
createdRoleIds.push(created.id);
const patch = await fetch(`${API_BASE}/api/roles/${created.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ descriptionEn: "after", descriptionAr: "بعد" }),
});
assert.equal(patch.status, 200);
const updated = await patch.json();
assert.equal(updated.id, created.id);
assert.equal(updated.name, name, "name must be unchanged");
assert.equal(updated.descriptionEn, "after");
assert.equal(updated.descriptionAr, "بعد");
const dbRow = await pool.query(
`SELECT name, description_en, description_ar FROM roles WHERE id = $1`,
[created.id],
);
assert.equal(dbRow.rows[0].name, name);
assert.equal(dbRow.rows[0].description_en, "after");
assert.equal(dbRow.rows[0].description_ar, "بعد");
});
test("DELETE /api/roles/:id refuses to delete a system role with 400", async () => {
const sysRow = await pool.query(
`SELECT id FROM roles WHERE name = 'admin' LIMIT 1`,
);
assert.ok(sysRow.rowCount > 0, "system 'admin' role must exist");
const sysId = sysRow.rows[0].id;
const res = await fetch(`${API_BASE}/api/roles/${sysId}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 400);
const body = await res.json();
assert.match(String(body.error || ""), /system role/i);
const stillThere = await pool.query(`SELECT id FROM roles WHERE id = $1`, [
sysId,
]);
assert.equal(stillThere.rowCount, 1, "system role must not be deleted");
});
test("DELETE /api/roles/:id returns 409 with userCount/groupCount when in use", async () => {
const name = uniqueRoleName("role_inuse");
const create = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name }),
});
assert.equal(create.status, 201);
const created = await create.json();
createdRoleIds.push(created.id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
[consumerUserId, created.id],
);
await pool.query(
`INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
[consumerGroupId, created.id],
);
const res = await fetch(`${API_BASE}/api/roles/${created.id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 409);
const body = await res.json();
assert.match(String(body.error || ""), /in use/i);
assert.equal(body.userCount, 1);
assert.equal(body.groupCount, 1);
const stillThere = await pool.query(`SELECT id FROM roles WHERE id = $1`, [
created.id,
]);
assert.equal(stillThere.rowCount, 1, "in-use role must not be deleted");
});
test("DELETE /api/roles/:id returns 204 and removes the row when unused", async () => {
const name = uniqueRoleName("role_del");
const create = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name }),
});
assert.equal(create.status, 201);
const created = await create.json();
const res = await fetch(`${API_BASE}/api/roles/${created.id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const gone = await pool.query(`SELECT id FROM roles WHERE id = $1`, [
created.id,
]);
assert.equal(gone.rowCount, 0, "role row should be gone after delete");
});