Skip to content
File

Blob: tests/worker/api/inboxes.test.ts

typescript500 lines
1import { env } from "cloudflare:test";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import { createDb } from "@/worker/db";
4import { apiRequest, resetWorkerState, seedAdminCookieSession, seedDomain, seedInbox, seedSession } from "./helpers";
5 
6const fetchMock = vi.fn<typeof fetch>();
7 
8function jsonResponse(payload: unknown) {
9 return new Response(JSON.stringify(payload), {
10 headers: {
11 "content-type": "application/json",
12 },
13 });
14}
15 
16describe("worker api /api/public/inboxes and /api/protected/inboxes", () => {
17 beforeEach(async () => {
18 fetchMock.mockReset();
19 vi.stubGlobal("fetch", fetchMock);
20 await resetWorkerState();
21 });
22 
23 afterEach(() => {
24 vi.unstubAllGlobals();
25 });
26 
27 it("creates a temporary inbox for an active domain", async () => {
28 await seedDomain("mail.test");
29 fetchMock.mockResolvedValue(
30 jsonResponse({
31 success: true,
32 action: "test",
33 hostname: "mismatch.example",
34 }),
35 );
36 
37 const response = await apiRequest("/api/public/inboxes", {
38 body: {
39 domain: "mail.test",
40 ttlHours: 24,
41 turnstileToken: "turnstile-token",
42 },
43 });
44 
45 expect(response.status).toBe(201);
46 
47 const payload = (await response.json()) as {
48 address: string;
49 expiresAt: string;
50 token: string;
51 ttlHours: number;
52 };
53 
54 expect(payload.address).toMatch(/@mail\.test$/);
55 expect(payload.ttlHours).toBe(24);
56 expect(new Date(payload.expiresAt).toISOString()).toBe(payload.expiresAt);
57 
58 const db = createDb(env.DB.withSession("first-primary"));
59 const inbox = await db.query.inboxes.findFirst({
60 where: (table, { eq }) => eq(table.fullAddress, payload.address),
61 });
62 
63 expect(inbox?.domain).toBe("mail.test");
64 expect(inbox?.isPermanent).toBe(false);
65 expect(await env.SESSIONS.get(`token:${payload.token}`)).not.toBeNull();
66 });
67 
68 it("rejects invalid create inbox request bodies", async () => {
69 const response = await apiRequest("/api/public/inboxes", {
70 body: {
71 ttlHours: 24,
72 turnstileToken: "turnstile-token",
73 },
74 });
75 
76 expect(response.status).toBe(400);
77 await expect(response.json()).resolves.toEqual({
78 error: "A domain is required",
79 });
80 });
81 
82 it("rejects inbox creation when turnstile verification fails", async () => {
83 await seedDomain("mail.test");
84 fetchMock.mockResolvedValue(
85 jsonResponse({
86 success: false,
87 "error-codes": ["invalid-input-response"],
88 }),
89 );
90 
91 const response = await apiRequest("/api/public/inboxes", {
92 body: {
93 domain: "mail.test",
94 ttlHours: 24,
95 turnstileToken: "turnstile-token",
96 },
97 });
98 
99 expect(response.status).toBe(403);
100 await expect(response.json()).resolves.toEqual({
101 error: "Human verification failed. Please try again.",
102 });
103 });
104 
105 it("rejects inbox creation for unavailable domains", async () => {
106 fetchMock.mockResolvedValue(
107 jsonResponse({
108 success: true,
109 action: "test",
110 }),
111 );
112 
113 const response = await apiRequest("/api/public/inboxes", {
114 body: {
115 domain: "missing.test",
116 ttlHours: 24,
117 turnstileToken: "turnstile-token",
118 },
119 });
120 
121 expect(response.status).toBe(400);
122 await expect(response.json()).resolves.toEqual({
123 error: "Requested domain is not available",
124 });
125 });
126 
127 it("requires inbox auth for inbox detail", async () => {
128 await seedDomain("mail.test");
129 const inbox = await seedInbox({
130 address: "reader@mail.test",
131 });
132 
133 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`);
134 
135 expect(response.status).toBe(401);
136 await expect(response.json()).resolves.toEqual({
137 error: "Unauthorized",
138 });
139 });
140 
141 it("rejects the wrong user token for inbox detail", async () => {
142 await seedDomain("mail.test");
143 const inbox = await seedInbox({
144 address: "reader@mail.test",
145 });
146 const token = await seedSession({
147 type: "user",
148 address: "someone-else@mail.test",
149 });
150 
151 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
152 token,
153 });
154 
155 expect(response.status).toBe(403);
156 await expect(response.json()).resolves.toEqual({
157 error: "Forbidden",
158 });
159 });
160 
161 it("rejects bearer admin tokens on the user inbox class", async () => {
162 await seedDomain("mail.test");
163 const inbox = await seedInbox({
164 address: "reader@mail.test",
165 });
166 const token = await seedSession({ type: "admin", sub: "00000000-0000-4000-8000-000000000001" });
167 
168 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
169 token,
170 });
171 
172 expect(response.status).toBe(401);
173 });
174 
175 it("rejects admin cookie alone on the user inbox class", async () => {
176 await seedDomain("mail.test");
177 const inbox = await seedInbox({
178 address: "reader@mail.test",
179 });
180 const { cookie } = await seedAdminCookieSession();
181 
182 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
183 cookie,
184 });
185 
186 expect(response.status).toBe(401);
187 });
188 
189 it("rejects invalid session tokens for inbox detail", async () => {
190 await seedDomain("mail.test");
191 const inbox = await seedInbox({
192 address: "reader@mail.test",
193 });
194 
195 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
196 token: "tok_missing",
197 });
198 
199 expect(response.status).toBe(401);
200 await expect(response.json()).resolves.toEqual({
201 error: "Unauthorized",
202 });
203 });
204 
205 it("returns 404 when the admin-inspect inbox does not exist", async () => {
206 const { cookie } = await seedAdminCookieSession();
207 
208 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent("missing@mail.test")}?admin=1`, {
209 cookie,
210 });
211 
212 expect(response.status).toBe(404);
213 await expect(response.json()).resolves.toEqual({
214 error: "Inbox not found",
215 });
216 });
217 
218 it("returns 410 when the requested temporary inbox has expired", async () => {
219 await seedDomain("mail.test");
220 const inbox = await seedInbox({
221 address: "reader@mail.test",
222 createdAt: new Date("2026-01-01T00:00:00.000Z"),
223 expiresAt: new Date("2026-01-02T00:00:00.000Z"),
224 });
225 const token = await seedSession({
226 type: "user",
227 address: inbox.fullAddress,
228 });
229 
230 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
231 token,
232 });
233 
234 expect(response.status).toBe(410);
235 await expect(response.json()).resolves.toEqual({
236 error: "Inbox has expired",
237 });
238 });
239 
240 it("returns 410 for admin-inspect on an expired inbox", async () => {
241 await seedDomain("mail.test");
242 const inbox = await seedInbox({
243 address: "reader@mail.test",
244 createdAt: new Date("2026-01-01T00:00:00.000Z"),
245 expiresAt: new Date("2026-01-02T00:00:00.000Z"),
246 });
247 const { cookie } = await seedAdminCookieSession();
248 
249 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}?admin=1`, {
250 cookie,
251 });
252 
253 expect(response.status).toBe(410);
254 });
255 
256 it("extends a temporary inbox for the owning user", async () => {
257 await seedDomain("mail.test");
258 const createdAt = new Date(Date.now() - 5 * 60 * 60 * 1000); // 5 hours ago
259 const initialExpiresAt = new Date(createdAt.getTime() + 24 * 60 * 60 * 1000); // 24h after creation
260 const expectedExpiresAt = new Date(createdAt.getTime() + 72 * 60 * 60 * 1000); // 72h after creation
261 const inbox = await seedInbox({
262 address: "reader@mail.test",
263 createdAt,
264 expiresAt: initialExpiresAt,
265 });
266 const token = await seedSession({
267 type: "user",
268 address: inbox.fullAddress,
269 });
270 
271 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/extend`, {
272 method: "POST",
273 token,
274 body: {
275 ttlHours: 72,
276 },
277 });
278 
279 expect(response.status).toBe(200);
280 
281 const payload = (await response.json()) as {
282 address: string;
283 expiresAt: string;
284 ttlHours: number;
285 };
286 
287 expect(payload).toEqual({
288 address: inbox.fullAddress,
289 expiresAt: expectedExpiresAt.toISOString(),
290 ttlHours: 72,
291 });
292 
293 const db = createDb(env.DB.withSession("first-primary"));
294 const storedInbox = await db.query.inboxes.findFirst({
295 where: (table, { eq }) => eq(table.id, inbox.id),
296 });
297 
298 expect(storedInbox?.expiresAt?.toISOString()).toBe(expectedExpiresAt.toISOString());
299 expect(await env.SESSIONS.get(`token:${token}`)).not.toBeNull();
300 });
301 
302 it("rejects inbox extension for admin inspection mode", async () => {
303 await seedDomain("mail.test");
304 const inbox = await seedInbox({
305 address: "reader@mail.test",
306 });
307 const { cookie } = await seedAdminCookieSession();
308 
309 const response = await apiRequest(
310 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/extend?admin=1`,
311 {
312 method: "POST",
313 cookie,
314 body: { ttlHours: 72 },
315 },
316 );
317 
318 expect(response.status).toBe(403);
319 await expect(response.json()).resolves.toEqual({
320 error: "Admin inspection for temporary inboxes is read-only",
321 });
322 });
323 
324 it("issues a websocket ticket for an authorized session", async () => {
325 await seedDomain("mail.test");
326 const inbox = await seedInbox({
327 address: "reader@mail.test",
328 });
329 const token = await seedSession({
330 type: "user",
331 address: inbox.fullAddress,
332 });
333 
334 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/ws-ticket`, {
335 method: "POST",
336 token,
337 });
338 
339 expect(response.status).toBe(200);
340 
341 const payload = (await response.json()) as {
342 ticket: string;
343 };
344 
345 expect(payload.ticket).toMatch(/^wst_/);
346 await expect(env.SESSIONS.get(`ws-ticket:${payload.ticket}`, "json")).resolves.toEqual({
347 address: inbox.fullAddress,
348 session: {
349 type: "user",
350 address: inbox.fullAddress,
351 },
352 });
353 });
354 
355 it("issues an admin-scoped websocket ticket via cookie + ?admin=1", async () => {
356 await seedDomain("mail.test");
357 const inbox = await seedInbox({
358 address: "reader@mail.test",
359 });
360 const { cookie } = await seedAdminCookieSession();
361 
362 const response = await apiRequest(
363 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/ws-ticket?admin=1`,
364 {
365 method: "POST",
366 cookie,
367 },
368 );
369 
370 expect(response.status).toBe(200);
371 
372 const payload = (await response.json()) as { ticket: string };
373 await expect(env.SESSIONS.get(`ws-ticket:${payload.ticket}`, "json")).resolves.toEqual({
374 address: inbox.fullAddress,
375 session: { type: "admin", sub: "00000000-0000-4000-8000-000000000001" },
376 });
377 });
378 
379 it("rejects cross-origin ws-ticket on admin-inspect", async () => {
380 await seedDomain("mail.test");
381 const inbox = await seedInbox({
382 address: "reader@mail.test",
383 });
384 const { cookie } = await seedAdminCookieSession();
385 
386 const response = await apiRequest(
387 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/ws-ticket?admin=1`,
388 {
389 method: "POST",
390 cookie,
391 origin: "https://attacker.example",
392 },
393 );
394 
395 expect(response.status).toBe(403);
396 });
397 
398 it("ignores the bearer token when ?admin=1 is set", async () => {
399 await seedDomain("mail.test");
400 const inbox = await seedInbox({
401 address: "reader@mail.test",
402 });
403 const userToken = await seedSession({
404 type: "user",
405 address: inbox.fullAddress,
406 });
407 
408 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}?admin=1`, {
409 token: userToken,
410 });
411 
412 expect(response.status).toBe(401);
413 });
414 
415 it("deletes a temporary inbox for the owning user", async () => {
416 await seedDomain("mail.test");
417 const inbox = await seedInbox({
418 address: "reader@mail.test",
419 });
420 const token = await seedSession({
421 type: "user",
422 address: inbox.fullAddress,
423 });
424 
425 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
426 method: "DELETE",
427 token,
428 });
429 
430 expect(response.status).toBe(200);
431 await expect(response.json()).resolves.toEqual({ ok: true });
432 
433 const db = createDb(env.DB.withSession("first-primary"));
434 const storedInbox = await db.query.inboxes.findFirst({
435 where: (table, { eq }) => eq(table.id, inbox.id),
436 });
437 
438 expect(storedInbox).toBeUndefined();
439 });
440 
441 it("rejects delete for permanent inboxes via admin-inspect", async () => {
442 await seedDomain("mail.test");
443 const inbox = await seedInbox({
444 address: "admin@mail.test",
445 isPermanent: true,
446 });
447 const { cookie } = await seedAdminCookieSession();
448 
449 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}?admin=1`, {
450 method: "DELETE",
451 cookie,
452 });
453 
454 expect(response.status).toBe(403);
455 await expect(response.json()).resolves.toEqual({
456 error: "Permanent inboxes cannot be deleted",
457 });
458 });
459 
460 it("admin-inspect can delete a temporary inbox", async () => {
461 await seedDomain("mail.test");
462 const inbox = await seedInbox({
463 address: "reader@mail.test",
464 });
465 const { cookie } = await seedAdminCookieSession();
466 
467 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}?admin=1`, {
468 method: "DELETE",
469 cookie,
470 });
471 
472 expect(response.status).toBe(200);
473 
474 const db = createDb(env.DB.withSession("first-primary"));
475 const stored = await db.query.inboxes.findFirst({
476 where: (table, { eq }) => eq(table.id, inbox.id),
477 });
478 expect(stored).toBeUndefined();
479 });
480 
481 it("bearer wins on user inbox class even when cookie is also present", async () => {
482 await seedDomain("mail.test");
483 const inbox = await seedInbox({
484 address: "reader@mail.test",
485 });
486 const userToken = await seedSession({
487 type: "user",
488 address: inbox.fullAddress,
489 });
490 const { cookie } = await seedAdminCookieSession();
491 
492 const response = await apiRequest(`/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}`, {
493 token: userToken,
494 cookie,
495 });
496 
497 expect(response.status).toBe(200);
498 });
499});