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 adminUsername; let adminCookie; let consumerUsername; let consumerCookie; const createdAppIds = []; const createdPermissionIds = []; const createdRoleIds = []; const createdGroupIds = []; const createdUserIds = []; let appId; let permA; let permB; let permC; let roleA; let roleB; let roleC; let userA; // direct role A only — has permA let userAB; // direct roles A and B — has permA and permB let userViaGroup; // in a group that has role A — has permA via group let helperGroupId; let groupGrantedAppId; // app explicitly granted to a group via group_apps let groupAppGrantUserId; // member of that group; sees the app even with no perms let groupAppGroupId; async function login(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 username = `${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`, [ username, `${username}@example.com`, TEST_PASSWORD_HASH, `${prefix} User`, ], ); const id = r.rows[0].id; createdUserIds.push(id); return { id, username }; } 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, perms) { 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); for (const p of perms) { await pool.query( `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`, [id, p.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; } async function makeApp(prefix, stamp) { const slug = `${prefix}_${stamp}`; const r = await pool.query( `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order) VALUES ($1, $2, $3, $4, true, 999) RETURNING id`, [slug, slug, slug, `/${slug}`], ); const id = r.rows[0].id; createdAppIds.push(id); return id; } before(async () => { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; // Admin user (granted system-wide admin role). const admin = await makeUser("api_admin", stamp); adminUsername = admin.username; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [admin.id], ); // Plain consumer (used to verify the endpoint requires admin). const consumer = await makeUser("api_consumer", stamp); consumerUsername = consumer.username; adminCookie = await login(adminUsername, TEST_PASSWORD); consumerCookie = await login(consumerUsername, TEST_PASSWORD); permA = await makePermission("api.perm.a", stamp); permB = await makePermission("api.perm.b", stamp); permC = await makePermission("api.perm.c", stamp); roleA = await makeRole("api_role_a", stamp, [permA]); roleB = await makeRole("api_role_b", stamp, [permB]); roleC = await makeRole("api_role_c", stamp, [permC]); // userA: direct roleA → has permA. ({ id: userA } = await makeUser("api_user_a", stamp)); await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, [userA, roleA], ); // userAB: direct roles A and B → has permA and permB. ({ id: userAB } = await makeUser("api_user_ab", stamp)); await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2), ($1, $3)`, [userAB, roleA, roleB], ); // userViaGroup: gets roleA via membership in helperGroup → has permA via group. ({ id: userViaGroup } = await makeUser("api_user_viagrp", stamp)); helperGroupId = await makeGroup("api_helper_grp", stamp); await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [userViaGroup, helperGroupId], ); await pool.query( `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, [helperGroupId, roleA], ); // App that we will run impact previews against. appId = await makeApp("api_impact_app", stamp); // Separate scenario for the group-grant offset: an app with no permissions // but explicitly granted to a group via group_apps. A member of that group // sees the app even after we add a permission requirement they don't have. groupGrantedAppId = await makeApp("api_grpapp", stamp); groupAppGroupId = await makeGroup("api_grpapp_grp", stamp); await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, [groupAppGroupId, groupGrantedAppId], ); ({ id: groupAppGrantUserId } = await makeUser("api_grpapp_user", stamp)); await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [groupAppGrantUserId, groupAppGroupId], ); }); after(async () => { if (createdAppIds.length) { await pool.query( `DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query( `DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [ createdAppIds, ]); } 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 group_apps 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 app_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(); }); async function preview(appIdToCheck, permissionIds, cookie = adminCookie) { return fetch( `${API_BASE}/api/apps/${appIdToCheck}/permissions/impact-preview`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ permissionIds }), }, ); } test("preview short-circuits with noChange=true when candidate matches current set", async () => { // Seed app with permA so the candidate exactly matches. await pool.query( `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [appId, permA.id], ); try { const res = await preview(appId, [permA.id]); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.noChange, true); assert.equal(body.affectedUserCount, 0); // currentlyVisibleUserCount must be the real audience size — not // hardcoded to 0 — so consumers reading a noChange response still // get an accurate count of how many users currently see the app. // At minimum userA, userAB, and userViaGroup hold permA and should // appear in the count. assert.ok( body.currentlyVisibleUserCount >= 3, `expected currentlyVisibleUserCount >= 3, got ${body.currentlyVisibleUserCount}`, ); assert.deepEqual(body.groups, []); } finally { await pool.query( `DELETE FROM app_permissions WHERE app_id = $1 AND permission_id = $2`, [appId, permA.id], ); } }); test("preview reports affected users when adding a permission requirement to an unrestricted app", async () => { // App is currently unrestricted — every non-admin user can see it. // Candidate: require permB. Only userAB has permB. // Expected: at minimum, userA and userViaGroup lose access (they have // permA only). Other unrelated users in the DB also currently see the // app (since it's unrestricted), so we use a >= assertion to stay // robust against test data drift in the surrounding DB. const res = await preview(appId, [permB.id]); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.noChange, false); // Sanity: a positive number of users currently see the unrestricted app // and at least the two test users without permB lose access. assert.ok(body.currentlyVisibleUserCount >= 3); assert.ok( body.affectedUserCount >= 2, `expected at least 2 affected, got ${body.affectedUserCount}`, ); // userAB has permB so they keep access — affected must be strictly less // than currently visible. assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount); }); test("preview narrows affected users when candidate keeps a permission they already hold", async () => { // Seed the app with permA AND permB required (OR semantics: holding // either one grants visibility). Then ask "what if we remove permB?" // — which keeps permA. Only users who exclusively have permB lose // access, so userA and userViaGroup are unaffected (they still have // permA). userAB also keeps access via permA. await pool.query( `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2), ($1, $3) ON CONFLICT DO NOTHING`, [appId, permA.id, permB.id], ); try { const res = await preview(appId, [permA.id]); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.noChange, false); // None of our scenario users should lose access since they all have // permA (or are in a group that grants permA). Other users in the // surrounding DB might lose access if they only had permB, but our // three tracked users must NOT. // We verify by sanity-checking that currentlyVisibleUserCount stays // sensible; the strict invariant we rely on is the comparison below. assert.ok(body.currentlyVisibleUserCount >= 3); // affected must be < currentlyVisibleUserCount because userA, userAB // and userViaGroup all keep access via permA. assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount); } finally { await pool.query( `DELETE FROM app_permissions WHERE app_id = $1 AND permission_id IN ($2, $3)`, [appId, permA.id, permB.id], ); } }); test("preview surfaces groups that grant the app via group_apps and they offset the loss", async () => { // groupGrantedAppId is unrestricted and explicitly granted to // groupAppGroupId. Adding a requirement that no test user holds // (permC, only assigned to roleC, which nobody in our scenario has) // would otherwise hide the app from groupAppGrantUserId — but that // user is in a group that grants the app, so they keep access. const res = await preview(groupGrantedAppId, [permC.id]); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.noChange, false); // Groups list must include the group that grants the app. const groupNames = body.groups.map((g) => g.id); assert.ok( groupNames.includes(groupAppGroupId), `expected groups to include ${groupAppGroupId}, got ${JSON.stringify(body.groups)}`, ); // groupAppGrantUserId must NOT be counted as affected — they keep // access via the group_apps grant. We can't directly read the affected // user IDs (the endpoint only returns counts), but we can prove the // offset works by re-running the preview against an *identical app* // without the group-grant and confirming the count is strictly higher // by at least 1. We achieve this by inserting and removing a // group_apps row on the same app. await pool.query( `DELETE FROM group_apps WHERE group_id = $1 AND app_id = $2`, [groupAppGroupId, groupGrantedAppId], ); try { const without = await preview(groupGrantedAppId, [permC.id]); assert.equal(without.status, 200); const wbody = await without.json(); // Removing the group grant should INCREASE affectedUserCount by at // least 1 (groupAppGrantUserId now has no offset). assert.ok( wbody.affectedUserCount >= body.affectedUserCount + 1, `expected affected count to grow when group_apps offset is removed (with offset: ${body.affectedUserCount}, without: ${wbody.affectedUserCount})`, ); } finally { // Restore the grant so other tests / cleanup remain happy. await pool.query( `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [groupAppGroupId, groupGrantedAppId], ); } }); test("preview returns 404 for an unknown app", async () => { const max = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`); const unknownId = max.rows[0].m + 9999; const res = await preview(unknownId, [permA.id]); assert.equal(res.status, 404); }); test("preview returns 400 when permissionIds is missing or invalid", async () => { const noBody = await fetch( `${API_BASE}/api/apps/${appId}/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/apps/${appId}/permissions/impact-preview`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: ["abc"] }), }, ); assert.equal(wrong.status, 400); }); test("preview requires admin", async () => { const res = await preview(appId, [permA.id], consumerCookie); assert.ok( res.status === 401 || res.status === 403, `expected 401/403, got ${res.status}`, ); });