feat(audit): expose structured before/after evidence

audit_events already stored before_json/after_json, but the API and UI only
ever surfaced metadata -- the audit trail could say something happened but
never show what changed. Add before/after to AuditEventOut, resolve a safe
entity_ref/entity_link for vehicle/booking/data-quality-issue entities
(customer stays label-only; no customer detail route exists in this PoC),
and render a human-readable change summary in the UI with the raw
before/after/metadata JSON kept behind a <details> disclosure rather than
shown by default.
This commit is contained in:
NuklearRabbit
2026-08-02 05:33:23 +02:00
parent f5212959b4
commit 7e34f55005
4 changed files with 203 additions and 16 deletions
+50
View File
@@ -113,6 +113,24 @@ test("bookings page: pagination renders at most 25 rows and page 2 differs from
await expect(prevButton).toBeEnabled();
});
test("return preview correctly reports blocked (not maintenance) for damage reported", async ({
page,
request,
}) => {
await resetDemoData(request);
await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("55000");
await page.getByLabel("Fuel level (%)").fill("40");
await page.getByRole("checkbox", { name: "Damage reported" }).check();
await page.getByRole("button", { name: "Review return" }).click();
// The preview is the server's authoritative evaluation: damage always routes to
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
const statusRegion = page.locator(".impact-preview");
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
});
test("data quality page: status and rule-type filters work", async ({ page }) => {
await page.goto("/data-quality");
await expect(page.locator(".data-table")).toBeVisible();
@@ -174,6 +192,38 @@ test("audit page: action filter works", async ({ page }) => {
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy();
});
test("audit page: shows human-readable before/after and a safe entity link", async ({
page,
request,
}) => {
await resetDemoData(request);
// demo/reset deletes the acting session's own cookie, so submit the return through
// page.request instead -- it shares the browser context's still-valid OM session from
// beforeEach rather than the now-logged-out standalone `request` fixture.
const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", {
data: {
end_odometer_km: 60000,
fuel_level_percent: 55,
cleanliness_ok: true,
damage_reported: false,
technical_warning: false,
},
headers: { "Idempotency-Key": "e2e-audit-before-after-check" },
});
expect(submitted.ok()).toBeTruthy();
await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4);
await expect(changeCell).toContainText("status");
await expect(changeCell).toContainText("returned");
const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3);
await expect(entityCell.locator("a")).toHaveAttribute("href", /\/bookings\/BK-/);
});
test("knowledge page: form submits and clears input", async ({ page }) => {
await page.goto("/knowledge");
const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/);
+32 -3
View File
@@ -1,9 +1,25 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { AuditEvent } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
function describeChanges(before: Record<string, unknown> | null, after: Record<string, unknown> | null): string {
if (!before && !after) return "No recorded change detail.";
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
const lines: string[] = [];
for (const key of keys) {
const b = before?.[key];
const a = after?.[key];
if (JSON.stringify(b) === JSON.stringify(a)) continue;
if (b === undefined) lines.push(`${key}: set to ${JSON.stringify(a)}`);
else if (a === undefined) lines.push(`${key}: was ${JSON.stringify(b)}`);
else lines.push(`${key}: ${JSON.stringify(b)}${JSON.stringify(a)}`);
}
return lines.length > 0 ? lines.join("; ") : "No field-level change detected.";
}
export function Audit() {
const { user } = useAuth();
const [events, setEvents] = useState<AuditEvent[] | null>(null);
@@ -60,7 +76,8 @@ export function Audit() {
<th scope="col">Actor</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Correlation</th>
<th scope="col">Change</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody>
@@ -73,8 +90,20 @@ export function Audit() {
</td>
<td data-label="Actor"><strong>{e.actor_label}</strong><small className="table-subtext">{e.actor_type}</small></td>
<td data-label="Action">{e.action.replace(/_/g, " ")}</td>
<td data-label="Entity">{e.entity_type}</td>
<td className="mono" data-label="Correlation"><details><summary>{e.correlation_id.slice(0, 8)}</summary><pre>{JSON.stringify(e.metadata ?? {}, null, 2)}</pre></details></td>
<td data-label="Entity">
{e.entity_link ? (
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
) : (
e.entity_ref ?? e.entity_type
)}
</td>
<td data-label="Change">{describeChanges(e.before, e.after)}</td>
<td className="mono" data-label="Details">
<details>
<summary>{e.correlation_id.slice(0, 8)}</summary>
<pre>{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
</details>
</td>
</tr>
))}
</tbody>