Add automated tests for assigning permissions to a role (Task #107)
Adds artifacts/api-server/tests/role-permissions-assign.test.mjs covering
the previously-uncovered role permission-assignment endpoints in
artifacts/api-server/src/routes/roles.ts:
- PUT /api/roles/:id/permissions
- Replaces an existing set (verifies returned body and the row set in
role_permissions in the DB).
- System-role guard: returns 400 with code "system_role_permissions"
and leaves the system role's permissions unchanged.
- Unknown permission id: returns 404 and the rejected PUT does NOT
partially apply (DB set unchanged).
- POST /api/roles/:id/permissions
- Adds a single permission, returns 201, and is idempotent on repeat
add (no duplicate row, still 201).
- System-role guard: 400 with code "system_role_permissions",
DB unchanged.
- Unknown permission id: 404, DB unchanged.
- DELETE /api/roles/:id/permissions/:permissionId
- Removes the permission and returns 204; idempotent on second delete.
- Idempotent (204, not 404) for an unknown permission id; the role's
other permissions are not collaterally removed.
- System-role guard: 400 with code "system_role_permissions",
DB permissions for system role unchanged.
Style mirrors tests/roles-crud.test.mjs and role-permission-audit.test.mjs:
test admin user provisioned in `before`, login via /api/auth/login, full
cleanup of created roles/users in `after`. Tests run via the standard
`pnpm --filter @workspace/api-server test` command.
Verified all 8 new tests pass against the running API server. The two
pre-existing failures in app-permissions-unique.test.mjs are unrelated
and already tracked by a separate task.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user