4379c7294c
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.
338 lines
11 KiB
JavaScript
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}`);
|
|
});
|