import { describe, expect, test } from "bun:test"; import { createCaptchaEngine } from "../src/engine.ts"; import { MemoryCaptchaStore } from "../src/stores/memory.ts"; import type { CaptchaChallengeGenerator } from "../src/types.ts"; function fixture() { let now = 1_700_000_000_000; let seed = 7; const generator: CaptchaChallengeGenerator = { type: "number", generate: () => ({ type: "number", presentation: "visual", prompt: "Enter 42", answer: "42", answerKind: "text", inputMode: "numeric", audioSequence: ["four", "two"], }), }; const engine = createCaptchaEngine({ secret: "captcha-test-secret-with-at-least-thirty-two-characters", store: new MemoryCaptchaStore(), generators: [generator], defaultType: "number", minCompletionMs: 0, now: () => now, randomBytes(length) { const bytes = new Uint8Array(length); for (let index = 0; index < length; index += 1) { seed = (seed * 1664525 + 1013904223) >>> 0; bytes[index] = seed & 255; } return bytes; }, audioRenderer: { contentType: "audio/wav", async render() { return new Uint8Array([82, 73, 70, 70]); } }, }); return { engine, advance: (milliseconds: number) => { now += milliseconds; } }; } describe("self-hosted CAPTCHA engine", () => { test("creates, solves, and consumes a challenge and response token", async () => { const { engine } = fixture(); const challenge = await engine.create({ action: "signup", hostname: "example.test", sessionId: "s1" }); const solved = await engine.verify({ challengeId: challenge.id, action: "signup", answer: "42", hostname: "example.test", sessionId: "s1" }); expect(solved.success).toBe(true); expect(solved.responseToken).toBeString(); const accepted = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" }); expect(accepted.success).toBe(true); const replay = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" }); expect(replay).toMatchObject({ success: false, code: "already-used" }); }); test("rejects wrong answers and enforces attempt limits", async () => { const { engine } = fixture(); const challenge = await engine.create({ action: "login", maxAttempts: 2 }); expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "1" })).toMatchObject({ success: false, code: "incorrect-answer" }); expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "2" })).toMatchObject({ success: false, code: "attempts-exhausted" }); }); test("binds challenges to actions, hosts, and sessions", async () => { const { engine } = fixture(); const challenge = await engine.create({ action: "checkout", hostname: "shop.test", sessionId: "abc" }); expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "42", hostname: "shop.test", sessionId: "abc" })).toMatchObject({ code: "action-mismatch" }); expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "other.test", sessionId: "abc" })).toMatchObject({ code: "hostname-mismatch" }); expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "shop.test", sessionId: "wrong" })).toMatchObject({ code: "session-mismatch" }); }); test("expires challenges", async () => { const { engine, advance } = fixture(); const challenge = await engine.create({ action: "contact", expiresInMs: 1000 }); advance(1001); expect(await engine.verify({ challengeId: challenge.id, action: "contact", answer: "42" })).toMatchObject({ success: false, code: "expired" }); }); test("protects audio with an unguessable challenge key", async () => { const { engine } = fixture(); const challenge = await engine.create({ action: "contact", presentation: "audio" }); expect(challenge.audioUrl).toContain("/audio/"); const url = new URL(challenge.audioUrl!, "https://example.test"); expect(await engine.renderAudio(challenge.id, "wrong")).toBeUndefined(); const rendered = await engine.renderAudio(challenge.id, url.searchParams.get("key")!); expect(rendered?.contentType).toBe("audio/wav"); }); test("creates and verifies the not-robot checkbox challenge after the minimum completion time", async () => { let now = 1_700_000_000_000; let seed = 19; const engine = createCaptchaEngine({ secret: "not-robot-test-secret-with-at-least-thirty-two-characters", store: new MemoryCaptchaStore(), minCompletionMs: 800, now: () => now, randomBytes(length) { const bytes = new Uint8Array(length); for (let index = 0; index < length; index += 1) { seed = (seed * 1664525 + 1013904223) >>> 0; bytes[index] = seed & 255; } return bytes; }, }); const challenge = await engine.create({ action: "not-robot-demo", type: "not-robot" }); expect(challenge).toMatchObject({ type: "not-robot", presentation: "invisible", inputMode: "none", }); expect(challenge.metadata).toMatchObject({ interaction: "checkbox", minCompletionMs: 800, }); const tooFast = await engine.verify({ challengeId: challenge.id, action: "not-robot-demo", honeypot: "", timingToken: challenge.timingToken, }); expect(tooFast).toMatchObject({ success: false, code: "risk-rejected" }); now += 800; const solved = await engine.verify({ challengeId: challenge.id, action: "not-robot-demo", honeypot: "", timingToken: challenge.timingToken, }); expect(solved.success).toBe(true); expect(solved.responseToken).toBeString(); }); test("normalizes and validates visual disturbance percentages", async () => { const { engine } = fixture(); const easy = await engine.create({ action: "image-easy", disturbance: 25 }); expect(easy.metadata?.disturbance).toBe(25); const hard = await engine.create({ action: "image-hard", disturbance: 75 }); expect(hard.metadata?.disturbance).toBe(75); await expect(engine.create({ action: "too-easy", disturbance: 24 })).rejects.toThrow( "disturbance must be between 25 and 75", ); await expect(engine.create({ action: "too-hard", disturbance: 76 })).rejects.toThrow( "disturbance must be between 25 and 75", ); }); test("resolves explicit and random image renderer styles", async () => { const { engine } = fixture(); const explicit = await engine.create({ action: "styled-explicit", imageStyle: "spiderweb", }); expect(explicit.metadata).toMatchObject({ requestedImageStyle: "spiderweb", imageStyle: "spiderweb", }); const pooled = await engine.create({ action: "styled-random", imageStyle: "random", allowedStyles: ["snow", "wave"], }); expect(["snow", "wave"]).toContain(pooled.metadata?.imageStyle); expect(pooled.metadata?.imageStylePool).toEqual(["snow", "wave"]); const forced = await engine.create({ action: "styled-forced-random", imageStyle: "classic", randomizeStyle: true, allowedStyles: "cut,striped", }); expect(["cut", "striped"]).toContain(forced.metadata?.imageStyle); expect(forced.metadata?.requestedImageStyle).toBe("classic"); }); test("validates image renderer style pools", async () => { const { engine } = fixture(); await expect(engine.create({ action: "unknown-style", imageStyle: "unknown" as never, })).rejects.toThrow("imageStyle must be one of"); await expect(engine.create({ action: "empty-style-pool", allowedStyles: ["snow"], excludedStyles: ["snow"], })).rejects.toThrow("No CAPTCHA image styles remain"); await expect(engine.create({ action: "excluded-explicit-style", imageStyle: "classic", excludedStyles: ["classic"], })).rejects.toThrow("is not available in the configured style pool"); }); });