Git commit prior to merge
This commit is contained in:
@@ -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,")));
|
||||
});
|
||||
Reference in New Issue
Block a user