diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index f2a767d1d8a..a472a69074e 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -12,6 +12,8 @@ import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js"; import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js"; import { createMockAccount, + createMessageReactionPayloadForTest, + createNewMessagePayloadForTest, createMockRequest, createMockResponse, dispatchWebhookPayloadForTest, @@ -160,6 +162,13 @@ describe("BlueBubbles webhook monitor", () => { return (await dispatchWebhookPayloadForTest({ body: payload, url })).res; } + async function dispatchWebhookPayloadDirect(payload: unknown, url = "/bluebubbles-webhook") { + return handleBlueBubblesWebhookRequest( + createMockRequest("POST", url, payload), + createMockResponse(), + ); + } + beforeEach(() => { resetBlueBubblesMonitorTestState({ createRuntime: createMockRuntime, @@ -188,17 +197,10 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from allowed sender", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from allowed sender", + date: Date.now(), + }); const res = await dispatchWebhookPayload(payload); @@ -214,17 +216,10 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from blocked sender", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from blocked sender", + date: Date.now(), + }); const res = await dispatchWebhookPayload(payload); @@ -240,17 +235,10 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from blocked sender", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from blocked sender", + date: Date.now(), + }); const res = await dispatchWebhookPayload(payload); @@ -267,17 +255,7 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ date: Date.now() }); await dispatchWebhookPayload(payload); @@ -293,17 +271,7 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ date: Date.now() }); await dispatchWebhookPayload(payload); @@ -321,17 +289,11 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello again", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-2", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello again", + guid: "msg-2", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -349,17 +311,11 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from anyone", - handle: { address: "+15559999999" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from anyone", + handle: { address: "+15559999999" }, + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -373,17 +329,7 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ date: Date.now() }); await dispatchWebhookPayload(payload); @@ -399,18 +345,12 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from group", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -424,18 +364,12 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from group", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -450,18 +384,11 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from group", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from group", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -476,18 +403,12 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello from allowed group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello from allowed group", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -504,18 +425,12 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ groupPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "bert, can you help me?", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "bert, can you help me?", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -532,18 +447,12 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ groupPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello everyone", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello everyone", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -557,18 +466,12 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ groupPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello everyone", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello everyone", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -582,23 +485,17 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ groupPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello group", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - chatName: "Family", - participants: [ - { address: "+15551234567", displayName: "Alice" }, - { address: "+15557654321", displayName: "Bob" }, - ], - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello group", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + chatName: "Family", + participants: [ + { address: "+15551234567", displayName: "Alice" }, + { address: "+15557654321", displayName: "Bob" }, + ], + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -615,20 +512,14 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ groupPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello everyone", - handle: { address: "+15551234567" }, - senderName: "Alice", - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - chatName: "Family Chat", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "hello everyone", + senderName: "Alice", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + chatName: "Family Chat", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -653,18 +544,11 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ groupPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -680,18 +564,10 @@ describe("BlueBubbles webhook monitor", () => { it("uses sender as from label for DM messages", async () => { setupWebhookTarget(); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - senderName: "Alice", - isGroup: false, - isFromMe: false, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + senderName: "Alice", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -779,51 +655,31 @@ describe("BlueBubbles webhook monitor", () => { const messageId = "race-msg-1"; const chatGuid = "iMessage;-;+15551234567"; - const payloadA = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: messageId, - chatGuid, - date: Date.now(), - }, - }; + const payloadA = createNewMessagePayloadForTest({ + guid: messageId, + chatGuid, + date: Date.now(), + }); - const payloadB = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: messageId, - chatGuid, - attachments: [ - { - guid: "att-1", - mimeType: "image/jpeg", - totalBytes: 1024, - }, - ], - date: Date.now(), - }, - }; + const payloadB = createNewMessagePayloadForTest({ + guid: messageId, + chatGuid, + attachments: [ + { + guid: "att-1", + mimeType: "image/jpeg", + totalBytes: 1024, + }, + ], + date: Date.now(), + }); - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", payloadA), - createMockResponse(), - ); + await dispatchWebhookPayloadDirect(payloadA); // Simulate the real-world delay where the attachment-bearing webhook arrives shortly after. await vi.advanceTimersByTimeAsync(300); - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", payloadB), - createMockResponse(), - ); + await dispatchWebhookPayloadDirect(payloadB); // Not flushed yet; still within the debounce window. expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); @@ -847,23 +703,16 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - replyTo: { - guid: "msg-0", - text: "original message", - handle: { address: "+15550000000", displayName: "Alice" }, - }, - date: Date.now(), + const payload = createNewMessagePayloadForTest({ + text: "replying now", + chatGuid: "iMessage;-;+15551234567", + replyTo: { + guid: "msg-0", + text: "original message", + handle: { address: "+15550000000", displayName: "Alice" }, }, - }; + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -882,23 +731,16 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - replyTo: { - guid: "p:1/msg-0", - text: "original message", - handle: { address: "+15550000000", displayName: "Alice" }, - }, - date: Date.now(), + const payload = createNewMessagePayloadForTest({ + text: "replying now", + chatGuid: "iMessage;-;+15551234567", + replyTo: { + guid: "p:1/msg-0", + text: "original message", + handle: { address: "+15550000000", displayName: "Alice" }, }, - }; + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -916,38 +758,29 @@ describe("BlueBubbles webhook monitor", () => { const chatGuid = "iMessage;+;chat-reply-cache"; - const originalPayload = { - type: "new-message", - data: { - text: "original message (cached)", - handle: { address: "+15550000000" }, - isGroup: true, - isFromMe: false, - guid: "cache-msg-0", - chatGuid, - date: Date.now(), - }, - }; + const originalPayload = createNewMessagePayloadForTest({ + text: "original message (cached)", + handle: { address: "+15550000000" }, + isGroup: true, + guid: "cache-msg-0", + chatGuid, + date: Date.now(), + }); await dispatchWebhookPayload(originalPayload); // Only assert the reply message behavior below. mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - const replyPayload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "cache-msg-1", - chatGuid, - // Only the GUID is provided; sender/body must be hydrated. - replyToMessageGuid: "cache-msg-0", - date: Date.now(), - }, - }; + const replyPayload = createNewMessagePayloadForTest({ + text: "replying now", + isGroup: true, + guid: "cache-msg-1", + chatGuid, + // Only the GUID is provided; sender/body must be hydrated. + replyToMessageGuid: "cache-msg-0", + date: Date.now(), + }); await dispatchWebhookPayload(replyPayload); @@ -967,19 +800,12 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - threadOriginatorGuid: "msg-0", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "replying now", + threadOriginatorGuid: "msg-0", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -995,18 +821,11 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "Loved this idea", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "Loved this idea", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1022,18 +841,12 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: 'Reacted 😅 to "nice one"', - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-2", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: 'Reacted 😅 to "nice one"', + guid: "msg-2", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1060,18 +873,10 @@ describe("BlueBubbles webhook monitor", () => { }, }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1100,18 +905,12 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "/status", - handle: { address: "+15551234567" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "/status", + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1130,18 +929,13 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "/status", - handle: { address: "+15559999999" }, - isGroup: true, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;+;chat123456", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "/status", + handle: { address: "+15559999999" }, + isGroup: true, + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1158,17 +952,12 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "/status", - handle: { address: "+15559999999" }, - isGroup: false, - isFromMe: false, - guid: "msg-dm-open-unauthorized", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "/status", + handle: { address: "+15559999999" }, + guid: "msg-dm-open-unauthorized", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1192,18 +981,10 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1220,18 +1001,10 @@ describe("BlueBubbles webhook monitor", () => { }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1244,18 +1017,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.onReplyStart?.(); @@ -1278,18 +1043,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.onReplyStart?.(); @@ -1313,18 +1070,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( async () => EMPTY_DISPATCH_RESULT, @@ -1351,18 +1100,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1388,36 +1129,24 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const inboundPayload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(inboundPayload); // Send response did not include a message id, so nothing should be enqueued yet. expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - const fromMePayload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15557654321" }, - isGroup: false, - isFromMe: true, - guid: "msg-out-456", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "replying now", + handle: { address: "+15557654321" }, + isFromMe: true, + guid: "msg-out-456", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(fromMePayload); @@ -1442,35 +1171,23 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const inboundPayload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(inboundPayload); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); - const fromMePayload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15557654321" }, - isGroup: false, - isFromMe: true, - guid: "msg-out-789", - chatIdentifier: "+15551234567", - date: Date.now(), - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "replying now", + handle: { address: "+15557654321" }, + isFromMe: true, + guid: "msg-out-789", + chatIdentifier: "+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(fromMePayload); @@ -1491,17 +1208,9 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "pairing", allowFrom: [] }), }); - const payload = { - type: "message-reaction", - data: { - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - associatedMessageGuid: "msg-original-123", - associatedMessageType: 2000, - date: Date.now(), - }, - }; + const payload = createMessageReactionPayloadForTest({ + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1513,17 +1222,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "message-reaction", - data: { - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - associatedMessageGuid: "msg-original-123", - associatedMessageType: 2000, // Heart reaction added - date: Date.now(), - }, - }; + const payload = createMessageReactionPayloadForTest({ + associatedMessageType: 2000, // Heart reaction added + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1538,17 +1240,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "message-reaction", - data: { - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - associatedMessageGuid: "msg-original-123", - associatedMessageType: 3000, // Heart reaction removed - date: Date.now(), - }, - }; + const payload = createMessageReactionPayloadForTest({ + associatedMessageType: 3000, // Heart reaction removed + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1563,17 +1258,10 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); - const payload = { - type: "message-reaction", - data: { - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: true, // From self - associatedMessageGuid: "msg-original-123", - associatedMessageType: 2000, - date: Date.now(), - }, - }; + const payload = createMessageReactionPayloadForTest({ + isFromMe: true, // From self + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1586,17 +1274,11 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); // Test thumbs up reaction (2001) - const payload = { - type: "message-reaction", - data: { - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - associatedMessageGuid: "msg-123", - associatedMessageType: 2001, // Thumbs up - date: Date.now(), - }, - }; + const payload = createMessageReactionPayloadForTest({ + associatedMessageGuid: "msg-123", + associatedMessageType: 2001, // Thumbs up + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1613,18 +1295,11 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "p:1/msg-uuid-12345", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + guid: "p:1/msg-uuid-12345", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1640,18 +1315,11 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const payload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "p:1/msg-uuid-12345", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + guid: "p:1/msg-uuid-12345", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1712,39 +1380,25 @@ describe("BlueBubbles webhook monitor", () => { }); unregister = registration.unregister; - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook?password=password-a", { - type: "new-message", - data: { - text: "message for account a", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "a-msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, + await dispatchWebhookPayload( + createNewMessagePayloadForTest({ + text: "message for account a", + guid: "a-msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), }), - createMockResponse(), + "/bluebubbles-webhook?password=password-a", ); - await flushAsync(); - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook?password=password-b", { - type: "new-message", - data: { - text: "message for account b", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "b-msg-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, + await dispatchWebhookPayload( + createNewMessagePayloadForTest({ + text: "message for account b", + guid: "b-msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), }), - createMockResponse(), + "/bluebubbles-webhook?password=password-b", ); - await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2); const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; @@ -1769,18 +1423,13 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmHistoryLimit: 2 }), }); - await dispatchWebhookPayload({ - type: "new-message", - data: { + await dispatchWebhookPayload( + createNewMessagePayloadForTest({ text: "current text", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", chatGuid: "iMessage;-;+15550002002", date: Date.now(), - }, - }); + }), + ); const callArgs = getFirstDispatchCall(); const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; @@ -1803,43 +1452,26 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmHistoryLimit: 4 }), }); - const mkPayload = (guid: string, text: string, now: number) => ({ - type: "new-message", - data: { + const mkPayload = (guid: string, text: string, now: number) => + createNewMessagePayloadForTest({ text, - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, guid, chatGuid: "iMessage;-;+15550003003", date: now, - }, - }); + }); let now = 1_700_000_000_000; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); try { - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-1", "first text", now)), - createMockResponse(), - ); - await flushAsync(); + await dispatchWebhookPayload(mkPayload("msg-1", "first text", now)); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); now += 1_000; - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-2", "second text", now)), - createMockResponse(), - ); - await flushAsync(); + await dispatchWebhookPayload(mkPayload("msg-2", "second text", now)); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); now += 6_000; - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-3", "third text", now)), - createMockResponse(), - ); - await flushAsync(); + await dispatchWebhookPayload(mkPayload("msg-3", "third text", now)); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0]; @@ -1848,11 +1480,7 @@ describe("BlueBubbles webhook monitor", () => { expect(thirdHistory.map((entry) => entry.body)).toContain("third text"); now += 10_000; - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-4", "fourth text", now)), - createMockResponse(), - ); - await flushAsync(); + await dispatchWebhookPayload(mkPayload("msg-4", "fourth text", now)); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); } finally { nowSpy.mockRestore(); @@ -1875,22 +1503,14 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmHistoryLimit: 20 }), }); - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", { - type: "new-message", - data: { - text: "latest text", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-bomb-1", - chatGuid: "iMessage;-;+15550004004", - date: Date.now(), - }, + await dispatchWebhookPayload( + createNewMessagePayloadForTest({ + text: "latest text", + guid: "msg-bomb-1", + chatGuid: "iMessage;-;+15550004004", + date: Date.now(), }), - createMockResponse(), ); - await flushAsync(); const callArgs = getFirstDispatchCall(); const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; @@ -1905,17 +1525,11 @@ describe("BlueBubbles webhook monitor", () => { it("ignores messages from self (fromMe=true)", async () => { setupWebhookTarget(); - const payload = { - type: "new-message", - data: { - text: "my own message", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: true, - guid: "msg-1", - date: Date.now(), - }, - }; + const payload = createNewMessagePayloadForTest({ + text: "my own message", + isFromMe: true, + date: Date.now(), + }); await dispatchWebhookPayload(payload); @@ -1936,51 +1550,33 @@ describe("BlueBubbles webhook monitor", () => { }); const timestamp = Date.now(); - const inboundPayload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-self-0", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + guid: "msg-self-0", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(inboundPayload); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - const fromMePayload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: true, - guid: "msg-self-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "replying now", + isFromMe: true, + guid: "msg-self-1", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(fromMePayload); - const reflectedPayload = { - type: "new-message", - data: { - text: "replying now", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-self-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const reflectedPayload = createNewMessagePayloadForTest({ + text: "replying now", + guid: "msg-self-2", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(reflectedPayload); @@ -1992,18 +1588,12 @@ describe("BlueBubbles webhook monitor", () => { account: createMockAccount({ dmPolicy: "open" }), }); - const inboundPayload = { - type: "new-message", - data: { - text: "genuinely new message", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-inbound-1", - chatGuid: "iMessage;-;+15551234567", - date: Date.now(), - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + text: "genuinely new message", + guid: "msg-inbound-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }); await dispatchWebhookPayload(inboundPayload); @@ -2019,45 +1609,28 @@ describe("BlueBubbles webhook monitor", () => { }); const timestamp = Date.now(); - const fromMePayload = { - type: "new-message", - data: { - text: "ttl me", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: true, - guid: "msg-self-ttl-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "ttl me", + isFromMe: true, + guid: "msg-self-ttl-1", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), - createMockResponse(), - ); + await dispatchWebhookPayloadDirect(fromMePayload); await vi.runAllTimersAsync(); mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); vi.advanceTimersByTime(10_001); - const reflectedPayload = { - type: "new-message", - data: { - text: "ttl me", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-self-ttl-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const reflectedPayload = createNewMessagePayloadForTest({ + text: "ttl me", + guid: "msg-self-ttl-2", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); - await handleBlueBubblesWebhookRequest( - createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload), - createMockResponse(), - ); + await dispatchWebhookPayloadDirect(reflectedPayload); await vi.runAllTimersAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); @@ -2069,35 +1642,25 @@ describe("BlueBubbles webhook monitor", () => { }); const timestamp = Date.now(); - const fromMePayload = { - type: "new-message", - data: { - text: "shared text", - handle: { address: "+15557654321" }, - isGroup: false, - isFromMe: true, - guid: "msg-normal-fromme", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "shared text", + handle: { address: "+15557654321" }, + isFromMe: true, + guid: "msg-normal-fromme", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(fromMePayload); mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - const inboundPayload = { - type: "new-message", - data: { - text: "shared text", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-normal-inbound", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + text: "shared text", + guid: "msg-normal-inbound", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(inboundPayload); @@ -2110,35 +1673,24 @@ describe("BlueBubbles webhook monitor", () => { }); const timestamp = Date.now(); - const fromMePayload = { - type: "new-message", - data: { - text: "user-authored self prompt", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: true, - guid: "msg-self-user-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "user-authored self prompt", + isFromMe: true, + guid: "msg-self-user-1", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(fromMePayload); mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - const reflectedPayload = { - type: "new-message", - data: { - text: "user-authored self prompt", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-self-user-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const reflectedPayload = createNewMessagePayloadForTest({ + text: "user-authored self prompt", + guid: "msg-self-user-2", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(reflectedPayload); @@ -2159,51 +1711,33 @@ describe("BlueBubbles webhook monitor", () => { }); const timestamp = Date.now(); - const inboundPayload = { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-self-race-0", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + guid: "msg-self-race-0", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(inboundPayload); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - const fromMePayload = { - type: "new-message", - data: { - text: "same text", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: true, - guid: "msg-self-race-1", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "same text", + isFromMe: true, + guid: "msg-self-race-1", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(fromMePayload); - const reflectedPayload = { - type: "new-message", - data: { - text: "same text", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-self-race-2", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const reflectedPayload = createNewMessagePayloadForTest({ + text: "same text", + guid: "msg-self-race-2", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(reflectedPayload); @@ -2216,35 +1750,25 @@ describe("BlueBubbles webhook monitor", () => { }); const timestamp = Date.now(); - const fromMePayload = { - type: "new-message", - data: { - text: "shared inferred text", - handle: null, - isGroup: false, - isFromMe: true, - guid: "msg-inferred-fromme", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const fromMePayload = createNewMessagePayloadForTest({ + text: "shared inferred text", + handle: null, + isFromMe: true, + guid: "msg-inferred-fromme", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(fromMePayload); mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); - const inboundPayload = { - type: "new-message", - data: { - text: "shared inferred text", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-inferred-inbound", - chatGuid: "iMessage;-;+15551234567", - date: timestamp, - }, - }; + const inboundPayload = createNewMessagePayloadForTest({ + text: "shared inferred text", + guid: "msg-inferred-inbound", + chatGuid: "iMessage;-;+15551234567", + date: timestamp, + }); await dispatchWebhookPayload(inboundPayload); diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 155b53c9614..0dc080b132a 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -1,4 +1,3 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBlueBubblesMonitorTestRuntime, @@ -10,14 +9,20 @@ import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js"; import { + LOOPBACK_REMOTE_ADDRESSES_FOR_TEST, createMockAccount, createHangingWebhookRequestForTest, - createMockRequestForTest, createMockResponse, + createLoopbackWebhookRequestParamsForTest, + createNewMessagePayloadForTest, + createPasswordQueryRequestParamsForTest, + createProtectedWebhookAccountForTest, + createRemoteWebhookRequestParamsForTest, dispatchWebhookPayloadForTest, + expectWebhookRequestStatusForTest, + expectWebhookStatusForTest, setupWebhookTargetForTest, setupWebhookTargetsForTest, - type WebhookRequestParams, } from "./monitor.webhook.test-helpers.js"; import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js"; @@ -100,8 +105,6 @@ 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 { @@ -174,59 +177,12 @@ describe("BlueBubbles webhook monitor", () => { }; } - function createNewMessagePayload(dataOverrides: Record = {}) { - return { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - ...dataOverrides, - }, - }; - } - - function createProtectedWebhookAccount(password = TEST_WEBHOOK_PASSWORD) { - return createMockAccount({ password }); - } - function setupProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) { - const account = createProtectedWebhookAccount(password); + const account = createProtectedWebhookAccountForTest(password); setupWebhookTarget({ account }); return account; } - function createRemoteWebhookRequestParams(overrides: WebhookRequestParams = {}) { - return { - body: createNewMessagePayload(), - remoteAddress: TEST_REMOTE_ADDRESS, - ...overrides, - }; - } - - function createPasswordQueryRequestParams( - password = TEST_WEBHOOK_PASSWORD, - overrides: Omit = {}, - ) { - return createRemoteWebhookRequestParams({ - url: `/bluebubbles-webhook?password=${password}`, - ...overrides, - }); - } - - function createLoopbackWebhookRequestParams( - remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES)[number], - overrides: Omit = {}, - ) { - return { - body: createNewMessagePayload(), - remoteAddress, - ...overrides, - }; - } - function registerWebhookTargets( params: Array<{ account: ResolvedBlueBubblesAccount; @@ -240,53 +196,30 @@ describe("BlueBubbles webhook monitor", () => { unregister = registration.unregister; } - async function expectWebhookStatus( - req: IncomingMessage, - expectedStatus: number, - expectedBody?: string, - ) { - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(expectedStatus); - if (expectedBody !== undefined) { - expect(res.body).toBe(expectedBody); - } - 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(); - await expectWebhookRequestStatus({ method: "GET" }, 405); + await expectWebhookRequestStatusForTest({ method: "GET" }, 405); }); it("accepts POST requests with valid JSON payload", async () => { setupWebhookTarget(); - const payload = createNewMessagePayload({ date: Date.now() }); - await expectWebhookRequestStatus({ body: payload }, 200, "ok"); + const payload = createNewMessagePayloadForTest({ date: Date.now() }); + await expectWebhookRequestStatusForTest({ body: payload }, 200, "ok"); }); it("rejects requests with invalid JSON", async () => { setupWebhookTarget(); - await expectWebhookRequestStatus({ body: "invalid json {{" }, 400); + await expectWebhookRequestStatusForTest({ body: "invalid json {{" }, 400); }); it("accepts URL-encoded payload wrappers", async () => { setupWebhookTarget(); - const payload = createNewMessagePayload({ date: Date.now() }); + const payload = createNewMessagePayloadForTest({ date: Date.now() }); const encodedBody = new URLSearchParams({ payload: JSON.stringify(payload), }).toString(); - await expectWebhookRequestStatus({ body: encodedBody }, 200, "ok"); + await expectWebhookRequestStatusForTest({ body: encodedBody }, 200, "ok"); }); it("returns 408 when request body times out (Slow-Loris protection)", async () => { @@ -319,20 +252,29 @@ describe("BlueBubbles webhook monitor", () => { "/bluebubbles-webhook?password=wrong-token", ); const onSpy = vi.spyOn(req, "on"); - await expectWebhookStatus(req, 401); + await expectWebhookStatusForTest(req, 401); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); }); it("authenticates via password query parameter", async () => { setupProtectedWebhookTarget(); - await expectWebhookRequestStatus(createPasswordQueryRequestParams(), 200); + await expectWebhookRequestStatusForTest( + createPasswordQueryRequestParamsForTest({ + body: createNewMessagePayloadForTest(), + password: TEST_WEBHOOK_PASSWORD, + }), + 200, + ); }); it("authenticates via x-password header", async () => { setupProtectedWebhookTarget(); - await expectWebhookRequestStatus( - createRemoteWebhookRequestParams({ - headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret + await expectWebhookRequestStatusForTest( + createRemoteWebhookRequestParamsForTest({ + body: createNewMessagePayloadForTest(), + overrides: { + headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret + }, }), 200, ); @@ -340,12 +282,18 @@ describe("BlueBubbles webhook monitor", () => { it("rejects unauthorized requests with wrong password", async () => { setupProtectedWebhookTarget(); - await expectWebhookRequestStatus(createPasswordQueryRequestParams("wrong-token"), 401); + await expectWebhookRequestStatusForTest( + createPasswordQueryRequestParamsForTest({ + body: createNewMessagePayloadForTest(), + password: "wrong-token", + }), + 401, + ); }); it("rejects ambiguous routing when multiple targets match the same password", async () => { - const accountA = createProtectedWebhookAccount(); - const accountB = createProtectedWebhookAccount(); + const accountA = createProtectedWebhookAccountForTest(TEST_WEBHOOK_PASSWORD); + const accountB = createProtectedWebhookAccountForTest(TEST_WEBHOOK_PASSWORD); const sinkA = vi.fn(); const sinkB = vi.fn(); registerWebhookTargets([ @@ -353,13 +301,19 @@ describe("BlueBubbles webhook monitor", () => { { account: accountB, statusSink: sinkB }, ]); - await expectWebhookRequestStatus(createPasswordQueryRequestParams(), 401); + await expectWebhookRequestStatusForTest( + createPasswordQueryRequestParamsForTest({ + body: createNewMessagePayloadForTest(), + password: TEST_WEBHOOK_PASSWORD, + }), + 401, + ); expect(sinkA).not.toHaveBeenCalled(); expect(sinkB).not.toHaveBeenCalled(); }); it("ignores targets without passwords when a password-authenticated target matches", async () => { - const accountStrict = createProtectedWebhookAccount(); + const accountStrict = createProtectedWebhookAccountForTest(TEST_WEBHOOK_PASSWORD); const accountWithoutPassword = createMockAccount({ password: undefined }); const sinkStrict = vi.fn(); const sinkWithoutPassword = vi.fn(); @@ -368,15 +322,26 @@ describe("BlueBubbles webhook monitor", () => { { account: accountWithoutPassword, statusSink: sinkWithoutPassword }, ]); - await expectWebhookRequestStatus(createPasswordQueryRequestParams(), 200); + await expectWebhookRequestStatusForTest( + createPasswordQueryRequestParamsForTest({ + body: createNewMessagePayloadForTest(), + password: TEST_WEBHOOK_PASSWORD, + }), + 200, + ); expect(sinkStrict).toHaveBeenCalledTimes(1); expect(sinkWithoutPassword).not.toHaveBeenCalled(); }); it("requires authentication for loopback requests when password is configured", async () => { setupProtectedWebhookTarget(); - for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES) { - await expectWebhookRequestStatus(createLoopbackWebhookRequestParams(remoteAddress), 401); + for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES_FOR_TEST) { + await expectWebhookRequestStatusForTest( + createLoopbackWebhookRequestParamsForTest(remoteAddress, { + body: createNewMessagePayloadForTest(), + }), + 401, + ); } }); @@ -390,8 +355,11 @@ describe("BlueBubbles webhook monitor", () => { { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, ]; for (const headers of headerVariants) { - await expectWebhookRequestStatus( - createLoopbackWebhookRequestParams("127.0.0.1", { headers }), + await expectWebhookRequestStatusForTest( + createLoopbackWebhookRequestParamsForTest("127.0.0.1", { + body: createNewMessagePayloadForTest(), + overrides: { headers }, + }), 401, ); } @@ -410,7 +378,7 @@ describe("BlueBubbles webhook monitor", () => { vi.mocked(resolveChatGuidForTarget).mockClear(); setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) }); - const payload = createNewMessagePayload({ + const payload = createNewMessagePayloadForTest({ text: "hello from group", isGroup: true, chatId: "123", @@ -437,7 +405,7 @@ describe("BlueBubbles webhook monitor", () => { }); setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) }); - const payload = createNewMessagePayload({ + const payload = createNewMessagePayloadForTest({ text: "hello from group", isGroup: true, chat: { chatGuid: "iMessage;+;chat123456" }, diff --git a/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts b/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts index 74159839b0d..a81a638da86 100644 --- a/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts +++ b/extensions/bluebubbles/src/monitor.webhook.test-helpers.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { vi } from "vitest"; +import { expect, vi } from "vitest"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { handleBlueBubblesWebhookRequest } from "./monitor.js"; import { registerBlueBubblesWebhookTarget } from "./monitor.js"; @@ -15,6 +15,8 @@ export type WebhookRequestParams = { remoteAddress?: string; }; +export const LOOPBACK_REMOTE_ADDRESSES_FOR_TEST = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const; + export function createMockAccount( overrides: Partial = {}, ): ResolvedBlueBubblesAccount { @@ -34,6 +36,38 @@ export function createMockAccount( }; } +export function createProtectedWebhookAccountForTest(password = "test-password") { + return createMockAccount({ password }); +} + +export function createNewMessagePayloadForTest(dataOverrides: Record = {}) { + return { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + ...dataOverrides, + }, + }; +} + +export function createMessageReactionPayloadForTest(dataOverrides: Record = {}) { + return { + type: "message-reaction", + data: { + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + associatedMessageGuid: "msg-original-123", + associatedMessageType: 2000, + ...dataOverrides, + }, + }; +} + export function createMockRequest( method: string, url: string, @@ -81,6 +115,52 @@ export function createMockRequestForTest(params: WebhookRequestParams = {}): Inc ); } +export function createRemoteWebhookRequestParamsForTest( + params: { + body?: unknown; + remoteAddress?: string; + overrides?: WebhookRequestParams; + } = {}, +): WebhookRequestParams { + return { + body: params.body ?? {}, + remoteAddress: params.remoteAddress ?? "192.168.1.100", + ...params.overrides, + }; +} + +export function createPasswordQueryRequestParamsForTest( + params: { + body?: unknown; + password?: string; + remoteAddress?: string; + overrides?: Omit; + } = {}, +): WebhookRequestParams { + return createRemoteWebhookRequestParamsForTest({ + body: params.body, + remoteAddress: params.remoteAddress, + overrides: { + url: `/bluebubbles-webhook?password=${params.password ?? "test-password"}`, + ...params.overrides, + }, + }); +} + +export function createLoopbackWebhookRequestParamsForTest( + remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number], + params: { + body?: unknown; + overrides?: Omit; + } = {}, +): WebhookRequestParams { + return { + body: params.body ?? {}, + remoteAddress, + ...params.overrides, + }; +} + export function createHangingWebhookRequestForTest( url = "/bluebubbles-webhook?password=test-password", remoteAddress = "127.0.0.1", @@ -121,6 +201,29 @@ export async function dispatchWebhookPayloadForTest(params: WebhookRequestParams return { handled, res }; } +export async function expectWebhookStatusForTest( + req: IncomingMessage, + expectedStatus: number, + expectedBody?: string, +) { + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); + expect(res.statusCode).toBe(expectedStatus); + if (expectedBody !== undefined) { + expect(res.body).toBe(expectedBody); + } + return res; +} + +export async function expectWebhookRequestStatusForTest( + params: WebhookRequestParams, + expectedStatus: number, + expectedBody?: string, +) { + return expectWebhookStatusForTest(createMockRequestForTest(params), expectedStatus, expectedBody); +} + export function registerWebhookTargetForTest(params: { core: PluginRuntime; account?: ResolvedBlueBubblesAccount;