Skip to content
File

Blob: tests/worker/turnstile.test.ts

typescript189 lines
1import { env } from "cloudflare:workers";
2import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
3 
4import type * as TurnstileModule from "@/worker/services/turnstile";
5 
6// tests/setup.ts globally mocks @/worker/services/turnstile to bypass
7// verification. This file tests the real verifier, so import the actual
8// module via vi.importActual and use that for every assertion.
9let verifyTurnstileToken: typeof TurnstileModule.verifyTurnstileToken;
10 
11beforeAll(async () => {
12 const actual = await vi.importActual<typeof TurnstileModule>("@/worker/services/turnstile");
13 verifyTurnstileToken = actual.verifyTurnstileToken;
14});
15 
16const ORIGINAL_FETCH = globalThis.fetch;
17 
18afterEach(() => {
19 globalThis.fetch = ORIGINAL_FETCH;
20 vi.unstubAllEnvs();
21});
22 
23describe("verifyTurnstileToken — fail-closed semantics", () => {
24 it("returns 503 (deploy config error) when secret is missing", async () => {
25 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "" } as Env, {
26 expectedAction: "sign-in",
27 requestUrl: "https://auth.limic.dev/sign-in",
28 token: "anything",
29 });
30 expect(result.ok).toBe(false);
31 if (!result.ok) {
32 expect(result.status).toBe(503);
33 expect(result.reason).toBe("missing_secret");
34 }
35 });
36 
37 it("returns 503 missing_site_key when only the site key is missing", async () => {
38 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SITE_KEY: "" } as Env, {
39 expectedAction: "sign-in",
40 requestUrl: "https://auth.limic.dev/sign-in",
41 token: "anything",
42 });
43 expect(result.ok).toBe(false);
44 if (!result.ok) {
45 expect(result.status).toBe(503);
46 expect(result.reason).toBe("missing_site_key");
47 }
48 });
49 
50 it("returns 503 missing_secret when both keys are missing (secret checked first)", async () => {
51 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SITE_KEY: "", TURNSTILE_SECRET_KEY: "" } as Env, {
52 expectedAction: "sign-in",
53 requestUrl: "https://auth.limic.dev/sign-in",
54 token: "anything",
55 });
56 expect(result.ok).toBe(false);
57 if (!result.ok) {
58 expect(result.status).toBe(503);
59 expect(result.reason).toBe("missing_secret");
60 }
61 });
62 
63 it("returns 400 when the token is missing", async () => {
64 const result = await verifyTurnstileToken(env, {
65 expectedAction: "sign-in",
66 requestUrl: "https://auth.limic.dev/sign-in",
67 token: "",
68 });
69 expect(result.ok).toBe(false);
70 if (!result.ok) {
71 expect(result.status).toBe(400);
72 expect(result.reason).toBe("missing_token");
73 }
74 });
75 
76 it("returns 403 verification_failed when siteverify says success=false", async () => {
77 globalThis.fetch = vi.fn().mockResolvedValue(
78 new Response(JSON.stringify({ success: false, "error-codes": ["timeout-or-duplicate"] }), {
79 headers: { "content-type": "application/json" },
80 }),
81 );
82 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, {
83 expectedAction: "sign-in",
84 requestUrl: "https://auth.limic.dev/sign-in",
85 token: "tok",
86 });
87 expect(result.ok).toBe(false);
88 if (!result.ok) {
89 expect(result.status).toBe(403);
90 expect(result.reason).toBe("verification_failed");
91 }
92 });
93 
94 it("returns 403 action_mismatch when the action does not match", async () => {
95 globalThis.fetch = vi.fn().mockResolvedValue(
96 new Response(JSON.stringify({ success: true, action: "different-action", hostname: "auth.limic.dev" }), {
97 headers: { "content-type": "application/json" },
98 }),
99 );
100 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, {
101 expectedAction: "sign-in",
102 requestUrl: "https://auth.limic.dev/sign-in",
103 token: "tok",
104 });
105 expect(result.ok).toBe(false);
106 if (!result.ok) {
107 expect(result.status).toBe(403);
108 expect(result.reason).toBe("action_mismatch");
109 }
110 });
111 
112 it("returns 403 hostname_mismatch when siteverify reports a different hostname", async () => {
113 globalThis.fetch = vi.fn().mockResolvedValue(
114 new Response(JSON.stringify({ success: true, action: "sign-in", hostname: "evil.example.com" }), {
115 headers: { "content-type": "application/json" },
116 }),
117 );
118 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, {
119 expectedAction: "sign-in",
120 requestUrl: "https://auth.limic.dev/sign-in",
121 token: "tok",
122 });
123 expect(result.ok).toBe(false);
124 if (!result.ok) {
125 expect(result.status).toBe(403);
126 expect(result.reason).toBe("hostname_mismatch");
127 }
128 });
129 
130 it("returns ok when siteverify reports success with the matching action and hostname", async () => {
131 globalThis.fetch = vi.fn().mockResolvedValue(
132 new Response(JSON.stringify({ success: true, action: "sign-in", hostname: "auth.limic.dev" }), {
133 headers: { "content-type": "application/json" },
134 }),
135 );
136 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, {
137 expectedAction: "sign-in",
138 requestUrl: "https://auth.limic.dev/sign-in",
139 token: "tok",
140 });
141 expect(result.ok).toBe(true);
142 });
143 
144 it('accepts a Cloudflare test-key response with action="test" and skips action/hostname checks', async () => {
145 globalThis.fetch = vi.fn().mockResolvedValue(
146 new Response(JSON.stringify({ success: true, action: "test", hostname: "anywhere.example" }), {
147 headers: { "content-type": "application/json" },
148 }),
149 );
150 const result = await verifyTurnstileToken(env, {
151 expectedAction: "sign-in",
152 requestUrl: "http://localhost/sign-in",
153 token: "tok",
154 });
155 expect(result.ok).toBe(true);
156 });
157 
158 it("accepts a Cloudflare test-key response with metadata.result_with_testing_key and no action", async () => {
159 globalThis.fetch = vi.fn().mockResolvedValue(
160 new Response(JSON.stringify({ success: true, metadata: { result_with_testing_key: true } }), {
161 headers: { "content-type": "application/json" },
162 }),
163 );
164 const result = await verifyTurnstileToken(env, {
165 expectedAction: "sign-in",
166 requestUrl: "http://localhost/sign-in",
167 token: "tok",
168 });
169 expect(result.ok).toBe(true);
170 });
171 
172 it("does not apply the test-response carve-out when the secret is not a test secret", async () => {
173 globalThis.fetch = vi.fn().mockResolvedValue(
174 new Response(JSON.stringify({ success: true, action: "test" }), {
175 headers: { "content-type": "application/json" },
176 }),
177 );
178 const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, {
179 expectedAction: "sign-in",
180 requestUrl: "https://auth.limic.dev/sign-in",
181 token: "tok",
182 });
183 expect(result.ok).toBe(false);
184 if (!result.ok) {
185 expect(result.reason).toBe("action_mismatch");
186 }
187 });
188});