diff --git a/artifacts/api-server/tests/role-permissions-assign.test.mjs b/artifacts/api-server/tests/role-permissions-assign.test.mjs new file mode 100644 index 00000000..adef09f2 --- /dev/null +++ b/artifacts/api-server/tests/role-permissions-assign.test.mjs @@ -0,0 +1,326 @@ +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; +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=")); +} + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + adminUsername = `role_perm_assign_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 Assign 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); +}); + +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(); +}); + +function uniqueRoleName(prefix) { + const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${stamp}`; +} + +async function createRole(prefix = "role_perm_assign") { + const res = await fetch(`${API_BASE}/api/roles`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ name: uniqueRoleName(prefix) }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + createdRoleIds.push(body.id); + return body; +} + +async function getTwoPermissionIds() { + const rows = await pool.query( + `SELECT id FROM permissions ORDER BY id LIMIT 2`, + ); + assert.ok(rows.rowCount >= 2, "need at least 2 seeded permissions"); + return [rows.rows[0].id, rows.rows[1].id]; +} + +async function getSystemAdminRoleId() { + const r = await pool.query(`SELECT id FROM roles WHERE name = 'admin' LIMIT 1`); + assert.ok(r.rowCount > 0, "system 'admin' role must exist"); + return r.rows[0].id; +} + +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); +} + +test("PUT /api/roles/:id/permissions replaces the role's permission set", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // Seed an initial set with only p1. + const seed = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1] }), + }); + assert.equal(seed.status, 200); + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p1].sort((a, b) => a - b)); + + // Replace with [p2] — p1 should be gone, p2 should be present. + const replace = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p2] }), + }); + assert.equal(replace.status, 200); + const body = await replace.json(); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 1); + assert.equal(body[0].id, p2); + + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p2]); +}); + +test("PUT /api/roles/:id/permissions on a system role returns 400 with code system_role_permissions", async () => { + const sysId = await getSystemAdminRoleId(); + const [p1] = await getTwoPermissionIds(); + const beforeIds = await getDbPermissionIdsForRole(sysId); + + const res = await fetch(`${API_BASE}/api/roles/${sysId}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1] }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, "system_role_permissions"); + + // System role's permissions must not have been altered by the rejected call. + assert.deepEqual(await getDbPermissionIdsForRole(sysId), beforeIds); +}); + +test("PUT /api/roles/:id/permissions with an unknown permission id returns 404", async () => { + const role = await createRole(); + const [p1] = await getTwoPermissionIds(); + const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM permissions`); + const unknownId = maxRow.rows[0].m + 9999; + + const res = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1, unknownId] }), + }); + assert.equal(res.status, 404); + const body = await res.json(); + assert.match(String(body.error || ""), /permission not found/i); + + // The role's permissions must remain empty — the rejected PUT must not + // partially apply. + assert.deepEqual(await getDbPermissionIdsForRole(role.id), []); +}); + +test("POST /api/roles/:id/permissions adds one permission and returns 201", async () => { + const role = await createRole(); + const [p1] = await getTwoPermissionIds(); + + const res = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: p1 }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(body.roleId, role.id); + assert.equal(body.permissionId, p1); + + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p1]); + + // Re-adding the same permission must remain idempotent (no duplicate row, + // still returns 201) so the UI can safely retry. + const again = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: p1 }), + }); + assert.equal(again.status, 201); + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p1]); +}); + +test("POST /api/roles/:id/permissions on a system role returns 400 with code system_role_permissions", async () => { + const sysId = await getSystemAdminRoleId(); + const [p1] = await getTwoPermissionIds(); + const beforeIds = await getDbPermissionIdsForRole(sysId); + + const res = await fetch(`${API_BASE}/api/roles/${sysId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: p1 }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, "system_role_permissions"); + + assert.deepEqual(await getDbPermissionIdsForRole(sysId), beforeIds); +}); + +test("POST /api/roles/:id/permissions with an unknown permission id returns 404", async () => { + const role = await createRole(); + const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM permissions`); + const unknownId = maxRow.rows[0].m + 9999; + + const res = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: unknownId }), + }); + assert.equal(res.status, 404); + const body = await res.json(); + assert.match(String(body.error || ""), /permission not found/i); + + assert.deepEqual(await getDbPermissionIdsForRole(role.id), []); +}); + +test("DELETE /api/roles/:id/permissions/:permissionId removes the permission and returns 204", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + const seed = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1, p2] }), + }); + assert.equal(seed.status, 200); + + const del = await fetch( + `${API_BASE}/api/roles/${role.id}/permissions/${p1}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(del.status, 204); + + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p2]); + + // Deleting a permission the role does not have must still return 204 + // (idempotent) without affecting the rest of the set. + const delAgain = await fetch( + `${API_BASE}/api/roles/${role.id}/permissions/${p1}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(delAgain.status, 204); + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p2]); +}); + +test("DELETE /api/roles/:id/permissions/:permissionId is idempotent (204) for an unknown permission id", async () => { + const role = await createRole(); + const [p1] = await getTwoPermissionIds(); + + // Seed the role with one real permission so we can confirm the unknown + // delete does not collaterally remove anything. + const seed = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: [p1] }), + }); + assert.equal(seed.status, 200); + + const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM permissions`); + const unknownId = maxRow.rows[0].m + 9999; + + // The DELETE handler intentionally returns 204 (idempotent) when the + // permission id doesn't exist — there's nothing to remove. Locking this + // explicitly so the contract isn't accidentally changed to 404 later. + const res = await fetch( + `${API_BASE}/api/roles/${role.id}/permissions/${unknownId}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + + // The role's existing permissions must be untouched. + assert.deepEqual(await getDbPermissionIdsForRole(role.id), [p1]); +}); + +test("DELETE /api/roles/:id/permissions/:permissionId on a system role returns 400 with code system_role_permissions", async () => { + const sysId = await getSystemAdminRoleId(); + const beforeIds = await getDbPermissionIdsForRole(sysId); + assert.ok(beforeIds.length > 0, "system 'admin' role should have permissions seeded"); + const targetPermId = beforeIds[0]; + + const res = await fetch( + `${API_BASE}/api/roles/${sysId}/permissions/${targetPermId}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, "system_role_permissions"); + + // The system role must keep all of its permissions. + assert.deepEqual(await getDbPermissionIdsForRole(sysId), beforeIds); +});