From 896f690ac0a58e5af261249a203b8709a8ea9be5 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 10:32:54 +0000 Subject: [PATCH] Git commit prior to merge --- artifacts/api-server/src/routes/audit.ts | 39 +++- .../audit-logs-forced-only-filter.test.mjs | 214 ++++++++++++++++++ artifacts/tx-os/src/locales/ar.json | 15 +- artifacts/tx-os/src/locales/en.json | 11 +- artifacts/tx-os/src/pages/admin.tsx | 111 ++++++++- .../src/generated/api.schemas.ts | 16 ++ lib/api-client-react/src/generated/api.ts | 3 +- lib/api-spec/openapi.yaml | 25 +- lib/api-zod/src/generated/api.ts | 18 +- 9 files changed, 438 insertions(+), 14 deletions(-) create mode 100644 artifacts/api-server/tests/audit-logs-forced-only-filter.test.mjs diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts index 39c4fd2e..e4e499ae 100644 --- a/artifacts/api-server/src/routes/audit.ts +++ b/artifacts/api-server/src/routes/audit.ts @@ -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)); diff --git a/artifacts/api-server/tests/audit-logs-forced-only-filter.test.mjs b/artifacts/api-server/tests/audit-logs-forced-only-filter.test.mjs new file mode 100644 index 00000000..0ac5dd7e --- /dev/null +++ b/artifacts/api-server/tests/audit-logs-forced-only-filter.test.mjs @@ -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,"))); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 98301aa6..c84a4437 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -483,7 +483,9 @@ "from": "من", "to": "إلى", "apply": "تطبيق", - "reset": "إعادة ضبط" + "reset": "إعادة ضبط", + "forcedOnly": "عمليات الحذف القسري فقط", + "forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط." }, "target": "الهدف: {{type}} #{{id}}", "showing": "عرض {{shown}} من أصل {{total}}", @@ -564,7 +566,16 @@ "message_two": "رسالتان", "message_few": "{{count}} رسائل", "message_many": "{{count}} رسالةً", - "message_other": "{{count}} رسالة" + "message_other": "{{count}} رسالة", + "open_zero": "بدون فتحات", + "open_one": "فتحة واحدة", + "open_two": "فتحتان", + "open_few": "{{count}} فتحات", + "open_many": "{{count}} فتحةً", + "open_other": "{{count}} فتحة" + }, + "dependencies": { + "label": "التبعيات المُزالة" }, "summary": { "app": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 08e5a045..a6fe326d 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -480,7 +480,9 @@ "from": "From", "to": "To", "apply": "Apply", - "reset": "Reset" + "reset": "Reset", + "forcedOnly": "Forced deletions only", + "forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services." }, "target": "Target: {{type}} #{{id}}", "showing": "Showing {{shown}} of {{total}}", @@ -517,7 +519,12 @@ "conversation_one": "{{count}} conversation", "conversation_other": "{{count}} conversations", "message_one": "{{count}} message", - "message_other": "{{count}} messages" + "message_other": "{{count}} messages", + "open_one": "{{count}} open", + "open_other": "{{count}} opens" + }, + "dependencies": { + "label": "Cleared dependencies" }, "summary": { "app": { diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 0d71afea..9e119c1e 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -3959,6 +3959,44 @@ function formatAuditSummary( } } +// Map of well-known dependency-count metadata keys → unit i18n root used by +// `unitLabel`. Keys not in this map are skipped from the inline chip strip. +const DEPENDENCY_COUNT_KEYS: Record = { + orderCount: "order", + messageCount: "message", + noteCount: "note", + conversationCount: "conversation", + groupCount: "group", + memberCount: "member", + appCount: "app", + roleCount: "role", + openCount: "open", +}; + +function isForceDeleteEntry(entry: AuditLogEntry): boolean { + const action = entry.action; + if (action.endsWith(".force_delete")) return true; + if (action === "user.delete" || action === "app.delete" || action === "group.delete") { + const meta = asRecord(entry.metadata); + return meta.force === true; + } + return false; +} + +function dependencyChips( + entry: AuditLogEntry, + t: AuditTFunction, +): Array<{ key: string; label: string }> { + const meta = asRecord(entry.metadata); + const chips: Array<{ key: string; label: string }> = []; + for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) { + const value = asNumber(meta[metaKey]); + if (value == null || value <= 0) continue; + chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value) }); + } + return chips; +} + function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -3967,6 +4005,8 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { const actor = entry.actor; const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase(); const summary = formatAuditSummary(entry, t, lang); + const isForced = isForceDeleteEntry(entry); + const chips = isForced ? dependencyChips(entry, t) : []; return (
{entry.action} @@ -4015,6 +4060,23 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { {formatAuditTimestamp(entry.createdAt, lang)}
+ {chips.length > 0 && ( +
+ {chips.map((chip) => ( + + {chip.label} + + ))} +
+ )} {hasMetadata && ( +