import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CrowdsecVerdict } from "./crowdsec-api"; import { type CrowdsecReportStatus, crowdsecReportEnabled, getLastCrowdsecReport, reportCrowdsecSignal, resetCrowdsecReportCache, verifyCrowdsecReporting, } from "./crowdsec-report"; // The signal-push watcher is tested against a deterministic in-memory Redis // fake (NX lock + token cache) and a mocked fetch that routes the CAPI paths. const state = vi.hoisted(() => ({ map: new Map(), sendAlert: 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, }, __esModule: true, })); vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); vi.mock("@/lib/services/alert", () => ({ sendAlert: state.sendAlert, ddosDetected: vi.fn(), })); const tick = () => new Promise((resolve) => setTimeout(resolve, 20)); const CAPI = "https://capi.example.test/v3"; const MACHINE = "m".repeat(48); const PASSWORD = "Strong!1P@ssw0rdStrong!1P@ssw0rd"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function signalInput(ip = "198.51.100.9") { const verdict: CrowdsecVerdict = { ip, reputation: "malicious", score: 5, aggressiveness: 4, confidence: "0.95", behaviors: ["http:bruteforce", "http:scan"], falsePositive: false, checkedAt: Date.now(), }; return { ip, category: "api", ttlSeconds: 86_400, verdict, meta: { source: "crowdsec" as const, category: "api", reputation: verdict.reputation, score: verdict.score, behaviors: verdict.behaviors, ttlSeconds: 86_400, blockedAt: Date.now(), }, }; } describe("crowdsec-report", () => { let fetchMock: ReturnType; function routeCapi(overrides: Record = {}) { const statusFor = (path: string) => overrides[path] ?? (path === "/signals" ? 200 : 200); fetchMock.mockImplementation((url: string) => { const path = String(url).replace(CAPI, ""); const status = statusFor(path); if (status !== 200) { return Promise.resolve(jsonResponse({ message: "boom" }, status)); } if (path === "/watchers/login") { return Promise.resolve( jsonResponse({ token: "jwt-xyz", expire: new Date(Date.now() + 3_600_000).toISOString(), }), ); } return Promise.resolve(jsonResponse({})); }); } beforeEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); state.sendAlert.mockReset(); resetCrowdsecReportCache(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true"); vi.stubEnv("CROWDSEC_REPORT_MACHINE_ID", MACHINE); vi.stubEnv("CROWDSEC_REPORT_PASSWORD", PASSWORD); vi.stubEnv("CROWDSEC_CAPI_BASE_URL", CAPI); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecReportCache(); vi.restoreAllMocks(); }); it("is enabled only when the toggle and credentials are present", async () => { expect(await crowdsecReportEnabled()).toBe(true); vi.stubEnv("CROWDSEC_REPORT_ENABLED", ""); resetCrowdsecReportCache(); expect(await crowdsecReportEnabled()).toBe(false); // Machine id supplied but no password: falls back to generating a // stable credential pair persisted in Redis. vi.stubEnv("CROWDSEC_REPORT_ENABLED", "true"); vi.stubEnv("CROWDSEC_REPORT_PASSWORD", ""); resetCrowdsecReportCache(); expect(await crowdsecReportEnabled()).toBe(true); const storedMachine = state.map.get("crowdsec:report:machine"); expect(storedMachine).toMatch(/^[A-Za-z0-9]{48}$/); expect(state.map.get("crowdsec:report:pass")).toBeTruthy(); }); it("does nothing when the channel is disabled", async () => { vi.stubEnv("CROWDSEC_REPORT_ENABLED", ""); resetCrowdsecReportCache(); await reportCrowdsecSignal(signalInput()); expect(fetchMock).not.toHaveBeenCalled(); }); it("registers once, caches the token and pushes one signal per IP", async () => { routeCapi(); await reportCrowdsecSignal(signalInput("198.51.100.10")); await reportCrowdsecSignal(signalInput("198.51.100.11")); await reportCrowdsecSignal(signalInput("198.51.100.10")); // Let the fire-and-forget network body land. await new Promise((resolve) => setTimeout(resolve, 20)); const urls = fetchMock.mock.calls.map((call) => String(call[0])); expect(urls.filter((u) => u.endsWith("/watchers/register"))).toHaveLength( 1, ); expect(urls.filter((u) => u.endsWith("/watchers/login"))).toHaveLength(1); expect(urls.filter((u) => u.endsWith("/signals"))).toHaveLength(2); // No enrollment requested without an attachment key. expect(urls.some((u) => u.endsWith("/watchers/enroll"))).toBe(false); }); it("builds a well-formed CrowdSec signal with a ban decision", async () => { routeCapi(); await reportCrowdsecSignal(signalInput()); await new Promise((resolve) => setTimeout(resolve, 20)); const signalsCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/signals"), ); expect(signalsCall).toBeDefined(); if (!signalsCall) throw new Error("expected a /signals call"); const init = signalsCall[1] as { body: string; headers: Record; }; const body = JSON.parse(init.body) as Record[]; expect(body).toHaveLength(1); const signal = body[0] as { machine_id: string; scenario: string; scenario_version: string; source: { scope: string; value: string; ip: string }; decisions: { scope: string; type: string; value: string; duration: string; }[]; context: { key: string; value: string }[]; created_at: string; start_at: string; stop_at: string; }; expect(signal.machine_id).toBe(MACHINE); expect(signal.scenario).toBe("community/anti-ddos-block"); expect(signal.scenario_version).toBe("1.0.0"); expect(signal.source).toEqual({ scope: "ip", value: "198.51.100.9", ip: "198.51.100.9", }); expect(signal.decisions).toHaveLength(1); expect(signal.decisions[0]).toMatchObject({ origin: "crowdsec", scope: "ip", type: "ban", value: "198.51.100.9", }); expect(String(signal.decisions[0].duration)).toMatch(/^24h0m0s$/); for (const key of ["created_at", "start_at", "stop_at"] as const) { expect(typeof signal[key]).toBe("string"); } expect( signal.context.find((c) => c.key === "crowdsec_reputation")?.value, ).toBe("malicious"); }); it("records a healthy last-report state after a successful push", async () => { routeCapi(); await reportCrowdsecSignal(signalInput()); await new Promise((resolve) => setTimeout(resolve, 20)); const last = await getLastCrowdsecReport(); expect(last?.ok).toBe(true); }); it("never throws and logs the failure when the CAPI rejects the signal", async () => { fetchMock.mockImplementation((url: string) => { const path = String(url).replace(CAPI, ""); if (path === "/watchers/login") { return Promise.resolve( jsonResponse({ token: "jwt-xyz", expire: new Date(Date.now() + 3_600_000).toISOString(), }), ); } if (path === "/signals") { return Promise.resolve(jsonResponse({ message: "boom" }, 500)); } return Promise.resolve(jsonResponse({})); }); await expect(reportCrowdsecSignal(signalInput())).resolves.toBeUndefined(); await tick(); const last: CrowdsecReportStatus | null = await getLastCrowdsecReport(); expect(last?.ok).toBe(false); expect(last?.message).toContain("signal push rejected"); }); it("counts a failed push and raises a cooldown-gated ops alert", async () => { fetchMock.mockImplementation((url: string) => { const path = String(url).replace(CAPI, ""); if (path === "/watchers/login") { return Promise.resolve( jsonResponse({ token: "jwt-xyz", expire: new Date(Date.now() + 3_600_000).toISOString(), }), ); } if (path === "/signals") { return Promise.resolve(jsonResponse({ message: "boom" }, 500)); } return Promise.resolve(jsonResponse({})); }); await reportCrowdsecSignal(signalInput("198.51.100.20")); await tick(); const today = new Date().toISOString().slice(0, 10); expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("1"); expect(state.sendAlert).toHaveBeenCalledTimes(1); const [input] = state.sendAlert.mock.calls[0]; expect(input.type).toBe("ddos"); expect(input.severity).toBe("warning"); expect(input.context).toMatchObject({ ip: "198.51.100.20" }); // A second failed push inside the cooldown window stays silent. await reportCrowdsecSignal(signalInput("198.51.100.21")); await tick(); expect(state.sendAlert).toHaveBeenCalledTimes(1); expect(state.map.get(`crowdsec:stat:report_fail:${today}`)).toBe("2"); }); it("tallies successful pushes into the daily stats histogram", async () => { routeCapi(); await reportCrowdsecSignal(signalInput("198.51.100.30")); await reportCrowdsecSignal(signalInput("198.51.100.31")); await tick(); const today = new Date().toISOString().slice(0, 10); expect(state.map.get(`crowdsec:stat:reports:${today}`)).toBe("2"); expect(state.sendAlert).not.toHaveBeenCalled(); }); it("raises an info alert the first time the channel heals after failures", async () => { fetchMock.mockImplementation((url: string) => { const path = String(url).replace(CAPI, ""); if (path === "/watchers/login") { return Promise.resolve( jsonResponse({ token: "jwt-xyz", expire: new Date(Date.now() + 3_600_000).toISOString(), }), ); } if (path === "/signals") { return Promise.resolve(jsonResponse({ message: "boom" }, 500)); } return Promise.resolve(jsonResponse({})); }); await reportCrowdsecSignal(signalInput("198.51.100.40")); await tick(); expect(state.sendAlert).toHaveBeenCalledTimes(1); expect((await getLastCrowdsecReport())?.ok).toBe(false); // Channel heals: the first success after a failure is worth a notice. routeCapi(); await reportCrowdsecSignal(signalInput("198.51.100.41")); await tick(); const last: CrowdsecReportStatus | null = await getLastCrowdsecReport(); expect(last?.ok).toBe(true); expect(state.sendAlert).toHaveBeenCalledTimes(2); const alerts = state.sendAlert.mock.calls.map(([input]) => input); expect(alerts[0].severity).toBe("warning"); expect(alerts[1].severity).toBe("info"); expect(alerts[1].message).toContain("recovered"); expect(alerts[1].context).toMatchObject({ ip: "198.51.100.41" }); }); it("verifies the watcher channel end to end", async () => { routeCapi(); const status = await verifyCrowdsecReporting(); expect(status.ok).toBe(true); expect(String(fetchMock.mock.calls[0][0])).toContain("/watchers/register"); }); it("reports a clear reason when verification is impossible", async () => { vi.stubEnv("CROWDSEC_REPORT_ENABLED", ""); resetCrowdsecReportCache(); const status = await verifyCrowdsecReporting(); expect(status.ok).toBe(false); expect(status.message).toContain("CROWDSEC_REPORT_ENABLED"); expect(fetchMock).not.toHaveBeenCalled(); }); });