test: reduce webhook auth test duplication

This commit is contained in:
Peter Steinberger 2026-03-13 21:35:29 +00:00
parent de9ea76b6c
commit d5d2fe1b0e
1 changed files with 128 additions and 280 deletions

View File

@ -302,65 +302,101 @@ describe("BlueBubbles webhook monitor", () => {
}; };
} }
describe("webhook parsing + auth handling", () => { async function dispatchWebhook(req: IncomingMessage) {
it("rejects non-POST requests", async () => { const res = createMockResponse();
const account = createMockAccount(); const handled = await handleBlueBubblesWebhookRequest(req, res);
const config: OpenClawConfig = {}; return { handled, res };
const core = createMockRuntime(); }
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({ 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,
);
if (params?.remoteAddress) {
setRequestRemoteAddress(req, params.remoteAddress);
}
return req;
}
function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
const req = new EventEmitter() as IncomingMessage & { destroy: ReturnType<typeof vi.fn> };
req.method = "POST";
req.url = url;
req.headers = {};
req.destroy = vi.fn();
setRequestRemoteAddress(req, "127.0.0.1");
return req;
}
function registerWebhookTargets(
params: Array<{
account: ResolvedBlueBubblesAccount;
statusSink?: (event: unknown) => void;
}>,
) {
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const unregisterFns = params.map(({ account, statusSink }) =>
registerBlueBubblesWebhookTarget({
account, account,
config, config,
runtime: { log: vi.fn(), error: vi.fn() }, runtime: { log: vi.fn(), error: vi.fn() },
core, core,
path: "/bluebubbles-webhook", path: "/bluebubbles-webhook",
}); statusSink,
}),
);
const req = createMockRequest("GET", "/bluebubbles-webhook", {}); unregister = () => {
const res = createMockResponse(); for (const unregisterFn of unregisterFns) {
unregisterFn();
}
};
}
const handled = await handleBlueBubblesWebhookRequest(req, res); async function expectWebhookStatus(
req: IncomingMessage,
expectedStatus: number,
expectedBody?: string,
) {
const { handled, res } = await dispatchWebhook(req);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatus);
if (expectedBody !== undefined) {
expect(res.body).toBe(expectedBody);
}
return res;
}
expect(handled).toBe(true); describe("webhook parsing + auth handling", () => {
expect(res.statusCode).toBe(405); it("rejects non-POST requests", async () => {
setupWebhookTarget();
const req = createWebhookRequestForTest({ method: "GET" });
await expectWebhookStatus(req, 405);
}); });
it("accepts POST requests with valid JSON payload", async () => { it("accepts POST requests with valid JSON payload", async () => {
setupWebhookTarget(); setupWebhookTarget();
const payload = createNewMessagePayload({ date: Date.now() }); const payload = createNewMessagePayload({ date: Date.now() });
const req = createWebhookRequestForTest({ body: payload });
const req = createMockRequest("POST", "/bluebubbles-webhook", payload); await expectWebhookStatus(req, 200, "ok");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
}); });
it("rejects requests with invalid JSON", async () => { it("rejects requests with invalid JSON", async () => {
const account = createMockAccount(); setupWebhookTarget();
const config: OpenClawConfig = {}; const req = createWebhookRequestForTest({ body: "invalid json {{" });
const core = createMockRuntime(); await expectWebhookStatus(req, 400);
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
}); });
it("accepts URL-encoded payload wrappers", async () => { it("accepts URL-encoded payload wrappers", async () => {
@ -369,42 +405,17 @@ describe("BlueBubbles webhook monitor", () => {
const encodedBody = new URLSearchParams({ const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload), payload: JSON.stringify(payload),
}).toString(); }).toString();
const req = createWebhookRequestForTest({ body: encodedBody });
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); await expectWebhookStatus(req, 200, "ok");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
}); });
it("returns 408 when request body times out (Slow-Loris protection)", async () => { it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const account = createMockAccount(); setupWebhookTarget();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
// Create a request that never sends data or ends (simulates slow-loris) // Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage; const req = createHangingWebhookRequest();
req.method = "POST";
req.url = "/bluebubbles-webhook?password=test-password";
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
req.destroy = vi.fn();
const res = createMockResponse(); const res = createMockResponse();
@ -424,140 +435,62 @@ describe("BlueBubbles webhook monitor", () => {
it("rejects unauthorized requests before reading the body", async () => { it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" }); const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {}; setupWebhookTarget({ account });
const core = createMockRuntime(); const req = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=wrong-token";
req.headers = {};
const onSpy = vi.spyOn(req, "on"); const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = { await expectWebhookStatus(req, 401);
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
}); });
it("authenticates via password query parameter", async () => { it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" }); const account = createMockAccount({ password: "secret-token" });
// Mock non-localhost request
const req = createMockRequest(
"POST",
"/bluebubbles-webhook?password=secret-token",
createNewMessagePayload(),
);
setRequestRemoteAddress(req, "192.168.1.100");
setupWebhookTarget({ account }); setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
const res = createMockResponse(); url: "/bluebubbles-webhook?password=secret-token",
const handled = await handleBlueBubblesWebhookRequest(req, res); body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
expect(handled).toBe(true); });
expect(res.statusCode).toBe(200); await expectWebhookStatus(req, 200);
}); });
it("authenticates via x-password header", async () => { it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" }); const account = createMockAccount({ password: "secret-token" });
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
createNewMessagePayload(),
{ "x-password": "secret-token" }, // pragma: allowlist secret
);
setRequestRemoteAddress(req, "192.168.1.100");
setupWebhookTarget({ account }); setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
const res = createMockResponse(); body: createNewMessagePayload(),
const handled = await handleBlueBubblesWebhookRequest(req, res); headers: { "x-password": "secret-token" }, // pragma: allowlist secret
remoteAddress: "192.168.1.100",
expect(handled).toBe(true); });
expect(res.statusCode).toBe(200); await expectWebhookStatus(req, 200);
}); });
it("rejects unauthorized requests with wrong password", async () => { it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" }); const account = createMockAccount({ password: "secret-token" });
const req = createMockRequest(
"POST",
"/bluebubbles-webhook?password=wrong-token",
createNewMessagePayload(),
);
setRequestRemoteAddress(req, "192.168.1.100");
setupWebhookTarget({ account }); setupWebhookTarget({ account });
const req = createWebhookRequestForTest({
const res = createMockResponse(); url: "/bluebubbles-webhook?password=wrong-token",
const handled = await handleBlueBubblesWebhookRequest(req, res); body: createNewMessagePayload(),
remoteAddress: "192.168.1.100",
expect(handled).toBe(true); });
expect(res.statusCode).toBe(401); await expectWebhookStatus(req, 401);
}); });
it("rejects ambiguous routing when multiple targets match the same password", async () => { it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" }); const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" }); const accountB = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkA = vi.fn(); const sinkA = vi.fn();
const sinkB = vi.fn(); const sinkB = vi.fn();
registerWebhookTargets([
{ account: accountA, statusSink: sinkA },
{ account: accountB, statusSink: sinkB },
]);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { const req = createWebhookRequestForTest({
type: "new-message", url: "/bluebubbles-webhook?password=secret-token",
data: { body: createNewMessagePayload(),
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100", remoteAddress: "192.168.1.100",
};
const unregisterA = registerBlueBubblesWebhookTarget({
account: accountA,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkA,
}); });
const unregisterB = registerBlueBubblesWebhookTarget({ await expectWebhookStatus(req, 401);
account: accountB,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkB,
});
unregister = () => {
unregisterA();
unregisterB();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(sinkA).not.toHaveBeenCalled(); expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).not.toHaveBeenCalled(); expect(sinkB).not.toHaveBeenCalled();
}); });
@ -565,107 +498,38 @@ describe("BlueBubbles webhook monitor", () => {
it("ignores targets without passwords when a password-authenticated target matches", async () => { it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" }); const accountStrict = createMockAccount({ password: "secret-token" });
const accountWithoutPassword = createMockAccount({ password: undefined }); const accountWithoutPassword = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkStrict = vi.fn(); const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn(); const sinkWithoutPassword = vi.fn();
registerWebhookTargets([
{ account: accountStrict, statusSink: sinkStrict },
{ account: accountWithoutPassword, statusSink: sinkWithoutPassword },
]);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { const req = createWebhookRequestForTest({
type: "new-message", url: "/bluebubbles-webhook?password=secret-token",
data: { body: createNewMessagePayload(),
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100", remoteAddress: "192.168.1.100",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
account: accountStrict,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkStrict,
}); });
const unregisterNoPassword = registerBlueBubblesWebhookTarget({ await expectWebhookStatus(req, 200);
account: accountWithoutPassword,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
statusSink: sinkWithoutPassword,
});
unregister = () => {
unregisterStrict();
unregisterNoPassword();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkStrict).toHaveBeenCalledTimes(1); expect(sinkStrict).toHaveBeenCalledTimes(1);
expect(sinkWithoutPassword).not.toHaveBeenCalled(); expect(sinkWithoutPassword).not.toHaveBeenCalled();
}); });
it("requires authentication for loopback requests when password is configured", async () => { it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" }); const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {}; setupWebhookTarget({ account });
const core = createMockRuntime();
setBlueBubblesRuntime(core);
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createMockRequest("POST", "/bluebubbles-webhook", { const req = createWebhookRequestForTest({
type: "new-message", body: createNewMessagePayload(),
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress, remoteAddress,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
}); });
await expectWebhookStatus(req, 401);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
loopbackUnregister();
} }
}); });
it("rejects targets without passwords for loopback and proxied-looking requests", async () => { it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
const account = createMockAccount({ password: undefined }); const account = createMockAccount({ password: undefined });
const config: OpenClawConfig = {}; setupWebhookTarget({ account });
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const headerVariants: Record<string, string>[] = [ const headerVariants: Record<string, string>[] = [
{ host: "localhost" }, { host: "localhost" },
@ -673,28 +537,12 @@ describe("BlueBubbles webhook monitor", () => {
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
]; ];
for (const headers of headerVariants) { for (const headers of headerVariants) {
const req = createMockRequest( const req = createWebhookRequestForTest({
"POST", body: createNewMessagePayload(),
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
headers, headers,
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1", remoteAddress: "127.0.0.1",
}; });
const res = createMockResponse(); await expectWebhookStatus(req, 401);
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
} }
}); });