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)
This commit is contained in:
Riyadh
2026-05-01 07:12:12 +00:00
parent ea196ea24f
commit 07753bb3e9
5 changed files with 635 additions and 0 deletions
+48
View File
@@ -4,6 +4,7 @@ import {
userRolesTable,
groupRolesTable,
userGroupsTable,
rolePermissionsTable,
} from "@workspace/db";
export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
@@ -90,6 +91,53 @@ export async function emitExecutiveMeetingsDaysChanged(
}
}
/**
* #216: Resolve every user that currently holds `permissionId` (via any role
* they hold directly through `user_roles` or transitively through
* `group_roles` -> `user_groups`) and emit `apps_changed` to each of them.
*
* Used by app-permission CRUD: when an app's required-permission set
* changes, the visibility of that app for any holder of the affected
* permission may have changed (gained or lost), so they need to refetch
* `/api/apps`. Users who do not hold the permission cannot be affected by
* this change in isolation, so we deliberately skip them — admins always
* see all apps and refetch on the response of their own write.
*/
export async function emitAppsChangedToPermissionHolders(
permissionId: number,
): Promise<void> {
if (!Number.isInteger(permissionId)) return;
const roleIdRows = await db
.select({ roleId: rolePermissionsTable.roleId })
.from(rolePermissionsTable)
.where(eq(rolePermissionsTable.permissionId, permissionId));
const roleIds = Array.from(new Set(roleIdRows.map((r) => r.roleId)));
if (roleIds.length === 0) return;
const directRows = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(inArray(userRolesTable.roleId, roleIds));
const groupIdRows = await db
.select({ groupId: groupRolesTable.groupId })
.from(groupRolesTable)
.where(inArray(groupRolesTable.roleId, roleIds));
const groupIds = groupIdRows.map((r) => r.groupId);
const indirectRows = groupIds.length > 0
? await db
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(inArray(userGroupsTable.groupId, groupIds))
: [];
await emitAppsChangedToUsers([
...directRows.map((r) => r.userId),
...indirectRows.map((r) => r.userId),
]);
}
/**
* Notify every holder of `roleId` that the role's permission set has been
* updated, so the client can invalidate cached `/api/auth/me` and
+8
View File
@@ -23,6 +23,7 @@ import {
parsePermissionAuditFilters,
recordPermissionAudit,
} from "../lib/permission-audit";
import { emitAppsChangedToPermissionHolders } from "../lib/realtime";
import {
CreateAppBody,
UpdateAppBody,
@@ -712,6 +713,10 @@ router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise<voi
permissionName: perm.name,
},
});
// #216: visibility of this app for permission holders just changed —
// wake them up so /api/apps refetches without a manual reload. Only
// emitted when an actual row was inserted (not a no-op retry).
await emitAppsChangedToPermissionHolders(perm.id);
}
res.status(201).json({ appId: app.id, permissionId });
@@ -792,6 +797,9 @@ router.delete(
permissionName: perm?.name ?? null,
},
});
// #216: dropping a required permission can change app visibility for
// anyone who held it, so wake them up to refetch /api/apps.
await emitAppsChangedToPermissionHolders(permissionId);
}
res.sendStatus(204);
@@ -144,6 +144,17 @@ after(async () => {
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
[createdAppIds],
);
// The new #231 tests below exercise POST /api/apps which writes
// audit_logs + permission_audit rows alongside the app row. Clean both
// up here so reruns stay idempotent and don't accumulate stamped rows.
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,
]);
@@ -318,3 +329,93 @@ test("non-admins receive 403 from every app-permissions admin endpoint", async (
// None of the rejected calls should have written a row.
assert.deepEqual(await getPairsForApp(appId), []);
});
// #231: POST /apps with permissionIds[] commits the app row and its permission
// rows in a single transaction. The route also pre-validates every permission
// id so it can reject the whole call with 404 before touching apps_table — the
// two tests below pin both halves of that contract so a future refactor can't
// silently start leaving orphaned app rows behind on a bad permission id.
test("POST /api/apps with permissionIds[] commits the app and its permission rows together", async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const slug = `apc_create_with_perms_${stamp}`;
const { id: p1 } = await createPermission("apc.create.a");
const { id: p2 } = await createPermission("apc.create.b");
const res = await fetch(`${API_BASE}/api/apps`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({
slug,
nameAr: slug,
nameEn: slug,
iconName: "Box",
route: `/${slug}`,
color: "#000000",
permissionIds: [p1, p2],
}),
});
assert.equal(res.status, 201);
const created = await res.json();
assert.ok(Number.isInteger(created.id));
createdAppIds.push(created.id);
// Both permission rows landed atomically with the app.
assert.deepEqual(
await getPairsForApp(created.id),
[p1, p2].sort((a, b) => a - b),
);
// The audit_logs row for app.create exists and records the gating
// permissionIds the admin requested, so the history view shows the gate
// was set at create time (not in a separate POST).
const audit = await pool.query(
`SELECT metadata FROM audit_logs
WHERE action = 'app.create' AND target_type = 'app' AND target_id = $1`,
[created.id],
);
assert.equal(audit.rowCount, 1);
assert.deepEqual(
[...audit.rows[0].metadata.permissionIds].sort((a, b) => a - b),
[p1, p2].sort((a, b) => a - b),
);
});
test("POST /api/apps with an unknown permissionId rolls back: no orphan app row, no audit row", async () => {
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const slug = `apc_create_rollback_${stamp}`;
const { id: realPerm } = await createPermission("apc.rollback.real");
const unknownPermRow = await pool.query(
`SELECT COALESCE(MAX(id), 0) AS m FROM permissions`,
);
const unknownPermId = unknownPermRow.rows[0].m + 9999;
const res = await fetch(`${API_BASE}/api/apps`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({
slug,
nameAr: slug,
nameEn: slug,
iconName: "Box",
route: `/${slug}`,
color: "#000000",
permissionIds: [realPerm, unknownPermId],
}),
});
assert.equal(res.status, 404);
// The slug must NOT exist in apps — even partial creation would leave the
// app reachable in an "unrestricted" state, defeating the gate the admin
// asked for. Using the slug (not an id) is intentional: the request never
// got an id back, so this is the only handle we have on the would-be row.
const orphan = await pool.query(`SELECT id FROM apps WHERE slug = $1`, [slug]);
assert.equal(orphan.rowCount, 0, "no orphan app row should exist after a 404");
// And no audit_logs row should claim we created it either.
const audit = await pool.query(
`SELECT 1 FROM audit_logs
WHERE action = 'app.create' AND metadata->>'slug' = $1`,
[slug],
);
assert.equal(audit.rowCount, 0, "no app.create audit row should exist after a 404");
});
@@ -0,0 +1,371 @@
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:<id>` 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();
}
});
@@ -271,3 +271,110 @@ test("PUT /api/roles/:id/permissions emits role_permissions_changed to direct an
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();
}
});