Skip to content
File

Blob: tests/worker/invite-flow.test.ts

typescript231 lines
1import { env, exports } from "cloudflare:workers";
2import { eq, sql } from "drizzle-orm";
3import { beforeAll, beforeEach, describe, expect, it } from "vitest";
4 
5const SELF = exports.default;
6 
7import { makeDb } from "@/worker/db";
8import { users, invites } from "@/worker/db/schema";
9import { encodeBase64Url, sha256 } from "@/worker/services/crypto";
10 
11import { ISSUER, testHeaders } from "./helpers";
12 
13const seedInvite = async (options: {
14 id: string;
15 email: string;
16 expiresInMs?: number;
17 consumedAt?: string | null;
18}): Promise<string> => {
19 const db = makeDb(env);
20 const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
21 const token = encodeBase64Url(tokenBytes);
22 const tokenHash = await sha256(token);
23 const now = Date.now();
24 const expiresAt = new Date(now + (options.expiresInMs ?? 7 * 86400_000)).toISOString();
25 
26 await db.insert(invites).values({
27 id: options.id,
28 tokenHash,
29 email: options.email,
30 createdBy: "test-admin",
31 createdAt: new Date(now).toISOString(),
32 expiresAt,
33 consumedAt: options.consumedAt ?? null,
34 });
35 
36 return token;
37};
38 
39const acceptInvite = (token: string, body: Record<string, string>, ip = "10.0.0.1") =>
40 SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`, {
41 method: "POST",
42 headers: testHeaders({ "CF-Connecting-IP": ip }),
43 body: JSON.stringify(body),
44 });
45 
46describe("invite lookup", () => {
47 beforeAll(async () => {
48 const db = makeDb(env);
49 await db.delete(invites).where(sql`1 = 1`);
50 });
51 
52 it("rejects an unknown token with 404", async () => {
53 const res = await SELF.fetch(`${ISSUER}/api/invite/nonexistent-token`);
54 expect(res.status).toBe(404);
55 });
56 
57 it("returns 410 invite_expired for an expired token", async () => {
58 const token = await seedInvite({ id: "inv-expired", email: "expired@example.com", expiresInMs: -1000 });
59 const res = await SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`);
60 expect(res.status).toBe(410);
61 const body = (await res.json()) as { error: string };
62 expect(body.error).toBe("invite_expired");
63 });
64 
65 it("returns 410 invite_consumed for an already-used token", async () => {
66 const token = await seedInvite({
67 id: "inv-consumed",
68 email: "consumed@example.com",
69 consumedAt: new Date().toISOString(),
70 });
71 const res = await SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`);
72 expect(res.status).toBe(410);
73 const body = (await res.json()) as { error: string };
74 expect(body.error).toBe("invite_consumed");
75 });
76 
77 it("returns 200 with metadata for a valid token", async () => {
78 const token = await seedInvite({ id: "inv-valid", email: "valid@example.com" });
79 const res = await SELF.fetch(`${ISSUER}/api/invite/${encodeURIComponent(token)}`);
80 expect(res.status).toBe(200);
81 const body = (await res.json()) as { state: string; email: string };
82 expect(body.state).toBe("valid");
83 expect(body.email).toBe("valid@example.com");
84 });
85});
86 
87// Single-use consume semantics: one CAS updates rows matching token,
88// null `consumed_at`, and unexpired `expires_at`. SQLite serializes
89// writers; losers re-read for failure classification. signUpEmail is
90// outside the consume, so the policy is failure-closed.
91describe("invite accept flow", () => {
92 beforeEach(async () => {
93 const db = makeDb(env);
94 await db.delete(invites).where(sql`1 = 1`);
95 // users.email is unique; clear emails owned by this describe block.
96 await db.delete(users).where(sql`email LIKE 'inv-%@example.com' OR email LIKE 'race-%' OR email LIKE 'reuse-%'`);
97 });
98 
99 it("successful POST creates user/account/session and consumes invite", async () => {
100 const token = await seedInvite({ id: "inv-happy", email: "race-happy@example.com" });
101 const res = await acceptInvite(token, {
102 name: "Happy",
103 password: "long-enough-password",
104 turnstileToken: "test",
105 });
106 expect(res.status).toBe(200);
107 
108 const db = makeDb(env);
109 const [row] = await db.select().from(invites).where(eq(invites.id, "inv-happy"));
110 expect(row?.consumedAt).toBeTruthy();
111 
112 const [user] = await db.select().from(users).where(eq(users.email, "race-happy@example.com"));
113 expect(user).toBeTruthy();
114 const accounts = await env.DB.prepare("SELECT id FROM accounts WHERE user_id = ?").bind(user!.id).all();
115 expect(accounts.results.length).toBeGreaterThan(0);
116 const sessions = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ?").bind(user!.id).all();
117 expect(sessions.results.length).toBeGreaterThan(0);
118 }, 30_000);
119 
120 it("two parallel POSTs — one 200, one 410 consumed; exactly one user row", async () => {
121 const token = await seedInvite({ id: "inv-race", email: "race-1@example.com" });
122 const [a, b] = await Promise.all([
123 acceptInvite(token, { name: "First", password: "long-enough-password", turnstileToken: "test" }, "10.0.1.1"),
124 acceptInvite(token, { name: "Second", password: "long-enough-password", turnstileToken: "test" }, "10.0.1.2"),
125 ]);
126 
127 const statuses = [a.status, b.status].sort((x, y) => x - y);
128 expect(statuses[0]).toBe(200);
129 expect(statuses[1]).toBe(410);
130 
131 const loser = a.status === 200 ? b : a;
132 const loserBody = (await loser.json()) as { error: string };
133 expect(loserBody.error).toBe("invite_consumed");
134 
135 const db = makeDb(env);
136 const userRows = await db.select().from(users).where(eq(users.email, "race-1@example.com"));
137 expect(userRows).toHaveLength(1);
138 }, 30_000);
139 
140 it("second POST after success returns 410 consumed", async () => {
141 const token = await seedInvite({ id: "inv-second", email: "race-2@example.com" });
142 const first = await acceptInvite(token, {
143 name: "User",
144 password: "long-enough-password",
145 turnstileToken: "test",
146 });
147 expect(first.status).toBe(200);
148 const second = await acceptInvite(token, {
149 name: "User",
150 password: "long-enough-password",
151 turnstileToken: "test",
152 });
153 expect(second.status).toBe(410);
154 const body = (await second.json()) as { error: string };
155 expect(body.error).toBe("invite_consumed");
156 }, 30_000);
157 
158 it("expired POST returns 410 and does not consume invite or create a user", async () => {
159 const token = await seedInvite({ id: "inv-expired-post", email: "race-expired@example.com", expiresInMs: -1000 });
160 const res = await acceptInvite(token, {
161 name: "Expired",
162 password: "long-enough-password",
163 turnstileToken: "test",
164 });
165 expect(res.status).toBe(410);
166 const body = (await res.json()) as { error: string };
167 expect(body.error).toBe("invite_expired");
168 
169 const db = makeDb(env);
170 const [row] = await db.select().from(invites).where(eq(invites.id, "inv-expired-post"));
171 expect(row?.consumedAt).toBeNull();
172 const userRows = await db.select().from(users).where(eq(users.email, "race-expired@example.com"));
173 expect(userRows).toHaveLength(0);
174 }, 30_000);
175 
176 it("password too short returns 400 and does not consume invite", async () => {
177 const token = await seedInvite({ id: "inv-short-pw", email: "race-short@example.com" });
178 const res = await acceptInvite(token, {
179 name: "Short",
180 password: "short", // 5 chars, below the 12 minimum
181 turnstileToken: "test",
182 });
183 expect(res.status).toBe(400);
184 const body = (await res.json()) as { error: string };
185 expect(body.error).toBe("invalid_password");
186 
187 const db = makeDb(env);
188 const [row] = await db.select().from(invites).where(eq(invites.id, "inv-short-pw"));
189 expect(row?.consumedAt).toBeNull();
190 }, 30_000);
191 
192 it("password too long returns 400 and does not consume invite", async () => {
193 const token = await seedInvite({ id: "inv-long-pw", email: "race-long@example.com" });
194 const res = await acceptInvite(token, {
195 name: "Long",
196 password: "x".repeat(129), // one over the 128 maximum
197 turnstileToken: "test",
198 });
199 expect(res.status).toBe(400);
200 
201 const db = makeDb(env);
202 const [row] = await db.select().from(invites).where(eq(invites.id, "inv-long-pw"));
203 expect(row?.consumedAt).toBeNull();
204 }, 30_000);
205 
206 it("signup failure after consume returns a generic error and leaves invite consumed", async () => {
207 const db = makeDb(env);
208 await db.insert(users).values({
209 id: "usr-reuse-existing",
210 name: "Existing",
211 email: "reuse-1@example.com",
212 emailVerified: true,
213 });
214 const token = await seedInvite({ id: "inv-reuse", email: "reuse-1@example.com" });
215 
216 const res = await acceptInvite(token, {
217 name: "Second",
218 password: "long-enough-password",
219 turnstileToken: "test",
220 });
221 expect(res.status).toBe(500);
222 const body = (await res.json()) as { error: string; message: string };
223 expect(body.error).toBe("signup_failed");
224 expect(body.message).toBe("Could not complete signup. Please contact your administrator.");
225 expect(body.message).not.toMatch(/already exists/i);
226 
227 const [row] = await db.select().from(invites).where(eq(invites.id, "inv-reuse"));
228 expect(row?.consumedAt).toBeTruthy();
229 }, 30_000);
230});