Skip to content
Open
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
111 changes: 111 additions & 0 deletions packages/das/src/api/miners/pagination.spec.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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,
},
});
});
});
9 changes: 8 additions & 1 deletion packages/das/src/api/miners/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down