Add functionality to download role permission audit history as a CSV file
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
This commit is contained in:
@@ -466,3 +466,208 @@ test("GET /api/roles/:id/audit filters by date range", async () => {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user