mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 17:02:46 +00:00
492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
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<typeof import("./reactions.js")>("./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<WebhookRequestParams, "remoteAddress">,
|
|
) {
|
|
return expectWebhookRequestStatusForTest(
|
|
createLoopbackWebhookRequestParamsForTest(remoteAddress, { overrides }),
|
|
expectedStatus,
|
|
);
|
|
}
|
|
|
|
async function expectProtectedLoopbackWebhookRequestStatus(
|
|
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
|
|
expectedStatus: number,
|
|
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
|
|
) {
|
|
setupProtectedWebhookTarget();
|
|
return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides);
|
|
}
|
|
|
|
async function expectPasswordlessLoopbackWebhookRequestStatus(
|
|
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
|
|
expectedStatus: number,
|
|
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
|
|
) {
|
|
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<string, string>[] = [
|
|
{ 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),
|
|
);
|
|
});
|
|
});
|
|
});
|