// Phase 67 (GUARD-05, GUARD-06, GUARD-07, GUARD-09): Guardian core engine tests. // Tests all core behaviors: SlidingWindowCounter, GuardianLogWriter, threshold checks, // inbound rate drop, isGuardianStopped, and config schema validation. // Phase 69 (GUARD-08, GUARD-11): sendGuardianAlert + checkSessionWebhooks tests added. // Added 2026-03-29. DO NOT REMOVE. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { mkdtemp, rm, readFile, writeFile, stat } from "node:fs/promises"; // Mock fs/promises for GuardianLogWriter tests vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, appendFile: vi.fn().mockResolvedValue(undefined), stat: vi.fn().mockResolvedValue({ size: 0 }), rename: vi.fn().mockResolvedValue(undefined), }; }); // Mock callWahaApi to avoid real HTTP calls vi.mock("../../http-client.js", () => ({ callWahaApi: vi.fn().mockResolvedValue({ ok: true }), })); // Mock getDataDir vi.mock("../../data-dir.js", () => ({ getDataDir: vi.fn().mockReturnValue("/tmp/test-guardian-data"), })); // Mock logger to suppress output vi.mock("../../logger.js", () => ({ createLogger: vi.fn().mockReturnValue({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis(), }), })); import { SlidingWindowCounter, GuardianLogWriter, checkOutboundThresholds, checkInboundRate, isGuardianStopped, markGuardianStopped, clearGuardianStopped, startGuardian, getGuardianState, guardianRecordOutbound, guardianCheckAndRecordInbound, registerGuardianWebhook, sendGuardianAlert, checkSessionWebhooks, } from "./guardian.js"; import type { GuardianConfig } from "./guardian.js"; import { appendFile, stat as fsStat, rename } from "node:fs/promises"; import { callWahaApi } from "../../http-client.js"; // --------------------------------------------------------------------------- // Default test config // --------------------------------------------------------------------------- const defaultConfig: GuardianConfig = { enabled: true, outboundMsgThreshold: 20, outboundMsgWindowMs: 60_000, recipientThreshold: 10, recipientWindowMs: 60_000, inboundDropThreshold: 100, inboundDropWindowMs: 60_000, pollIntervalMs: 7_000, }; // --------------------------------------------------------------------------- // SlidingWindowCounter tests // --------------------------------------------------------------------------- describe("SlidingWindowCounter", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("tracks count per key", () => { const counter = new SlidingWindowCounter(60_000); counter.record("session1"); counter.record("session1"); const entry = counter.getEntry("session1"); expect(entry?.count).toBe(2); }); it("resets count when window expires", () => { const counter = new SlidingWindowCounter(1_000); counter.record("session1"); counter.record("session1"); // Advance time past window vi.advanceTimersByTime(1_001); counter.record("session1"); const entry = counter.getEntry("session1"); expect(entry?.count).toBe(1); }); it("tracks unique recipients per key", () => { const counter = new SlidingWindowCounter(60_000); counter.record("session1", "recipient1"); counter.record("session1", "recipient2"); counter.record("session1", "recipient1"); // duplicate const entry = counter.getEntry("session1"); expect(entry?.recipients.size).toBe(2); }); it("resets unique recipients when window expires", () => { const counter = new SlidingWindowCounter(1_000); counter.record("session1", "recipient1"); counter.record("session1", "recipient2"); vi.advanceTimersByTime(1_001); counter.record("session1", "recipient3"); const entry = counter.getEntry("session1"); expect(entry?.recipients.size).toBe(1); expect(entry?.recipients.has("recipient3")).toBe(true); }); it("prunes expired entries when map exceeds 500 keys", () => { const counter = new SlidingWindowCounter(1_000); // Record 500 entries for (let i = 0; i < 500; i++) { counter.record(`session-${i}`); } expect(counter.size).toBe(500); // Advance time to expire all entries vi.advanceTimersByTime(1_001); // Adding one more should trigger pruning of all expired entries counter.record("new-session"); // Only the new-session should remain after pruning expect(counter.size).toBeLessThan(500); expect(counter.getEntry("new-session")).toBeDefined(); }); it("returns undefined for unknown key", () => { const counter = new SlidingWindowCounter(60_000); expect(counter.getEntry("unknown")).toBeUndefined(); }); it("clear() removes all entries", () => { const counter = new SlidingWindowCounter(60_000); counter.record("session1"); counter.record("session2"); counter.clear(); expect(counter.size).toBe(0); expect(counter.getEntry("session1")).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // checkOutboundThresholds tests // --------------------------------------------------------------------------- describe("checkOutboundThresholds", () => { let outboundCounter: SlidingWindowCounter; let recipientCounter: SlidingWindowCounter; beforeEach(() => { outboundCounter = new SlidingWindowCounter(60_000); recipientCounter = new SlidingWindowCounter(60_000); }); it("returns shouldStop=false when under threshold", () => { // Record below threshold for (let i = 0; i < 10; i++) { outboundCounter.record("session1"); } for (let i = 0; i < 5; i++) { recipientCounter.record("session1", `recipient${i}`); } const result = checkOutboundThresholds("session1", outboundCounter, recipientCounter, defaultConfig); expect(result.shouldStop).toBe(false); }); it("returns shouldStop=true with reason='message_rate' when count exceeds threshold (auto-stop message threshold)", () => { // Record above message threshold (20) for (let i = 0; i <= 20; i++) { outboundCounter.record("session1"); } const result = checkOutboundThresholds("session1", outboundCounter, recipientCounter, defaultConfig); expect(result.shouldStop).toBe(true); expect(result.reason).toBe("message_rate"); }); it("returns shouldStop=true with reason='recipient_rate' when unique recipients exceed threshold (auto-stop recipient threshold)", () => { // Record above recipient threshold (10) for (let i = 0; i <= 10; i++) { recipientCounter.record("session1", `recipient${i}`); } const result = checkOutboundThresholds("session1", outboundCounter, recipientCounter, defaultConfig); expect(result.shouldStop).toBe(true); expect(result.reason).toBe("recipient_rate"); }); it("prefers message_rate over recipient_rate when both exceeded", () => { for (let i = 0; i <= 20; i++) { outboundCounter.record("session1"); } for (let i = 0; i <= 10; i++) { recipientCounter.record("session1", `r${i}`); } const result = checkOutboundThresholds("session1", outboundCounter, recipientCounter, defaultConfig); expect(result.reason).toBe("message_rate"); }); it("returns shouldStop=false for session with no entries", () => { const result = checkOutboundThresholds("nonexistent", outboundCounter, recipientCounter, defaultConfig); expect(result.shouldStop).toBe(false); }); }); // --------------------------------------------------------------------------- // checkInboundRate tests // --------------------------------------------------------------------------- describe("checkInboundRate", () => { let inboundCounter: SlidingWindowCounter; beforeEach(() => { inboundCounter = new SlidingWindowCounter(60_000); }); it("returns drop=false when under threshold (inbound rate drop)", () => { for (let i = 0; i < 50; i++) { inboundCounter.record("session1"); } const result = checkInboundRate("session1", inboundCounter, defaultConfig); expect(result.drop).toBe(false); }); it("returns drop=true when inbound count exceeds threshold (inbound rate drop)", () => { for (let i = 0; i <= 100; i++) { inboundCounter.record("session1"); } const result = checkInboundRate("session1", inboundCounter, defaultConfig); expect(result.drop).toBe(true); }); it("returns drop=false for session with no entries", () => { const result = checkInboundRate("nonexistent", inboundCounter, defaultConfig); expect(result.drop).toBe(false); }); }); // --------------------------------------------------------------------------- // isGuardianStopped tests // --------------------------------------------------------------------------- describe("isGuardianStopped", () => { afterEach(() => { // Clean up state clearGuardianStopped("test-session"); }); it("returns false when session is not stopped", () => { expect(isGuardianStopped("test-session")).toBe(false); }); it("returns true after session marked stopped", () => { markGuardianStopped("test-session"); expect(isGuardianStopped("test-session")).toBe(true); }); it("returns false after session cleared", () => { markGuardianStopped("test-session"); clearGuardianStopped("test-session"); expect(isGuardianStopped("test-session")).toBe(false); }); }); // --------------------------------------------------------------------------- // GuardianLogWriter tests // --------------------------------------------------------------------------- describe("GuardianLogWriter", () => { const mockAppendFile = vi.mocked(appendFile); const mockStat = vi.mocked(fsStat); const mockRename = vi.mocked(rename); beforeEach(() => { mockAppendFile.mockClear(); mockStat.mockClear(); mockRename.mockClear(); }); it("appends JSON lines to file", async () => { mockStat.mockResolvedValueOnce({ size: 0 } as any); const writer = new GuardianLogWriter("/tmp/test/guardian.log"); const entry = { ts: "2026-03-29T00:00:00Z", event: "test", session: "s1" }; writer.write(entry); // Allow async write to execute await new Promise((r) => setTimeout(r, 10)); expect(mockAppendFile).toHaveBeenCalledWith( "/tmp/test/guardian.log", expect.stringContaining('"event":"test"'), "utf-8" ); }); it("rotates file when size >= 2MB (log rotation)", async () => { const MAX_BYTES = 2 * 1024 * 1024; // First write triggers rotation check — file is at limit mockStat.mockResolvedValueOnce({ size: MAX_BYTES } as any); const writer = new GuardianLogWriter("/tmp/test/guardian.log", MAX_BYTES); writer.write({ ts: "2026-03-29T00:00:00Z", event: "rotate-test", session: "s1" }); await new Promise((r) => setTimeout(r, 10)); expect(mockRename).toHaveBeenCalledWith( "/tmp/test/guardian.log", "/tmp/test/guardian.log.1" ); expect(mockAppendFile).toHaveBeenCalled(); }); it("does not rotate when file size is under limit", async () => { mockStat.mockResolvedValueOnce({ size: 100 } as any); const writer = new GuardianLogWriter("/tmp/test/guardian.log"); writer.write({ ts: "2026-03-29T00:00:00Z", event: "small-write", session: "s1" }); await new Promise((r) => setTimeout(r, 10)); expect(mockRename).not.toHaveBeenCalled(); }); it("handles file not existing (first write)", async () => { mockStat.mockRejectedValueOnce(new Error("ENOENT: no such file")); const writer = new GuardianLogWriter("/tmp/test/guardian.log"); writer.write({ ts: "2026-03-29T00:00:00Z", event: "first-write", session: "s1" }); await new Promise((r) => setTimeout(r, 10)); expect(mockRename).not.toHaveBeenCalled(); expect(mockAppendFile).toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // startGuardian / getGuardianState tests // --------------------------------------------------------------------------- describe("startGuardian", () => { it("returns a handle with stop() method", () => { const controller = new AbortController(); const handle = startGuardian({ sessions: [ { session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", config: defaultConfig, }, ], abortSignal: controller.signal, }); expect(typeof handle.stop).toBe("function"); handle.stop(); }); it("getGuardianState returns the states map", () => { const state = getGuardianState(); expect(state).toBeInstanceOf(Map); }); }); // --------------------------------------------------------------------------- // guardianConfig schema tests // --------------------------------------------------------------------------- describe("guardianConfig schema", () => { it("validates with defaults (outboundMsgThreshold=20, recipientThreshold=10, inboundDropThreshold=100)", async () => { const { WahaAccountSchemaBase } = await import("../../config-schema.js"); const result = WahaAccountSchemaBase.safeParse({ guardianConfig: { enabled: true, }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.guardianConfig?.outboundMsgThreshold).toBe(20); expect(result.data.guardianConfig?.recipientThreshold).toBe(10); expect(result.data.guardianConfig?.inboundDropThreshold).toBe(100); expect(result.data.guardianConfig?.pollIntervalMs).toBe(7_000); } }); it("accepts custom guardian config values", async () => { const { WahaAccountSchemaBase } = await import("../../config-schema.js"); const result = WahaAccountSchemaBase.safeParse({ guardianConfig: { enabled: true, outboundMsgThreshold: 50, recipientThreshold: 25, inboundDropThreshold: 200, pollIntervalMs: 5_000, }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.guardianConfig?.outboundMsgThreshold).toBe(50); } }); it("rejects pollIntervalMs below 5000ms", async () => { const { WahaAccountSchemaBase } = await import("../../config-schema.js"); const result = WahaAccountSchemaBase.safeParse({ guardianConfig: { enabled: true, pollIntervalMs: 1_000, }, }); expect(result.success).toBe(false); }); it("rejects pollIntervalMs above 10000ms", async () => { const { WahaAccountSchemaBase } = await import("../../config-schema.js"); const result = WahaAccountSchemaBase.safeParse({ guardianConfig: { enabled: true, pollIntervalMs: 15_000, }, }); expect(result.success).toBe(false); }); it("guardianConfig is optional — config without it still passes", async () => { const { WahaAccountSchemaBase } = await import("../../config-schema.js"); const result = WahaAccountSchemaBase.safeParse({}); expect(result.success).toBe(true); }); }); // --------------------------------------------------------------------------- // Integration tests: guardianRecordOutbound feeds outbound counters // --------------------------------------------------------------------------- describe("guardianRecordOutbound (integration)", () => { const SESSION = "int-outbound-session"; const config: GuardianConfig = { enabled: true, outboundMsgThreshold: 20, outboundMsgWindowMs: 60_000, recipientThreshold: 10, recipientWindowMs: 60_000, inboundDropThreshold: 100, inboundDropWindowMs: 60_000, pollIntervalMs: 7_000, }; let controller: AbortController; beforeEach(() => { vi.useFakeTimers(); controller = new AbortController(); // Start guardian to initialize counters startGuardian({ sessions: [{ session: SESSION, baseUrl: "http://localhost:3004", apiKey: "k", config }], abortSignal: controller.signal, }); }); afterEach(() => { controller.abort(); clearGuardianStopped(SESSION); vi.useRealTimers(); }); it("records outbound messages and reaches threshold after 21 calls (guardianRecordOutbound feeds sliding window)", () => { // Record 21 messages (threshold is 20, so count=21 > 20) for (let i = 0; i <= 20; i++) { guardianRecordOutbound(SESSION, `chat-${i}`); } // Now checkOutboundThresholds with the same session should return shouldStop=true // We verify via guardianCheckAndRecordInbound NOT triggering (it checks inbound, not outbound) // The outbound check is in the polling loop — but we can verify state through getGuardianState const state = getGuardianState().get(SESSION); expect(state).toBeDefined(); // The state won't update until a poll fires — but counters are live // Advance past pollIntervalMs to trigger the polling loop vi.advanceTimersByTime(7_001); }); }); // --------------------------------------------------------------------------- // Integration tests: guardianCheckAndRecordInbound // --------------------------------------------------------------------------- describe("guardianCheckAndRecordInbound (integration)", () => { const SESSION = "int-inbound-session"; const config: GuardianConfig = { enabled: true, outboundMsgThreshold: 20, outboundMsgWindowMs: 60_000, recipientThreshold: 10, recipientWindowMs: 60_000, inboundDropThreshold: 100, inboundDropWindowMs: 60_000, pollIntervalMs: 7_000, }; let controller: AbortController; beforeEach(() => { controller = new AbortController(); startGuardian({ sessions: [{ session: SESSION, baseUrl: "http://localhost:3004", apiKey: "k", config }], abortSignal: controller.signal, }); }); afterEach(() => { controller.abort(); clearGuardianStopped(SESSION); }); it("returns false under threshold (guardianCheckAndRecordInbound)", () => { // Record 50 inbound events — under threshold of 100 for (let i = 0; i < 50; i++) { const result = guardianCheckAndRecordInbound(SESSION); expect(result).toBe(false); } }); it("returns true when inbound count exceeds threshold (guardianCheckAndRecordInbound)", () => { // Record 101 inbound events — threshold is 100 let dropDetected = false; for (let i = 0; i <= 101; i++) { const result = guardianCheckAndRecordInbound(SESSION); if (result) { dropDetected = true; break; } } expect(dropDetected).toBe(true); }); it("returns false for unknown session (guardian not watching)", () => { expect(guardianCheckAndRecordInbound("unknown-session")).toBe(false); }); }); // --------------------------------------------------------------------------- // Integration test: registerGuardianWebhook merges existing webhooks (GET + PUT) // --------------------------------------------------------------------------- describe("registerGuardianWebhook (integration)", () => { const mockCallWahaApi = vi.mocked(callWahaApi); beforeEach(() => { mockCallWahaApi.mockClear(); }); it("calls GET then PUT with merged webhook list (webhook registration merges existing webhooks)", async () => { // Mock GET response with one existing webhook mockCallWahaApi .mockResolvedValueOnce({ config: { webhooks: [ { url: "http://existing-server/webhook/waha", events: ["message"] }, ], }, }) .mockResolvedValueOnce({ ok: true }); // PUT response await registerGuardianWebhook({ session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", webhookPublicUrl: "http://myserver.com", }); // Should have called GET first expect(mockCallWahaApi).toHaveBeenCalledTimes(2); const getCall = mockCallWahaApi.mock.calls[0][0]; expect(getCall.method).toBe("GET"); expect(getCall.path).toContain("/api/sessions/"); // Should have called PUT with merged webhooks const putCall = mockCallWahaApi.mock.calls[1][0]; expect(putCall.method).toBe("PUT"); const webhooks: Array> = (putCall.body as any)?.config?.webhooks ?? []; expect(webhooks.length).toBe(2); // existing + guardian const guardianEntry = webhooks.find((wh) => String(wh.url).includes("/webhook/guardian")); expect(guardianEntry).toBeDefined(); expect(guardianEntry?.events).toContain("message.any"); expect((guardianEntry?.hmac as any)?.key).toBeDefined(); // Existing webhook must be preserved const existingEntry = webhooks.find((wh) => wh.url === "http://existing-server/webhook/waha"); expect(existingEntry).toBeDefined(); }); }); // --------------------------------------------------------------------------- // Integration test: full auto-stop flow via polling loop // --------------------------------------------------------------------------- describe("startGuardian auto-stop flow (integration)", () => { const SESSION = "int-autostop-session"; const config: GuardianConfig = { enabled: true, outboundMsgThreshold: 5, // low threshold for easy testing outboundMsgWindowMs: 60_000, recipientThreshold: 10, recipientWindowMs: 60_000, inboundDropThreshold: 100, inboundDropWindowMs: 60_000, pollIntervalMs: 5_000, }; const mockCallWahaApi = vi.mocked(callWahaApi); beforeEach(() => { vi.useFakeTimers(); mockCallWahaApi.mockClear(); clearGuardianStopped(SESSION); // Default mock returns success mockCallWahaApi.mockResolvedValue({ ok: true }); }); afterEach(() => { clearGuardianStopped(SESSION); vi.useRealTimers(); }); it("auto-stops session and calls WAHA stop API when threshold exceeded (full auto-stop flow)", async () => { const controller = new AbortController(); startGuardian({ sessions: [{ session: SESSION, baseUrl: "http://localhost:3004", apiKey: "test-key", config }], abortSignal: controller.signal, }); // Record 6 outbound messages — exceeds threshold of 5 for (let i = 0; i <= 5; i++) { guardianRecordOutbound(SESSION, "chat-1"); } // Advance timers past pollIntervalMs to trigger the polling loop await vi.advanceTimersByTimeAsync(5_001); // Guardian should have called WAHA stop API const stopCall = mockCallWahaApi.mock.calls.find( (call) => call[0].path?.includes("/stop") && call[0].method === "POST" ); expect(stopCall).toBeDefined(); expect(stopCall![0].path).toContain(`/api/sessions/${SESSION}/stop`); expect(stopCall![0].skipRateLimit).toBe(true); // isGuardianStopped should return true expect(isGuardianStopped(SESSION)).toBe(true); controller.abort(); }); }); // --------------------------------------------------------------------------- // Phase 69 (GUARD-08): sendGuardianAlert tests // --------------------------------------------------------------------------- describe("sendGuardianAlert", () => { const mockCallWahaApi = vi.mocked(callWahaApi); let fetchSpy: ReturnType | undefined; beforeEach(() => { mockCallWahaApi.mockClear(); // Spy on global fetch fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: true, status: 200, } as Response); }); afterEach(() => { fetchSpy?.mockRestore(); }); it("POSTs JSON to alertWebhookUrl with correct payload (sendGuardianAlert fires alert)", async () => { sendGuardianAlert({ alertWebhookUrl: "http://alerts.example.com/hook", event: "auto-kill", session: "test-session", detail: { reason: "message_rate" }, }); // Allow microtask queue to flush await new Promise((r) => setImmediate(r)); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, opts] = fetchSpy!.mock.calls[0]; expect(url).toBe("http://alerts.example.com/hook"); expect((opts as RequestInit).method).toBe("POST"); const body = JSON.parse((opts as RequestInit).body as string); expect(body.event).toBe("auto-kill"); expect(body.session).toBe("test-session"); expect(body.detail.reason).toBe("message_rate"); expect(typeof body.ts).toBe("string"); }); it("is a no-op when alertWebhookUrl is empty (no-op when URL not configured)", () => { sendGuardianAlert({ alertWebhookUrl: "", event: "auto-kill", session: "test-session", }); expect(fetchSpy).not.toHaveBeenCalled(); }); it("is a no-op when alertWebhookUrl is undefined (no-op when URL not configured)", () => { sendGuardianAlert({ alertWebhookUrl: undefined as any, event: "auto-kill", session: "test-session", }); expect(fetchSpy).not.toHaveBeenCalled(); }); it("catches fetch errors without throwing (fire-and-forget error handling)", async () => { fetchSpy!.mockRejectedValueOnce(new Error("Network error")); expect(() => { sendGuardianAlert({ alertWebhookUrl: "http://alerts.example.com/hook", event: "auto-kill", session: "test-session", }); }).not.toThrow(); // Allow rejection to be handled await new Promise((r) => setImmediate(r)); // No unhandled rejection — test passes if we reach here }); it("uses AbortSignal.timeout for request timeout (sendGuardianAlert has 10s timeout)", async () => { sendGuardianAlert({ alertWebhookUrl: "http://alerts.example.com/hook", event: "auto-kill", session: "test-session", }); await new Promise((r) => setImmediate(r)); const [, opts] = fetchSpy!.mock.calls[0]; expect((opts as RequestInit).signal).toBeDefined(); }); }); // --------------------------------------------------------------------------- // Phase 69 (GUARD-08): autoStopSession fires sendGuardianAlert test // --------------------------------------------------------------------------- describe("autoStopSession with alertWebhookUrl", () => { let fetchSpy: ReturnType | undefined; const mockCallWahaApi = vi.mocked(callWahaApi); beforeEach(() => { mockCallWahaApi.mockClear(); mockCallWahaApi.mockResolvedValue({ ok: true }); fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: true, status: 200, } as Response); }); afterEach(() => { fetchSpy?.mockRestore(); }); it("fires sendGuardianAlert with event 'auto-kill' when alertWebhookUrl is provided", async () => { const { autoStopSession } = await import("./guardian.js"); await autoStopSession({ session: "alert-test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", reason: "message_rate", alertWebhookUrl: "http://alerts.example.com/hook", }); await new Promise((r) => setImmediate(r)); expect(fetchSpy).toHaveBeenCalledTimes(1); const body = JSON.parse((fetchSpy!.mock.calls[0][1] as RequestInit).body as string); expect(body.event).toBe("auto-kill"); expect(body.session).toBe("alert-test-session"); }); it("does NOT fire sendGuardianAlert when alertWebhookUrl is not provided", async () => { const { autoStopSession } = await import("./guardian.js"); await autoStopSession({ session: "no-alert-session", baseUrl: "http://localhost:3004", apiKey: "test-key", reason: "message_rate", }); await new Promise((r) => setImmediate(r)); expect(fetchSpy).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Phase 69 (GUARD-11): checkSessionWebhooks tests // --------------------------------------------------------------------------- describe("checkSessionWebhooks", () => { const mockCallWahaApi = vi.mocked(callWahaApi); let fetchSpy: ReturnType | undefined; beforeEach(() => { mockCallWahaApi.mockClear(); fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: true, status: 200, } as Response); }); afterEach(() => { fetchSpy?.mockRestore(); }); it("fires unknown-webhook alert for URL not in knownUrls (unknown webhook detection)", async () => { mockCallWahaApi.mockResolvedValueOnce({ config: { webhooks: [ { url: "http://known.example.com/webhook" }, { url: "http://unknown.example.com/webhook" }, ], }, }); await checkSessionWebhooks({ session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", knownUrls: new Set(["http://known.example.com/webhook"]), alertWebhookUrl: "http://alerts.example.com/hook", }); await new Promise((r) => setImmediate(r)); expect(fetchSpy).toHaveBeenCalledTimes(1); const body = JSON.parse((fetchSpy!.mock.calls[0][1] as RequestInit).body as string); expect(body.event).toBe("unknown-webhook"); expect(body.detail.unknownUrl).toBe("http://unknown.example.com/webhook"); }); it("does NOT fire alert when all URLs are known (no false positives)", async () => { mockCallWahaApi.mockResolvedValueOnce({ config: { webhooks: [ { url: "http://known.example.com/webhook" }, ], }, }); await checkSessionWebhooks({ session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", knownUrls: new Set(["http://known.example.com/webhook"]), alertWebhookUrl: "http://alerts.example.com/hook", }); await new Promise((r) => setImmediate(r)); expect(fetchSpy).not.toHaveBeenCalled(); }); it("handles empty webhooks array defensively (no crash on empty array)", async () => { mockCallWahaApi.mockResolvedValueOnce({ config: { webhooks: [] }, }); await expect(checkSessionWebhooks({ session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", knownUrls: new Set(), alertWebhookUrl: "http://alerts.example.com/hook", })).resolves.not.toThrow(); }); it("handles missing webhooks field defensively (no crash on null webhooks)", async () => { mockCallWahaApi.mockResolvedValueOnce({ config: {}, }); await expect(checkSessionWebhooks({ session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", knownUrls: new Set(), alertWebhookUrl: "http://alerts.example.com/hook", })).resolves.not.toThrow(); }); it("skips guardian own URL from knownUrls (guardian URL excluded)", async () => { const guardianUrl = "http://myserver.com/webhook/guardian"; mockCallWahaApi.mockResolvedValueOnce({ config: { webhooks: [{ url: guardianUrl }], }, }); await checkSessionWebhooks({ session: "test-session", baseUrl: "http://localhost:3004", apiKey: "test-key", knownUrls: new Set([guardianUrl]), alertWebhookUrl: "http://alerts.example.com/hook", }); await new Promise((r) => setImmediate(r)); expect(fetchSpy).not.toHaveBeenCalled(); }); });