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"; 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; let directHolderId; let directHolderCookie; let groupHolderId; let groupHolderCookie; let helperGroupId; let outsiderId; let outsiderCookie; let testRoleId; let permIdA; let permIdB; const createdRoleIds = []; const createdGroupIds = []; 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 }; } /** * Open an authenticated socket and resolve once `connect` fires (so the server * has joined the user's room and is ready to deliver events). Records every * `role_permissions_changed` payload it receives in the returned `events` * array so callers can assert on what arrived (or didn't), and exposes a * `waitForEvent(timeoutMs)` that resolves as soon as the next event lands. */ 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("role_permissions_changed", (payload) => { events.push(payload); while (waiters.length > 0) waiters.shift()(payload); }); socket.on("connect", () => resolve({ socket, events, waitForEvent(timeoutMs = 1000) { // If an event already arrived before the caller started waiting, // resolve immediately so we don't time out spuriously. 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 role_permissions_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 (used to call PUT /api/roles/:id/permissions). const admin = await makeUser("rt_admin", stamp); adminId = admin.id; adminUsername = admin.username; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, [adminId], ); adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); // The non-system role whose permissions we will mutate. const roleRow = await pool.query( `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`, [`rt_role_${stamp}`, "realtime test role"], ); testRoleId = roleRow.rows[0].id; createdRoleIds.push(testRoleId); // Two seeded permissions are enough to drive a real PUT. const perms = await pool.query( `SELECT id FROM permissions ORDER BY id LIMIT 2`, ); assert.ok(perms.rowCount >= 2, "need at least 2 seeded permissions"); permIdA = perms.rows[0].id; permIdB = perms.rows[1].id; // directHolder gets the role through user_roles directly. const direct = await makeUser("rt_direct", stamp); directHolderId = direct.id; await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, [directHolderId, testRoleId], ); directHolderCookie = await loginAndGetCookie(direct.username, TEST_PASSWORD); // groupHolder gets the role transitively: user_groups -> group_roles. const grouped = await makeUser("rt_grouped", stamp); groupHolderId = grouped.id; const groupRow = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`, [`rt_group_${stamp}`, "realtime test 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 does not hold the role at all (direct or via group). const outsider = await makeUser("rt_outsider", stamp); outsiderId = outsider.id; outsiderCookie = await loginAndGetCookie(outsider.username, TEST_PASSWORD); }); 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_permission_audit 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, ]); } 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("PUT /api/roles/:id/permissions emits role_permissions_changed to direct and group-derived holders, but not to outsiders", async () => { const direct = await connectSocket(directHolderCookie); const grouped = await connectSocket(groupHolderCookie); const outsider = await connectSocket(outsiderCookie); try { const put = await fetch( `${API_BASE}/api/roles/${testRoleId}/permissions`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionIds: [permIdA, permIdB] }), }, ); assert.equal(put.status, 200, "PUT should succeed"); // Wait for the event on each holder rather than sleeping a fixed amount — // resolves as soon as the broadcast lands and fails fast on regressions. const [directPayload, groupedPayload] = await Promise.all([ direct.waitForEvent(2000), grouped.waitForEvent(2000), ]); assert.deepEqual(directPayload, { roleId: testRoleId }); assert.deepEqual(groupedPayload, { roleId: testRoleId }); assert.equal( direct.events.length, 1, `direct holder should receive exactly one role_permissions_changed event, got ${direct.events.length}`, ); assert.equal( grouped.events.length, 1, `group-derived holder should receive exactly one role_permissions_changed event, got ${grouped.events.length}`, ); // Negative case: give the loop a moment after both holders received their // 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 role_permissions_changed, got ${outsider.events.length} event(s)`, ); } finally { direct.socket.disconnect(); grouped.socket.disconnect(); outsider.socket.disconnect(); } }); // #215: the full-replace PUT is covered above, but the per-permission // POST/DELETE endpoints share the same emit contract and must also wake up // holders without spamming outsiders. Each test below uses a fresh role so // the seeded testRoleId state from the PUT test above can't bleed in. async function makeFreshRoleWithMembers(label) { const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const r = await pool.query( `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`, [`rt_${label}_${stamp}`, `realtime ${label} role`], ); const roleId = r.rows[0].id; createdRoleIds.push(roleId); // Reuse the existing direct/grouped/outsider users — direct + grouped get // membership in this fresh role so they should receive the broadcast. await pool.query( `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, [directHolderId, roleId], ); await pool.query( `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, [helperGroupId, roleId], ); return roleId; } test("POST /api/roles/:id/permissions emits role_permissions_changed to direct + group holders, not outsiders", async () => { const roleId = await makeFreshRoleWithMembers("post"); const direct = await connectSocket(directHolderCookie); const grouped = await connectSocket(groupHolderCookie); const outsider = await connectSocket(outsiderCookie); try { const post = await fetch( `${API_BASE}/api/roles/${roleId}/permissions`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: adminCookie }, body: JSON.stringify({ permissionId: permIdA }), }, ); assert.equal(post.status, 201, "POST should succeed"); const [directPayload, groupedPayload] = await Promise.all([ direct.waitForEvent(2000), grouped.waitForEvent(2000), ]); assert.deepEqual(directPayload, { roleId }); assert.deepEqual(groupedPayload, { roleId }); 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 role_permissions_changed, got ${outsider.events.length} event(s)`, ); } finally { direct.socket.disconnect(); grouped.socket.disconnect(); outsider.socket.disconnect(); } }); test("DELETE /api/roles/:id/permissions/:permissionId emits role_permissions_changed to direct + group holders, not outsiders", async () => { const roleId = await makeFreshRoleWithMembers("delete"); // Seed the role with a permission row directly so the DELETE has something // to remove (and so the assertion isolates the DELETE emit, not a prior // POST emit landing in the buffer). await pool.query( `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`, [roleId, permIdA], ); const direct = await connectSocket(directHolderCookie); const grouped = await connectSocket(groupHolderCookie); const outsider = await connectSocket(outsiderCookie); try { const del = await fetch( `${API_BASE}/api/roles/${roleId}/permissions/${permIdA}`, { method: "DELETE", headers: { Cookie: adminCookie } }, ); assert.equal(del.status, 204, "DELETE should succeed"); const [directPayload, groupedPayload] = await Promise.all([ direct.waitForEvent(2000), grouped.waitForEvent(2000), ]); assert.deepEqual(directPayload, { roleId }); assert.deepEqual(groupedPayload, { roleId }); 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 role_permissions_changed, got ${outsider.events.length} event(s)`, ); } finally { direct.socket.disconnect(); grouped.socket.disconnect(); outsider.socket.disconnect(); } });