Task #151: Add automated tests for the live role-permission updates
Adds `artifacts/api-server/tests/role-permissions-realtime.test.mjs`,
covering the `role_permissions_changed` socket event emitted from
`PUT /api/roles/:id/permissions`.
The test:
- Stands up an admin caller plus three holders/non-holders:
- a direct holder via `user_roles`,
- an indirect holder reached via `group_roles` -> `user_groups`,
- an outsider with no claim on the role.
- Logs each in via `/api/auth/login`, opens an authenticated
`socket.io-client` socket per user against `/api/socket.io` (the
same path/transports the real client uses) and waits for `connect`
before issuing the PUT, so the user-room join is guaranteed.
- Asserts both holders receive exactly one
`role_permissions_changed` event with payload `{ roleId }`.
- Asserts the outsider receives zero such events.
Also adds `socket.io-client` as a devDependency on
`@workspace/api-server` (no production code touched).
Notes / non-deviations:
- Pre-existing typecheck errors in `routes/groups.ts` and
`routes/executive-meetings.ts` are unrelated and were not
introduced by this change.
- The audit-style file `role-permission-audit.test.mjs` was used as
the setup/teardown template per the task description.
- After code review feedback, replaced the fixed 250 ms sleep with
a promise-based `waitForEvent(timeoutMs)` helper so the holder
assertions resolve as soon as the broadcast lands (test now
finishes in ~700 ms instead of ~1850 ms). A short 100 ms grace
is still used before the outsider negative-case assertion to
give a regression emit time to arrive.
Follow-ups proposed:
- #215 cover the same fan-out for POST/DELETE permission endpoints.
- #216 cover the sibling `apps_changed` fan-out via
`emitAppsChangedToRoleHolders`.
Replit-Task-Id: 1c3092d0-7339-470a-bb2a-aa7e48d976d2
This commit is contained in:
@@ -46,6 +46,7 @@
|
|||||||
"esbuild-plugin-pino": "^2.3.3",
|
"esbuild-plugin-pino": "^2.3.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"pino-pretty": "^13",
|
"pino-pretty": "^13",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"thread-stream": "3.1.0"
|
"thread-stream": "3.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+3
@@ -178,6 +178,9 @@ importers:
|
|||||||
pino-pretty:
|
pino-pretty:
|
||||||
specifier: ^13
|
specifier: ^13
|
||||||
version: 13.1.3
|
version: 13.1.3
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
thread-stream:
|
thread-stream:
|
||||||
specifier: 3.1.0
|
specifier: 3.1.0
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user