import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { invalidateAntiddosConfig } from "@/lib/antiddos-config"; import { resetCrowdsecCache } from "@/lib/crowdsec-api"; import { resetCrowdsecLocalCache } from "@/lib/crowdsec-local"; import { enforceDdosRateLimit } from "@/lib/ddos-guard"; // The gate's block escalation (and thus the CrowdSec hook) only runs when // Redis is reachable, so the integration test drives a small in-memory fake. const state = vi.hoisted(() => ({ map: new Map(), z: 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, zadd: async (key: string, score: number, member: string) => { const list = state.z.get(key) ?? []; list.push([score, member]); list.sort((a, b) => a[0] - b[0]); state.z.set(key, list); return 1; }, zremrangebyscore: async (key: string, min: number, max: number) => { const list = (state.z.get(key) ?? []).filter( ([score]) => score < min || score > max, ); state.z.set(key, list); return 1; }, zcard: async (key: string) => (state.z.get(key) ?? []).length, sadd: async (key: string, member: string) => { const members = new Set( (state.map.get(key) ?? "").split("\u0001").filter(Boolean), ); members.add(member); state.map.set(key, [...members].join("\u0001")); return 1; }, srem: async (key: string, member: string) => { const members = new Set( (state.map.get(key) ?? "").split("\u0001").filter(Boolean), ); const before = members.size; members.delete(member); state.map.set(key, [...members].join("\u0001")); return before - members.size; }, smembers: async (key: string) => (state.map.get(key) ?? "").split("\u0001").filter(Boolean), }, __esModule: true, })); function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function proxiedRequest(ip: string): NextRequest { return new NextRequest("https://hotel.test/api/balance", { headers: { "cf-ray": "abc-AMS", "cf-connecting-ip": ip }, }); } function directRequest(ip: string): NextRequest { return new NextRequest("https://hotel.test/api/balance", { headers: { "x-real-ip": ip }, }); } async function pump(req: NextRequest, calls: number): Promise { let blocks = 0; for (let i = 0; i < calls; i += 1) { const decision = await enforceDdosRateLimit(req); if (decision.outcome === "block") blocks += 1; } return blocks; } const apiLimit = "3"; const maxViolations = "2"; const crowdsecKey = "test-cs-key"; describe("anti-DDoS automatic CrowdSec blocks", () => { let fetchMock: ReturnType; beforeEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); state.z.clear(); state.sendAlert.mockReset(); resetCrowdsecCache(); resetCrowdsecLocalCache(); invalidateAntiddosConfig(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("ANTI_DDOS_ENABLED", "true"); vi.stubEnv("ANTI_DDOS_API_LIMIT", apiLimit); vi.stubEnv("ANTI_DDOS_MAX_VIOLATIONS", maxViolations); vi.stubEnv("ANTI_DDOS_VIOLATION_WINDOW_SEC", "60"); vi.stubEnv("CROWDSEC_API_KEY", crowdsecKey); vi.stubEnv("CLOUDFLARE_API_TOKEN", ""); vi.stubEnv("CLOUDFLARE_ZONE_ID", ""); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); state.map.clear(); resetCrowdsecCache(); resetCrowdsecLocalCache(); invalidateAntiddosConfig(); }); it("blocks a community-flagged offender before the local threshold", async () => { fetchMock.mockResolvedValue( jsonResponse({ ip: "198.51.100.71", reputation: "malicious", confidence: "0.9", scores: { overall: { total: 5 } }, classifications: { false_positives: [] }, }), ); const ip = "198.51.100.71"; // The first three requests pass inside the API bucket; the fourth trips // it, which is where the gate consults CrowdSec (never on the hot path). const firstFour = await pump(proxiedRequest(ip), 4); expect(firstFour).toBe(1); // Let the fire-and-forget lookup + block write settle. await new Promise((resolve) => setTimeout(resolve, 50)); // The community block is already in the shared gate key after a single // violation — far below the gate's own 2-violation hard-block threshold... expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec"); // ...so every subsequent request is shed immediately via the block check. const blocks = await pump(proxiedRequest(ip), 4); expect(blocks).toBe(4); }); it("leaves a community-safe offender to the ordinary gate logic", async () => { fetchMock.mockResolvedValue( jsonResponse({ ip: "198.51.100.72", reputation: "safe", scores: { overall: { total: 0 } }, classifications: { false_positives: [] }, }), ); const ip = "198.51.100.72"; // With a 3-request API limit and 2 allowed violations, exactly the // second repeat request trips the ordinary hard block — value "1", // never "crowdsec". const blocks = await pump(proxiedRequest(ip), 5); expect(blocks).toBe(2); expect(state.map.get(`antiddos:block:${ip}`)).toBe("1"); }); it("never auto-blocks traffic without the API key", async () => { vi.stubEnv("CROWDSEC_API_KEY", ""); await pump(proxiedRequest("198.51.100.73"), 5); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).not.toHaveBeenCalled(); }); it("respects the runtime CrowdSec toggle from the config", async () => { vi.stubEnv("CROWDSEC_AUTO_BLOCK_ENABLED", "false"); invalidateAntiddosConfig(); await pump(proxiedRequest("198.51.100.74"), 5); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).not.toHaveBeenCalled(); }); it("keeps gate decisions unchanged when the CrowdSec API fails", async () => { fetchMock.mockResolvedValue(jsonResponse({ message: "boom" }, 500)); const ip = "198.51.100.75"; const blocks = await pump(proxiedRequest(ip), 5); expect(blocks).toBe(2); expect(state.map.get(`antiddos:block:${ip}`)).toBe("1"); }); it("uses the configured score threshold for ambiguous verdicts", async () => { vi.stubEnv("CROWDSEC_BLOCK_SCORE", "3"); invalidateAntiddosConfig(); fetchMock.mockResolvedValue( jsonResponse({ ip: "198.51.100.76", reputation: "suspicious", scores: { overall: { total: 3 } }, classifications: { false_positives: [] }, }), ); const ip = "198.51.100.76"; await pump(proxiedRequest(ip), 4); await new Promise((resolve) => setTimeout(resolve, 50)); expect(state.map.get(`antiddos:block:${ip}`)).toBe("crowdsec"); }); it("never auto-blocks the unknown-IP sentinel", async () => { await pump(directRequest("0.0.0.0"), 1); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).not.toHaveBeenCalled(); }); it("passes the unknown-IP sentinel through even when a stale block key exists", async () => { state.map.set("antiddos:block:0.0.0.0", "1"); const decision = await enforceDdosRateLimit(directRequest("0.0.0.0")); expect(decision.outcome).toBe("pass"); expect(fetchMock).not.toHaveBeenCalled(); }); it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => { vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true"); vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080"); vi.stubEnv("CROWDSEC_LAPI_API_KEY", "local-bouncer-key"); fetchMock.mockImplementation(async (input) => { if (String(input).startsWith("http://127.0.0.1:18080/")) { return jsonResponse([ { origin: "crowdsec", type: "ban", scope: "ip", value: "198.51.100.88", duration: "1h", }, ]); } return jsonResponse({ message: "unexpected upstream" }, 500); }); const ip = "198.51.100.88"; const blocks = await pump(proxiedRequest(ip), 1); expect(blocks).toBe(1); expect(fetchMock).toHaveBeenCalledTimes(1); }); });