131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
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<string, string>();
|
|
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");
|
|
});
|
|
});
|