Skip to content
File

Blob: tests/scripts/send-local-email.test.ts

typescript203 lines
1import { createServer, type RequestListener, type Server } from "node:http";
2import { afterEach, describe, expect, it, vi } from "vitest";
3import { detectLocalEmailEndpoint, resolveEndpoint } from "../../scripts/send-local-email.mjs";
4 
5const EMAIL_HANDLER_PATH = "/cdn-cgi/handler/email";
6const PUBLIC_CONFIG_PATH = "/api/public/config";
7const INVALID_EMAIL_RESPONSE =
8 "Invalid email. Your request must include URL parameters specifying the `from` and `to` addresses, as well as an email in the body";
9const FLAMEMAIL_INDEX_HTML = `<!doctype html><html><head><title>flamemail</title></head><body></body></html>`;
10const openServers = new Set<Server>();
11 
12afterEach(async () => {
13 await Promise.all([...openServers].map(closeServer));
14 openServers.clear();
15});
16 
17function closeServer(server: Server) {
18 return new Promise<void>((resolve, reject) => {
19 server.close((error) => {
20 if (error) {
21 reject(error);
22 return;
23 }
24 
25 resolve();
26 });
27 });
28}
29 
30function listenOnPort(server: Server, port: number) {
31 return new Promise<void>((resolve, reject) => {
32 const onError = (error: Error) => {
33 server.off("listening", onListening);
34 reject(error);
35 };
36 const onListening = () => {
37 server.off("error", onError);
38 resolve();
39 };
40 
41 server.once("error", onError);
42 server.once("listening", onListening);
43 server.listen(port, "127.0.0.1");
44 });
45}
46 
47async function createMockServer(candidatePorts: number[], responder: RequestListener) {
48 for (const port of candidatePorts) {
49 const server = createServer(responder);
50 
51 try {
52 await listenOnPort(server, port);
53 openServers.add(server);
54 return { port, server };
55 } catch (error) {
56 await closeServer(server).catch(() => {});
57 
58 if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") {
59 continue;
60 }
61 
62 throw error;
63 }
64 }
65 
66 throw new Error(`Could not bind a mock email handler on ports: ${candidatePorts.join(", ")}`);
67}
68 
69async function createMockFlamemailServer(candidatePorts: number[], options?: { configStatus?: 200 | 503 }) {
70 const configStatus = options?.configStatus ?? 200;
71 
72 return createMockServer(candidatePorts, (request, response) => {
73 const requestPath = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
74 
75 if (requestPath === "/") {
76 response.writeHead(200, {
77 "content-type": "text/html; charset=utf-8",
78 });
79 response.end(FLAMEMAIL_INDEX_HTML);
80 return;
81 }
82 
83 if (requestPath === PUBLIC_CONFIG_PATH) {
84 response.writeHead(configStatus, {
85 "content-type": "application/json; charset=utf-8",
86 });
87 
88 if (configStatus === 200) {
89 response.end(JSON.stringify({ turnstileSiteKey: "1x00000000000000000000AA" }));
90 return;
91 }
92 
93 response.end(JSON.stringify({ error: "Human verification is temporarily unavailable." }));
94 return;
95 }
96 
97 if (requestPath === EMAIL_HANDLER_PATH) {
98 response.writeHead(400, {
99 "content-type": "text/plain; charset=utf-8",
100 });
101 response.end(INVALID_EMAIL_RESPONSE);
102 return;
103 }
104 
105 response.writeHead(404);
106 response.end("Not found");
107 });
108}
109 
110async function createMockGenericEmailServer(candidatePorts: number[]) {
111 return createMockServer(candidatePorts, (request, response) => {
112 const requestPath = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
113 
114 if (requestPath === EMAIL_HANDLER_PATH) {
115 response.writeHead(400, {
116 "content-type": "text/plain; charset=utf-8",
117 });
118 response.end(INVALID_EMAIL_RESPONSE);
119 return;
120 }
121 
122 response.writeHead(404);
123 response.end("Not found");
124 });
125}
126 
127describe("send-local-email endpoint detection", () => {
128 it("finds a Flamemail dev server on a shifted vite-style port", async () => {
129 const { port } = await createMockFlamemailServer([5184, 5185, 5186, 5187, 5188]);
130 
131 const endpoint = await detectLocalEmailEndpoint({
132 candidatePorts: [port],
133 });
134 
135 expect(endpoint).toBe(`http://127.0.0.1:${port}${EMAIL_HANDLER_PATH}`);
136 });
137 
138 it("skips unrelated Cloudflare dev servers and finds Flamemail on a later port", async () => {
139 const genericServer = await createMockGenericEmailServer([5189]);
140 const flamemailServer = await createMockFlamemailServer([5190]);
141 
142 const endpoint = await detectLocalEmailEndpoint({
143 candidatePorts: [genericServer.port, flamemailServer.port],
144 });
145 
146 expect(endpoint).toBe(`http://127.0.0.1:${flamemailServer.port}${EMAIL_HANDLER_PATH}`);
147 });
148 
149 it("accepts Flamemail config responses that fail closed with 503", async () => {
150 const { port } = await createMockFlamemailServer([5191], { configStatus: 503 });
151 
152 const endpoint = await detectLocalEmailEndpoint({
153 candidatePorts: [port],
154 });
155 
156 expect(endpoint).toBe(`http://127.0.0.1:${port}${EMAIL_HANDLER_PATH}`);
157 });
158 
159 it("prefers an explicit endpoint over auto detection", async () => {
160 const detectEndpoint = vi.fn();
161 const explicitEndpoint = "http://127.0.0.1:4173/cdn-cgi/handler/email";
162 
163 const endpoint = await resolveEndpoint(
164 {
165 dryRun: false,
166 endpoint: explicitEndpoint,
167 },
168 { detectEndpoint },
169 );
170 
171 expect(endpoint).toBe(explicitEndpoint);
172 expect(detectEndpoint).not.toHaveBeenCalled();
173 });
174 
175 it("skips endpoint detection during dry runs", async () => {
176 const detectEndpoint = vi.fn();
177 
178 const endpoint = await resolveEndpoint(
179 {
180 dryRun: true,
181 endpoint: "",
182 },
183 { detectEndpoint },
184 );
185 
186 expect(endpoint).toBeNull();
187 expect(detectEndpoint).not.toHaveBeenCalled();
188 });
189 
190 it("reports checked ports when no local handler matches", async () => {
191 const fetchImpl = vi.fn().mockResolvedValue(new Response("Not found", { status: 404 }));
192 
193 await expect(
194 detectLocalEmailEndpoint({
195 candidatePorts: [5173, 5174],
196 fetchImpl,
197 }),
198 ).rejects.toThrow(
199 "Could not detect a local Flamemail dev server. Checked ports: 5173, 5174. Start npm run dev or pass --endpoint.",
200 );
201 });
202});