630d739732
## Original task The existing role-permission tests cover the system-role guard, unknown-permission 404, and the admin happy paths for PUT/POST/DELETE /api/roles/:id/permissions, but they never exercised the requireAdmin middleware with a real non-admin session. A silent regression of requireAdmin on these routes would let regular users edit role permissions undetected. ## What changed Added a new test file: artifacts/api-server/tests/role-permissions-non-admin.test.mjs It mirrors the style of role-permissions-assign.test.mjs: - Creates an admin user (used only to seed a target role with two known permissions). - Creates a regular user with no role assignments. - For each of PUT, POST, and DELETE on /api/roles/:id/permissions, logs in as the regular user and asserts the response status is 403. - After every rejected call, asserts the role's permission set in role_permissions is byte-for-byte unchanged. - For POST it deliberately picks a permission the role does NOT already have, so any regression would change the row count. - Cleans up created users, roles, role_permissions, and role_permission_audit rows in after(). ## Verification `pnpm --filter @workspace/api-server test` — all 192 tests pass, including the 3 new ones. ## Deviations None. Scope kept tight to the task description. ## Follow-up Proposed #226: parallel non-admin 403 coverage for POST/PATCH/DELETE /api/roles (CRUD), which today have no non-admin rejection tests either.
211 lines
7.5 KiB
JavaScript
211 lines
7.5 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import pg from "pg";
|
|
|
|
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 consumerId;
|
|
let consumerUsername;
|
|
let consumerCookie;
|
|
let targetRoleId;
|
|
let seededPermissionIds = [];
|
|
const createdRoleIds = [];
|
|
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 getDbPermissionIdsForRole(roleId) {
|
|
const rows = await pool.query(
|
|
`SELECT permission_id FROM role_permissions WHERE role_id = $1 ORDER BY permission_id`,
|
|
[roleId],
|
|
);
|
|
return rows.rows.map((r) => r.permission_id);
|
|
}
|
|
|
|
before(async () => {
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
// Admin user — used only to seed the target role with a known permission set
|
|
// so we can verify later that the rejected non-admin calls did not mutate it.
|
|
adminUsername = `role_perm_nonadmin_admin_${stamp}`;
|
|
const a = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Role Perm NonAdmin Admin', 'en', true) RETURNING id`,
|
|
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
adminId = a.rows[0].id;
|
|
createdUserIds.push(adminId);
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
|
[adminId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id)
|
|
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
|
|
ON CONFLICT DO NOTHING`,
|
|
[adminId],
|
|
);
|
|
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
|
|
|
// Regular (non-admin) user — has no role assignments at all.
|
|
consumerUsername = `role_perm_nonadmin_user_${stamp}`;
|
|
const c = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Role Perm NonAdmin User', 'en', true) RETURNING id`,
|
|
[consumerUsername, `${consumerUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
consumerId = c.rows[0].id;
|
|
createdUserIds.push(consumerId);
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id)
|
|
SELECT $1, id FROM groups WHERE name = 'Everyone'
|
|
ON CONFLICT DO NOTHING`,
|
|
[consumerId],
|
|
);
|
|
consumerCookie = await loginAndGetCookie(consumerUsername, TEST_PASSWORD);
|
|
|
|
// Create a target role via the admin API and seed it with two permissions
|
|
// so DELETE has something concrete to attempt to remove and we can detect
|
|
// any unwanted mutation in either direction.
|
|
const roleName = `role_perm_nonadmin_target_${stamp}`;
|
|
const roleRes = await fetch(`${API_BASE}/api/roles`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ name: roleName }),
|
|
});
|
|
assert.equal(roleRes.status, 201);
|
|
const roleBody = await roleRes.json();
|
|
targetRoleId = roleBody.id;
|
|
createdRoleIds.push(targetRoleId);
|
|
|
|
const permRows = await pool.query(
|
|
`SELECT id FROM permissions ORDER BY id LIMIT 2`,
|
|
);
|
|
assert.ok(permRows.rowCount >= 2, "need at least 2 seeded permissions");
|
|
seededPermissionIds = [permRows.rows[0].id, permRows.rows[1].id];
|
|
|
|
const seed = await fetch(
|
|
`${API_BASE}/api/roles/${targetRoleId}/permissions`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: seededPermissionIds }),
|
|
},
|
|
);
|
|
assert.equal(seed.status, 200);
|
|
assert.deepEqual(
|
|
await getDbPermissionIdsForRole(targetRoleId),
|
|
[...seededPermissionIds].sort((a, b) => a - b),
|
|
);
|
|
});
|
|
|
|
after(async () => {
|
|
if (createdRoleIds.length) {
|
|
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,
|
|
]);
|
|
}
|
|
for (const uid of createdUserIds) {
|
|
if (uid !== undefined) {
|
|
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 returns 403 for a non-admin user and does not mutate the role", async () => {
|
|
const before = await getDbPermissionIdsForRole(targetRoleId);
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/roles/${targetRoleId}/permissions`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: consumerCookie },
|
|
body: JSON.stringify({ permissionIds: [] }),
|
|
},
|
|
);
|
|
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
|
|
|
// The role's permission set must be byte-for-byte identical after the
|
|
// rejected call — the requireAdmin guard must short-circuit before any
|
|
// DB write happens.
|
|
assert.deepEqual(await getDbPermissionIdsForRole(targetRoleId), before);
|
|
});
|
|
|
|
test("POST /api/roles/:id/permissions returns 403 for a non-admin user and does not mutate the role", async () => {
|
|
const before = await getDbPermissionIdsForRole(targetRoleId);
|
|
|
|
// Pick a permission id that is NOT already on the role so that, if the
|
|
// guard ever regressed, we'd see the row count change.
|
|
const extraRow = await pool.query(
|
|
`SELECT id FROM permissions WHERE id <> ALL($1::int[]) ORDER BY id LIMIT 1`,
|
|
[before],
|
|
);
|
|
assert.ok(extraRow.rowCount > 0, "need a permission not already on the role");
|
|
const extraPermissionId = extraRow.rows[0].id;
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/roles/${targetRoleId}/permissions`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: consumerCookie },
|
|
body: JSON.stringify({ permissionId: extraPermissionId }),
|
|
},
|
|
);
|
|
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
|
|
|
assert.deepEqual(await getDbPermissionIdsForRole(targetRoleId), before);
|
|
});
|
|
|
|
test("DELETE /api/roles/:id/permissions/:permissionId returns 403 for a non-admin user and does not mutate the role", async () => {
|
|
const before = await getDbPermissionIdsForRole(targetRoleId);
|
|
assert.ok(
|
|
before.length > 0,
|
|
"target role should still have its seeded permissions",
|
|
);
|
|
const targetPermissionId = before[0];
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/roles/${targetRoleId}/permissions/${targetPermissionId}`,
|
|
{ method: "DELETE", headers: { Cookie: consumerCookie } },
|
|
);
|
|
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
|
|
|
assert.deepEqual(await getDbPermissionIdsForRole(targetRoleId), before);
|
|
});
|