Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/app/api/auth/login/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { NextRequest } from "next/server";

const { mockVerifyTurnstileToken } = vi.hoisted(() => ({
mockVerifyTurnstileToken: vi.fn(),
}));

vi.mock("@/lib/turnstile", () => ({
verifyTurnstileToken: mockVerifyTurnstileToken,
}));

import { POST } from "../route";

function makeRequest(body: Record<string, unknown>) {
return new Request("http://localhost:3000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}) as unknown as NextRequest;
}

beforeEach(() => {
mockVerifyTurnstileToken.mockReset();
});

describe("POST /api/auth/login - Turnstile failure handling", () => {
it("returns a clear 400 (not a generic 500) when Turnstile verification fails", async () => {
mockVerifyTurnstileToken.mockResolvedValue({
success: false,
reason: "invalid-input-response",
});

const response = await POST(
makeRequest({
email: "someone@example.com",
password: "whatever-password",
turnstileToken: "bad-token",
})
);
const body = await response.json();

expect(response.status).toBe(400);
expect(body).toEqual({
success: false,
error: "Verification failed: invalid-input-response",
});
});

it("never reaches the database lookup when Turnstile fails - the route short-circuits before it", async () => {
// No supabase mock is set up at all in this file. If the route tried to
// query the (unconfigured) database after a failed Turnstile check, it
// would throw and this test would fail loudly instead of silently
// passing - which is exactly what we want to catch as a regression.
mockVerifyTurnstileToken.mockResolvedValue({ success: false, reason: "timeout-or-duplicate" });

const response = await POST(
makeRequest({ email: "a@b.com", password: "x", turnstileToken: "bad" })
);

expect(response.status).toBe(400);
expect(mockVerifyTurnstileToken).toHaveBeenCalledOnce();
});
});
97 changes: 97 additions & 0 deletions src/lib/__tests__/turnstile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { verifyTurnstileToken } from "../turnstile";

const ORIGINAL_SECRET = process.env.TURNSTILE_SECRET_KEY;

beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
if (ORIGINAL_SECRET === undefined) {
delete process.env.TURNSTILE_SECRET_KEY;
} else {
process.env.TURNSTILE_SECRET_KEY = ORIGINAL_SECRET;
}
});

describe("verifyTurnstileToken", () => {
it("skips verification (success: true) when TURNSTILE_SECRET_KEY is not configured", async () => {
delete process.env.TURNSTILE_SECRET_KEY;

const result = await verifyTurnstileToken("any-token");

expect(result).toEqual({ success: true });
expect(fetch).not.toHaveBeenCalled();
});

it("fails with a clear reason when the token is missing, once a secret is configured", async () => {
process.env.TURNSTILE_SECRET_KEY = "test-secret";

const resultUndefined = await verifyTurnstileToken(undefined);
const resultNull = await verifyTurnstileToken(null);
const resultEmpty = await verifyTurnstileToken("");

for (const result of [resultUndefined, resultNull, resultEmpty]) {
expect(result.success).toBe(false);
expect(result.reason).toBe("Missing verification token");
}
expect(fetch).not.toHaveBeenCalled();
});

it("succeeds when Cloudflare confirms the token", async () => {
process.env.TURNSTILE_SECRET_KEY = "test-secret";
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ success: true }), { status: 200 })
);

const result = await verifyTurnstileToken("valid-token");

expect(result).toEqual({ success: true });
});

it("fails with the joined error-codes when Cloudflare rejects the token", async () => {
process.env.TURNSTILE_SECRET_KEY = "test-secret";
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({ success: false, "error-codes": ["invalid-input-response", "timeout-or-duplicate"] }),
{ status: 200 }
)
);

const result = await verifyTurnstileToken("bad-token");

expect(result.success).toBe(false);
expect(result.reason).toBe("invalid-input-response, timeout-or-duplicate");
});

it("falls back to a generic reason when Cloudflare rejects without error-codes", async () => {
process.env.TURNSTILE_SECRET_KEY = "test-secret";
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ success: false }), { status: 200 })
);

const result = await verifyTurnstileToken("bad-token");

expect(result).toEqual({ success: false, reason: "Verification failed" });
});

it("returns a clear, non-throwing reason when the verification request itself fails (network error/timeout)", async () => {
process.env.TURNSTILE_SECRET_KEY = "test-secret";
vi.mocked(fetch).mockRejectedValue(new Error("fetch failed"));

const result = await verifyTurnstileToken("some-token");

expect(result).toEqual({ success: false, reason: "Couldn't reach the verification service" });
});

it("returns a clear, non-throwing reason when Cloudflare's response body isn't valid JSON", async () => {
process.env.TURNSTILE_SECRET_KEY = "test-secret";
vi.mocked(fetch).mockResolvedValue(new Response("<html>502 Bad Gateway</html>", { status: 502 }));

const result = await verifyTurnstileToken("some-token");

expect(result).toEqual({ success: false, reason: "Couldn't reach the verification service" });
});
});