Files
TX/artifacts/api-server/tests/role-permissions-realtime.test.mjs
T
riyadhafraa 87b16fd256 Task #244: Permissions impact preview + live update test sweep (focused subset)
Landed 3 of 11 umbrella items, deferred the rest as 3 well-scoped follow-ups.

#231 — POST /apps with permissionIds[] is now pinned by two tests in
app-permissions-crud.test.mjs: success commits the app + permission rows
together with an audit_logs row, and an unknown permissionId returns 404
without leaving an orphan app row or a stray app.create audit row. Extended
the after() to clean up audit_logs + permission_audit so reruns stay
idempotent.

#215 — Added two socket tests in role-permissions-realtime.test.mjs for the
per-permission POST and DELETE endpoints, mirroring the existing PUT
coverage. Both assert direct + group-derived holders receive
role_permissions_changed and outsiders do not. Each test creates a fresh
role via makeFreshRoleWithMembers() so prior state can't bleed in.

#216 — Found a real gap: apps.ts emitted nothing when an app's required-
permission set changed. Added emitAppsChangedToPermissionHolders() to
lib/realtime.ts (resolves users via role_permissions -> user_roles and
group_roles -> user_groups, dedupes, reuses emitAppsChangedToUsers), and
wired it into POST/DELETE /apps/:id/permissions — only emitted when an
actual row was inserted/deleted, not on no-op retries. New test file
apps-permissions-realtime.test.mjs covers direct holder + group-derived
holder receipt and an idempotent no-op DELETE NOT emitting.

Skipped (already done): #226 (non-admin gates already covered),
#229 (impact-preview already handles the removal branch).

Validation: 13/13 tests across the 3 modified files pass; 66/66 across
related permission/audit suites pass; full server suite is 236/238 with
the 2 failures (executive-meetings notifications, service-orders status
matrix) being pre-existing in untouched files.

Architect review: APPROVED with no critical/high findings; took the
optional hardening suggestion to add group-holder coverage to the #216
tests so both legs of the helper's resolution path are exercised.

Files: artifacts/api-server/src/lib/realtime.ts,
       artifacts/api-server/src/routes/apps.ts,
       artifacts/api-server/tests/app-permissions-crud.test.mjs,
       artifacts/api-server/tests/role-permissions-realtime.test.mjs,
       artifacts/api-server/tests/apps-permissions-realtime.test.mjs (new)
2026-05-01 07:12:12 +00:00

381 lines
13 KiB
JavaScript

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