import { describe, expect, test } from "bun:test"; import { Database } from "bun:sqlite"; import { RedisCaptchaStore, SqliteCaptchaStore, managedCaptchaProvider, type CaptchaChallengeRecord, type CaptchaResponseTokenRecord, type RedisCaptchaClient, type SqliteDatabaseLike, } from "../src/index.ts"; const challenge = (id: string, expiresAt = Date.now() + 60_000): CaptchaChallengeRecord => ({ id, provider: "self-hosted", type: "text", presentation: "visual", action: "signup", publicChallenge: { id, provider: "self-hosted", type: "text", presentation: "visual", action: "signup", prompt: "Type the text", createdAt: Date.now(), expiresAt, responseField: "wrn-captcha-response", }, answerDigest: "digest", answerSalt: "salt", answerKind: "text", caseSensitive: false, attempts: 0, maxAttempts: 3, metadata: {}, createdAt: Date.now(), expiresAt, }); const token = (tokenHash: string): CaptchaResponseTokenRecord => ({ tokenHash, provider: "self-hosted", action: "signup", createdAt: Date.now(), expiresAt: Date.now() + 60_000, }); describe("CAPTCHA durable stores", () => { test("SQLite atomically mutates, consumes, deletes, and collects records", async () => { const db = new Database(":memory:"); const store = new SqliteCaptchaStore(db as unknown as SqliteDatabaseLike); await store.createChallenge(challenge("c1")); expect((await store.incrementAttempts("c1", Date.now()))?.attempts).toBe(1); expect(await store.consumeChallenge("c1", Date.now())).toMatchObject({ consumedAt: expect.any(Number), }); expect(await store.consumeChallenge("c1", Date.now())).toBeUndefined(); await store.createToken(token("t1")); expect(await store.consumeToken("t1", Date.now())).toMatchObject({ consumedAt: expect.any(Number), }); expect(await store.consumeToken("t1", Date.now())).toBeUndefined(); await store.deleteChallenge("c1"); await store.deleteToken("t1"); await store.createChallenge(challenge("expired", Date.now() - 1)); await store.gc(Date.now()); expect(await store.getChallenge("expired")).toBeUndefined(); db.close(); }); test("Redis fallback locking preserves one-time consumption without Lua", async () => { const values = new Map(); const redis: RedisCaptchaClient = { get: (key) => values.get(key) ?? null, set: (key, value) => void values.set(key, value), del: (key) => (values.delete(key) ? 1 : 0), }; const store = new RedisCaptchaStore(redis, { prefix: "test:" }); await store.createChallenge(challenge("c1")); expect((await store.incrementAttempts("c1", Date.now()))?.attempts).toBe(1); const consumed = await Promise.all([ store.consumeChallenge("c1", Date.now()), store.consumeChallenge("c1", Date.now()), ]); expect(consumed.filter(Boolean)).toHaveLength(1); await store.createToken(token("t1")); const tokens = await Promise.all([ store.consumeToken("t1", Date.now()), store.consumeToken("t1", Date.now()), ]); expect(tokens.filter(Boolean)).toHaveLength(1); await store.deleteChallenge("c1"); await store.deleteToken("t1"); expect(await store.getChallenge("c1")).toBeUndefined(); }); test("managed provider separates public creation from secret verification", async () => { const requests: RequestInit[] = []; const provider = managedCaptchaProvider({ baseUrl: "https://captcha.test/", siteKey: "site", secretKey: "secret", fetch: (async (_url, init) => { requests.push(init ?? {}); return Response.json( requests.length === 1 ? { id: "c1", provider: "managed", type: "text", presentation: "visual", action: "signup", prompt: "Type", createdAt: 1, expiresAt: 2, responseField: "token", } : { success: true, provider: "managed", action: "signup" }, ); }) as typeof fetch, }); await provider.createChallenge({ action: "signup" }); await provider.verify({ action: "signup", providerToken: "answer" }); expect(new Headers(requests[0]?.headers).has("authorization")).toBe(false); expect(new Headers(requests[1]?.headers).get("authorization")).toBe("Bearer secret"); }); });