293 lines
9.3 KiB
TypeScript
293 lines
9.3 KiB
TypeScript
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("random challenges resolve to an interactive challenge type", async () => {
|
|
const engine = createCaptchaEngine({
|
|
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
|
|
store: new MemoryCaptchaStore(),
|
|
defaultType: "random",
|
|
minCompletionMs: 0,
|
|
});
|
|
const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]);
|
|
|
|
for (let index = 0; index < 12; index += 1) {
|
|
const challenge = await engine.create({ action: "random-check" });
|
|
expect(supported.has(challenge.type)).toBe(true);
|
|
expect(challenge.type).not.toBe("random");
|
|
}
|
|
});
|
|
|
|
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"],
|
|
});
|
|
const pooledStyle = pooled.metadata?.imageStyle;
|
|
expect(typeof pooledStyle).toBe("string");
|
|
expect(["snow", "wave"]).toContain(pooledStyle as string);
|
|
expect(pooled.metadata?.imageStylePool).toEqual(["snow", "wave"]);
|
|
|
|
const forced = await engine.create({
|
|
action: "styled-forced-random",
|
|
imageStyle: "classic",
|
|
randomizeStyle: true,
|
|
allowedStyles: "cut,striped",
|
|
});
|
|
const forcedStyle = forced.metadata?.imageStyle;
|
|
expect(typeof forcedStyle).toBe("string");
|
|
expect(["cut", "striped"]).toContain(forcedStyle as string);
|
|
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");
|
|
});
|
|
});
|