diff --git a/server/src/audit.ts b/server/src/audit.ts index 2b5357c41..d6965cf85 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -501,6 +501,13 @@ function encodeCursor(cursor: AuditCursor) { return Buffer.from(JSON.stringify(cursor)).toString("base64url"); } +export class AuditQueryError extends Error { + constructor(message: string) { + super(message); + this.name = "AuditQueryError"; + } +} + function decodeCursor(cursor: string): AuditCursor { try { const parsed = JSON.parse( @@ -508,11 +515,12 @@ function decodeCursor(cursor: string): AuditCursor { ) as AuditCursor; if (!parsed.id || Number.isNaN(Date.parse(parsed.createdAt))) { - throw new Error("invalid cursor"); + throw new AuditQueryError("cursor must be a valid audit page cursor"); } return parsed; - } catch { - throw new Error("cursor must be a valid audit page cursor"); + } catch (error) { + if (error instanceof AuditQueryError) throw error; + throw new AuditQueryError("cursor must be a valid audit page cursor"); } } @@ -583,13 +591,6 @@ export function createAuditReader(database: Database): AuditReader { }; } -export class AuditQueryError extends Error { - constructor(message: string) { - super(message); - this.name = "AuditQueryError"; - } -} - export function auditQueryFromUrl(url: URL): AuditEventQuery { const rawLimit = url.searchParams.get("limit") ?? "50"; const trimmedLimit = rawLimit.trim(); @@ -610,8 +611,19 @@ export function auditQueryFromUrl(url: URL): AuditEventQuery { throw new AuditQueryError('Query parameter "to" must be a valid date.'); } + /* + * Fail fast on a stale or hand-edited bookmark. Without this the raw string travels into + * `createAuditReader.list`, where `decodeCursor` threw a generic `Error` that escaped the + * route's `AuditQueryError` catch as a 500. A corrupt cursor is a caller error, not a server + * failure, and answers 400 like a bad `from`/`to` already does. + */ + const cursor = optional("cursor"); + if (cursor !== undefined) { + decodeCursor(cursor); + } + return { - cursor: optional("cursor"), + cursor, limit, eventType: optional("eventType"), actorUserId: optional("actorUserId"), diff --git a/server/tests/audit-cursor.test.ts b/server/tests/audit-cursor.test.ts new file mode 100644 index 000000000..ef58ccf9a --- /dev/null +++ b/server/tests/audit-cursor.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { AuditQueryError, auditQueryFromUrl } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { testEnvironment } from "./support/environment"; + +const config = loadConfig({ ...testEnvironment() }); + +const adminAuth = { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => ({ + user: { id: "admin", email: "admin@openbot.test" }, + }), + }, +}; + +function validCursor(): string { + return Buffer.from( + JSON.stringify({ id: "event-1", createdAt: "2026-08-13T12:00:00.000Z" }), + ).toString("base64url"); +} + +describe("audit cursor validation", () => { + test("a corrupt cursor is a query error, not a server failure", () => { + expect(() => + auditQueryFromUrl( + new URL("http://openbot.local/api/admin/audit-events?cursor=!!bogus!!"), + ), + ).toThrow(AuditQueryError); + expect(() => + auditQueryFromUrl( + new URL( + "http://openbot.local/api/admin/audit-events?cursor=bm90LWpzb24=", + ), + ), + ).toThrow(/cursor must be a valid audit page cursor/); + }); + + test("a well-formed cursor still parses", () => { + const query = auditQueryFromUrl( + new URL( + `http://openbot.local/api/admin/audit-events?cursor=${validCursor()}&limit=10`, + ), + ); + expect(query.cursor).toBe(validCursor()); + expect(query.limit).toBe(10); + }); + + test("the admin route answers 400 for a corrupt cursor instead of 500", async () => { + const app = createApp( + config, + adminAuth, + { rolesForUser: async () => ["admin"] }, + { + list: async () => { + throw new Error("must not reach the store with a bad cursor"); + }, + }, + ); + + const response = await app.request( + "http://openbot.local/api/admin/audit-events?cursor=!!bogus!!", + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "cursor must be a valid audit page cursor", + }); + }); + + test("the admin route still pages with a valid cursor", async () => { + const queries: unknown[] = []; + const app = createApp( + config, + adminAuth, + { rolesForUser: async () => ["admin"] }, + { + list: async (query) => { + queries.push(query); + return { events: [], nextCursor: undefined }; + }, + }, + ); + + const response = await app.request( + `http://openbot.local/api/admin/audit-events?cursor=${validCursor()}`, + ); + + expect(response.status).toBe(200); + expect(queries).toHaveLength(1); + }); +});