import { env, exports } from "cloudflare:workers"; import { sql } from "drizzle-orm"; import { beforeAll, describe, expect, it } from "vitest"; const SELF = exports.default; import { makeAuth } from "@/worker/auth"; import { makeDb } from "@/worker/db"; import { invites } from "@/worker/db/schema"; import { encodeBase64Url, sha256 } from "@/worker/services/crypto"; import { ISSUER, signInForCookie } from "./helpers"; // Cursor encoding for /api/admin/invites is `|`. ISO // timestamps contain colons, so a `:` delimiter would split the // timestamp itself; the pipe is colon-free in both halves. // // This spec walks two pages of seeded invites and asserts the second // page strictly continues from the first with no overlap and no gap. describe("/api/admin/invites pagination", () => { const adminCred = { email: "invite-pagination-admin@example.com", password: "correct-horse-battery-staple", name: "Invite Pagination Admin", }; let cookie: string; beforeAll(async () => { const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER }); await auth.api.signUpEmail({ body: adminCred, asResponse: false }); await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", adminCred.email).run(); const db = makeDb(env); // Wipe other test seeds so the page boundary is deterministic. await db.delete(invites).where(sql`1 = 1`); // Seed five invites with monotonically increasing ISO timestamps so // (createdAt desc, id desc) ordering is unambiguous. The colons in // the timestamps are exactly what the cursor parser must not split // on. const base = Date.parse("2026-04-27T10:00:00.000Z"); for (let i = 0; i < 5; i += 1) { const tokenBytes = crypto.getRandomValues(new Uint8Array(16)); const token = encodeBase64Url(tokenBytes); await db.insert(invites).values({ id: `inv_pagi_${i}`, tokenHash: await sha256(token), email: `pagi-${i}@example.com`, createdBy: adminCred.email, createdAt: new Date(base + i * 1000).toISOString(), expiresAt: new Date(base + 7 * 86400_000).toISOString(), }); } cookie = await signInForCookie(adminCred.email, adminCred.password, "10.74.0.1"); }); it("returns the requested page size + a usable nextCursor; second page continues without overlap", async () => { const firstRes = await SELF.fetch(`${ISSUER}/api/admin/invites?limit=2`, { headers: { cookie }, }); expect(firstRes.status).toBe(200); const first = (await firstRes.json()) as { invites: Array<{ id: string; createdAt: string }>; nextCursor: string | null; }; expect(first.invites).toHaveLength(2); expect(first.nextCursor).toBeTruthy(); // Cursor must NOT have been split on the ISO colon: the encoded // pipe lives between the timestamp's `Z` and the inv_ id. expect(first.nextCursor).toContain("|inv_pagi_"); const secondRes = await SELF.fetch( `${ISSUER}/api/admin/invites?limit=2&cursor=${encodeURIComponent(first.nextCursor!)}`, { headers: { cookie } }, ); expect(secondRes.status).toBe(200); const second = (await secondRes.json()) as { invites: Array<{ id: string; createdAt: string }>; nextCursor: string | null; }; expect(second.invites).toHaveLength(2); const firstIds = first.invites.map((i) => i.id); const secondIds = second.invites.map((i) => i.id); const overlap = firstIds.filter((id) => secondIds.includes(id)); expect(overlap).toEqual([]); // Second page rows must be strictly older than the first page's // last row. createdAt comparisons are lexicographic-safe on ISO 8601. const firstPageMin = first.invites[first.invites.length - 1]!.createdAt; for (const row of second.invites) { expect(row.createdAt <= firstPageMin).toBe(true); } }); it("malformed cursor (missing delimiter) is treated as no cursor and returns the first page", async () => { const res = await SELF.fetch(`${ISSUER}/api/admin/invites?limit=2&cursor=garbage-no-delimiter`, { headers: { cookie }, }); expect(res.status).toBe(200); const body = (await res.json()) as { invites: Array }; expect(body.invites).toHaveLength(2); }); });