Task #159: Block non-admin users from changing role permissions (tests)
## 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. Replit-Task-Id: 87446596-45f9-4241-acd6-299888b4feb4
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user