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