Files
TX/artifacts/api-server/tests/role-permissions-impact.test.mjs
T

338 lines
11 KiB
JavaScript
Raw Normal View History

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}`);
});