338 lines
11 KiB
JavaScript
338 lines
11 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;
|
||
|
|
const createdRoleIds = [];
|
||
|
|
const createdGroupIds = [];
|
||
|
|
const createdUserIds = [];
|
||
|
|
const createdPermissionIds = [];
|
||
|
|
|
||
|
|
let directUserId;
|
||
|
|
let groupedUserId;
|
||
|
|
let bothUserId;
|
||
|
|
let helperGroupId;
|
||
|
|
let mainRoleId;
|
||
|
|
let aloneRoleId;
|
||
|
|
let alsoGrantsP1RoleId;
|
||
|
|
let permA;
|
||
|
|
let permB;
|
||
|
|
let permC;
|
||
|
|
|
||
|
|
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 makeUser(prefix, stamp) {
|
||
|
|
const r = await pool.query(
|
||
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||
|
|
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||
|
|
[
|
||
|
|
`${prefix}_${stamp}`,
|
||
|
|
`${prefix}_${stamp}@example.com`,
|
||
|
|
TEST_PASSWORD_HASH,
|
||
|
|
`${prefix} User`,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
const id = r.rows[0].id;
|
||
|
|
createdUserIds.push(id);
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function makePermission(prefix, stamp) {
|
||
|
|
const r = await pool.query(
|
||
|
|
`INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id, name`,
|
||
|
|
[`${prefix}_${stamp}`, `${prefix} permission`],
|
||
|
|
);
|
||
|
|
const row = r.rows[0];
|
||
|
|
createdPermissionIds.push(row.id);
|
||
|
|
return row;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function makeRole(prefix, stamp) {
|
||
|
|
const r = await pool.query(
|
||
|
|
`INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`,
|
||
|
|
[`${prefix}_${stamp}`, `${prefix} role`],
|
||
|
|
);
|
||
|
|
const id = r.rows[0].id;
|
||
|
|
createdRoleIds.push(id);
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function makeGroup(prefix, stamp) {
|
||
|
|
const r = await pool.query(
|
||
|
|
`INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`,
|
||
|
|
[`${prefix}_${stamp}`, `${prefix} group`],
|
||
|
|
);
|
||
|
|
const id = r.rows[0].id;
|
||
|
|
createdGroupIds.push(id);
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
before(async () => {
|
||
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||
|
|
adminUsername = `imp_admin_${stamp}`;
|
||
|
|
adminId = await makeUser("imp_admin", stamp);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||
|
|
[adminId],
|
||
|
|
);
|
||
|
|
// overwrite the username we want to log in with (makeUser uses prefix_stamp)
|
||
|
|
adminUsername = (
|
||
|
|
await pool.query(`SELECT username FROM users WHERE id = $1`, [adminId])
|
||
|
|
).rows[0].username;
|
||
|
|
|
||
|
|
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||
|
|
|
||
|
|
permA = await makePermission("perm_a", stamp);
|
||
|
|
permB = await makePermission("perm_b", stamp);
|
||
|
|
permC = await makePermission("perm_c", stamp);
|
||
|
|
|
||
|
|
// Main role: has perms A, B, C.
|
||
|
|
mainRoleId = await makeRole("imp_main", stamp);
|
||
|
|
for (const p of [permA, permB, permC]) {
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`,
|
||
|
|
[mainRoleId, p.id],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Alone role: has only perm A. Used to ensure a user keeps perm A after main loses it.
|
||
|
|
aloneRoleId = await makeRole("imp_alone", stamp);
|
||
|
|
alsoGrantsP1RoleId = aloneRoleId;
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`,
|
||
|
|
[aloneRoleId, permA.id],
|
||
|
|
);
|
||
|
|
|
||
|
|
// directUser: has main role directly. No other roles.
|
||
|
|
directUserId = await makeUser("imp_direct", stamp);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
|
||
|
|
[directUserId, mainRoleId],
|
||
|
|
);
|
||
|
|
|
||
|
|
// groupedUser: receives main role via a group.
|
||
|
|
groupedUserId = await makeUser("imp_grouped", stamp);
|
||
|
|
helperGroupId = await makeGroup("imp_helper", stamp);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
|
||
|
|
[groupedUserId, helperGroupId],
|
||
|
|
);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
|
||
|
|
[helperGroupId, mainRoleId],
|
||
|
|
);
|
||
|
|
|
||
|
|
// bothUser: has main role directly AND aloneRole (which also grants perm A).
|
||
|
|
bothUserId = await makeUser("imp_both", stamp);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
|
||
|
|
[bothUserId, mainRoleId],
|
||
|
|
);
|
||
|
|
await pool.query(
|
||
|
|
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
|
||
|
|
[bothUserId, aloneRoleId],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
after(async () => {
|
||
|
|
if (createdRoleIds.length) {
|
||
|
|
await pool.query(`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [
|
||
|
|
createdRoleIds,
|
||
|
|
]);
|
||
|
|
await pool.query(`DELETE FROM group_roles 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,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
if (createdGroupIds.length) {
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
|
||
|
|
[createdGroupIds],
|
||
|
|
);
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
|
||
|
|
[createdGroupIds],
|
||
|
|
);
|
||
|
|
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
|
||
|
|
createdGroupIds,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
if (createdPermissionIds.length) {
|
||
|
|
await pool.query(
|
||
|
|
`DELETE FROM role_permissions WHERE permission_id = ANY($1::int[])`,
|
||
|
|
[createdPermissionIds],
|
||
|
|
);
|
||
|
|
await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [
|
||
|
|
createdPermissionIds,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
for (const uid of createdUserIds) {
|
||
|
|
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("POST /api/roles/:id/permissions/impact-preview returns empty when no permissions are removed", async () => {
|
||
|
|
const res = await fetch(
|
||
|
|
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||
|
|
body: JSON.stringify({ permissionIds: [permA.id, permB.id, permC.id] }),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(res.status, 200);
|
||
|
|
const body = await res.json();
|
||
|
|
assert.deepEqual(body.removed, []);
|
||
|
|
assert.equal(body.totalAffectedUsers, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("POST /api/roles/:id/permissions/impact-preview reports affected users and groups for each removed permission", async () => {
|
||
|
|
// Remove perm B and perm C from the role (keep perm A).
|
||
|
|
const res = await fetch(
|
||
|
|
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||
|
|
body: JSON.stringify({ permissionIds: [permA.id] }),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(res.status, 200);
|
||
|
|
const body = await res.json();
|
||
|
|
|
||
|
|
assert.equal(body.removed.length, 2);
|
||
|
|
const byName = new Map(body.removed.map((r) => [r.permissionName, r]));
|
||
|
|
const b = byName.get(permB.name);
|
||
|
|
const c = byName.get(permC.name);
|
||
|
|
assert.ok(b, "expected perm B in removed list");
|
||
|
|
assert.ok(c, "expected perm C in removed list");
|
||
|
|
|
||
|
|
// Three role-holders: directUser (direct), groupedUser (via helperGroup), bothUser (direct).
|
||
|
|
// For perm B and perm C, no other role grants them, so all 3 lose them.
|
||
|
|
assert.equal(b.userCount, 3);
|
||
|
|
assert.equal(c.userCount, 3);
|
||
|
|
|
||
|
|
// The role is granted by 1 group (helperGroup).
|
||
|
|
assert.equal(b.groupCount, 1);
|
||
|
|
assert.equal(c.groupCount, 1);
|
||
|
|
assert.equal(b.groups.length, 1);
|
||
|
|
assert.equal(b.groups[0].id, helperGroupId);
|
||
|
|
assert.ok(typeof b.groups[0].name === "string" && b.groups[0].name.length > 0);
|
||
|
|
|
||
|
|
assert.equal(body.totalAffectedUsers, 3);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("impact-preview excludes users that still get the permission via another role", async () => {
|
||
|
|
// Remove ALL permissions, including perm A which is also granted to bothUser via aloneRole.
|
||
|
|
const res = await fetch(
|
||
|
|
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||
|
|
body: JSON.stringify({ permissionIds: [] }),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(res.status, 200);
|
||
|
|
const body = await res.json();
|
||
|
|
assert.equal(body.removed.length, 3);
|
||
|
|
const byName = new Map(body.removed.map((r) => [r.permissionName, r]));
|
||
|
|
const a = byName.get(permA.name);
|
||
|
|
assert.ok(a, "expected perm A in removed list");
|
||
|
|
// bothUser still has perm A via aloneRole, so only 2 of 3 lose it.
|
||
|
|
assert.equal(a.userCount, 2);
|
||
|
|
// For B and C nobody else grants them, so all 3 lose them.
|
||
|
|
assert.equal(byName.get(permB.name).userCount, 3);
|
||
|
|
assert.equal(byName.get(permC.name).userCount, 3);
|
||
|
|
// Total distinct affected users is 3 (all three lose at least one permission).
|
||
|
|
assert.equal(body.totalAffectedUsers, 3);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("impact-preview returns 404 for an unknown role", async () => {
|
||
|
|
const res = await fetch(
|
||
|
|
`${API_BASE}/api/roles/9999999/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||
|
|
body: JSON.stringify({ permissionIds: [] }),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(res.status, 404);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("impact-preview returns 400 when permissionIds is missing or invalid", async () => {
|
||
|
|
const noBody = await fetch(
|
||
|
|
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||
|
|
body: JSON.stringify({}),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(noBody.status, 400);
|
||
|
|
|
||
|
|
const wrong = await fetch(
|
||
|
|
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||
|
|
body: JSON.stringify({ permissionIds: ["abc"] }),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.equal(wrong.status, 400);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("impact-preview requires admin", async () => {
|
||
|
|
const username = `imp_consumer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||
|
|
const r = await pool.query(
|
||
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||
|
|
VALUES ($1, $2, $3, 'consumer', 'en', true) RETURNING id`,
|
||
|
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||
|
|
);
|
||
|
|
const consumerId = r.rows[0].id;
|
||
|
|
createdUserIds.push(consumerId);
|
||
|
|
const consumerCookie = await loginAndGetCookie(username, TEST_PASSWORD);
|
||
|
|
const res = await fetch(
|
||
|
|
`${API_BASE}/api/roles/${mainRoleId}/permissions/impact-preview`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json", Cookie: consumerCookie },
|
||
|
|
body: JSON.stringify({ permissionIds: [] }),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.ok(res.status === 401 || res.status === 403, `expected 401/403, got ${res.status}`);
|
||
|
|
});
|