diff --git a/app/api/routes-f/__tests__/audit.test.ts b/app/api/routes-f/__tests__/audit.test.ts new file mode 100644 index 00000000..4f8fe3b2 --- /dev/null +++ b/app/api/routes-f/__tests__/audit.test.ts @@ -0,0 +1,75 @@ +/** + * Routes-F audit endpoint tests. + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { GET } from "../audit/route"; +import { __test__setAuditEvents } from "@/lib/routes-f/store"; +import { __test__resetRateLimit } from "@/lib/routes-f/rate-limit"; +import { AuditEvent } from "@/lib/routes-f/types"; + +const mockEvents: AuditEvent[] = [ + { id: "e3", actor: "u1", action: "A", target: "T", timestamp: "2026-02-24T12:00:00Z" }, + { id: "e2", actor: "u2", action: "B", target: "T", timestamp: "2026-02-24T11:00:00Z" }, + { id: "e1", actor: "u1", action: "C", target: "T", timestamp: "2026-02-24T10:00:00Z" }, +]; + +const makeRequest = (search = "") => + new Request(`http://localhost/api/routes-f/audit${search}`); + +describe("GET /api/routes-f/audit", () => { + beforeEach(() => { + __test__setAuditEvents(mockEvents); + __test__resetRateLimit(); + }); + + it("returns latest events first", async () => { + const res = await GET(makeRequest("?limit=2")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(2); + expect(body.items[0].id).toBe("e3"); + expect(body.items[1].id).toBe("e2"); + expect(body.nextCursor).toBe("e2"); + }); + + it("uses cursor for pagination", async () => { + const res = await GET(makeRequest("?limit=1&cursor=e3")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].id).toBe("e2"); + expect(body.nextCursor).toBe("e2"); + }); + + it("returns null nextCursor on last page", async () => { + const res = await GET(makeRequest("?limit=5&cursor=e2")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0].id).toBe("e1"); + expect(body.nextCursor).toBeNull(); + }); + + it("handles null cursor correctly (initial page)", async () => { + const res = await GET(makeRequest("?limit=1")); + const body = await res.json(); + expect(body.items[0].id).toBe("e3"); + }); + + it("limits results to max 100", async () => { + const res = await GET(makeRequest("?limit=200")); + const body = await res.json(); + // In this test we only have 3, so it will return all 3 + expect(body.items).toHaveLength(3); + }); +}); diff --git a/app/api/routes-f/audit/route.ts b/app/api/routes-f/audit/route.ts new file mode 100644 index 00000000..53eaf93e --- /dev/null +++ b/app/api/routes-f/audit/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { recordMetric } from "@/lib/routes-f/metrics"; +import { applyRateLimitHeaders, checkRateLimit } from "@/lib/routes-f/rate-limit"; +import { getAuditTrail } from "@/lib/routes-f/store"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + + const limiter = checkRateLimit({ + headers: req.headers, + routeKey: "routes-f/audit", + }); + + const headers = new Headers(); + applyRateLimitHeaders(headers, limiter); + + if (!limiter.allowed) { + headers.set("Retry-After", String(limiter.retryAfterSeconds)); + return NextResponse.json( + { + error: "Rate limit exceeded", + policy: limiter.policy, + }, + { status: 429, headers } + ); + } + + // Parse query parameters + const limitParam = parseInt(searchParams.get("limit") || "20", 10); + const limit = Math.min(Math.max(limitParam, 1), 100); + const cursor = searchParams.get("cursor") || undefined; + + recordMetric("audit"); + + const result = getAuditTrail({ limit, cursor }); + + return NextResponse.json(result, { headers }); +} diff --git a/lib/routes-f/metrics.ts b/lib/routes-f/metrics.ts index 071a61d8..50c21b75 100644 --- a/lib/routes-f/metrics.ts +++ b/lib/routes-f/metrics.ts @@ -6,6 +6,7 @@ const METRIC_KEYS: MetricsKey[] = [ "export", "maintenance", "metrics", + "audit", ]; const totals: Record = { @@ -14,6 +15,7 @@ const totals: Record = { export: 0, maintenance: 0, metrics: 0, + audit: 0, }; const eventTimestamps: Record = { @@ -22,6 +24,7 @@ const eventTimestamps: Record = { export: [], maintenance: [], metrics: [], + audit: [], }; const WINDOW_MS = 24 * 60 * 60 * 1000; diff --git a/lib/routes-f/store.ts b/lib/routes-f/store.ts index b62e6ce3..f87ca169 100644 --- a/lib/routes-f/store.ts +++ b/lib/routes-f/store.ts @@ -1,4 +1,4 @@ -import { MaintenanceWindow, RoutesFRecord } from "./types"; +import { AuditEvent, MaintenanceWindow, RoutesFRecord } from "./types"; let routesFRecords: RoutesFRecord[] = [ { @@ -45,6 +45,44 @@ let routesFRecords: RoutesFRecord[] = [ let maintenanceWindows: MaintenanceWindow[] = []; +let auditEvents: AuditEvent[] = [ + { + id: "ae-005", + actor: "system-bot", + action: "HEALTH_CHECK_PASSED", + target: "routes-f/health", + timestamp: "2026-02-24T11:00:00.000Z", + }, + { + id: "ae-004", + actor: "admin-alice", + action: "FLAG_UPDATED", + target: "routes-f/flags/new-ui", + timestamp: "2026-02-24T10:30:00.000Z", + }, + { + id: "ae-003", + actor: "admin-bob", + action: "CACHE_PURGED", + target: "routes-f/cache/global", + timestamp: "2026-02-24T09:15:00.000Z", + }, + { + id: "ae-002", + actor: "system-cron", + action: "METRICS_ROTATED", + target: "routes-f/metrics", + timestamp: "2026-02-24T00:00:00.000Z", + }, + { + id: "ae-001", + actor: "admin-alice", + action: "MAINTENANCE_SCHEDULED", + target: "routes-f/maintenance", + timestamp: "2026-02-23T22:00:00.000Z", + }, +]; + export function getRoutesFRecords(): RoutesFRecord[] { return [...routesFRecords]; } @@ -155,6 +193,37 @@ export function clearMaintenanceWindows() { maintenanceWindows = []; } +export function getAuditTrail(params: { + limit: number; + cursor?: string; +}): { items: AuditEvent[]; nextCursor: string | null } { + const sortedEvents = [...auditEvents].sort((a, b) => + b.timestamp.localeCompare(a.timestamp) + ); + + let startIndex = 0; + if (params.cursor) { + startIndex = sortedEvents.findIndex(e => e.id === params.cursor) + 1; + } + + // If cursor is invalid or points to end, return empty + if (startIndex === 0 && params.cursor) { + return { items: [], nextCursor: null }; + } + + const items = sortedEvents.slice(startIndex, startIndex + params.limit); + const nextCursor = + items.length > 0 && startIndex + items.length < sortedEvents.length + ? items[items.length - 1].id + : null; + + return { items, nextCursor }; +} + +export function __test__setAuditEvents(events: AuditEvent[]) { + auditEvents = [...events]; +} + export function __test__setMaintenanceWindows(windows: MaintenanceWindow[]) { maintenanceWindows = [...windows]; } diff --git a/lib/routes-f/types.ts b/lib/routes-f/types.ts index 8d660d1c..47b4afff 100644 --- a/lib/routes-f/types.ts +++ b/lib/routes-f/types.ts @@ -15,12 +15,21 @@ export interface MaintenanceWindow { createdAt: string; } +export interface AuditEvent { + id: string; + actor: string; + action: string; + target: string; + timestamp: string; +} + export type MetricsKey = | "flags" | "search" | "export" | "maintenance" - | "metrics"; + | "metrics" + | "audit"; export interface MetricsSnapshot { generatedAt: string;