import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cloudflareEnabled, getCloudflareApiConfig, listCloudflareBlocks, maybeAutoBlockCloudflare, removeCloudflareBlock, resetCloudflareAutoBlockCache, sweepExpiredCloudflareBlocks, verifyCloudflareConnection, } from "./cloudflare-api"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function envForRealSetup(): void { vi.stubEnv("CLOUDFLARE_API_TOKEN", "test-api-token"); vi.stubEnv("CLOUDFLARE_ZONE_ID", "z123"); } describe("cloudflare-api", () => { let fetchMock: ReturnType; beforeEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); resetCloudflareAutoBlockCache(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); resetCloudflareAutoBlockCache(); }); it("is configured only when a token and a zone id are present", () => { vi.stubEnv("CLOUDFLARE_API_TOKEN", "tok"); expect(cloudflareEnabled()).toBe(false); vi.stubEnv("CLOUDFLARE_ZONE_ID", "z1"); expect(cloudflareEnabled()).toBe(true); const config = getCloudflareApiConfig(); expect(config.token).toBe("tok"); expect(config.zoneId).toBe("z1"); expect(config.baseUrl).toBe("https://api.cloudflare.com/client/v4"); }); it("reports a missing credential without calling the API", async () => { const status = await verifyCloudflareConnection(); expect(status.ok).toBe(false); expect(status.message).toContain("CLOUDFLARE_API_TOKEN"); expect(fetchMock).not.toHaveBeenCalled(); }); it("verifies the zone when the token is valid", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse({ success: true, errors: [], result: { id: "z123", name: "example.com" }, }), ); const status = await verifyCloudflareConnection(); expect(status.ok).toBe(true); expect(status.zoneName).toBe("example.com"); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(String(url)).toContain("/zones/z123"); expect(init.headers.Authorization).toBe("Bearer test-api-token"); }); it("surfaces a rejected credential", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse( { success: false, errors: [{ code: 10000, message: "Invalid token" }], result: null, }, 403, ), ); const status = await verifyCloudflareConnection(); expect(status.ok).toBe(false); expect(status.message).toContain("Invalid token"); }); it("creates an IP Access Rule for a blocked client", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse({ success: true, errors: [], result: { id: "rule1" } }), ); await maybeAutoBlockCloudflare({ ip: "192.0.2.55", ttlSeconds: 600, category: "api", enabled: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(String(url)).toContain("/zones/z123/firewall/access_rules/rules"); expect(init.method).toBe("POST"); const body = JSON.parse(init.body as string); expect(body.mode).toBe("block"); expect(body.configuration).toEqual({ target: "ip", value: "192.0.2.55" }); expect(body.notes).toContain("category=api"); expect(body.notes).toContain("ttl=600s"); }); it("supports IPv6 client addresses", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse({ success: true, errors: [], result: { id: "rule6" } }), ); await maybeAutoBlockCloudflare({ ip: "2001:db8::5", ttlSeconds: 3600, category: "auth", enabled: true, }); const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); expect(body.configuration.value).toBe("2001:db8::5"); }); it("dedupes until the block expires", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse({ success: true, errors: [], result: { id: "rule1" } }), ); for (let i = 0; i < 3; i += 1) { await maybeAutoBlockCloudflare({ ip: "198.51.100.1", ttlSeconds: 600, category: "api", enabled: true, }); } expect(fetchMock).toHaveBeenCalledTimes(1); }); it("does nothing when the runtime toggle is off", async () => { envForRealSetup(); await maybeAutoBlockCloudflare({ ip: "198.51.100.2", ttlSeconds: 600, category: "api", enabled: false, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("does nothing without credentials", async () => { await maybeAutoBlockCloudflare({ ip: "198.51.100.3", ttlSeconds: 600, category: "api", enabled: true, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("never auto-blocks the unknown-IP sentinel", async () => { envForRealSetup(); await maybeAutoBlockCloudflare({ ip: "0.0.0.0", ttlSeconds: 600, category: "api", enabled: true, }); expect(fetchMock).not.toHaveBeenCalled(); }); it("swallows API failures instead of throwing on the hot path", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse( { success: false, errors: [{ code: 9109, message: "Not enough quota" }], result: null, }, 400, ), ); await expect( maybeAutoBlockCloudflare({ ip: "198.51.100.4", ttlSeconds: 600, category: "api", enabled: true, }), ).resolves.toBeUndefined(); }); it("sweeps expired blocks and deletes their edge rules", async () => { envForRealSetup(); fetchMock .mockResolvedValueOnce( jsonResponse({ success: true, errors: [], result: { id: "rule-x" } }), ) .mockResolvedValueOnce( jsonResponse({ success: true, errors: [], result: {} }), ); await maybeAutoBlockCloudflare({ ip: "198.51.100.9", ttlSeconds: 1, category: "auth", enabled: true, }); await new Promise((resolve) => setTimeout(resolve, 1_100)); const removed = await sweepExpiredCloudflareBlocks(); expect(removed).toBe(1); expect(fetchMock).toHaveBeenCalledTimes(2); expect(String(fetchMock.mock.calls[1][0])).toContain( "/firewall/access_rules/rules/rule-x", ); expect(fetchMock.mock.calls[1][1].method).toBe("DELETE"); expect(await listCloudflareBlocks()).toHaveLength(0); }); it("lists active blocks closest to expiry first", async () => { envForRealSetup(); fetchMock.mockResolvedValue( jsonResponse({ success: true, errors: [], result: { id: "rule1" } }), ); await maybeAutoBlockCloudflare({ ip: "198.51.100.7", ttlSeconds: 600, category: "api", enabled: true, }); const blocks = await listCloudflareBlocks(); expect(blocks).toHaveLength(1); expect(blocks[0].ip).toBe("198.51.100.7"); expect(blocks[0].remainingSeconds).toBeGreaterThan(0); expect(blocks[0].expired).toBe(false); }); it("removes a tracked block through the admin action", async () => { envForRealSetup(); fetchMock .mockResolvedValueOnce( jsonResponse({ success: true, errors: [], result: { id: "rule-y" } }), ) .mockResolvedValueOnce( jsonResponse({ success: true, errors: [], result: {} }), ); await maybeAutoBlockCloudflare({ ip: "198.51.100.8", ttlSeconds: 600, category: "pages", enabled: true, }); const first = await removeCloudflareBlock("198.51.100.8"); expect(first.removed).toBe(true); expect(String(fetchMock.mock.calls[1][0])).toContain("/rules/rule-y"); const second = await removeCloudflareBlock("198.51.100.8"); expect(second.removed).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(2); }); it("keeps local tracking when the Cloudflare delete fails", async () => { envForRealSetup(); fetchMock .mockResolvedValueOnce( jsonResponse({ success: true, errors: [], result: { id: "rule-z" } }), ) .mockResolvedValueOnce( jsonResponse( { success: false, errors: [{ code: 6003, message: "boom" }], result: null, }, 400, ), ); await maybeAutoBlockCloudflare({ ip: "198.51.100.6", ttlSeconds: 600, category: "api", enabled: true, }); const result = await removeCloudflareBlock("198.51.100.6"); expect(result.removed).toBe(false); expect(result.message).toContain("boom"); expect(await listCloudflareBlocks()).toHaveLength(1); }); });