Skip to content
File

Blob: tests/worker/api/helpers.ts

typescript273 lines
1import { createExecutionContext, env } from "cloudflare:test";
2import type { SessionRecord } from "@/shared/contracts";
3import worker from "@/worker/index";
4import { createDb } from "@/worker/db";
5import { attachments, domains, emails, inboxes } from "@/worker/db/schema";
6import { ADMIN_COOKIE_HEADER_NAME } from "@/worker/services/cookies";
7import { storeAttachment, storeEmailBody, storeRawEmail } from "@/worker/services/storage";
8 
9const encoder = new TextEncoder();
10 
11function getDatabase() {
12 return createDb(env.DB.withSession("first-primary"));
13}
14 
15function splitAddress(address: string) {
16 const separatorIndex = address.indexOf("@");
17 if (separatorIndex <= 0 || separatorIndex === address.length - 1) {
18 throw new Error(`Invalid inbox address: ${address}`);
19 }
20 
21 return {
22 localPart: address.slice(0, separatorIndex),
23 domain: address.slice(separatorIndex + 1),
24 };
25}
26 
27async function clearSessions() {
28 let cursor: string | undefined;
29 
30 do {
31 const result = await env.SESSIONS.list({ cursor });
32 await Promise.all(result.keys.map((key) => env.SESSIONS.delete(key.name)));
33 cursor = result.list_complete ? undefined : result.cursor;
34 } while (cursor);
35}
36 
37async function clearStorage() {
38 let cursor: string | undefined;
39 
40 do {
41 const result = await env.STORAGE.list({ cursor });
42 const keys = result.objects.map((object) => object.key);
43 if (keys.length > 0) {
44 await env.STORAGE.delete(keys);
45 }
46 cursor = result.truncated ? result.cursor : undefined;
47 } while (cursor);
48}
49 
50export async function resetWorkerState() {
51 const db = getDatabase();
52 
53 await clearStorage();
54 await clearSessions();
55 await db.delete(attachments);
56 await db.delete(emails);
57 await db.delete(inboxes);
58 await db.delete(domains);
59}
60 
61export async function seedDomain(domainName: string, isActive = true) {
62 const now = new Date();
63 const record = {
64 id: `domain_${crypto.randomUUID()}`,
65 domain: domainName,
66 isActive,
67 createdAt: now,
68 };
69 
70 await getDatabase().insert(domains).values(record);
71 return record;
72}
73 
74interface SeedInboxOptions {
75 address: string;
76 createdAt?: Date;
77 expiresAt?: Date | null;
78 id?: string;
79 isPermanent?: boolean;
80}
81 
82export async function seedInbox(options: SeedInboxOptions) {
83 const { localPart, domain } = splitAddress(options.address);
84 const createdAt = options.createdAt ?? new Date();
85 const defaultExpiresAt = new Date(createdAt.getTime() + 24 * 60 * 60 * 1000);
86 const record = {
87 id: options.id ?? `inbox_${crypto.randomUUID()}`,
88 localPart,
89 domain,
90 fullAddress: options.address,
91 isPermanent: options.isPermanent ?? false,
92 createdAt,
93 expiresAt: (options.isPermanent ?? false) ? null : (options.expiresAt ?? defaultExpiresAt),
94 };
95 
96 await getDatabase().insert(inboxes).values(record);
97 return record;
98}
99 
100export async function seedSession(session: SessionRecord, token = `tok_${crypto.randomUUID().replace(/-/g, "")}`) {
101 await env.SESSIONS.put(`token:${token}`, JSON.stringify(session), {
102 expirationTtl: 60 * 60,
103 });
104 
105 if (session.type === "user") {
106 await env.SESSIONS.put(`inbox-token:${session.address}`, token, {
107 expirationTtl: 60 * 60,
108 });
109 }
110 
111 return token;
112}
113 
114interface SeedAttachmentOptions {
115 content: ArrayBuffer | string;
116 contentType?: string | null;
117 filename?: string | null;
118 id?: string;
119}
120 
121interface SeedEmailOptions {
122 address: string;
123 attachments?: SeedAttachmentOptions[];
124 fromAddress?: string;
125 fromName?: string | null;
126 html?: string | null;
127 id?: string;
128 inboxId: string;
129 isRead?: boolean;
130 raw?: ArrayBuffer | string;
131 receivedAt?: Date;
132 subject?: string;
133 text?: string | null;
134}
135 
136function toArrayBuffer(value: ArrayBuffer | string) {
137 if (typeof value === "string") {
138 return encoder.encode(value).buffer;
139 }
140 
141 return value;
142}
143 
144export async function seedEmail(options: SeedEmailOptions) {
145 const emailId = options.id ?? `email_${crypto.randomUUID()}`;
146 const bodyKey = await storeEmailBody(env.STORAGE, emailId, {
147 text: options.text ?? null,
148 html: options.html ?? null,
149 });
150 
151 await storeRawEmail(env.STORAGE, emailId, toArrayBuffer(options.raw ?? `Raw email for ${emailId}`));
152 
153 await getDatabase()
154 .insert(emails)
155 .values({
156 id: emailId,
157 inboxId: options.inboxId,
158 recipientAddress: options.address,
159 fromAddress: options.fromAddress ?? "sender@example.com",
160 fromName: options.fromName ?? "Sender",
161 subject: options.subject ?? "Test subject",
162 receivedAt: options.receivedAt ?? new Date(),
163 isRead: options.isRead ?? false,
164 sizeBytes: typeof options.raw === "string" ? options.raw.length : 128,
165 hasAttachments: (options.attachments?.length ?? 0) > 0,
166 bodyKey,
167 });
168 
169 const storedAttachments = [] as Array<{
170 contentType: string | null;
171 filename: string | null;
172 id: string;
173 storageKey: string;
174 }>;
175 
176 for (const attachment of options.attachments ?? []) {
177 const attachmentId = attachment.id ?? `attachment_${crypto.randomUUID()}`;
178 const storageKey = await storeAttachment(env.STORAGE, emailId, attachmentId, {
179 content: toArrayBuffer(attachment.content),
180 filename: attachment.filename,
181 contentType: attachment.contentType,
182 });
183 
184 await getDatabase()
185 .insert(attachments)
186 .values({
187 id: attachmentId,
188 emailId,
189 filename: attachment.filename ?? null,
190 contentType: attachment.contentType ?? null,
191 sizeBytes: typeof attachment.content === "string" ? attachment.content.length : attachment.content.byteLength,
192 storageKey,
193 });
194 
195 storedAttachments.push({
196 id: attachmentId,
197 filename: attachment.filename ?? null,
198 contentType: attachment.contentType ?? null,
199 storageKey,
200 });
201 }
202 
203 return {
204 bodyKey,
205 emailId,
206 attachments: storedAttachments,
207 };
208}
209 
210interface ApiRequestOptions {
211 body?: unknown;
212 cookie?: string;
213 envOverrides?: Partial<Env>;
214 headers?: HeadersInit;
215 method?: string;
216 origin?: string | null;
217 token?: string;
218}
219 
220const DEFAULT_BASE_URL = "https://flamemail.devbin.tools";
221 
222export function apiRequest(path: string, options: ApiRequestOptions = {}) {
223 const headers = new Headers(options.headers);
224 
225 if (options.token) {
226 headers.set("authorization", `Bearer ${options.token}`);
227 }
228 
229 if (options.cookie) {
230 headers.set("cookie", options.cookie);
231 }
232 
233 if (options.origin === null) {
234 headers.delete("origin");
235 } else if (options.origin !== undefined) {
236 headers.set("origin", options.origin);
237 } else if (!headers.has("origin")) {
238 headers.set("origin", DEFAULT_BASE_URL);
239 }
240 
241 let body: BodyInit | undefined;
242 if (options.body !== undefined) {
243 headers.set("content-type", "application/json");
244 body = JSON.stringify(options.body);
245 }
246 
247 const request = new Request(`${DEFAULT_BASE_URL}${path}`, {
248 method: options.method ?? (body ? "POST" : "GET"),
249 headers,
250 body,
251 }) as Parameters<typeof worker.fetch>[0];
252 
253 return worker.fetch(
254 request,
255 {
256 ...env,
257 ...options.envOverrides,
258 },
259 createExecutionContext(),
260 );
261}
262 
263// Seed sub matches the operator allowlist baked into vitest.config.ts.
264export const ALLOWED_OPERATOR_SUB = "00000000-0000-4000-8000-000000000001";
265 
266export async function seedAdminCookieSession(
267 options: { sub?: string } = {},
268): Promise<{ token: string; cookie: string; sub: string }> {
269 const sub = options.sub ?? ALLOWED_OPERATOR_SUB;
270 const token = await seedSession({ type: "admin", sub });
271 return { token, cookie: `${ADMIN_COOKIE_HEADER_NAME}=${token}`, sub };
272}