import { env, exports } from "cloudflare:workers"; import { eq, sql } from "drizzle-orm"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; const SELF = exports.default; import { makeDb } from "@/worker/db"; import { users, invites } from "@/worker/db/schema"; import { encodeBase64Url, sha256 } from "@/worker/services/crypto"; import { ISSUER, testHeaders } from "./helpers"; const seedInvite = async (options: { id: string; email: string; expiresInMs?: number; consumedAt?: string | null; }): Promise => { const db = makeDb(env); const tokenBytes = crypto.getRandomValues(new Uint8Array(32)); const token = encodeBase64Url(tokenBytes); const tokenHash = await sha256(token); const now = Date.now(); const expiresAt = new Date(now + (options.expiresInMs ?? 7 * 86400_000)).toISOString(); await db.insert(invites).values({ id: options.id, tokenHash, email: options.email, createdBy: "test-admin", createdAt: new Date(now).toISOString(), expiresAt, consumedAt: options.consumedAt ?? null, }); return token; }; const acceptInvite = (token: string, body: Record, ip = "10.0.0.1") => SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": ip }), body: JSON.stringify(body), }); describe("invite lookup", () => { beforeAll(async () => { const db = makeDb(env); await db.delete(invites).where(sql`1 = 1`); }); it("rejects an unknown token with 404", async () => { const res = await SELF.fetch(`${ISSUER}/api/invite/nonexistent-token`); expect(res.status).toBe(404); }); it("returns 410 invite_expired for an expired token", async () => { const token = await seedInvite({ id: "inv-expired", email: "expired@example.com", expiresInMs: -1000 }); const res = await SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`); expect(res.status).toBe(410); const body = (await res.json()) as { error: string }; expect(body.error).toBe("invite_expired"); }); it("returns 410 invite_consumed for an already-used token", async () => { const token = await seedInvite({ id: "inv-consumed", email: "consumed@example.com", consumedAt: new Date().toISOString(), }); const res = await SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`); expect(res.status).toBe(410); const body = (await res.json()) as { error: string }; expect(body.error).toBe("invite_consumed"); }); it("returns 200 with metadata for a valid token", async () => { const token = await seedInvite({ id: "inv-valid", email: "valid@example.com" }); const res = await SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`); expect(res.status).toBe(200); const body = (await res.json()) as { state: string; email: string }; expect(body.state).toBe("valid"); expect(body.email).toBe("valid@example.com"); }); }); // Single-use consume semantics: one CAS updates rows matching token, // null `consumed_at`, and unexpired `expires_at`. SQLite serializes // writers; losers re-read for failure classification. signUpEmail is // outside the consume, so the policy is failure-closed. describe("invite accept flow", () => { beforeEach(async () => { const db = makeDb(env); await db.delete(invites).where(sql`1 = 1`); // users.email is unique; clear emails owned by this describe block. await db.delete(users).where(sql`email LIKE 'inv-%@example.com' OR email LIKE 'race-%' OR email LIKE 'reuse-%'`); }); it("successful POST creates user/account/session and consumes invite", async () => { const token = await seedInvite({ id: "inv-happy", email: "race-happy@example.com" }); const res = await acceptInvite(token, { name: "Happy", password: "long-enough-password", turnstileToken: "test", }); expect(res.status).toBe(200); const db = makeDb(env); const [row] = await db.select().from(invites).where(eq(invites.id, "inv-happy")); expect(row?.consumedAt).toBeTruthy(); const [user] = await db.select().from(users).where(eq(users.email, "race-happy@example.com")); expect(user).toBeTruthy(); const accounts = await env.DB.prepare("SELECT id FROM accounts WHERE user_id = ?").bind(user!.id).all(); expect(accounts.results.length).toBeGreaterThan(0); const sessions = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ?").bind(user!.id).all(); expect(sessions.results.length).toBeGreaterThan(0); }, 30_000); it("two parallel POSTs — one 200, one 410 consumed; exactly one user row", async () => { const token = await seedInvite({ id: "inv-race", email: "race-1@example.com" }); const [a, b] = await Promise.all([ acceptInvite(token, { name: "First", password: "long-enough-password", turnstileToken: "test" }, "10.0.1.1"), acceptInvite(token, { name: "Second", password: "long-enough-password", turnstileToken: "test" }, "10.0.1.2"), ]); const statuses = [a.status, b.status].sort((x, y) => x - y); expect(statuses[0]).toBe(200); expect(statuses[1]).toBe(410); const loser = a.status === 200 ? b : a; const loserBody = (await loser.json()) as { error: string }; expect(loserBody.error).toBe("invite_consumed"); const db = makeDb(env); const userRows = await db.select().from(users).where(eq(users.email, "race-1@example.com")); expect(userRows).toHaveLength(1); }, 30_000); it("second POST after success returns 410 consumed", async () => { const token = await seedInvite({ id: "inv-second", email: "race-2@example.com" }); const first = await acceptInvite(token, { name: "User", password: "long-enough-password", turnstileToken: "test", }); expect(first.status).toBe(200); const second = await acceptInvite(token, { name: "User", password: "long-enough-password", turnstileToken: "test", }); expect(second.status).toBe(410); const body = (await second.json()) as { error: string }; expect(body.error).toBe("invite_consumed"); }, 30_000); it("expired POST returns 410 and does not consume invite or create a user", async () => { const token = await seedInvite({ id: "inv-expired-post", email: "race-expired@example.com", expiresInMs: -1000 }); const res = await acceptInvite(token, { name: "Expired", password: "long-enough-password", turnstileToken: "test", }); expect(res.status).toBe(410); const body = (await res.json()) as { error: string }; expect(body.error).toBe("invite_expired"); const db = makeDb(env); const [row] = await db.select().from(invites).where(eq(invites.id, "inv-expired-post")); expect(row?.consumedAt).toBeNull(); const userRows = await db.select().from(users).where(eq(users.email, "race-expired@example.com")); expect(userRows).toHaveLength(0); }, 30_000); it("password too short returns 400 and does not consume invite", async () => { const token = await seedInvite({ id: "inv-short-pw", email: "race-short@example.com" }); const res = await acceptInvite(token, { name: "Short", password: "short", // 5 chars, below the 12 minimum turnstileToken: "test", }); expect(res.status).toBe(400); const body = (await res.json()) as { error: string }; expect(body.error).toBe("invalid_password"); const db = makeDb(env); const [row] = await db.select().from(invites).where(eq(invites.id, "inv-short-pw")); expect(row?.consumedAt).toBeNull(); }, 30_000); it("password too long returns 400 and does not consume invite", async () => { const token = await seedInvite({ id: "inv-long-pw", email: "race-long@example.com" }); const res = await acceptInvite(token, { name: "Long", password: "x".repeat(129), // one over the 128 maximum turnstileToken: "test", }); expect(res.status).toBe(400); const db = makeDb(env); const [row] = await db.select().from(invites).where(eq(invites.id, "inv-long-pw")); expect(row?.consumedAt).toBeNull(); }, 30_000); it("signup failure after consume returns a generic error and leaves invite consumed", async () => { const db = makeDb(env); await db.insert(users).values({ id: "usr-reuse-existing", name: "Existing", email: "reuse-1@example.com", emailVerified: true, }); const token = await seedInvite({ id: "inv-reuse", email: "reuse-1@example.com" }); const res = await acceptInvite(token, { name: "Second", password: "long-enough-password", turnstileToken: "test", }); expect(res.status).toBe(500); const body = (await res.json()) as { error: string; message: string }; expect(body.error).toBe("signup_failed"); expect(body.message).toBe("Could not complete signup. Please contact your administrator."); expect(body.message).not.toMatch(/already exists/i); const [row] = await db.select().from(invites).where(eq(invites.id, "inv-reuse")); expect(row?.consumedAt).toBeTruthy(); }, 30_000); });