import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; import { io as ioClient } from "socket.io-client"; // #216: when an app's required-permission set changes via the per-permission // CRUD endpoints, holders of the affected permission must receive an // `apps_changed` broadcast so /api/apps refetches without a manual reload. // Users who do not hold the permission must NOT be woken up — that contract // is what these tests pin down. The full-replace PUT path on /api/roles is // covered by `role-permissions-realtime.test.mjs`; this file is the mirror // for the app-side endpoints. 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 adminCookie; let holderId; let holderCookie; let groupHolderId; let groupHolderCookie; let helperGroupId; let outsiderId; let outsiderCookie; let testRoleId; let testPermId; let secondPermId; let testAppId; const createdRoleIds = []; const createdGroupIds = []; const createdAppIds = []; const createdPermIds = []; const createdUserIds = []; 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 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 }; } // Mirrors the connectSocket helper from role-permissions-realtime.test.mjs // but listens for `apps_changed` (a payload-less event) rather than // `role_permissions_changed`. Each call resolves on socket `connect` so the // user is already joined to its `user:` room when the test fires the // triggering write — without that, the broadcast can race the join and the // holder appears to silently miss the event. function connectSocket(cookie) { return new Promise((resolve, reject) => { const events = []; const waiters = []; const socket = ioClient(API_BASE, { path: "/api/socket.io", transports: ["websocket"], forceNew: true, reconnection: false, extraHeaders: { Cookie: cookie }, }); socket.on("apps_changed", (payload) => { events.push(payload ?? null); while (waiters.length > 0) waiters.shift()(payload ?? null); }); socket.on("connect", () => resolve({ socket, events, waitForEvent(timeoutMs = 2000) { if (events.length > 0) return Promise.resolve(events[events.length - 1]); return new Promise((res, rej) => { const timer = setTimeout(() => { const idx = waiters.indexOf(once); if (idx >= 0) waiters.splice(idx, 1); rej(new Error(`Timed out waiting for apps_changed after ${timeoutMs}ms`)); }, timeoutMs); const once = (payload) => { clearTimeout(timer); res(payload); }; waiters.push(once); }); }, }), ); socket.on("connect_error", (err) => reject(err)); }); } function waitMs(ms) { return new Promise((r) => setTimeout(r, ms)); } before(async () => { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; // Admin makes the per-permission writes via the requireAdmin gate. const admin = await makeUser("apr_admin", stamp); adminId = admin.id; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [adminId], ); adminCookie = await loginAndGetCookie(admin.username, TEST_PASSWORD); // Two fresh permissions: one we'll attach/detach to the test app (the // "subject"), and a second one the holder will own that should NOT trigger // a broadcast — proves the emit is keyed on the affected permission, not // a blanket fan-out to every permission holder in the system. const p1 = await pool.query( `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`, [`apr.subject.${stamp}`, "apps-permissions-realtime subject"], ); testPermId = p1.rows[0].id; createdPermIds.push(testPermId); const p2 = await pool.query( `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`, [`apr.unrelated.${stamp}`, "apps-permissions-realtime unrelated"], ); secondPermId = p2.rows[0].id; createdPermIds.push(secondPermId); // Non-system role that holds the subject permission. Holder gets this role // directly via user_roles so they're a permission holder via the canonical // path getRoleHolderUserIds uses internally. const roleRow = await pool.query( `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`, [`apr_role_${stamp}`, "apps-permissions-realtime role"], ); testRoleId = roleRow.rows[0].id; createdRoleIds.push(testRoleId); await pool.query( `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`, [testRoleId, testPermId], ); const holder = await makeUser("apr_holder", stamp); holderId = holder.id; await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, [holderId, testRoleId], ); holderCookie = await loginAndGetCookie(holder.username, TEST_PASSWORD); // groupHolder gets the permission transitively: user_groups -> group_roles // -> role_permissions. Mirrors the second leg of the helper's resolution // logic so a regression that drops the group-derived path would surface // here instead of slipping past with only the direct path tested. const grouped = await makeUser("apr_grouped", stamp); groupHolderId = grouped.id; const groupRow = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`, [`apr_group_${stamp}`, "apps-permissions-realtime group"], ); helperGroupId = groupRow.rows[0].id; createdGroupIds.push(helperGroupId); await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [groupHolderId, helperGroupId], ); await pool.query( `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, [helperGroupId, testRoleId], ); groupHolderCookie = await loginAndGetCookie(grouped.username, TEST_PASSWORD); // Outsider holds NO role and NO permission, so they must never receive // apps_changed for these writes. const outsider = await makeUser("apr_outsider", stamp); outsiderId = outsider.id; outsiderCookie = await loginAndGetCookie(outsider.username, TEST_PASSWORD); // The app the per-permission CRUD will mutate. Created without any // permission rows so each test starts from a known state. const slug = `apr_app_${stamp}`; const appRow = 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}`], ); testAppId = appRow.rows[0].id; createdAppIds.push(testAppId); }); after(async () => { if (createdAppIds.length) { await pool.query( `DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [createdAppIds], ); await pool.query( `DELETE FROM permission_audit WHERE target_kind = 'app' AND target_id = ANY($1::int[])`, [createdAppIds], ); await pool.query( `DELETE FROM audit_logs WHERE target_type = 'app' AND target_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 groups WHERE id = ANY($1::int[])`, [createdGroupIds]); } if (createdPermIds.length) { await pool.query( `DELETE FROM app_permissions WHERE permission_id = ANY($1::int[])`, [createdPermIds], ); await pool.query( `DELETE FROM role_permissions WHERE permission_id = ANY($1::int[])`, [createdPermIds], ); await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [createdPermIds]); } 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/apps/:id/permissions emits apps_changed to direct + group permission holders, not outsiders", async () => { const direct = await connectSocket(holderCookie); const grouped = await connectSocket(groupHolderCookie); const outsider = await connectSocket(outsiderCookie); try { const res = await fetch( `${API_BASE}/api/apps/${testAppId}/permissions`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionId: testPermId }), }, ); assert.equal(res.status, 201, "POST /apps/:id/permissions should succeed"); // Both legs of the helper's resolution path must wake up: direct via // user_roles and indirect via user_groups -> group_roles -> role_permissions. await Promise.all([ direct.waitForEvent(2000), grouped.waitForEvent(2000), ]); assert.equal(direct.events.length, 1); assert.equal(grouped.events.length, 1); // Negative case: give the loop a moment after the holders' events so a // (regression) extra emit to the outsider would have time to arrive, // then assert nothing landed. await waitMs(100); assert.equal( outsider.events.length, 0, `outsider must not receive apps_changed, got ${outsider.events.length} event(s)`, ); } finally { direct.socket.disconnect(); grouped.socket.disconnect(); outsider.socket.disconnect(); } }); test("DELETE /api/apps/:id/permissions/:permissionId emits apps_changed to direct + group permission holders, not outsiders", async () => { // Sanity: the previous test left the (testAppId, testPermId) row in // place, which is what the DELETE will remove. Asserting the precondition // here makes a future test-order change fail loudly instead of silently // turning this test into a no-op. const pre = await pool.query( `SELECT 1 FROM app_permissions WHERE app_id = $1 AND permission_id = $2`, [testAppId, testPermId], ); assert.equal(pre.rowCount, 1, "precondition: the pair must exist before DELETE"); const direct = await connectSocket(holderCookie); const grouped = await connectSocket(groupHolderCookie); const outsider = await connectSocket(outsiderCookie); try { const res = await fetch( `${API_BASE}/api/apps/${testAppId}/permissions/${testPermId}`, { method: "DELETE", headers: { Cookie: adminCookie } }, ); assert.equal(res.status, 204, "DELETE /apps/:id/permissions/:pid should succeed"); await Promise.all([ direct.waitForEvent(2000), grouped.waitForEvent(2000), ]); assert.equal(direct.events.length, 1); assert.equal(grouped.events.length, 1); await waitMs(100); assert.equal( outsider.events.length, 0, `outsider must not receive apps_changed, got ${outsider.events.length} event(s)`, ); } finally { direct.socket.disconnect(); grouped.socket.disconnect(); outsider.socket.disconnect(); } }); test("DELETE no-op (pair already gone) does not emit apps_changed", async () => { // Re-deleting the same pair from the previous test should be a 204 no-op // (the route is idempotent) AND must not trigger a spurious broadcast, // since nothing actually changed in app_permissions. const holder = await connectSocket(holderCookie); try { const res = await fetch( `${API_BASE}/api/apps/${testAppId}/permissions/${testPermId}`, { method: "DELETE", headers: { Cookie: adminCookie } }, ); assert.equal(res.status, 204, "idempotent re-DELETE should still be 204"); // Wait long enough that a real broadcast would have arrived, then assert // the holder's event buffer is empty. await waitMs(200); assert.equal( holder.events.length, 0, `no-op DELETE must not emit apps_changed, got ${holder.events.length} event(s)`, ); } finally { holder.socket.disconnect(); } });