refactor(test): dedupe bluebubbles monitor helpers

This commit is contained in:
Peter Steinberger 2026-03-22 02:11:18 +00:00
parent 7b344b8a8a
commit 4f210e98a5
4 changed files with 427 additions and 318 deletions

View File

@ -1,26 +1,25 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
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 { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js";
import {
createMockAccount,
createMockRequest,
createMockResponse,
dispatchWebhookPayloadForTest,
flushAsync,
registerWebhookTargetForTest,
registerWebhookTargetsForTest,
setupWebhookTargetForTest,
setupWebhookTargetsForTest,
} from "./monitor.webhook.test-helpers.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
vi.mock("./send.js", () => ({
@ -79,13 +78,6 @@ const mockMatchesMentionWithExplicit = vi.fn(
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
type DispatchReplyParams = Parameters<
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
const EMPTY_DISPATCH_RESULT = {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} as const;
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
);
@ -110,59 +102,31 @@ const mockResolveChunkMode = vi.fn(() => "length" as const);
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 as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
hasControlCommand: mockHasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
formatAgentEnvelope: mockFormatAgentEnvelope,
formatInboundEnvelope: mockFormatInboundEnvelope,
resolveEnvelopeFormatOptions:
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply: mockBuildPairingReply,
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
},
media: {
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath: mockResolveStorePath,
readSessionUpdatedAt: mockReadSessionUpdatedAt,
},
mentions: {
buildMentionRegexes: mockBuildMentionRegexes,
matchesMentionPatterns: mockMatchesMentionPatterns,
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention,
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
},
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,
});
}
@ -182,13 +146,14 @@ describe("BlueBubbles webhook monitor", () => {
config?: OpenClawConfig;
core?: PluginRuntime;
}) {
const core = params?.core ?? createMockRuntime();
unregister = registerWebhookTargetForTest({
core,
const registration = setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
});
return { core };
unregister = registration.unregister;
return { core: registration.core };
}
async function dispatchWebhookPayload(payload: unknown, url = "/bluebubbles-webhook") {
@ -196,19 +161,17 @@ describe("BlueBubbles webhook monitor", () => {
}
beforeEach(() => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
resetBlueBubblesSelfChatCache();
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());
resetBlueBubblesMonitorTestState({
createRuntime: createMockRuntime,
fetchHistoryMock: mockFetchBlueBubblesHistory,
readAllowFromStoreMock: mockReadAllowFromStore,
upsertPairingRequestMock: mockUpsertPairingRequest,
resolveRequireMentionMock: mockResolveRequireMention,
hasControlCommandMock: mockHasControlCommand,
resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers,
buildMentionRegexesMock: mockBuildMentionRegexes,
extraReset: resetBlueBubblesSelfChatCache,
});
});
afterEach(() => {
@ -806,10 +769,12 @@ describe("BlueBubbles webhook monitor", () => {
};
}) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
unregister = registerWebhookTargetForTest({
const registration = setupWebhookTargetForTest({
createCore: createMockRuntime,
core,
account: createMockAccount({ dmPolicy: "open" }),
});
unregister = registration.unregister;
const messageId = "race-msg-1";
const chatGuid = "iMessage;-;+15551234567";
@ -1740,14 +1705,12 @@ describe("BlueBubbles webhook monitor", () => {
accountId: "acc-b",
};
const core = createMockRuntime();
const [unregisterA, unregisterB] = registerWebhookTargetsForTest({
const registration = setupWebhookTargetsForTest({
createCore: createMockRuntime,
core,
accounts: [{ account: accountA }, { account: accountB }],
});
unregister = () => {
unregisterA();
unregisterB();
};
unregister = registration.unregister;
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook?password=password-a", {

View File

@ -1,25 +1,25 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
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,
registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js";
import {
createMockAccount,
createMockRequest,
createHangingWebhookRequestForTest,
createMockRequestForTest,
createMockResponse,
dispatchWebhookPayloadForTest,
registerWebhookTargetForTest,
registerWebhookTargetsForTest,
setupWebhookTargetForTest,
setupWebhookTargetsForTest,
type WebhookRequestParams,
} from "./monitor.webhook.test-helpers.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
vi.mock("./send.js", () => ({
@ -78,13 +78,6 @@ const mockMatchesMentionWithExplicit = vi.fn(
);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
type DispatchReplyParams = Parameters<
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
const EMPTY_DISPATCH_RESULT = {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} as const;
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
);
@ -107,88 +100,53 @@ 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 LOOPBACK_REMOTE_ADDRESSES = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const;
const TEST_REMOTE_ADDRESS = "192.168.1.100";
const TEST_WEBHOOK_PASSWORD = "secret-token";
function createMockRuntime(): PluginRuntime {
return createPluginRuntimeMock({
system: {
enqueueSystemEvent: mockEnqueueSystemEvent,
},
channel: {
text: {
chunkMarkdownText: mockChunkMarkdownText,
chunkByNewline: mockChunkByNewline,
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
chunkTextWithMode: mockChunkTextWithMode,
resolveChunkMode:
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
hasControlCommand: mockHasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
formatAgentEnvelope: mockFormatAgentEnvelope,
formatInboundEnvelope: mockFormatInboundEnvelope,
resolveEnvelopeFormatOptions:
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply: mockBuildPairingReply,
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
},
media: {
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath: mockResolveStorePath,
readSessionUpdatedAt: mockReadSessionUpdatedAt,
},
mentions: {
buildMentionRegexes: mockBuildMentionRegexes,
matchesMentionPatterns: mockMatchesMentionPatterns,
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention,
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
},
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,
});
}
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());
resetBlueBubblesMonitorTestState({
createRuntime: createMockRuntime,
fetchHistoryMock: mockFetchBlueBubblesHistory,
readAllowFromStoreMock: mockReadAllowFromStore,
upsertPairingRequestMock: mockUpsertPairingRequest,
resolveRequireMentionMock: mockResolveRequireMention,
hasControlCommandMock: mockHasControlCommand,
resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers,
buildMentionRegexesMock: mockBuildMentionRegexes,
});
});
afterEach(() => {
@ -201,19 +159,19 @@ describe("BlueBubbles webhook monitor", () => {
core?: PluginRuntime;
statusSink?: (event: unknown) => void;
}) {
const account = params?.account ?? createMockAccount();
const config = params?.config ?? {};
const core = params?.core ?? createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
const registration = setupWebhookTargetForTest({
createCore: createMockRuntime,
core: params?.core,
account: params?.account,
config: params?.config,
statusSink: params?.statusSink,
});
return { account, config, core };
unregister = registration.unregister;
return {
account: registration.account,
config: registration.config,
core: registration.core,
};
}
function createNewMessagePayload(dataOverrides: Record<string, unknown> = {}) {
@ -230,44 +188,43 @@ describe("BlueBubbles webhook monitor", () => {
};
}
function setRequestRemoteAddress(req: IncomingMessage, remoteAddress: string) {
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
function createProtectedWebhookAccount(password = TEST_WEBHOOK_PASSWORD) {
return createMockAccount({ password });
}
function setupProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) {
const account = createProtectedWebhookAccount(password);
setupWebhookTarget({ account });
return account;
}
function createRemoteWebhookRequestParams(overrides: WebhookRequestParams = {}) {
return {
body: createNewMessagePayload(),
remoteAddress: TEST_REMOTE_ADDRESS,
...overrides,
};
}
async function dispatchWebhook(req: IncomingMessage) {
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
return { handled, res };
function createPasswordQueryRequestParams(
password = TEST_WEBHOOK_PASSWORD,
overrides: Omit<WebhookRequestParams, "url"> = {},
) {
return createRemoteWebhookRequestParams({
url: `/bluebubbles-webhook?password=${password}`,
...overrides,
});
}
function createWebhookRequestForTest(params?: {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
}) {
const req = createMockRequest(
params?.method ?? "POST",
params?.url ?? "/bluebubbles-webhook",
params?.body ?? {},
params?.headers,
params?.remoteAddress,
);
return req;
}
function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
const req = new EventEmitter() as IncomingMessage;
const destroyMock = vi.fn();
req.method = "POST";
req.url = url;
req.headers = {};
req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
setRequestRemoteAddress(req, "127.0.0.1");
return { req, destroyMock };
function createLoopbackWebhookRequestParams(
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES)[number],
overrides: Omit<WebhookRequestParams, "remoteAddress"> = {},
) {
return {
body: createNewMessagePayload(),
remoteAddress,
...overrides,
};
}
function registerWebhookTargets(
@ -276,17 +233,11 @@ describe("BlueBubbles webhook monitor", () => {
statusSink?: (event: unknown) => void;
}>,
) {
const core = createMockRuntime();
const unregisterFns = registerWebhookTargetsForTest({
core,
const registration = setupWebhookTargetsForTest({
createCore: createMockRuntime,
accounts: params,
});
unregister = () => {
for (const unregisterFn of unregisterFns) {
unregisterFn();
}
};
unregister = registration.unregister;
}
async function expectWebhookStatus(
@ -294,7 +245,8 @@ describe("BlueBubbles webhook monitor", () => {
expectedStatus: number,
expectedBody?: string,
) {
const { handled, res } = await dispatchWebhook(req);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatus);
if (expectedBody !== undefined) {
@ -303,24 +255,29 @@ describe("BlueBubbles webhook monitor", () => {
return res;
}
async function expectWebhookRequestStatus(
params: WebhookRequestParams,
expectedStatus: number,
expectedBody?: string,
) {
return expectWebhookStatus(createMockRequestForTest(params), expectedStatus, expectedBody);
}
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
setupWebhookTarget();
const req = createWebhookRequestForTest({ method: "GET" });
await expectWebhookStatus(req, 405);
await expectWebhookRequestStatus({ method: "GET" }, 405);
});
it("accepts POST requests with valid JSON payload", async () => {
setupWebhookTarget();
const payload = createNewMessagePayload({ date: Date.now() });
const req = createWebhookRequestForTest({ body: payload });
await expectWebhookStatus(req, 200, "ok");
await expectWebhookRequestStatus({ body: payload }, 200, "ok");
});
it("rejects requests with invalid JSON", async () => {
setupWebhookTarget();
const req = createWebhookRequestForTest({ body: "invalid json {{" });
await expectWebhookStatus(req, 400);
await expectWebhookRequestStatus({ body: "invalid json {{" }, 400);
});
it("accepts URL-encoded payload wrappers", async () => {
@ -329,8 +286,7 @@ describe("BlueBubbles webhook monitor", () => {
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const req = createWebhookRequestForTest({ body: encodedBody });
await expectWebhookStatus(req, 200, "ok");
await expectWebhookRequestStatus({ body: encodedBody }, 200, "ok");
});
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
@ -339,7 +295,7 @@ describe("BlueBubbles webhook monitor", () => {
setupWebhookTarget();
// Create a request that never sends data or ends (simulates slow-loris)
const { req, destroyMock } = createHangingWebhookRequest();
const { req, destroyMock } = createHangingWebhookRequestForTest();
const res = createMockResponse();
@ -358,50 +314,38 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
setupProtectedWebhookTarget();
const { req } = createHangingWebhookRequestForTest(
"/bluebubbles-webhook?password=wrong-token",
);
const onSpy = vi.spyOn(req, "on");
await expectWebhookStatus(req, 401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=secret-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 200);
setupProtectedWebhookTarget();
await expectWebhookRequestStatus(createPasswordQueryRequestParams(), 200);
});
it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
body: createNewMessagePayload(),
headers: { "x-password": "secret-token" }, // pragma: allowlist secret
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 200);
setupProtectedWebhookTarget();
await expectWebhookRequestStatus(
createRemoteWebhookRequestParams({
headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret
}),
200,
);
});
it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" });
setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=wrong-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 401);
setupProtectedWebhookTarget();
await expectWebhookRequestStatus(createPasswordQueryRequestParams("wrong-token"), 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 accountA = createProtectedWebhookAccount();
const accountB = createProtectedWebhookAccount();
const sinkA = vi.fn();
const sinkB = vi.fn();
registerWebhookTargets([
@ -409,18 +353,13 @@ describe("BlueBubbles webhook monitor", () => {
{ account: accountB, statusSink: sinkB },
]);
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=secret-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 401);
await expectWebhookRequestStatus(createPasswordQueryRequestParams(), 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 accountStrict = createProtectedWebhookAccount();
const accountWithoutPassword = createMockAccount({ password: undefined });
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
@ -429,25 +368,15 @@ describe("BlueBubbles webhook monitor", () => {
{ account: accountWithoutPassword, statusSink: sinkWithoutPassword },
]);
const req = createWebhookRequestForTest({
url: "/bluebubbles-webhook?password=secret-token",
body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
});
await expectWebhookStatus(req, 200);
await expectWebhookRequestStatus(createPasswordQueryRequestParams(), 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" });
setupWebhookTarget({ account });
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createWebhookRequestForTest({
body: createNewMessagePayload(),
remoteAddress,
});
await expectWebhookStatus(req, 401);
setupProtectedWebhookTarget();
for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES) {
await expectWebhookRequestStatus(createLoopbackWebhookRequestParams(remoteAddress), 401);
}
});
@ -461,12 +390,10 @@ describe("BlueBubbles webhook monitor", () => {
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
];
for (const headers of headerVariants) {
const req = createWebhookRequestForTest({
body: createNewMessagePayload(),
headers,
remoteAddress: "127.0.0.1",
});
await expectWebhookStatus(req, 401);
await expectWebhookRequestStatus(
createLoopbackWebhookRequestParams("127.0.0.1", { headers }),
401,
);
}
});

View File

@ -7,6 +7,14 @@ import { registerBlueBubblesWebhookTarget } from "./monitor.js";
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
export type WebhookRequestParams = {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
};
export function createMockAccount(
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
): ResolvedBlueBubblesAccount {
@ -63,6 +71,30 @@ export function createMockRequest(
return req;
}
export function createMockRequestForTest(params: WebhookRequestParams = {}): IncomingMessage {
return createMockRequest(
params.method ?? "POST",
params.url ?? "/bluebubbles-webhook",
params.body ?? {},
params.headers,
params.remoteAddress,
);
}
export function createHangingWebhookRequestForTest(
url = "/bluebubbles-webhook?password=test-password",
remoteAddress = "127.0.0.1",
) {
const req = new EventEmitter() as IncomingMessage;
const destroyMock = vi.fn();
req.method = "POST";
req.url = url;
req.headers = {};
req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress };
return { req, destroyMock };
}
export function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
const res = {
statusCode: 200,
@ -81,20 +113,8 @@ export async function flushAsync() {
}
}
export async function dispatchWebhookPayloadForTest(params?: {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
}) {
const req = createMockRequest(
params?.method ?? "POST",
params?.url ?? "/bluebubbles-webhook",
params?.body ?? {},
params?.headers,
params?.remoteAddress,
);
export async function dispatchWebhookPayloadForTest(params: WebhookRequestParams = {}) {
const req = createMockRequestForTest(params);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
@ -148,3 +168,59 @@ export function registerWebhookTargetsForTest(params: {
}),
);
}
export function setupWebhookTargetForTest(params: {
createCore: () => PluginRuntime;
core?: PluginRuntime;
account?: ResolvedBlueBubblesAccount;
config?: OpenClawConfig;
path?: string;
statusSink?: (event: unknown) => void;
runtime?: {
log: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
};
}) {
const account = params.account ?? createMockAccount();
const config = params.config ?? {};
const core = params.core ?? params.createCore();
const unregister = registerWebhookTargetForTest({
core,
account,
config,
path: params.path,
statusSink: params.statusSink,
runtime: params.runtime,
});
return { account, config, core, unregister };
}
export function setupWebhookTargetsForTest(params: {
createCore: () => PluginRuntime;
core?: PluginRuntime;
accounts: Array<{
account: ResolvedBlueBubblesAccount;
statusSink?: (event: unknown) => void;
}>;
config?: OpenClawConfig;
path?: string;
runtime?: {
log: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
};
}) {
const core = params.core ?? params.createCore();
const unregisterFns = registerWebhookTargetsForTest({
core,
accounts: params.accounts,
config: params.config,
path: params.path,
runtime: params.runtime,
});
const unregister = () => {
for (const unregisterFn of unregisterFns) {
unregisterFn();
}
};
return { core, unregister };
}

View File

@ -0,0 +1,143 @@
import { vi } from "vitest";
import type { BlueBubblesHistoryFetchResult } from "../../../extensions/bluebubbles/src/history.js";
import { _resetBlueBubblesShortIdState } from "../../../extensions/bluebubbles/src/monitor.js";
import type { PluginRuntime } from "../../../extensions/bluebubbles/src/runtime-api.js";
import { setBlueBubblesRuntime } from "../../../extensions/bluebubbles/src/runtime.js";
import { createPluginRuntimeMock } from "./plugin-runtime-mock.js";
export type DispatchReplyParams = Parameters<
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
>[0];
export const EMPTY_DISPATCH_RESULT = {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
} as const;
type BlueBubblesMonitorTestRuntimeMocks = {
enqueueSystemEvent: unknown;
chunkMarkdownText: unknown;
chunkByNewline: unknown;
chunkMarkdownTextWithMode: unknown;
chunkTextWithMode: unknown;
resolveChunkMode: unknown;
hasControlCommand: unknown;
dispatchReplyWithBufferedBlockDispatcher: unknown;
formatAgentEnvelope: unknown;
formatInboundEnvelope: unknown;
resolveEnvelopeFormatOptions: unknown;
resolveAgentRoute: unknown;
buildPairingReply: unknown;
readAllowFromStore: unknown;
upsertPairingRequest: unknown;
saveMediaBuffer: unknown;
resolveStorePath: unknown;
readSessionUpdatedAt: unknown;
buildMentionRegexes: unknown;
matchesMentionPatterns: unknown;
matchesMentionWithExplicit: unknown;
resolveGroupPolicy: unknown;
resolveRequireMention: unknown;
resolveCommandAuthorizedFromAuthorizers: unknown;
};
export function createBlueBubblesMonitorTestRuntime(
mocks: BlueBubblesMonitorTestRuntimeMocks,
): PluginRuntime {
return createPluginRuntimeMock({
system: {
enqueueSystemEvent: mocks.enqueueSystemEvent as PluginRuntime["system"]["enqueueSystemEvent"],
},
channel: {
text: {
chunkMarkdownText:
mocks.chunkMarkdownText as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkByNewline: mocks.chunkByNewline as PluginRuntime["channel"]["text"]["chunkByNewline"],
chunkMarkdownTextWithMode:
mocks.chunkMarkdownTextWithMode as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
chunkTextWithMode:
mocks.chunkTextWithMode as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
resolveChunkMode:
mocks.resolveChunkMode as PluginRuntime["channel"]["text"]["resolveChunkMode"],
hasControlCommand:
mocks.hasControlCommand as PluginRuntime["channel"]["text"]["hasControlCommand"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mocks.dispatchReplyWithBufferedBlockDispatcher as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
formatAgentEnvelope:
mocks.formatAgentEnvelope as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope:
mocks.formatInboundEnvelope as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
resolveEnvelopeFormatOptions:
mocks.resolveEnvelopeFormatOptions as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute:
mocks.resolveAgentRoute as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply:
mocks.buildPairingReply as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
readAllowFromStore:
mocks.readAllowFromStore as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest:
mocks.upsertPairingRequest as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
saveMediaBuffer:
mocks.saveMediaBuffer as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath:
mocks.resolveStorePath as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt:
mocks.readSessionUpdatedAt as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
},
mentions: {
buildMentionRegexes:
mocks.buildMentionRegexes as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns:
mocks.matchesMentionPatterns as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
matchesMentionWithExplicit:
mocks.matchesMentionWithExplicit as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
groups: {
resolveGroupPolicy:
mocks.resolveGroupPolicy as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention:
mocks.resolveRequireMention as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers:
mocks.resolveCommandAuthorizedFromAuthorizers as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
},
},
});
}
export function resetBlueBubblesMonitorTestState(params: {
createRuntime: () => PluginRuntime;
fetchHistoryMock: { mockResolvedValue: (value: BlueBubblesHistoryFetchResult) => unknown };
readAllowFromStoreMock: { mockResolvedValue: (value: string[]) => unknown };
upsertPairingRequestMock: {
mockResolvedValue: (value: { code: string; created: boolean }) => unknown;
};
resolveRequireMentionMock: { mockReturnValue: (value: boolean) => unknown };
hasControlCommandMock: { mockReturnValue: (value: boolean) => unknown };
resolveCommandAuthorizedFromAuthorizersMock: { mockReturnValue: (value: boolean) => unknown };
buildMentionRegexesMock: { mockReturnValue: (value: RegExp[]) => unknown };
extraReset?: () => void;
}) {
vi.clearAllMocks();
_resetBlueBubblesShortIdState();
params.extraReset?.();
params.fetchHistoryMock.mockResolvedValue({ entries: [], resolved: true });
params.readAllowFromStoreMock.mockResolvedValue([]);
params.upsertPairingRequestMock.mockResolvedValue({ code: "TESTCODE", created: true });
params.resolveRequireMentionMock.mockReturnValue(false);
params.hasControlCommandMock.mockReturnValue(false);
params.resolveCommandAuthorizedFromAuthorizersMock.mockReturnValue(false);
params.buildMentionRegexesMock.mockReturnValue([/\bbert\b/i]);
setBlueBubblesRuntime(params.createRuntime());
}