diff --git a/packages/das/src/api/miners/pagination.spec.ts b/packages/das/src/api/miners/pagination.spec.ts new file mode 100644 index 0000000..69caff9 --- /dev/null +++ b/packages/das/src/api/miners/pagination.spec.ts @@ -0,0 +1,111 @@ +import { BadRequestException } from "@nestjs/common"; +import { decodeCursor, encodeCursor, parsePaginationQuery } from "./pagination"; + +// Decoded cursor fields are bound to `::timestamptz` / `::int` casts in the +// keyset WHERE clause. decodeCursor must reject any payload Postgres would +// choke on — a tampered or truncated cursor has to come back as a 400, not +// bubble up as a QueryFailedError 500. + +function rawCursor(payload: Record): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +describe("decodeCursor", () => { + it("round-trips a cursor produced by encodeCursor", () => { + const encoded = encodeCursor({ + created_at: new Date("2026-06-01T12:34:56.000Z"), + repo_full_name: "Entrius/Das-GitHub-Mirror", + pr_number: 42, + }); + + expect(decodeCursor(encoded)).toEqual({ + createdAt: "2026-06-01T12:34:56.000Z", + repoFullName: "entrius/das-github-mirror", + number: 42, + }); + }); + + it("rejects a cursor that is not base64url JSON", () => { + expect(() => decodeCursor("not-a-cursor")).toThrow(BadRequestException); + }); + + it("rejects a cursor whose created_at is not a parseable timestamp", () => { + const encoded = rawCursor({ + created_at: "banana", + repo_full_name: "acme/repo", + number: 1, + }); + + expect(() => decodeCursor(encoded)).toThrow(BadRequestException); + }); + + it("rejects a cursor whose number is not an integer", () => { + const encoded = rawCursor({ + created_at: "2026-06-01T00:00:00.000Z", + repo_full_name: "acme/repo", + number: 1.5, + }); + + expect(() => decodeCursor(encoded)).toThrow(BadRequestException); + }); + + it("rejects a cursor with missing or mistyped fields", () => { + expect(() => + decodeCursor(rawCursor({ repo_full_name: "acme/repo", number: 1 })), + ).toThrow(BadRequestException); + expect(() => + decodeCursor( + rawCursor({ + created_at: "2026-06-01T00:00:00.000Z", + repo_full_name: 7, + number: 1, + }), + ), + ).toThrow(BadRequestException); + expect(() => + decodeCursor( + rawCursor({ + created_at: "2026-06-01T00:00:00.000Z", + repo_full_name: "acme/repo", + number: "1", + }), + ), + ).toThrow(BadRequestException); + }); +}); + +describe("parsePaginationQuery", () => { + it("propagates cursor validation instead of passing garbage to SQL", () => { + const encoded = rawCursor({ + created_at: "not-a-date", + repo_full_name: "acme/repo", + number: 1, + }); + + expect(() => parsePaginationQuery("50", encoded)).toThrow( + BadRequestException, + ); + }); + + it("returns null when neither limit nor cursor is provided", () => { + expect(parsePaginationQuery(undefined, undefined)).toBeNull(); + expect(parsePaginationQuery("", "")).toBeNull(); + }); + + it("accepts a valid limit with a valid cursor", () => { + const encoded = encodeCursor({ + created_at: "2026-06-01T00:00:00.000Z", + repo_full_name: "acme/repo", + issue_number: 7, + }); + + expect(parsePaginationQuery("25", encoded)).toEqual({ + limit: 25, + cursor: { + createdAt: "2026-06-01T00:00:00.000Z", + repoFullName: "acme/repo", + number: 7, + }, + }); + }); +}); diff --git a/packages/das/src/api/miners/pagination.ts b/packages/das/src/api/miners/pagination.ts index c39e44d..bce062a 100644 --- a/packages/das/src/api/miners/pagination.ts +++ b/packages/das/src/api/miners/pagination.ts @@ -61,11 +61,18 @@ export function decodeCursor(raw: string): DecodedCursor { throw new BadRequestException("cursor is invalid"); } + // The decoded fields feed `::timestamptz` / `::int` casts in the keyset + // WHERE clause, so anything Postgres would reject must be caught here — + // otherwise a tampered/truncated cursor surfaces as a QueryFailedError + // 500 instead of a 400. encodeCursor only ever emits toISOString() + // timestamps and integer PR/issue numbers, so no legitimate cursor is + // rejected by these checks. if ( typeof payload.created_at !== "string" || + Number.isNaN(Date.parse(payload.created_at)) || typeof payload.repo_full_name !== "string" || typeof payload.number !== "number" || - !Number.isFinite(payload.number) + !Number.isInteger(payload.number) ) { throw new BadRequestException("cursor is malformed"); }