Git commit prior to merge

This commit is contained in:
Riyadh
2026-04-29 10:32:54 +00:00
parent 1d47573947
commit 342b59de4b
9 changed files with 438 additions and 14 deletions
+36 -3
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter, type Request, type Response } from "express";
import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
import { and, desc, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm";
import { db } from "@workspace/db";
import { auditLogsTable, usersTable } from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
@@ -9,6 +9,22 @@ const router: IRouter = Router();
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const EXPORT_ROW_CAP = 50_000;
// Actions that explicitly mark a forced deletion. Both the dedicated
// `*.force_delete` action names and the legacy `*.delete` rows whose metadata
// carries `force: true` count as forced deletions for the audit-log filter.
const FORCE_DELETE_ACTIONS = [
"group.force_delete",
"user.force_delete",
"app.force_delete",
"service.force_delete",
] as const;
const SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG = [
"group.delete",
"user.delete",
"app.delete",
] as const;
function parseUtcDate(s: string): Date | null {
if (!DATE_RE.test(s)) return null;
const d = new Date(`${s}T00:00:00.000Z`);
@@ -17,12 +33,16 @@ function parseUtcDate(s: string): Date | null {
type AuditFilters = {
action: string;
forcedOnly: boolean;
fromDate: Date | null;
toDateExclusive: Date | null;
};
function parseFilters(req: Request, res: Response): AuditFilters | null {
const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
const forcedOnlyRaw =
typeof req.query.forcedOnly === "string" ? req.query.forcedOnly.trim() : "";
const forcedOnly = forcedOnlyRaw === "true" || forcedOnlyRaw === "1";
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
@@ -47,12 +67,25 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
res.status(400).json({ error: "'from' must be on or before 'to'" });
return null;
}
return { action, fromDate, toDateExclusive };
return { action, forcedOnly, fromDate, toDateExclusive };
}
function buildWhere(filters: AuditFilters): SQL | undefined {
const conditions: SQL[] = [];
if (filters.action) conditions.push(eq(auditLogsTable.action, filters.action));
if (filters.forcedOnly) {
// Either an explicit *.force_delete action, or a legacy *.delete row whose
// metadata records that the deletion was forced (force: true).
const forcedClause = or(
inArray(auditLogsTable.action, [...FORCE_DELETE_ACTIONS]),
and(
inArray(auditLogsTable.action, [...SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG]),
sql`${auditLogsTable.metadata} @> '{"force": true}'::jsonb`,
),
);
if (forcedClause) conditions.push(forcedClause);
} else if (filters.action) {
conditions.push(eq(auditLogsTable.action, filters.action));
}
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
if (filters.toDateExclusive)
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
@@ -0,0 +1,214 @@
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 });
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
let adminId;
let adminUsername;
let adminCookie;
const insertedAuditIds = [];
// Tag every seeded audit row's metadata with this token so the test can
// reliably identify its own rows even though `target_id` is a plain integer.
const TEST_TAG = `forced_only_test_${STAMP}`;
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 insertAuditRow({ action, targetType, targetId, metadata }) {
const r = await pool.query(
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
[
adminId,
action,
targetType,
targetId,
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
return r.rows[0].id;
}
// We don't care about the actual integer target_id values — only whether the
// row matches the filter. Use an arbitrary unique-per-row int.
let nextTargetId = 900000000;
const seedRow = (action, targetType, metadata) =>
insertAuditRow({
action,
targetType,
targetId: nextTargetId++,
metadata,
});
before(async () => {
adminUsername = `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, 'Audit Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// Each of the four explicit *.force_delete actions.
await seedRow("group.force_delete", "group", { name: "GrpA", memberCount: 3 });
await seedRow("user.force_delete", "user", { username: "UsrA", noteCount: 2 });
await seedRow("app.force_delete", "app", { nameEn: "AppA", groupCount: 1 });
await seedRow("service.force_delete", "service", { nameEn: "SvcA", orderCount: 5 });
// Legacy soft-delete rows with metadata.force=true.
await seedRow("group.delete", "group", { name: "GrpB", force: true, memberCount: 1 });
await seedRow("user.delete", "user", { username: "UsrB", force: true, messageCount: 4 });
await seedRow("app.delete", "app", { nameEn: "AppB", force: true, groupCount: 2 });
// Non-forced rows that must NOT match the forcedOnly filter.
await seedRow("group.delete", "group", { name: "GrpC", force: false });
await seedRow("user.delete", "user", { username: "UsrC" });
await seedRow("app.create", "app", { nameEn: "AppC" });
});
after(async () => {
if (insertedAuditIds.length) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
if (adminId !== undefined) {
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [adminId]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
}
await pool.end();
});
function ownEntries(body) {
return body.entries.filter(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
e.metadata.testTag === TEST_TAG,
);
}
async function listForcedOnly() {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?forcedOnly=true&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
return res.json();
}
test("forcedOnly=true returns explicit *.force_delete rows", async () => {
const body = await listForcedOnly();
const ours = ownEntries(body);
const actions = ours.map((e) => e.action);
assert.ok(actions.includes("group.force_delete"));
assert.ok(actions.includes("user.force_delete"));
assert.ok(actions.includes("app.force_delete"));
assert.ok(actions.includes("service.force_delete"));
});
test("forcedOnly=true includes legacy *.delete rows with metadata.force=true", async () => {
const body = await listForcedOnly();
const ours = ownEntries(body);
const legacyForced = ours.filter(
(e) =>
(e.action === "group.delete" ||
e.action === "user.delete" ||
e.action === "app.delete") &&
e.metadata?.force === true,
);
assert.equal(legacyForced.length, 3);
});
test("forcedOnly=true excludes non-force rows", async () => {
const body = await listForcedOnly();
const ours = ownEntries(body);
for (const e of ours) {
const isExplicit = e.action.endsWith(".force_delete");
const isLegacyForced =
(e.action === "group.delete" ||
e.action === "user.delete" ||
e.action === "app.delete") &&
e.metadata?.force === true;
assert.ok(
isExplicit || isLegacyForced,
`unexpected non-forced row in result: ${e.action} (force=${e.metadata?.force})`,
);
}
});
test("forcedOnly overrides action filter when both are provided", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?forcedOnly=true&action=app.create&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
const ours = ownEntries(body);
// No app.create rows should appear because forcedOnly takes precedence.
assert.ok(!ours.some((e) => e.action === "app.create"));
// But the seeded force-delete rows should still appear.
assert.ok(ours.some((e) => e.action === "group.force_delete"));
});
test("forcedOnly=false (default) ignores the force filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?action=app.create&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
const ours = ownEntries(body);
assert.ok(ours.some((e) => e.action === "app.create"));
});
test("CSV export honors forcedOnly", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?forcedOnly=true`,
{ headers: { Cookie: adminCookie, Accept: "text/csv" } },
);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
const text = await res.text();
// The seeded force-delete actions should appear; a non-forced create must not
// appear in OUR rows. Use the testTag to narrow to our rows.
const ourLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.ok(ourLines.length > 0, "expected at least one of our rows in CSV");
assert.ok(ourLines.some((l) => l.startsWith("group.force_delete,")));
assert.ok(ourLines.some((l) => l.startsWith("user.delete,")));
assert.ok(!ourLines.some((l) => l.startsWith("app.create,")));
});