Skip to content
File

Blob: tests/worker/services/turnstile.test.ts

typescript216 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import {
3 TURNSTILE_REQUIRED_MESSAGE,
4 TURNSTILE_UNAVAILABLE_MESSAGE,
5 verifyTurnstileToken,
6} from "@/worker/services/turnstile";
7 
8const fetchMock = vi.fn<typeof fetch>();
9 
10const requestOptions = {
11 expectedAction: "create_inbox",
12 remoteIp: "203.0.113.10",
13 requestUrl: "https://flamemail.devbin.tools/api/public/inboxes",
14 token: "turnstile-token",
15};
16 
17function makeEnv(secret?: string) {
18 return {
19 TURNSTILE_SECRET_KEY: secret,
20 } as Env;
21}
22 
23function jsonResponse(payload: unknown) {
24 return new Response(JSON.stringify(payload), {
25 headers: {
26 "content-type": "application/json",
27 },
28 });
29}
30 
31beforeEach(() => {
32 fetchMock.mockReset();
33 vi.stubGlobal("fetch", fetchMock);
34});
35 
36afterEach(() => {
37 vi.unstubAllGlobals();
38});
39 
40describe("verifyTurnstileToken", () => {
41 it("fails closed when the secret is missing", async () => {
42 const result = await verifyTurnstileToken(makeEnv(), requestOptions);
43 
44 expect(result).toEqual({
45 ok: false,
46 errorCodes: [],
47 message: TURNSTILE_UNAVAILABLE_MESSAGE,
48 reason: "missing_secret",
49 status: 503,
50 });
51 });
52 
53 it("rejects missing tokens", async () => {
54 const result = await verifyTurnstileToken(makeEnv("secret"), {
55 ...requestOptions,
56 token: " ",
57 });
58 
59 expect(result).toEqual({
60 ok: false,
61 errorCodes: [],
62 message: TURNSTILE_REQUIRED_MESSAGE,
63 reason: "missing_token",
64 status: 400,
65 });
66 });
67 
68 it("accepts loopback requests with official testing secrets without siteverify", async () => {
69 const result = await verifyTurnstileToken(makeEnv("1x0000000000000000000000000000000AA"), {
70 ...requestOptions,
71 requestUrl: "http://127.0.0.1:4173/api/public/inboxes",
72 });
73 
74 expect(result).toEqual({
75 ok: true,
76 response: {
77 success: true,
78 action: "test",
79 hostname: "127.0.0.1",
80 metadata: {
81 result_with_testing_key: true,
82 },
83 },
84 });
85 expect(fetchMock).not.toHaveBeenCalled();
86 });
87 
88 it("returns 503 when the siteverify request fails", async () => {
89 fetchMock.mockRejectedValue(new Error("network failure"));
90 
91 const result = await verifyTurnstileToken(makeEnv("secret"), requestOptions);
92 
93 expect(result).toEqual({
94 ok: false,
95 errorCodes: [],
96 message: TURNSTILE_UNAVAILABLE_MESSAGE,
97 reason: "siteverify_request_failed",
98 status: 503,
99 });
100 });
101 
102 it("returns 503 when siteverify returns invalid json", async () => {
103 fetchMock.mockResolvedValue(
104 new Response("{", {
105 headers: {
106 "content-type": "application/json",
107 },
108 }),
109 );
110 
111 const result = await verifyTurnstileToken(makeEnv("secret"), requestOptions);
112 
113 expect(result).toEqual({
114 ok: false,
115 errorCodes: [],
116 message: TURNSTILE_UNAVAILABLE_MESSAGE,
117 reason: "siteverify_invalid_json",
118 status: 503,
119 });
120 });
121 
122 it("returns 503 when the payload shape is invalid", async () => {
123 fetchMock.mockResolvedValue(jsonResponse({ invalid: true }));
124 
125 const result = await verifyTurnstileToken(makeEnv("secret"), requestOptions);
126 
127 expect(result).toEqual({
128 ok: false,
129 errorCodes: [],
130 message: TURNSTILE_UNAVAILABLE_MESSAGE,
131 reason: "siteverify_invalid_payload",
132 status: 503,
133 });
134 });
135 
136 it("returns 403 when verification is unsuccessful", async () => {
137 fetchMock.mockResolvedValue(
138 jsonResponse({
139 success: false,
140 "error-codes": ["invalid-input-response"],
141 }),
142 );
143 
144 const result = await verifyTurnstileToken(makeEnv("secret"), requestOptions);
145 
146 expect(result).toEqual({
147 ok: false,
148 errorCodes: ["invalid-input-response"],
149 message: TURNSTILE_REQUIRED_MESSAGE,
150 reason: "verification_failed",
151 status: 403,
152 });
153 });
154 
155 it("returns 403 when the verified action does not match", async () => {
156 fetchMock.mockResolvedValue(
157 jsonResponse({
158 success: true,
159 action: "admin_login",
160 hostname: "flamemail.devbin.tools",
161 }),
162 );
163 
164 const result = await verifyTurnstileToken(makeEnv("secret"), requestOptions);
165 
166 expect(result).toEqual({
167 ok: false,
168 errorCodes: [],
169 message: TURNSTILE_REQUIRED_MESSAGE,
170 reason: "action_mismatch",
171 status: 403,
172 });
173 });
174 
175 it("returns 403 when the verified hostname does not match", async () => {
176 fetchMock.mockResolvedValue(
177 jsonResponse({
178 success: true,
179 action: "create_inbox",
180 hostname: "evil.example",
181 }),
182 );
183 
184 const result = await verifyTurnstileToken(makeEnv("secret"), requestOptions);
185 
186 expect(result).toEqual({
187 ok: false,
188 errorCodes: [],
189 message: TURNSTILE_REQUIRED_MESSAGE,
190 reason: "hostname_mismatch",
191 status: 403,
192 });
193 });
194 
195 it("accepts Cloudflare testing-key responses with the testing action", async () => {
196 fetchMock.mockResolvedValue(
197 jsonResponse({
198 success: true,
199 action: "test",
200 hostname: "unexpected.example",
201 }),
202 );
203 
204 const result = await verifyTurnstileToken(makeEnv("1x0000000000000000000000000000000AA"), requestOptions);
205 
206 expect(result).toEqual({
207 ok: true,
208 response: {
209 success: true,
210 action: "test",
211 hostname: "unexpected.example",
212 },
213 });
214 });
215});