import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBlueBubblesMonitorTestRuntime, EMPTY_DISPATCH_RESULT, resetBlueBubblesMonitorTestState, type DispatchReplyParams, } from "../../../test/helpers/extensions/bluebubbles-monitor.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js"; import { LOOPBACK_REMOTE_ADDRESSES_FOR_TEST, createWebhookDispatchForTest, createMockAccount, createHangingWebhookRequestForTest, createLoopbackWebhookRequestParamsForTest, createPasswordQueryRequestParamsForTest, createProtectedWebhookAccountForTest, createRemoteWebhookRequestParamsForTest, createTimestampedNewMessagePayloadForTest, dispatchWebhookPayloadForTest, expectWebhookRequestStatusForTest, expectWebhookStatusForTest, setupWebhookTargetForTest, setupWebhookTargetsForTest, trackWebhookRegistrationForTest, type WebhookRequestParams, } from "./monitor.webhook.test-helpers.js"; import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; // Mock dependencies vi.mock("./send.js", () => ({ resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), })); vi.mock("./chat.js", () => ({ markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined), sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined), })); vi.mock("./attachments.js", () => ({ downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({ buffer: Buffer.from("test"), contentType: "image/jpeg", }), })); vi.mock("./reactions.js", async () => { const actual = await vi.importActual("./reactions.js"); return { ...actual, sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), }; }); vi.mock("./history.js", () => ({ fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), })); // Mock runtime const mockEnqueueSystemEvent = vi.fn(); const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true }); const mockResolveAgentRoute = vi.fn(() => ({ agentId: "main", channel: "bluebubbles", accountId: "default", sessionKey: "agent:main:bluebubbles:dm:+15551234567", mainSessionKey: "agent:main:main", matchedBy: "default", })); const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => regexes.some((r) => r.test(text)), ); const mockMatchesMentionWithExplicit = vi.fn( (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => { if (params.explicitWasMentioned) { return true; } return params.mentionRegexes.some((regex) => regex.test(params.text)); }, ); const mockResolveRequireMention = vi.fn(() => false); const mockResolveGroupPolicy = vi.fn(() => "open" as const); const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn( async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT, ); const mockHasControlCommand = vi.fn(() => false); const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ id: "test-media.jpg", path: "/tmp/test-media.jpg", size: Buffer.byteLength("test"), contentType: "image/jpeg", }); const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); const mockReadSessionUpdatedAt = vi.fn(() => undefined); const mockResolveEnvelopeFormatOptions = vi.fn(() => ({})); const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body); const mockChunkMarkdownText = vi.fn((text: string) => [text]); const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : [])); const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockResolveChunkMode = vi.fn(() => "length" as const); const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); const TEST_WEBHOOK_PASSWORD = "secret-token"; function createMockRuntime(): PluginRuntime { return createBlueBubblesMonitorTestRuntime({ enqueueSystemEvent: mockEnqueueSystemEvent, chunkMarkdownText: mockChunkMarkdownText, chunkByNewline: mockChunkByNewline, chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode, chunkTextWithMode: mockChunkTextWithMode, resolveChunkMode: mockResolveChunkMode, hasControlCommand: mockHasControlCommand, dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher, formatAgentEnvelope: mockFormatAgentEnvelope, formatInboundEnvelope: mockFormatInboundEnvelope, resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions, resolveAgentRoute: mockResolveAgentRoute, buildPairingReply: mockBuildPairingReply, readAllowFromStore: mockReadAllowFromStore, upsertPairingRequest: mockUpsertPairingRequest, saveMediaBuffer: mockSaveMediaBuffer, resolveStorePath: mockResolveStorePath, readSessionUpdatedAt: mockReadSessionUpdatedAt, buildMentionRegexes: mockBuildMentionRegexes, matchesMentionPatterns: mockMatchesMentionPatterns, matchesMentionWithExplicit: mockMatchesMentionWithExplicit, resolveGroupPolicy: mockResolveGroupPolicy, resolveRequireMention: mockResolveRequireMention, resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, }); } describe("BlueBubbles webhook monitor", () => { let unregister: () => void; beforeEach(() => { resetBlueBubblesMonitorTestState({ createRuntime: createMockRuntime, fetchHistoryMock: mockFetchBlueBubblesHistory, readAllowFromStoreMock: mockReadAllowFromStore, upsertPairingRequestMock: mockUpsertPairingRequest, resolveRequireMentionMock: mockResolveRequireMention, hasControlCommandMock: mockHasControlCommand, resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers, buildMentionRegexesMock: mockBuildMentionRegexes, }); }); afterEach(() => { unregister?.(); }); function setupWebhookTarget(params?: { account?: ResolvedBlueBubblesAccount; config?: OpenClawConfig; core?: PluginRuntime; statusSink?: (event: unknown) => void; }) { const registration = trackWebhookRegistrationForTest( setupWebhookTargetForTest({ createCore: createMockRuntime, core: params?.core, account: params?.account, config: params?.config, statusSink: params?.statusSink, }), (nextUnregister) => { unregister = nextUnregister; }, ); return { account: registration.account, config: registration.config, core: registration.core, }; } function setupProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) { return setupWebhookTargetAccount(createProtectedWebhookTarget(password).account); } function setupPasswordlessWebhookTarget() { return setupWebhookTargetAccount(createPasswordlessWebhookTarget().account); } function setupWebhookTargetAccount(account: ResolvedBlueBubblesAccount) { setupWebhookTarget({ account }); return account; } function createWebhookTarget( account: ResolvedBlueBubblesAccount, statusSink: (event: unknown) => void = vi.fn(), ) { return { account, statusSink }; } function createProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) { return createWebhookTarget(createProtectedWebhookAccountForTest(password)); } function createPasswordlessWebhookTarget() { return createWebhookTarget(createMockAccount({ password: undefined })); } function createProtectedPasswordQueryRequestParams(password = TEST_WEBHOOK_PASSWORD) { return createPasswordQueryRequestParamsForTest({ password }); } async function expectWebhookRequestStatusWithSetup( setup: () => void, params: WebhookRequestParams, expectedStatus: number, expectedBody?: string, ) { setup(); return expectWebhookRequestStatusForTest(params, expectedStatus, expectedBody); } async function dispatchWebhookPayloadWithSetup(setup: () => void, payload: unknown) { setup(); return dispatchWebhookPayloadForTest({ body: payload }); } async function expectProtectedPasswordQueryRequestStatus( expectedStatus: number, password = TEST_WEBHOOK_PASSWORD, ) { return expectWebhookRequestStatusForTest( createProtectedPasswordQueryRequestParams(password), expectedStatus, ); } async function expectProtectedWebhookRequestStatus( params: WebhookRequestParams, expectedStatus: number, expectedBody?: string, ) { return expectWebhookRequestStatusWithSetup( () => { setupProtectedWebhookTarget(); }, params, expectedStatus, expectedBody, ); } async function expectRegisteredWebhookRequestStatus( params: WebhookRequestParams, expectedStatus: number, expectedBody?: string, ) { return expectWebhookRequestStatusWithSetup( () => { setupWebhookTarget(); }, params, expectedStatus, expectedBody, ); } async function dispatchRegisteredWebhookPayload(payload: unknown) { return dispatchWebhookPayloadWithSetup(() => { setupWebhookTarget(); }, payload); } async function expectLoopbackWebhookRequestStatus( remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], expectedStatus: number, overrides?: Omit, ) { return expectWebhookRequestStatusForTest( createLoopbackWebhookRequestParamsForTest(remoteAddress, { overrides }), expectedStatus, ); } async function expectProtectedLoopbackWebhookRequestStatus( remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], expectedStatus: number, overrides?: Omit, ) { setupProtectedWebhookTarget(); return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides); } async function expectPasswordlessLoopbackWebhookRequestStatus( remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], expectedStatus: number, overrides?: Omit, ) { setupPasswordlessWebhookTarget(); return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides); } function registerWebhookTargets( params: Array<{ account: ResolvedBlueBubblesAccount; statusSink?: (event: unknown) => void; }>, ) { trackWebhookRegistrationForTest( setupWebhookTargetsForTest({ createCore: createMockRuntime, accounts: params, }), (nextUnregister) => { unregister = nextUnregister; }, ); } describe("webhook parsing + auth handling", () => { it("rejects non-POST requests", async () => { await expectRegisteredWebhookRequestStatus({ method: "GET" }, 405); }); it("accepts POST requests with valid JSON payload", async () => { const payload = createTimestampedNewMessagePayloadForTest(); await expectRegisteredWebhookRequestStatus({ body: payload }, 200, "ok"); }); it("rejects requests with invalid JSON", async () => { await expectRegisteredWebhookRequestStatus({ body: "invalid json {{" }, 400); }); it("accepts URL-encoded payload wrappers", async () => { const payload = createTimestampedNewMessagePayloadForTest(); const encodedBody = new URLSearchParams({ payload: JSON.stringify(payload), }).toString(); await expectRegisteredWebhookRequestStatus({ body: encodedBody }, 200, "ok"); }); it("returns 408 when request body times out (Slow-Loris protection)", async () => { vi.useFakeTimers(); try { setupWebhookTarget(); // Create a request that never sends data or ends (simulates slow-loris) const { req, destroyMock } = createHangingWebhookRequestForTest(); const { res, handledPromise } = createWebhookDispatchForTest(req); // Advance past the 30s timeout await vi.advanceTimersByTimeAsync(31_000); const handled = await handledPromise; expect(handled).toBe(true); expect(res.statusCode).toBe(408); expect(destroyMock).toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("rejects unauthorized requests before reading the body", async () => { setupProtectedWebhookTarget(); const { req } = createHangingWebhookRequestForTest( "/bluebubbles-webhook?password=wrong-token", ); const onSpy = vi.spyOn(req, "on"); await expectWebhookStatusForTest(req, 401); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); }); it("authenticates via password query parameter", async () => { await expectProtectedWebhookRequestStatus(createProtectedPasswordQueryRequestParams(), 200); }); it("authenticates via x-password header", async () => { await expectProtectedWebhookRequestStatus( createRemoteWebhookRequestParamsForTest({ overrides: { headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret }, }), 200, ); }); it("rejects unauthorized requests with wrong password", async () => { await expectProtectedWebhookRequestStatus( createProtectedPasswordQueryRequestParams("wrong-token"), 401, ); }); it("rejects ambiguous routing when multiple targets match the same password", async () => { const targetA = createProtectedWebhookTarget(); const targetB = createProtectedWebhookTarget(); registerWebhookTargets([targetA, targetB]); await expectProtectedPasswordQueryRequestStatus(401); expect(targetA.statusSink).not.toHaveBeenCalled(); expect(targetB.statusSink).not.toHaveBeenCalled(); }); it("ignores targets without passwords when a password-authenticated target matches", async () => { const strictTarget = createProtectedWebhookTarget(); const passwordlessTarget = createPasswordlessWebhookTarget(); registerWebhookTargets([strictTarget, passwordlessTarget]); await expectProtectedPasswordQueryRequestStatus(200); expect(strictTarget.statusSink).toHaveBeenCalledTimes(1); expect(passwordlessTarget.statusSink).not.toHaveBeenCalled(); }); it("requires authentication for loopback requests when password is configured", async () => { for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES_FOR_TEST) { await expectProtectedLoopbackWebhookRequestStatus(remoteAddress, 401); } }); it("rejects targets without passwords for loopback and proxied-looking requests", async () => { const headerVariants: Record[] = [ { host: "localhost" }, { host: "localhost", "x-forwarded-for": "203.0.113.10" }, { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, ]; for (const headers of headerVariants) { await expectPasswordlessLoopbackWebhookRequestStatus("127.0.0.1", 401, { headers }); } }); it("ignores unregistered webhook paths", async () => { const { handled } = await dispatchWebhookPayloadForTest({ url: "/unregistered-path", }); expect(handled).toBe(false); }); it("parses chatId when provided as a string (webhook variant)", async () => { const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockClear(); const payload = createTimestampedNewMessagePayloadForTest({ text: "hello from group", isGroup: true, chatId: "123", }); await dispatchRegisteredWebhookPayload(payload); expect(resolveChatGuidForTarget).toHaveBeenCalledWith( expect.objectContaining({ target: { kind: "chat_id", chatId: 123 }, }), ); }); it("extracts chatGuid from nested chat object fields (webhook variant)", async () => { const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(sendMessageBlueBubbles).mockClear(); vi.mocked(resolveChatGuidForTarget).mockClear(); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); return EMPTY_DISPATCH_RESULT; }); const payload = createTimestampedNewMessagePayloadForTest({ text: "hello from group", isGroup: true, chat: { chatGuid: "iMessage;+;chat123456" }, }); await dispatchRegisteredWebhookPayload(payload); expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); expect(sendMessageBlueBubbles).toHaveBeenCalledWith( "chat_guid:iMessage;+;chat123456", expect.any(String), expect.any(Object), ); }); }); });