Skip to content
File

Blob: tests/worker/admin-invites-pagination.test.ts

typescript106 lines
1import { env, exports } from "cloudflare:workers";
2import { sql } from "drizzle-orm";
3import { beforeAll, describe, expect, it } from "vitest";
4 
5const SELF = exports.default;
6 
7import { makeAuth } from "@/worker/auth";
8import { makeDb } from "@/worker/db";
9import { invites } from "@/worker/db/schema";
10import { encodeBase64Url, sha256 } from "@/worker/services/crypto";
11 
12import { ISSUER, signInForCookie } from "./helpers";
13 
14// Cursor encoding for /api/admin/invites is `<isoCreatedAt>|<id>`. ISO
15// timestamps contain colons, so a `:` delimiter would split the
16// timestamp itself; the pipe is colon-free in both halves.
17//
18// This spec walks two pages of seeded invites and asserts the second
19// page strictly continues from the first with no overlap and no gap.
20describe("/api/admin/invites pagination", () => {
21 const adminCred = {
22 email: "invite-pagination-admin@example.com",
23 password: "correct-horse-battery-staple",
24 name: "Invite Pagination Admin",
25 };
26 let cookie: string;
27 
28 beforeAll(async () => {
29 const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER });
30 await auth.api.signUpEmail({ body: adminCred, asResponse: false });
31 await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", adminCred.email).run();
32 
33 const db = makeDb(env);
34 // Wipe other test seeds so the page boundary is deterministic.
35 await db.delete(invites).where(sql`1 = 1`);
36 
37 // Seed five invites with monotonically increasing ISO timestamps so
38 // (createdAt desc, id desc) ordering is unambiguous. The colons in
39 // the timestamps are exactly what the cursor parser must not split
40 // on.
41 const base = Date.parse("2026-04-27T10:00:00.000Z");
42 for (let i = 0; i < 5; i += 1) {
43 const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
44 const token = encodeBase64Url(tokenBytes);
45 await db.insert(invites).values({
46 id: `inv_pagi_${i}`,
47 tokenHash: await sha256(token),
48 email: `pagi-${i}@example.com`,
49 createdBy: adminCred.email,
50 createdAt: new Date(base + i * 1000).toISOString(),
51 expiresAt: new Date(base + 7 * 86400_000).toISOString(),
52 });
53 }
54 
55 cookie = await signInForCookie(adminCred.email, adminCred.password, "10.74.0.1");
56 });
57 
58 it("returns the requested page size + a usable nextCursor; second page continues without overlap", async () => {
59 const firstRes = await SELF.fetch(`${ISSUER}/api/admin/invites?limit=2`, {
60 headers: { cookie },
61 });
62 expect(firstRes.status).toBe(200);
63 const first = (await firstRes.json()) as {
64 invites: Array<{ id: string; createdAt: string }>;
65 nextCursor: string | null;
66 };
67 expect(first.invites).toHaveLength(2);
68 expect(first.nextCursor).toBeTruthy();
69 // Cursor must NOT have been split on the ISO colon: the encoded
70 // pipe lives between the timestamp's `Z` and the inv_ id.
71 expect(first.nextCursor).toContain("|inv_pagi_");
72 
73 const secondRes = await SELF.fetch(
74 `${ISSUER}/api/admin/invites?limit=2&cursor=${encodeURIComponent(first.nextCursor!)}`,
75 { headers: { cookie } },
76 );
77 expect(secondRes.status).toBe(200);
78 const second = (await secondRes.json()) as {
79 invites: Array<{ id: string; createdAt: string }>;
80 nextCursor: string | null;
81 };
82 expect(second.invites).toHaveLength(2);
83 
84 const firstIds = first.invites.map((i) => i.id);
85 const secondIds = second.invites.map((i) => i.id);
86 const overlap = firstIds.filter((id) => secondIds.includes(id));
87 expect(overlap).toEqual([]);
88 
89 // Second page rows must be strictly older than the first page's
90 // last row. createdAt comparisons are lexicographic-safe on ISO 8601.
91 const firstPageMin = first.invites[first.invites.length - 1]!.createdAt;
92 for (const row of second.invites) {
93 expect(row.createdAt <= firstPageMin).toBe(true);
94 }
95 });
96 
97 it("malformed cursor (missing delimiter) is treated as no cursor and returns the first page", async () => {
98 const res = await SELF.fetch(`${ISSUER}/api/admin/invites?limit=2&cursor=garbage-no-delimiter`, {
99 headers: { cookie },
100 });
101 expect(res.status).toBe(200);
102 const body = (await res.json()) as { invites: Array<unknown> };
103 expect(body.invites).toHaveLength(2);
104 });
105});