49bf44f0a2
Introduce a new admin-only API endpoint for exporting role permission audit data to CSV, including resolved permission names and UTF-8 BOM for Excel compatibility. Frontend button and backend logic implemented to support this feature. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 0cb48020-8f0c-42bb-8fe8-d638905f7fce Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/g7BgHDL Replit-Helium-Checkpoint-Created: true
674 lines
24 KiB
JavaScript
674 lines
24 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 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_audit_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 Audit 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() {
|
|
const res = await fetch(`${API_BASE}/api/roles`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ name: uniqueRoleName("role_audit") }),
|
|
});
|
|
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];
|
|
}
|
|
|
|
test("PUT /api/roles/:id/permissions writes a role_permission_audit row", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
const before = await pool.query(
|
|
`SELECT COUNT(*)::int AS c FROM role_permission_audit WHERE role_id = $1`,
|
|
[role.id],
|
|
);
|
|
assert.equal(before.rows[0].c, 0);
|
|
|
|
const put = 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(put.status, 200);
|
|
|
|
const dbRows = await pool.query(
|
|
`SELECT actor_user_id, role_id, previous_permission_ids, new_permission_ids
|
|
FROM role_permission_audit
|
|
WHERE role_id = $1`,
|
|
[role.id],
|
|
);
|
|
assert.equal(dbRows.rowCount, 1);
|
|
const row = dbRows.rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.role_id, role.id);
|
|
assert.deepEqual(row.previous_permission_ids, []);
|
|
assert.deepEqual(
|
|
[...row.new_permission_ids].sort((a, b) => a - b),
|
|
[p1, p2].sort((a, b) => a - b),
|
|
);
|
|
});
|
|
|
|
test("PUT /api/roles/:id/permissions writes an audit row on every call (including no-op replaces)", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
const first = 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(first.status, 200);
|
|
|
|
// Re-submit the same set in a different order — the resulting permission
|
|
// set is identical, but the spec requires recording every PUT call.
|
|
const second = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: [p2, p1] }),
|
|
});
|
|
assert.equal(second.status, 200);
|
|
|
|
const dbRows = await pool.query(
|
|
`SELECT previous_permission_ids, new_permission_ids
|
|
FROM role_permission_audit
|
|
WHERE role_id = $1
|
|
ORDER BY id`,
|
|
[role.id],
|
|
);
|
|
assert.equal(
|
|
dbRows.rowCount,
|
|
2,
|
|
"every PUT call must append an audit row, including no-op saves",
|
|
);
|
|
const sorted = [p1, p2].sort((a, b) => a - b);
|
|
// Second row's previous and new arrays must be identical to the first
|
|
// row's "new" array, capturing the no-op save.
|
|
assert.deepEqual([...dbRows.rows[1].previous_permission_ids].sort((a, b) => a - b), sorted);
|
|
assert.deepEqual([...dbRows.rows[1].new_permission_ids].sort((a, b) => a - b), sorted);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit reports no-op saves with empty added/removed", async () => {
|
|
const role = await createRole();
|
|
const [p1] = await getTwoPermissionIds();
|
|
|
|
for (const set of [[p1], [p1]]) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: set }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
|
|
const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(get.status, 200);
|
|
const body = await get.json();
|
|
const { entries } = body;
|
|
assert.equal(entries.length, 2);
|
|
assert.equal(body.totalCount, 2);
|
|
assert.equal(body.offset, 0);
|
|
assert.equal(body.nextOffset, null);
|
|
// Latest entry is the no-op save: same set in/out, empty diffs.
|
|
assert.deepEqual(entries[0].previousPermissionIds, [p1]);
|
|
assert.deepEqual(entries[0].newPermissionIds, [p1]);
|
|
assert.deepEqual(entries[0].addedPermissionIds, []);
|
|
assert.deepEqual(entries[0].removedPermissionIds, []);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit returns entries newest first with diff fields and actor", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
// First change: add p1
|
|
const r1 = 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(r1.status, 200);
|
|
|
|
// Ensure the second change has a strictly later created_at so ordering is
|
|
// deterministic regardless of clock resolution.
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
|
|
// Second change: swap p1 -> p2
|
|
const r2 = 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(r2.status, 200);
|
|
|
|
const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(get.status, 200);
|
|
const body = await get.json();
|
|
assert.ok(Array.isArray(body.entries));
|
|
assert.equal(body.entries.length, 2);
|
|
assert.equal(body.totalCount, 2);
|
|
assert.equal(body.offset, 0);
|
|
assert.equal(body.limit, 10);
|
|
assert.equal(body.nextOffset, null);
|
|
|
|
const [latest, earlier] = body.entries;
|
|
assert.deepEqual(latest.previousPermissionIds, [p1]);
|
|
assert.deepEqual(latest.newPermissionIds, [p2]);
|
|
assert.deepEqual(latest.addedPermissionIds, [p2]);
|
|
assert.deepEqual(latest.removedPermissionIds, [p1]);
|
|
assert.ok(latest.actor);
|
|
assert.equal(latest.actor.id, adminId);
|
|
assert.equal(latest.actor.username, adminUsername);
|
|
|
|
assert.deepEqual(earlier.previousPermissionIds, []);
|
|
assert.deepEqual(earlier.newPermissionIds, [p1]);
|
|
assert.deepEqual(earlier.addedPermissionIds, [p1]);
|
|
assert.deepEqual(earlier.removedPermissionIds, []);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit returns 404 for an unknown role", async () => {
|
|
const res = await fetch(`${API_BASE}/api/roles/99999999/audit`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit honours the limit parameter", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
const sets = [[p1], [p2], [p1, p2], []];
|
|
for (const s of sets) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: s }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
const limited = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?limit=2`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(limited.status, 200);
|
|
const body = await limited.json();
|
|
assert.equal(body.entries.length, 2);
|
|
assert.equal(body.totalCount, 4);
|
|
assert.equal(body.limit, 2);
|
|
assert.equal(body.offset, 0);
|
|
// Two more rows beyond the page → nextOffset should point at row 2.
|
|
assert.equal(body.nextOffset, 2);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit paginates with offset and stops at the end", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
// 4 distinct permission sets → 4 audit rows, deterministic ordering thanks
|
|
// to the small inter-write delay.
|
|
const sets = [[p1], [p2], [p1, p2], []];
|
|
for (const s of sets) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: s }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
const page1Res = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=0`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(page1Res.status, 200);
|
|
const page1 = await page1Res.json();
|
|
assert.equal(page1.entries.length, 2);
|
|
assert.equal(page1.totalCount, 4);
|
|
assert.equal(page1.nextOffset, 2);
|
|
|
|
const page2Res = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=2`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(page2Res.status, 200);
|
|
const page2 = await page2Res.json();
|
|
assert.equal(page2.entries.length, 2);
|
|
assert.equal(page2.totalCount, 4);
|
|
// Reached the end of the table — there is nothing more to page through.
|
|
assert.equal(page2.nextOffset, null);
|
|
|
|
// Pages must not overlap.
|
|
const page1Ids = new Set(page1.entries.map((e) => e.id));
|
|
for (const e of page2.entries) {
|
|
assert.equal(page1Ids.has(e.id), false, "page1/page2 overlap");
|
|
}
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit filters by actorUserId", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
// Create a SECOND admin so we can attribute audit rows to two different
|
|
// actors and then verify that filtering returns only one of them.
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const otherUsername = `role_audit_admin_other_${stamp}`;
|
|
const otherRow = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Role Audit Admin Other', 'en', true) RETURNING id`,
|
|
[otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
const otherId = otherRow.rows[0].id;
|
|
createdUserIds.push(otherId);
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
|
[otherId],
|
|
);
|
|
const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD);
|
|
|
|
// Two writes by adminCookie + one by otherCookie.
|
|
for (const [cookie, set] of [
|
|
[adminCookie, [p1]],
|
|
[otherCookie, [p1, p2]],
|
|
[adminCookie, [p2]],
|
|
]) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: cookie },
|
|
body: JSON.stringify({ permissionIds: set }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
const filteredRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?actorUserId=${otherId}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(filteredRes.status, 200);
|
|
const filtered = await filteredRes.json();
|
|
assert.equal(filtered.totalCount, 1);
|
|
assert.equal(filtered.entries.length, 1);
|
|
assert.equal(filtered.entries[0].actor.id, otherId);
|
|
|
|
// actorUserId=0 means "no filter" — should match all 3 rows.
|
|
const allRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?actorUserId=0`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(allRes.status, 200);
|
|
const all = await allRes.json();
|
|
assert.equal(all.totalCount, 3);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit filters by date range", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
// Make a couple of writes "today" (UTC).
|
|
for (const set of [[p1], [p2]]) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: set }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
|
|
// Today is in range → both rows should match.
|
|
const inRangeRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?from=${today}&to=${today}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(inRangeRes.status, 200);
|
|
const inRange = await inRangeRes.json();
|
|
assert.equal(inRange.totalCount, 2);
|
|
|
|
// Yesterday-only window → no rows.
|
|
const pastRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?from=${yesterday}&to=${yesterday}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(pastRes.status, 200);
|
|
const past = await pastRes.json();
|
|
assert.equal(past.totalCount, 0);
|
|
assert.equal(past.entries.length, 0);
|
|
|
|
// Inverted range → 400.
|
|
const badRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?from=${tomorrow}&to=${yesterday}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(badRes.status, 400);
|
|
|
|
// Garbage date → 400.
|
|
const garbageRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?from=not-a-date`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(garbageRes.status, 400);
|
|
|
|
// Impossible calendar days are rejected too — without strict validation
|
|
// these would silently roll forward into a different real date.
|
|
for (const bad of ["2024-02-31", "2025-13-01", "2025-00-15", "2025-02-30"]) {
|
|
const r = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit?from=${bad}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(r.status, 400, `expected 400 for from=${bad}`);
|
|
}
|
|
});
|
|
|
|
// --- GET /api/roles/:id/audit.csv -------------------------------------------
|
|
// CSV export endpoint added for #205. The download is admin-only, returns
|
|
// `text/csv` with an attachment Content-Disposition, prepends a UTF-8 BOM
|
|
// for spreadsheet apps, and resolves permission IDs to human-readable
|
|
// names so the file is useful in Excel without a separate join.
|
|
|
|
// Parse a single CSV line into fields, honouring RFC 4180 double-quote
|
|
// escaping. Tests build comma-separated rows by hand on the server side
|
|
// so we need a small parser that knows about quoted fields. Returns null
|
|
// inputs as undefined.
|
|
function parseCsvLine(line) {
|
|
const fields = [];
|
|
let cur = "";
|
|
let inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (inQuotes) {
|
|
if (ch === '"') {
|
|
if (line[i + 1] === '"') {
|
|
cur += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = false;
|
|
}
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
} else if (ch === ",") {
|
|
fields.push(cur);
|
|
cur = "";
|
|
} else if (ch === '"') {
|
|
inQuotes = true;
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
}
|
|
fields.push(cur);
|
|
return fields;
|
|
}
|
|
|
|
test("GET /api/roles/:id/audit.csv exports CSV with headers and resolved permission names", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
// Look up the permission names so the assertions below don't depend
|
|
// on whatever the seeded data calls them.
|
|
const permRows = await pool.query(
|
|
`SELECT id, name FROM permissions WHERE id = ANY($1::int[])`,
|
|
[[p1, p2]],
|
|
);
|
|
const nameById = new Map(permRows.rows.map((r) => [r.id, r.name]));
|
|
|
|
// Two writes: first adds p1, second swaps p1 → p2 (so the second
|
|
// row should have one added and one removed permission).
|
|
for (const set of [[p1], [p2]]) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ permissionIds: set }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const ctype = res.headers.get("content-type") ?? "";
|
|
assert.match(ctype, /^text\/csv/i, `unexpected Content-Type: ${ctype}`);
|
|
const dispo = res.headers.get("content-disposition") ?? "";
|
|
assert.match(dispo, /attachment;\s*filename="role-/i);
|
|
assert.match(dispo, /\.csv"?$/i);
|
|
|
|
// Read raw bytes so we can verify the UTF-8 BOM is present — the
|
|
// standard `Response.text()` decoder strips a leading BOM, so we'd
|
|
// otherwise miss a regression where the server forgot to emit it.
|
|
// The BOM is required for Excel to render Arabic permission names
|
|
// correctly when an admin opens the file.
|
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
assert.deepEqual(
|
|
[buf[0], buf[1], buf[2]],
|
|
[0xef, 0xbb, 0xbf],
|
|
"missing UTF-8 BOM",
|
|
);
|
|
const body = new TextDecoder("utf-8").decode(buf.subarray(3));
|
|
|
|
const lines = body.split(/\r\n|\n/).filter((l) => l.length > 0);
|
|
assert.deepEqual(parseCsvLine(lines[0]), [
|
|
"timestamp",
|
|
"actor_username",
|
|
"added_permissions",
|
|
"removed_permissions",
|
|
]);
|
|
// Two writes → two data rows.
|
|
assert.equal(lines.length - 1, 2, `expected 2 data rows, got ${lines.length - 1}`);
|
|
|
|
// Newest first: row index 1 is the p1→p2 swap, row index 2 is the
|
|
// initial add of p1.
|
|
const swap = parseCsvLine(lines[1]);
|
|
const initial = parseCsvLine(lines[2]);
|
|
assert.equal(swap[1], adminUsername);
|
|
assert.equal(swap[2], nameById.get(p2));
|
|
assert.equal(swap[3], nameById.get(p1));
|
|
assert.equal(initial[1], adminUsername);
|
|
assert.equal(initial[2], nameById.get(p1));
|
|
assert.equal(initial[3], "");
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit.csv honours actorUserId and date filters", async () => {
|
|
const role = await createRole();
|
|
const [p1, p2] = await getTwoPermissionIds();
|
|
|
|
// Spin up a second admin actor so we can prove the actor filter
|
|
// narrows the export to one row out of three.
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const otherUsername = `role_audit_csv_other_${stamp}`;
|
|
const otherRow = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Role Audit CSV Other', 'en', true) RETURNING id`,
|
|
[otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
const otherId = otherRow.rows[0].id;
|
|
createdUserIds.push(otherId);
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
|
[otherId],
|
|
);
|
|
const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD);
|
|
|
|
for (const [cookie, set] of [
|
|
[adminCookie, [p1]],
|
|
[otherCookie, [p1, p2]],
|
|
[adminCookie, [p2]],
|
|
]) {
|
|
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json", Cookie: cookie },
|
|
body: JSON.stringify({ permissionIds: set }),
|
|
});
|
|
assert.equal(r.status, 200);
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
}
|
|
|
|
// actorUserId narrows export to one row.
|
|
const filteredRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit.csv?actorUserId=${otherId}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(filteredRes.status, 200);
|
|
const filtered = (await filteredRes.text()).slice(1); // strip BOM
|
|
const filteredLines = filtered.split(/\r\n|\n/).filter((l) => l.length > 0);
|
|
assert.equal(filteredLines.length, 2, "header + 1 data row expected");
|
|
assert.equal(parseCsvLine(filteredLines[1])[1], otherUsername);
|
|
|
|
// Yesterday-only window → no data rows but still returns a header.
|
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const pastRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit.csv?from=${yesterday}&to=${yesterday}`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(pastRes.status, 200);
|
|
const past = (await pastRes.text()).slice(1);
|
|
const pastLines = past.split(/\r\n|\n/).filter((l) => l.length > 0);
|
|
assert.equal(pastLines.length, 1, "header only when no rows match");
|
|
|
|
// Bad filter → 400, same as the JSON endpoint.
|
|
const badRes = await fetch(
|
|
`${API_BASE}/api/roles/${role.id}/audit.csv?from=not-a-date`,
|
|
{ headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(badRes.status, 400);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit.csv returns 404 for unknown role", async () => {
|
|
const res = await fetch(`${API_BASE}/api/roles/999999999/audit.csv`, {
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 404);
|
|
});
|
|
|
|
test("GET /api/roles/:id/audit.csv requires admin", async () => {
|
|
const role = await createRole();
|
|
|
|
// Brand-new user with no admin role assignment. They should be
|
|
// blocked at the requireAdmin middleware just like the JSON endpoint.
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const username = `role_audit_csv_nonadmin_${stamp}`;
|
|
const row = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'CSV Non-Admin', 'en', true) RETURNING id`,
|
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
createdUserIds.push(row.rows[0].id);
|
|
const cookie = await loginAndGetCookie(username, TEST_PASSWORD);
|
|
|
|
const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
// requireAdmin returns 403 for authenticated-but-not-admin users.
|
|
assert.equal(res.status, 403);
|
|
});
|