Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/api/routes-f/__tests__/audit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions app/api/routes-f/audit/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
3 changes: 3 additions & 0 deletions lib/routes-f/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const METRIC_KEYS: MetricsKey[] = [
"export",
"maintenance",
"metrics",
"audit",
];

const totals: Record<MetricsKey, number> = {
Expand All @@ -14,6 +15,7 @@ const totals: Record<MetricsKey, number> = {
export: 0,
maintenance: 0,
metrics: 0,
audit: 0,
};

const eventTimestamps: Record<MetricsKey, number[]> = {
Expand All @@ -22,6 +24,7 @@ const eventTimestamps: Record<MetricsKey, number[]> = {
export: [],
maintenance: [],
metrics: [],
audit: [],
};

const WINDOW_MS = 24 * 60 * 60 * 1000;
Expand Down
71 changes: 70 additions & 1 deletion lib/routes-f/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MaintenanceWindow, RoutesFRecord } from "./types";
import { AuditEvent, MaintenanceWindow, RoutesFRecord } from "./types";

let routesFRecords: RoutesFRecord[] = [
{
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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];
}
Expand Down
11 changes: 10 additions & 1 deletion lib/routes-f/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading