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(), 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; }, 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 { 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(); 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)["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("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)[ "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"); }); });