import { env } from "cloudflare:workers"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type * as TurnstileModule from "@/worker/services/turnstile"; // tests/setup.ts globally mocks @/worker/services/turnstile to bypass // verification. This file tests the real verifier, so import the actual // module via vi.importActual and use that for every assertion. let verifyTurnstileToken: typeof TurnstileModule.verifyTurnstileToken; beforeAll(async () => { const actual = await vi.importActual("@/worker/services/turnstile"); verifyTurnstileToken = actual.verifyTurnstileToken; }); const ORIGINAL_FETCH = globalThis.fetch; afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; vi.unstubAllEnvs(); }); describe("verifyTurnstileToken — fail-closed semantics", () => { it("returns 503 (deploy config error) when secret is missing", async () => { const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "anything", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(503); expect(result.reason).toBe("missing_secret"); } }); it("returns 503 missing_site_key when only the site key is missing", async () => { const result = await verifyTurnstileToken({ ...env, TURNSTILE_SITE_KEY: "" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "anything", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(503); expect(result.reason).toBe("missing_site_key"); } }); it("returns 503 missing_secret when both keys are missing (secret checked first)", async () => { const result = await verifyTurnstileToken({ ...env, TURNSTILE_SITE_KEY: "", TURNSTILE_SECRET_KEY: "" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "anything", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(503); expect(result.reason).toBe("missing_secret"); } }); it("returns 400 when the token is missing", async () => { const result = await verifyTurnstileToken(env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(400); expect(result.reason).toBe("missing_token"); } }); it("returns 403 verification_failed when siteverify says success=false", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: false, "error-codes": ["timeout-or-duplicate"] }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "tok", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(403); expect(result.reason).toBe("verification_failed"); } }); it("returns 403 action_mismatch when the action does not match", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, action: "different-action", hostname: "auth.limic.dev" }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "tok", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(403); expect(result.reason).toBe("action_mismatch"); } }); it("returns 403 hostname_mismatch when siteverify reports a different hostname", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, action: "sign-in", hostname: "evil.example.com" }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "tok", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(403); expect(result.reason).toBe("hostname_mismatch"); } }); it("returns ok when siteverify reports success with the matching action and hostname", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, action: "sign-in", hostname: "auth.limic.dev" }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "tok", }); expect(result.ok).toBe(true); }); it('accepts a Cloudflare test-key response with action="test" and skips action/hostname checks', async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, action: "test", hostname: "anywhere.example" }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken(env, { expectedAction: "sign-in", requestUrl: "http://localhost/sign-in", token: "tok", }); expect(result.ok).toBe(true); }); it("accepts a Cloudflare test-key response with metadata.result_with_testing_key and no action", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, metadata: { result_with_testing_key: true } }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken(env, { expectedAction: "sign-in", requestUrl: "http://localhost/sign-in", token: "tok", }); expect(result.ok).toBe(true); }); it("does not apply the test-response carve-out when the secret is not a test secret", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, action: "test" }), { headers: { "content-type": "application/json" }, }), ); const result = await verifyTurnstileToken({ ...env, TURNSTILE_SECRET_KEY: "real-prod-secret" } as Env, { expectedAction: "sign-in", requestUrl: "https://auth.limic.dev/sign-in", token: "tok", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("action_mismatch"); } }); });