Files
EpicNext-Cms/src/lib/crowdsec-api.test.ts
T
openhands 3e1a3f92c8
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m50s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
feat(security): recovery alerts, gate-block sharing, rolling-window burst and admin breakdown for CrowdSec
2026-09-23 15:06:16 +02:00

799 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type CrowdsecVerdict,
crowdsecEnabled,
getCrowdsecApiConfig,
getCrowdsecBlockMeta,
getCrowdsecQuotaUsage,
getLastCrowdsecVerify,
getMemoryVerdictCacheSize,
lookupCrowdsecVerdict,
maybeAutoBlockCrowdsec,
resetCrowdsecCache,
setLastCrowdsecVerify,
verdictIsMalicious,
verifyCrowdsecConnection,
} from "./crowdsec-api";
import { type CrowdsecDailyStat, getCrowdsecStats } from "./crowdsec-stats";
// 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<string, string>(),
z: new Map<string, Array<[number, string]>>(),
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;
},
decr: async (key: string) => {
const next = (Number(state.map.get(key)) || 0) - 1;
state.map.set(key, String(next));
return next;
},
expire: 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,
},
__esModule: true,
}));
vi.mock("@/lib/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
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> = {}): 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<typeof vi.fn>;
beforeEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
state.map.clear();
state.z.clear();
state.sendAlert.mockReset();
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<string, string>)["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<typeof redis>, "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("publishes the backoff to shared Redis so every instance respects it", 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,
});
// The shared marker exists and points into the future.
const until = Number(state.map.get("crowdsec:backoff-until"));
expect(Number.isFinite(until)).toBe(true);
expect(until).toBeGreaterThan(Date.now());
// A fresh instance (reset in-process state) still honours the marker.
resetCrowdsecCache();
await maybeAutoBlockCrowdsec({
ip: "203.0.113.44",
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("raises a critical ops alert when the CTI key is rejected (403)", 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 tick();
expect(state.sendAlert).toHaveBeenCalledTimes(1);
const [input] = state.sendAlert.mock.calls[0];
expect(input.type).toBe("ddos");
expect(input.severity).toBe("critical");
expect(input.message).toContain("403");
expect(input.message).toContain("CROWDSEC_API_KEY");
expect(input.context).toMatchObject({ status: 403 });
});
it("raises a warning ops alert when the CTI rate limit is hit (429)", 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 tick();
expect(state.sendAlert).toHaveBeenCalledTimes(1);
const [input] = state.sendAlert.mock.calls[0];
expect(input.type).toBe("ddos");
expect(input.severity).toBe("warning");
expect(input.message).toContain("rate limited");
expect(input.context).toMatchObject({ status: 429 });
});
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<string, string>)[
"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,
);
});
it("records why it blocked an IP next to the gate key", 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,
});
const meta = await getCrowdsecBlockMeta(blockIp());
expect(meta).not.toBeNull();
expect(meta?.source).toBe("crowdsec");
expect(meta?.reputation).toBe("malicious");
expect(meta?.score).toBe(5);
expect(meta?.behaviors).toEqual(["http:bruteforce", "http:scan"]);
expect(meta?.category).toBe("api");
expect(meta?.ttlSeconds).toBe(86_400);
expect(meta?.blockedAt).toBeGreaterThan(0);
});
it("stops consulting the API once today's quota is spent", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "2");
// Fresh Response per call — a consumed body must never be re-parsed.
fetchMock.mockImplementation(() =>
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
);
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(2);
expect(state.map.get(`antiddos:block:198.51.100.2`)).toBe("crowdsec");
// Third bucket-tripping IP arrives after the quota counter hit 2.
await maybeAutoBlockCrowdsec({
ip: "198.51.100.3",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(state.map.has(`antiddos:block:198.51.100.3`)).toBe(false);
const usage = await getCrowdsecQuotaUsage();
expect(usage.quota).toBe(2);
expect(usage.used).toBe(2);
expect(usage.exhausted).toBe(true);
});
it("exposes today's quota usage for the admin panel", async () => {
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "10000");
const before = await getCrowdsecQuotaUsage();
expect(before.quota).toBe(10000);
expect(before.used).toBe(0);
expect(before.exhausted).toBe(false);
expect(before.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
state.map.set(`crowdsec:usage:${before.date}`, "9876");
const after = await getCrowdsecQuotaUsage();
expect(after.used).toBe(9876);
});
it("caps the in-process verdict cache so it cannot grow forever", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
fetchMock.mockImplementation((url: string | URL) =>
Promise.resolve(
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
),
);
// One lookup per distinct IP (never cached before), exceeding the cap —
// the oldest entries are evicted first, so the cache stays bounded.
for (let i = 0; i < 2100; i += 1) {
await lookupCrowdsecVerdict(`198.51.100.${i}`);
}
expect(getMemoryVerdictCacheSize()).toBe(2000);
});
it("raises an ops alert when the daily quota is exhausted", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "1");
fetchMock.mockImplementation(() =>
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
);
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,
});
await tick();
expect(fetchMock).toHaveBeenCalledTimes(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.message).toContain("quota exhausted");
expect(input.context).toMatchObject({ quota: 1 });
});
it("alerts once when quota becomes available again after exhaustion", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "1");
fetchMock.mockImplementation(() =>
Promise.resolve(jsonResponse(maliciousItem(blockIp()))),
);
await maybeAutoBlockCrowdsec({
ip: blockIp(),
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
// Exhaust the counter.
await maybeAutoBlockCrowdsec({
ip: "198.51.100.2",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
// The counter is externally reset (new billing day / fresh deployment):
// the next successful reserve should call it out.
const date = new Date().toISOString().slice(0, 10);
state.map.set(`crowdsec:usage:${date}`, "0");
await maybeAutoBlockCrowdsec({
ip: "198.51.100.3",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
await tick();
expect(fetchMock).toHaveBeenCalledTimes(2);
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("available again");
expect(alerts[1].context).toMatchObject({ quota: 1 });
});
it("floods once per cooldown window when blocks burst past the threshold", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_ALERT_BLOCK_BURST", "2");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
fetchMock.mockImplementation((url: string | URL) =>
Promise.resolve(
jsonResponse(maliciousItem(String(url).split("/").pop() ?? "ip")),
),
);
await maybeAutoBlockCrowdsec({
ip: "198.51.100.71",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
await maybeAutoBlockCrowdsec({
ip: "198.51.100.72",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
// Third block in the same window: threshold crossed, but the alert is
// cooldown-gated so it still fires exactly once.
await maybeAutoBlockCrowdsec({
ip: "198.51.100.73",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
await tick();
expect(state.sendAlert).toHaveBeenCalledTimes(1);
const [input] = state.sendAlert.mock.calls[0];
expect(input.type).toBe("ddos");
expect(input.context).toMatchObject({ blocks: 2, threshold: 2 });
expect(state.map.get(`antiddos:block:198.51.100.72`)).toBe("crowdsec");
});
it("tallies lookups and blocks into the daily stats histogram", async () => {
vi.stubEnv("CROWDSEC_API_KEY", "cs_key");
vi.stubEnv("CROWDSEC_CTI_DAILY_QUOTA", "0");
fetchMock.mockImplementation((url: string | URL) => {
const ip = String(url).split("/").pop() ?? "ip";
return Promise.resolve(
jsonResponse(
ip === "198.51.100.83" ? suspiciousItem(ip, 3) : maliciousItem(ip),
),
);
});
await maybeAutoBlockCrowdsec({
ip: "198.51.100.81",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
await maybeAutoBlockCrowdsec({
ip: "198.51.100.82",
category: "api",
ttlSeconds: 600,
scoreThreshold: 4,
enabled: true,
});
await maybeAutoBlockCrowdsec({
ip: "198.51.100.83",
category: "api",
ttlSeconds: 600,
scoreThreshold: 7, // suspicious/known verdicts below threshold: lookup only
enabled: true,
});
await tick();
const stats = await getCrowdsecStats(1);
const today: CrowdsecDailyStat | undefined = stats.find(
(row) => row.date === new Date().toISOString().slice(0, 10),
);
expect(today?.lookups).toBe(3);
expect(today?.blocks).toBe(2);
expect(today?.reportFailures).toBe(0);
expect(today?.categories).toMatchObject({ api: 2 });
expect(today?.reputations).toMatchObject({ malicious: 2 });
expect(
state.map.get(
`crowdsec:stat:lookups:${new Date().toISOString().slice(0, 10)}`,
),
).toBe("3");
});
});