Files
TX/artifacts/api-server/tests/role-permissions-impact.test.mjs
Riyadh 4379c7294c Show users/groups impact before removing role permissions (Task #99)
Adds a per-permission impact preview to the role editor so admins can see
who would lose each permission before saving — and a confirmation step
when the change would actually revoke access from at least one user.

API
- New endpoint POST /api/roles/:id/permissions/impact-preview
- Body: { permissionIds: number[] } (the proposed kept set)
- Response: { removed: [{ permissionId, permissionName, userCount,
  groupCount, groups: [{id,name}] }], totalAffectedUsers }
- A user is counted as "affected" only when no other role they hold
  (direct or via groups) still grants the removed permission.
- Implemented in artifacts/api-server/src/routes/roles.ts using two
  drizzle selectDistinct queries (direct + via groups) merged in JS.
  Switched away from a sql`${arr}::int[]` approach that failed because
  drizzle expands JS arrays as a parameter list, not as a single array
  parameter. Now uses inArray().

OpenAPI / client
- Added schemas RolePermissionsImpactBody, RolePermissionImpactGroup,
  RolePermissionImpactItem, RolePermissionsImpact in
  lib/api-spec/openapi.yaml.
- Regenerated lib/api-client-react/src/generated/* (new
  usePreviewRolePermissionsImpact hook).

UI
- artifacts/tx-os/src/pages/admin.tsx RolesPanel:
  - Debounced (350ms) on-demand fetch only when at least one permission
    has been unchecked relative to the saved state.
  - Inline amber summary panel listing each removed permission with
    user/group counts, plus a total-affected-users line.
  - Confirmation modal before save when totalAffectedUsers > 0.
- Added EN/AR translations (impactTitle, impactPerPermission,
  impactViaGroups, impactTotal, confirmRemoval*) using i18next
  _one/_other plural keys to match existing pluralized strings.

Tests
- New artifacts/api-server/tests/role-permissions-impact.test.mjs
  (6 tests, all green): empty removals, group-derived impact,
  "covered by another role" exclusion, 404 on unknown role, 400 on
  invalid body, 403 for non-admin.
- Existing roles-crud tests still pass.
- E2E flow verified end to end via the testing tool: amber summary
  appears, confirmation dialog gates the destructive save, and the
  resulting permission set persists.

Code-review follow-ups (applied in this same task)
- Tightened the OpenAPI 404 description for the new endpoint to clarify
  that unknown permission IDs in the body are tolerated (only a missing
  role yields 404). Regenerated the API client.
- Added a request-sequencing guard in the role editor so a stale
  in-flight impact-preview response cannot overwrite a newer selection.
- When the impact preview fails while removals exist, the inline panel
  now shows an explicit error message and the Save button is disabled
  until the user resolves it (e.g. by changing the selection so the
  preview can re-run successfully). EN/AR translations added under
  admin.roles.impactError.

Notes
- The pre-existing test workflow failure (ECONNREFUSED on 127.0.0.1:8080
  before the API server is ready) is unrelated and already tracked.
- replit.md not updated — change is endpoint-level and does not alter
  documented architecture.
- A modification to artifacts/tx-os/public/opengraph.jpg may show up in
  the diff; it is a build artifact regenerated by the vite dev server
  on restart and is not part of this change.
2026-04-29 12:45:19 +00:00

338 lines
11 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;
const createdRoleIds = [];
const createdGroupIds = [];
const createdUserIds = [];
const createdPermissionIds = [];
let directUserId;
let groupedUserId;
let bothUserId;
let helperGroupId;
let mainRoleId;
let aloneRoleId;
let alsoGrantsP1RoleId;
let permA;
let permB;
let permC;
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 makeUser(prefix, stamp) {
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[
`${prefix}_${stamp}`,
`${prefix}_${stamp}@example.com`,
TEST_PASSWORD_HASH,
`${prefix} User`,
],
);
const id = r.rows[0].id;
createdUserIds.push(id);
return id;
}
async function makePermission(prefix, stamp) {
const r = await pool.query(
`INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id, name`,
[`${prefix}_${stamp}`, `${prefix} permission`],
);
const row = r.rows[0];
createdPermissionIds.push(row.id);
return row;
}
async function makeRole(prefix, stamp) {
const r = await pool.query(
`INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`,
[`${prefix}_${stamp}`, `${prefix} role`],
);
const id = r.rows[0].id;
createdRoleIds.push(id);
return id;
}
async function makeGroup(prefix, stamp) {
const r = await pool.query(
`INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`,
[`${prefix}_${stamp}`, `${prefix} group`],
);
const id = r.rows[0].id;
createdGroupIds.push(id);
return id;
}
before(async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
adminUsername = `imp_admin_${stamp}`;
adminId = await makeUser("imp_admin", stamp);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
// overwrite the username we want to log in with (makeUser uses prefix_stamp)
adminUsername = (
await pool.query(`SELECT username FROM users WHERE id = $1`, [adminId])
).rows[0].username;
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
permA = await makePermission("perm_a", stamp);
permB = await makePermission("perm_b", stamp);
permC = await makePermission("perm_c", stamp);
// Main role: has perms A, B, C.
mainRoleId = await makeRole("imp_main", stamp);
for (const p of [permA, permB, permC]) {
await pool.query(
`INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`,
[mainRoleId, p.id],
);
}
// Alone role: has only perm A. Used to ensure a user keeps perm A after main loses it.
aloneRoleId = await makeRole("imp_alone", stamp);
alsoGrantsP1RoleId = aloneRoleId;
await pool.query(
`INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`,
[aloneRoleId, permA.id],
);
// directUser: has main role directly. No other roles.
directUserId = await makeUser("imp_direct", stamp);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
[directUserId, mainRoleId],
);
// groupedUser: receives main role via a group.
groupedUserId = await makeUser("imp_grouped", stamp);
helperGroupId = await makeGroup("imp_helper", stamp);
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
[groupedUserId, helperGroupId],
);
await pool.query(
`INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
[helperGroupId, mainRoleId],
);
// bothUser: has main role directly AND aloneRole (which also grants perm A).
bothUserId = await makeUser("imp_both", stamp);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
[bothUserId, mainRoleId],
);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
[bothUserId, aloneRoleId],
);
});
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,
]);
}
if (createdPermissionIds.length) {
await pool.query(
`DELETE FROM role_permissions WHERE permission_id = ANY($1::int[])`,
[createdPermissionIds],
);
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/roles/:id/permissions/impact-preview returns empty when no permissions are removed", async () => {
const res = await fetch(
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionIds: [permA.id, permB.id, permC.id] }),
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.deepEqual(body.removed, []);
assert.equal(body.totalAffectedUsers, 0);
});
test("POST /api/roles/:id/permissions/impact-preview reports affected users and groups for each removed permission", async () => {
// Remove perm B and perm C from the role (keep perm A).
const res = await fetch(
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionIds: [permA.id] }),
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.removed.length, 2);
const byName = new Map(body.removed.map((r) => [r.permissionName, r]));
const b = byName.get(permB.name);
const c = byName.get(permC.name);
assert.ok(b, "expected perm B in removed list");
assert.ok(c, "expected perm C in removed list");
// Three role-holders: directUser (direct), groupedUser (via helperGroup), bothUser (direct).
// For perm B and perm C, no other role grants them, so all 3 lose them.
assert.equal(b.userCount, 3);
assert.equal(c.userCount, 3);
// The role is granted by 1 group (helperGroup).
assert.equal(b.groupCount, 1);
assert.equal(c.groupCount, 1);
assert.equal(b.groups.length, 1);
assert.equal(b.groups[0].id, helperGroupId);
assert.ok(typeof b.groups[0].name === "string" && b.groups[0].name.length > 0);
assert.equal(body.totalAffectedUsers, 3);
});
test("impact-preview excludes users that still get the permission via another role", async () => {
// Remove ALL permissions, including perm A which is also granted to bothUser via aloneRole.
const res = await fetch(
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionIds: [] }),
},
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.removed.length, 3);
const byName = new Map(body.removed.map((r) => [r.permissionName, r]));
const a = byName.get(permA.name);
assert.ok(a, "expected perm A in removed list");
// bothUser still has perm A via aloneRole, so only 2 of 3 lose it.
assert.equal(a.userCount, 2);
// For B and C nobody else grants them, so all 3 lose them.
assert.equal(byName.get(permB.name).userCount, 3);
assert.equal(byName.get(permC.name).userCount, 3);
// Total distinct affected users is 3 (all three lose at least one permission).
assert.equal(body.totalAffectedUsers, 3);
});
test("impact-preview returns 404 for an unknown role", async () => {
const res = await fetch(
`${API_BASE}/api/roles/9999999/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionIds: [] }),
},
);
assert.equal(res.status, 404);
});
test("impact-preview returns 400 when permissionIds is missing or invalid", async () => {
const noBody = await fetch(
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({}),
},
);
assert.equal(noBody.status, 400);
const wrong = await fetch(
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionIds: ["abc"] }),
},
);
assert.equal(wrong.status, 400);
});
test("impact-preview requires admin", async () => {
const username = `imp_consumer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'consumer', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const consumerId = r.rows[0].id;
createdUserIds.push(consumerId);
const consumerCookie = await loginAndGetCookie(username, TEST_PASSWORD);
const res = await fetch(
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
{
method: "POST",
headers: { "Content-Type": "application/json", Cookie: consumerCookie },
body: JSON.stringify({ permissionIds: [] }),
},
);
assert.ok(res.status === 401 || res.status === 403, `expected 401/403, got ${res.status}`);
});