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
34 changes: 23 additions & 11 deletions server/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,18 +501,26 @@ 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(
Buffer.from(cursor, "base64url").toString("utf8"),
) 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");
}
}

Expand Down Expand Up @@ -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();
Expand All @@ -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"),
Expand Down
93 changes: 93 additions & 0 deletions server/tests/audit-cursor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});