File
Blob: tests/worker/turnstile.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; |
| 3 | |
| 4 | import 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. |
| 9 | let verifyTurnstileToken: typeof TurnstileModule.verifyTurnstileToken; |
| 10 | |
| 11 | beforeAll(async () => { |
| 12 | const actual = await vi.importActual<typeof TurnstileModule>("@/worker/services/turnstile"); |
| 13 | verifyTurnstileToken = actual.verifyTurnstileToken; |
| 14 | }); |
| 15 | |
| 16 | const ORIGINAL_FETCH = globalThis.fetch; |
| 17 | |
| 18 | afterEach(() => { |
| 19 | globalThis.fetch = ORIGINAL_FETCH; |
| 20 | vi.unstubAllEnvs(); |
| 21 | }); |
| 22 | |
| 23 | describe("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 | }); |