diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index f0a3044b7ae..d6b4a42fe25 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -96,73 +96,49 @@ const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); function createMockRuntime(): PluginRuntime { return createPluginRuntimeMock({ system: { - enqueueSystemEvent: - mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"], + enqueueSystemEvent: mockEnqueueSystemEvent, }, channel: { text: { - chunkMarkdownText: - mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"], - chunkByNewline: - mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"], - chunkMarkdownTextWithMode: - mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"], - chunkTextWithMode: - mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"], - resolveChunkMode: - mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"], - hasControlCommand: - mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], + chunkMarkdownText: mockChunkMarkdownText, + chunkByNewline: mockChunkByNewline, + chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode, + chunkTextWithMode: mockChunkTextWithMode, + resolveChunkMode: mockResolveChunkMode, + hasControlCommand: mockHasControlCommand, }, reply: { - dispatchReplyWithBufferedBlockDispatcher: - mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], - formatAgentEnvelope: - mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], - formatInboundEnvelope: - mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"], - resolveEnvelopeFormatOptions: - mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], + dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher, + formatAgentEnvelope: mockFormatAgentEnvelope, + formatInboundEnvelope: mockFormatInboundEnvelope, + resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions, }, routing: { - resolveAgentRoute: - mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + resolveAgentRoute: mockResolveAgentRoute, }, pairing: { - buildPairingReply: - mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"], - readAllowFromStore: - mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"], - upsertPairingRequest: - mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"], + buildPairingReply: mockBuildPairingReply, + readAllowFromStore: mockReadAllowFromStore, + upsertPairingRequest: mockUpsertPairingRequest, }, media: { - saveMediaBuffer: - mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + saveMediaBuffer: mockSaveMediaBuffer, }, session: { - resolveStorePath: - mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], - readSessionUpdatedAt: - mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + resolveStorePath: mockResolveStorePath, + readSessionUpdatedAt: mockReadSessionUpdatedAt, }, mentions: { - buildMentionRegexes: - mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], - matchesMentionPatterns: - mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], - matchesMentionWithExplicit: - mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"], + buildMentionRegexes: mockBuildMentionRegexes, + matchesMentionPatterns: mockMatchesMentionPatterns, + matchesMentionWithExplicit: mockMatchesMentionWithExplicit, }, groups: { - resolveGroupPolicy: - mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], - resolveRequireMention: - mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], + resolveGroupPolicy: mockResolveGroupPolicy, + resolveRequireMention: mockResolveRequireMention, }, commands: { - resolveCommandAuthorizedFromAuthorizers: - mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], + resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, }, }, }); @@ -272,604 +248,6 @@ describe("BlueBubbles webhook monitor", () => { unregister?.(); }); - describe("webhook parsing + auth handling", () => { - it("rejects non-POST requests", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = createMockRequest("GET", "/bluebubbles-webhook", {}); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(405); - }); - - it("accepts POST requests with valid JSON payload", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; - - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("ok"); - }); - - it("rejects requests with invalid JSON", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{"); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(400); - }); - - it("accepts URL-encoded payload wrappers", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; - const encodedBody = new URLSearchParams({ - payload: JSON.stringify(payload), - }).toString(); - - const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("ok"); - }); - - it("returns 408 when request body times out (Slow-Loris protection)", async () => { - vi.useFakeTimers(); - try { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - // Create a request that never sends data or ends (simulates slow-loris) - const req = new EventEmitter() as IncomingMessage; - req.method = "POST"; - req.url = "/bluebubbles-webhook?password=test-password"; - req.headers = {}; - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - req.destroy = vi.fn(); - - const res = createMockResponse(); - - const handledPromise = handleBlueBubblesWebhookRequest(req, res); - - // Advance past the 30s timeout - await vi.advanceTimersByTimeAsync(31_000); - - const handled = await handledPromise; - expect(handled).toBe(true); - expect(res.statusCode).toBe(408); - expect(req.destroy).toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it("rejects unauthorized requests before reading the body", async () => { - const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = new EventEmitter() as IncomingMessage; - req.method = "POST"; - req.url = "/bluebubbles-webhook?password=wrong-token"; - req.headers = {}; - const onSpy = vi.spyOn(req, "on"); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); - }); - - it("authenticates via password query parameter", async () => { - const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - // Mock non-localhost request - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - }); - - it("authenticates via x-password header", async () => { - const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }, - { "x-password": "secret-token" }, - ); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - }); - - it("rejects unauthorized requests with wrong password", async () => { - const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - }); - - it("rejects ambiguous routing when multiple targets match the same password", async () => { - const accountA = createMockAccount({ password: "secret-token" }); - const accountB = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - const sinkA = vi.fn(); - const sinkB = vi.fn(); - - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - const unregisterA = registerBlueBubblesWebhookTarget({ - account: accountA, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkA, - }); - const unregisterB = registerBlueBubblesWebhookTarget({ - account: accountB, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkB, - }); - unregister = () => { - unregisterA(); - unregisterB(); - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).not.toHaveBeenCalled(); - }); - - it("ignores targets without passwords when a password-authenticated target matches", async () => { - const accountStrict = createMockAccount({ password: "secret-token" }); - const accountWithoutPassword = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - const sinkStrict = vi.fn(); - const sinkWithoutPassword = vi.fn(); - - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "192.168.1.100", - }; - - const unregisterStrict = registerBlueBubblesWebhookTarget({ - account: accountStrict, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkStrict, - }); - const unregisterNoPassword = registerBlueBubblesWebhookTarget({ - account: accountWithoutPassword, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkWithoutPassword, - }); - unregister = () => { - unregisterStrict(); - unregisterNoPassword(); - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(sinkStrict).toHaveBeenCalledTimes(1); - expect(sinkWithoutPassword).not.toHaveBeenCalled(); - }); - - it("requires authentication for loopback requests when password is configured", async () => { - const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { - const req = createMockRequest("POST", "/bluebubbles-webhook", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress, - }; - - const loopbackUnregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - - loopbackUnregister(); - } - }); - - it("rejects targets without passwords for loopback and proxied-looking requests", async () => { - const account = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - 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) { - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }, - headers, - ); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - } - }); - - it("ignores unregistered webhook paths", async () => { - const req = createMockRequest("POST", "/unregistered-path", {}); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - 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 account = createMockAccount({ groupPolicy: "open" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatId: "123", - date: Date.now(), - }, - }; - - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); - const res = createMockResponse(); - - await handleBlueBubblesWebhookRequest(req, res); - await flushAsync(); - - 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" }); - }); - - const account = createMockAccount({ groupPolicy: "open" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chat: { chatGuid: "iMessage;+;chat123456" }, - date: Date.now(), - }, - }; - - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); - const res = createMockResponse(); - - await handleBlueBubblesWebhookRequest(req, res); - await flushAsync(); - - expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); - expect(sendMessageBlueBubbles).toHaveBeenCalledWith( - "chat_guid:iMessage;+;chat123456", - expect.any(String), - expect.any(Object), - ); - }); - }); - describe("DM pairing behavior vs allowFrom", () => { it("allows DM from sender in allowFrom list", async () => { const account = createMockAccount({ diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts new file mode 100644 index 00000000000..cdb74bb35b9 --- /dev/null +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -0,0 +1,848 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import type { ResolvedBlueBubblesAccount } from "./accounts.js"; +import { fetchBlueBubblesHistory } from "./history.js"; +import { + handleBlueBubblesWebhookRequest, + registerBlueBubblesWebhookTarget, + resolveBlueBubblesMessageId, + _resetBlueBubblesShortIdState, +} from "./monitor.js"; +import { setBlueBubblesRuntime } from "./runtime.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", + accountId: "default", + sessionKey: "agent:main:bluebubbles:dm:+15551234567", +})); +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"); +type DispatchReplyParams = Parameters< + PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"] +>[0]; +const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn( + async (_params: DispatchReplyParams): Promise => undefined, +); +const mockHasControlCommand = vi.fn(() => false); +const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); +const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/test-media.jpg", + contentType: "image/jpeg", +}); +const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); +const mockReadSessionUpdatedAt = vi.fn(() => undefined); +const mockResolveEnvelopeFormatOptions = vi.fn(() => ({ + template: "channel+name+time", +})); +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"); +const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); + +function createMockRuntime(): PluginRuntime { + return createPluginRuntimeMock({ + system: { + enqueueSystemEvent: mockEnqueueSystemEvent, + }, + channel: { + text: { + chunkMarkdownText: mockChunkMarkdownText, + chunkByNewline: mockChunkByNewline, + chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode, + chunkTextWithMode: mockChunkTextWithMode, + resolveChunkMode: mockResolveChunkMode, + hasControlCommand: mockHasControlCommand, + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher, + formatAgentEnvelope: mockFormatAgentEnvelope, + formatInboundEnvelope: mockFormatInboundEnvelope, + resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions, + }, + routing: { + resolveAgentRoute: mockResolveAgentRoute, + }, + pairing: { + buildPairingReply: mockBuildPairingReply, + readAllowFromStore: mockReadAllowFromStore, + upsertPairingRequest: mockUpsertPairingRequest, + }, + media: { + saveMediaBuffer: mockSaveMediaBuffer, + }, + session: { + resolveStorePath: mockResolveStorePath, + readSessionUpdatedAt: mockReadSessionUpdatedAt, + }, + mentions: { + buildMentionRegexes: mockBuildMentionRegexes, + matchesMentionPatterns: mockMatchesMentionPatterns, + matchesMentionWithExplicit: mockMatchesMentionWithExplicit, + }, + groups: { + resolveGroupPolicy: mockResolveGroupPolicy, + resolveRequireMention: mockResolveRequireMention, + }, + commands: { + resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, + }, + }, + }); +} + +function createMockAccount( + overrides: Partial = {}, +): ResolvedBlueBubblesAccount { + return { + accountId: "default", + enabled: true, + configured: true, + config: { + serverUrl: "http://localhost:1234", + password: "test-password", + dmPolicy: "open", + groupPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ...overrides, + }, + }; +} + +function createMockRequest( + method: string, + url: string, + body: unknown, + headers: Record = {}, +): IncomingMessage { + if (headers.host === undefined) { + headers.host = "localhost"; + } + const parsedUrl = new URL(url, "http://localhost"); + const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password"); + const hasAuthHeader = + headers["x-guid"] !== undefined || + headers["x-password"] !== undefined || + headers["x-bluebubbles-guid"] !== undefined || + headers.authorization !== undefined; + if (!hasAuthQuery && !hasAuthHeader) { + parsedUrl.searchParams.set("password", "test-password"); + } + + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.url = `${parsedUrl.pathname}${parsedUrl.search}`; + req.headers = headers; + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; + + // Emit body data after a microtask + // oxlint-disable-next-line no-floating-promises + Promise.resolve().then(() => { + const bodyStr = typeof body === "string" ? body : JSON.stringify(body); + req.emit("data", Buffer.from(bodyStr)); + req.emit("end"); + }); + + return req; +} + +function createMockResponse(): ServerResponse & { body: string; statusCode: number } { + const res = { + statusCode: 200, + body: "", + setHeader: vi.fn(), + end: vi.fn((data?: string) => { + res.body = data ?? ""; + }), + } as unknown as ServerResponse & { body: string; statusCode: number }; + return res; +} + +const flushAsync = async () => { + for (let i = 0; i < 2; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +function getFirstDispatchCall(): DispatchReplyParams { + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; + if (!callArgs) { + throw new Error("expected dispatch call arguments"); + } + return callArgs; +} + +describe("BlueBubbles webhook monitor", () => { + let unregister: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset short ID state between tests for predictable behavior + _resetBlueBubblesShortIdState(); + mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true }); + mockReadAllowFromStore.mockResolvedValue([]); + mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); + mockResolveRequireMention.mockReturnValue(false); + mockHasControlCommand.mockReturnValue(false); + mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); + mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]); + + setBlueBubblesRuntime(createMockRuntime()); + }); + + afterEach(() => { + unregister?.(); + }); + + describe("webhook parsing + auth handling", () => { + it("rejects non-POST requests", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = createMockRequest("GET", "/bluebubbles-webhook", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(405); + }); + + it("accepts POST requests with valid JSON payload", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("ok"); + }); + + it("rejects requests with invalid JSON", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{"); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(400); + }); + + it("accepts URL-encoded payload wrappers", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + const encodedBody = new URLSearchParams({ + payload: JSON.stringify(payload), + }).toString(); + + const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("ok"); + }); + + it("returns 408 when request body times out (Slow-Loris protection)", async () => { + vi.useFakeTimers(); + try { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + // Create a request that never sends data or ends (simulates slow-loris) + const req = new EventEmitter() as IncomingMessage; + req.method = "POST"; + req.url = "/bluebubbles-webhook?password=test-password"; + req.headers = {}; + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + req.destroy = vi.fn(); + + const res = createMockResponse(); + + const handledPromise = handleBlueBubblesWebhookRequest(req, res); + + // Advance past the 30s timeout + await vi.advanceTimersByTimeAsync(31_000); + + const handled = await handledPromise; + expect(handled).toBe(true); + expect(res.statusCode).toBe(408); + expect(req.destroy).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects unauthorized requests before reading the body", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = new EventEmitter() as IncomingMessage; + req.method = "POST"; + req.url = "/bluebubbles-webhook?password=wrong-token"; + req.headers = {}; + const onSpy = vi.spyOn(req, "on"); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); + }); + + it("authenticates via password query parameter", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + // Mock non-localhost request + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("authenticates via x-password header", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, + { "x-password": "secret-token" }, + ); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("rejects unauthorized requests with wrong password", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + }); + + it("rejects ambiguous routing when multiple targets match the same password", async () => { + const accountA = createMockAccount({ password: "secret-token" }); + const accountB = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const sinkA = vi.fn(); + const sinkB = vi.fn(); + + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + const unregisterA = registerBlueBubblesWebhookTarget({ + account: accountA, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + statusSink: sinkA, + }); + const unregisterB = registerBlueBubblesWebhookTarget({ + account: accountB, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + statusSink: sinkB, + }); + unregister = () => { + unregisterA(); + unregisterB(); + }; + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).not.toHaveBeenCalled(); + }); + + it("ignores targets without passwords when a password-authenticated target matches", async () => { + const accountStrict = createMockAccount({ password: "secret-token" }); + const accountWithoutPassword = createMockAccount({ password: undefined }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const sinkStrict = vi.fn(); + const sinkWithoutPassword = vi.fn(); + + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + const unregisterStrict = registerBlueBubblesWebhookTarget({ + account: accountStrict, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + statusSink: sinkStrict, + }); + const unregisterNoPassword = registerBlueBubblesWebhookTarget({ + account: accountWithoutPassword, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + statusSink: sinkWithoutPassword, + }); + unregister = () => { + unregisterStrict(); + unregisterNoPassword(); + }; + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(sinkStrict).toHaveBeenCalledTimes(1); + expect(sinkWithoutPassword).not.toHaveBeenCalled(); + }); + + it("requires authentication for loopback requests when password is configured", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { + const req = createMockRequest("POST", "/bluebubbles-webhook", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress, + }; + + const loopbackUnregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + + loopbackUnregister(); + } + }); + + it("rejects targets without passwords for loopback and proxied-looking requests", async () => { + const account = createMockAccount({ password: undefined }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + 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) { + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, + headers, + ); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + } + }); + + it("ignores unregistered webhook paths", async () => { + const req = createMockRequest("POST", "/unregistered-path", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + 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 account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatId: "123", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + 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" }); + }); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chat: { chatGuid: "iMessage;+;chat123456" }, + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); + expect(sendMessageBlueBubbles).toHaveBeenCalledWith( + "chat_guid:iMessage;+;chat123456", + expect.any(String), + expect.any(Object), + ); + }); + }); +}); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 6a108777d2a..86da242cd9c 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,5 +1,6 @@ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; import type { FeishuMessageEvent } from "./bot.js"; import { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -143,38 +144,40 @@ describe("handleFeishuMessage command authorization", () => { }, }); mockEnqueueSystemEvent.mockReset(); - setFeishuRuntime({ - system: { - enqueueSystemEvent: mockEnqueueSystemEvent, - }, - channel: { - routing: { - resolveAgentRoute: mockResolveAgentRoute, + setFeishuRuntime( + createPluginRuntimeMock({ + system: { + enqueueSystemEvent: mockEnqueueSystemEvent, }, - reply: { - resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })), - formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), - finalizeInboundContext: mockFinalizeInboundContext, - dispatchReplyFromConfig: mockDispatchReplyFromConfig, - withReplyDispatcher: mockWithReplyDispatcher, - }, - commands: { - shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, - resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, + channel: { + routing: { + resolveAgentRoute: mockResolveAgentRoute, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })), + formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), + finalizeInboundContext: mockFinalizeInboundContext, + dispatchReplyFromConfig: mockDispatchReplyFromConfig, + withReplyDispatcher: mockWithReplyDispatcher, + }, + commands: { + shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, + resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, + }, + media: { + saveMediaBuffer: mockSaveMediaBuffer, + }, + pairing: { + readAllowFromStore: mockReadAllowFromStore, + upsertPairingRequest: mockUpsertPairingRequest, + buildPairingReply: mockBuildPairingReply, + }, }, media: { - saveMediaBuffer: mockSaveMediaBuffer, + detectMime: vi.fn(async () => "application/octet-stream"), }, - pairing: { - readAllowFromStore: mockReadAllowFromStore, - upsertPairingRequest: mockUpsertPairingRequest, - buildPairingReply: mockBuildPairingReply, - }, - }, - media: { - detectMime: vi.fn(async () => "application/octet-stream"), - }, - } as unknown as PluginRuntime); + }), + ); }); it("does not enqueue inbound preview text as system events", async () => { diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 83786728290..5de88065b0e 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -1,10 +1,11 @@ -import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js"; import * as dedup from "./dedup.js"; import { monitorSingleAccount } from "./monitor.account.js"; @@ -367,17 +368,19 @@ describe("Feishu inbound debounce regressions", () => { vi.useFakeTimers(); handlers = {}; handleFeishuMessageMock.mockClear(); - setFeishuRuntime({ - channel: { - debounce: { - createInboundDebouncer, - resolveInboundDebounceMs, + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + text: { + hasControlCommand, + }, }, - text: { - hasControlCommand, - }, - }, - } as unknown as PluginRuntime); + }), + ); }); afterEach(() => { diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 815659fbdb7..29418ac0395 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,5 +1,6 @@ import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; import { buildMSTeamsAttachmentPlaceholder, buildMSTeamsGraphMessageUrls, @@ -106,19 +107,17 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { throw new Error("too many redirects"); }); -const runtimeStub = { +const runtimeStub: PluginRuntime = createPluginRuntimeMock({ media: { - detectMime: detectMimeMock as unknown as PluginRuntime["media"]["detectMime"], + detectMime: detectMimeMock, }, channel: { media: { - fetchRemoteMedia: - fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], - saveMediaBuffer: - saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + fetchRemoteMedia: fetchRemoteMediaMock, + saveMediaBuffer: saveMediaBufferMock, }, }, -} as unknown as PluginRuntime; +}); type DownloadAttachmentsParams = Parameters[0]; type DownloadGraphMediaParams = Parameters[0]; diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 58cf4fad1b2..0857f8d5c3f 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; import type { StoredConversationReference } from "./conversation-store.js"; const graphUploadMockState = vi.hoisted(() => ({ uploadAndShareOneDrive: vi.fn(), @@ -38,7 +39,7 @@ const chunkMarkdownText = (text: string, limit: number) => { return chunks; }; -const runtimeStub = { +const runtimeStub: PluginRuntime = createPluginRuntimeMock({ channel: { text: { chunkMarkdownText, @@ -47,7 +48,7 @@ const runtimeStub = { convertMarkdownTables: (text: string) => text, }, }, -} as unknown as PluginRuntime; +}); const createNoopAdapter = (): MSTeamsAdapter => ({ continueConversation: async () => {},