import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type CrowdsecVerdict, crowdsecEnabled, getCrowdsecApiConfig, getLastCrowdsecVerify, maybeAutoBlockCrowdsec, resetCrowdsecCache, setLastCrowdsecVerify, verdictIsMalicious, verifyCrowdsecConnection, } from "./crowdsec-api"; // Unit-test the CTI client in isolation: a deterministic in-memory Redis fake // and a silenced logger, so fetch calls count only CrowdSec lookups. CrowdSec // deliberately never touches Cloudflare, so no Cloudflare surface is stubbed. const state = vi.hoisted(() => ({ map: new Map() })); vi.mock("@/lib/redis", () => ({ redis: { get: async (key: string) => state.map.get(key) ?? null, set: async ( key: string, value: string, _mode?: string, _seconds?: number, nx?: string, ) => { if (nx === "NX" && state.map.has(key)) return null; state.map.set(key, value); return "OK"; }, del: async (...keys: string[]) => { for (const key of keys) state.map.delete(key); return keys.length; }, pttl: async () => 60_000, }, __esModule: true, })); vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function maliciousItem(ip: string, score = 5): unknown { return { ip, reputation: "malicious", confidence: "0.95", scores: { overall: { aggressiveness: 4, total: score } }, behaviors: [{ name: "http:bruteforce" }, { name: "http:scan" }], classifications: { false_positives: [] }, }; } function suspiciousItem(ip: string, score: number): unknown { return { ip, reputation: "suspicious", scores: { overall: { total: score } }, }; } function verdict(minimal: Partial = {}): CrowdsecVerdict { return { ip: "198.51.100.1", reputation: "suspicious", score: 3, aggressiveness: 0, confidence: null, behaviors: [], falsePositive: false, checkedAt: Date.now(), ...minimal, }; } function blockIp(): string { return "198.51.100.1"; } describe("crowdsec-api", () => { let fetchMock: ReturnType; beforeEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecCache(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecCache(); vi.restoreAllMocks(); }); it("is enabled only when a non-blank API key is configured", () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); vi.stubEnv("CROWDSEC_CTI_BASE_URL", "https://cti.example.test"); expect(crowdsecEnabled()).toBe(true); expect(getCrowdsecApiConfig().apiKey).toBe("cs_key"); expect(getCrowdsecApiConfig().baseUrl).toBe("https://cti.example.test"); vi.stubEnv("CROWDSEC_API_KEY", " "); expect(crowdsecEnabled()).toBe(false); vi.stubEnv("CROWDSEC_CTI_BASE_URL", ""); expect(getCrowdsecApiConfig().baseUrl).toBe( "https://cti.api.crowdsec.net/v2", ); }); it("blocks malicious reputations at any threshold", () => { expect( verdictIsMalicious(verdict({ reputation: "malicious", score: 0 }), 5), ).toBe(true); }); it("never blocks safe or benign reputations", () => { expect(verdictIsMalicious(verdict({ reputation: "safe" }), 0)).toBe(false); expect(verdictIsMalicious(verdict({ reputation: "benign" }), 1)).toBe( false, ); }); it("vetoes a false-positive tag even for a malicious reputation", () => { expect( verdictIsMalicious( verdict({ reputation: "malicious", score: 5, falsePositive: true }), 4, ), ).toBe(false); }); it("applies the score threshold to suspicious/known attackers", () => { const v4 = verdict({ reputation: "suspicious", score: 4 }); expect(verdictIsMalicious(v4, 4)).toBe(true); expect(verdictIsMalicious(v4, 5)).toBe(false); expect(verdictIsMalicious(verdict({ score: 3 }), 4)).toBe(false); }); it("never blocks score-0 (unknown) IPs even at threshold 0", () => { expect( verdictIsMalicious(verdict({ score: 0, reputation: "unknown" }), 0), ).toBe(false); }); it("does nothing without an API key", async () => { await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("does nothing when the runtime toggle is off", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: false, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("never consults CrowdSec for the unknown-IP sentinel", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); await maybeAutoBlockCrowdsec({ ip: "0.0.0.0", category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("hard-blocks a malicious IP in the shared gate key only", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 86_400, scoreThreshold: 4, enabled: true, }); expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec"); // No Cloudflare keys may ever be written by CrowdSec. expect( [...state.map.keys()].some((key) => key.includes("cloudflare")), ).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(String(url)).toContain(`/smoke/${blockIp()}`); expect((init.headers as Record)["x-api-key"]).toBe( "cs_key", ); }); it("does not block an IP the community knows nothing about", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse({}, 404)); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false); }); it("respects a custom score threshold", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3))); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false); fetchMock.mockResolvedValue(jsonResponse(suspiciousItem(blockIp(), 3))); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 3, enabled: true, }); expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("crowdsec"); }); it("dedupes concurrent lookups into a single API call", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); await Promise.all([ maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }), maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }), ]); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("reuses the Redis verdict cache for repeat offenders", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); for (let i = 0; i < 3; i += 1) { await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); } expect(fetchMock).toHaveBeenCalledTimes(1); }); it("never shortens an existing longer host block", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse(maliciousItem(blockIp()))); state.map.set(`antiddos:block:${blockIp()}`, "1"); const redis = (await import("@/lib/redis")).redis; vi.spyOn(redis as NonNullable, "pttl").mockImplementation( async () => 86_400_000, ); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(state.map.get(`antiddos:block:${blockIp()}`)).toBe("1"); }); it("backs off after a 403 so it stops hammering a rejected key", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403)); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); await maybeAutoBlockCrowdsec({ ip: "203.0.113.9", category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("backs off after a 429 rate limit as well", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse({ message: "rate limited" }, 429)); await maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); await maybeAutoBlockCrowdsec({ ip: "198.51.100.2", category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("swallows API failures instead of throwing on the hot path", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500)); await expect( maybeAutoBlockCrowdsec({ ip: blockIp(), category: "api", ttlSeconds: 600, scoreThreshold: 4, enabled: true, }), ).resolves.toBeUndefined(); expect(state.map.has(`antiddos:block:${blockIp()}`)).toBe(false); }); it("reports a missing credential without calling the API", async () => { const status = await verifyCrowdsecConnection(); expect(status.ok).toBe(false); expect(status.message).toContain("CROWDSEC_API_KEY"); expect(fetchMock).not.toHaveBeenCalled(); }); it("verifies the key against the CTI probe endpoint", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue( jsonResponse({ ip: "1.1.1.1", reputation: "safe" }), ); const status = await verifyCrowdsecConnection(); expect(status.ok).toBe(true); expect(status.message).toContain("1.1.1.1"); expect(String(fetchMock.mock.calls[0][0])).toContain("/smoke/1.1.1.1"); expect( (fetchMock.mock.calls[0][1].headers as Record)[ "x-api-key" ], ).toBe("cs_key"); }); it("surfaces a rejected credential", async () => { vi.stubEnv("CROWDSEC_API_KEY", "cs_key"); fetchMock.mockResolvedValue(jsonResponse({ message: "Invalid key" }, 403)); const status = await verifyCrowdsecConnection(); expect(status.ok).toBe(false); expect(status.message).toContain("Invalid key"); }); it("round-trips the last verify status through Redis and memory", async () => { const status = { ok: true, message: "CTI key accepted", at: Date.now() }; await setLastCrowdsecVerify(status); expect(await getLastCrowdsecVerify()).toEqual(status); expect(JSON.parse(state.map.get("crowdsec:last-verify") ?? "{}")).toEqual( status, ); }); });