import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { checkCrowdsecLocalBlock, isBlockingCrowdsecDecision, parseCrowdsecDecisionDuration, resetCrowdsecLocalCache, } from "@/lib/crowdsec-local"; const state = vi.hoisted(() => ({ map: new Map(), sendAlert: vi.fn(), })); vi.mock("@/lib/services/alert", () => ({ sendAlert: state.sendAlert, ddosDetected: vi.fn(), })); 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; }, incr: async (key: string) => { const next = (Number(state.map.get(key)) || 0) + 1; state.map.set(key, String(next)); return next; }, expire: async () => 1, pexpire: async () => 1, pttl: async () => 60_000, }, __esModule: true, })); function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } const IP = "198.51.100.11"; const LAPI_URL = "http://127.0.0.1:18080"; describe("crowdsec-local app-layer bouncer", () => { let fetchMock: ReturnType; beforeEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecLocalCache(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true"); vi.stubEnv("CROWDSEC_LAPI_URL", LAPI_URL); vi.stubEnv("CROWDSEC_LAPI_API_KEY", "test-local-key"); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecLocalCache(); }); it("does nothing when the local stack is not enabled", async () => { vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "false"); const result = await checkCrowdsecLocalBlock(IP); expect(result.blocked).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); it("does nothing without a bouncer key", async () => { vi.stubEnv("CROWDSEC_LAPI_API_KEY", ""); const result = await checkCrowdsecLocalBlock(IP); expect(result.blocked).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); it("blocks an IP with a local ban decision and caches it", async () => { fetchMock.mockResolvedValue( jsonResponse([ { origin: "crowdsec", type: "ban", scope: "ip", value: IP, duration: "4h", }, ]), ); const first = await checkCrowdsecLocalBlock(IP); expect(first.blocked).toBe(true); expect(first.retryAfterSeconds).toBeGreaterThan(0); expect(fetchMock).toHaveBeenCalledTimes(1); expect(String(fetchMock.mock.calls[0][0])).toContain( `/v1/decisions?ip=${IP}`, ); const second = await checkCrowdsecLocalBlock(IP); expect(second.blocked).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("treats captcha decisions as blocks", async () => { fetchMock.mockResolvedValue( jsonResponse([{ type: "captcha", scope: "ip", value: IP }]), ); const result = await checkCrowdsecLocalBlock(IP); expect(result.blocked).toBe(true); }); it("passes non-blocking decisions and caches the negative", async () => { fetchMock.mockResolvedValue( jsonResponse([{ type: "probation", scope: "ip", value: IP }]), ); const first = await checkCrowdsecLocalBlock(IP); expect(first.blocked).toBe(false); const second = await checkCrowdsecLocalBlock(IP); expect(second.blocked).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("fails open when LAPI errors and backs off", async () => { fetchMock.mockRejectedValueOnce(new Error("connection refused")); const first = await checkCrowdsecLocalBlock(IP); expect(first.blocked).toBe(false); await new Promise((resolve) => setTimeout(resolve, 5)); const second = await checkCrowdsecLocalBlock(IP); expect(second.blocked).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("backs off for five minutes when the bouncer key is rejected", async () => { fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403)); const first = await checkCrowdsecLocalBlock(IP); expect(first.blocked).toBe(false); const second = await checkCrowdsecLocalBlock(IP); expect(second.blocked).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("does not query the LAPI for the unknown-IP sentinel", async () => { const result = await checkCrowdsecLocalBlock("0.0.0.0"); expect(result.blocked).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("crowdsec-local decision parsing", () => { it("parses Go-style durations into seconds", () => { expect(parseCrowdsecDecisionDuration("3h51m57s")).toBe( 3 * 3_600 + 51 * 60 + 57, ); expect(parseCrowdsecDecisionDuration("500ms")).toBeCloseTo(0.5); expect(parseCrowdsecDecisionDuration("")).toBe(0); expect(parseCrowdsecDecisionDuration(null)).toBe(0); }); it("recognises only ban/captcha ip/range decisions", () => { expect( isBlockingCrowdsecDecision({ type: "ban", scope: "ip", value: IP }), ).toBe(true); expect( isBlockingCrowdsecDecision({ type: "ban", scope: "range", value: IP }), ).toBe(true); expect( isBlockingCrowdsecDecision({ type: "captcha", scope: "ip", value: IP }), ).toBe(true); expect( isBlockingCrowdsecDecision({ type: "probation", scope: "ip", value: IP }), ).toBe(false); expect( isBlockingCrowdsecDecision({ type: "ban", scope: "as", value: IP }), ).toBe(false); expect(isBlockingCrowdsecDecision(null)).toBe(false); }); });