diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 4ba7be39..535d05e0 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -46,6 +46,7 @@ "esbuild-plugin-pino": "^2.3.3", "pg": "^8.20.0", "pino-pretty": "^13", + "socket.io-client": "^4.8.3", "thread-stream": "3.1.0" } } diff --git a/artifacts/api-server/tests/role-permissions-realtime.test.mjs b/artifacts/api-server/tests/role-permissions-realtime.test.mjs new file mode 100644 index 00000000..79cc16ba --- /dev/null +++ b/artifacts/api-server/tests/role-permissions-realtime.test.mjs @@ -0,0 +1,273 @@ +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(); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bcc0cf1..16b49841 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: pino-pretty: specifier: ^13 version: 13.1.3 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 thread-stream: specifier: 3.1.0 version: 3.1.0