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