From 639d0221ff584896f788360a28199a76d21784e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 05:31:13 +0000 Subject: [PATCH] test: dedupe line and whatsapp target resolution tests --- src/line/webhook.test.ts | 148 ++++------ src/whatsapp/resolve-outbound-target.test.ts | 288 ++++++++----------- 2 files changed, 182 insertions(+), 254 deletions(-) diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index 3c19ee587aa..513a0874e4c 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import type { WebhookRequestBody } from "@line/bot-sdk"; import { describe, expect, it, vi } from "vitest"; import { createLineWebhookMiddleware } from "./webhook.js"; @@ -17,126 +18,97 @@ const createRes = () => { return res; }; -describe("createLineWebhookMiddleware", () => { - it("parses JSON from raw string body", async () => { - const onEvents = vi.fn(async () => {}); - const secret = "secret"; - const rawBody = JSON.stringify({ events: [{ type: "message" }] }); - const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); +const SECRET = "secret"; - const req = { - headers: { "x-line-signature": sign(rawBody, secret) }, - body: rawBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - - expect(res.status).toHaveBeenCalledWith(200); - expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) })); +async function invokeWebhook(params: { + body: unknown; + headers?: Record; + onEvents?: ReturnType; + autoSign?: boolean; +}) { + const onEventsMock = params.onEvents ?? vi.fn(async () => {}); + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents: onEventsMock as unknown as (body: WebhookRequestBody) => Promise, }); - it("parses JSON from raw buffer body", async () => { - const onEvents = vi.fn(async () => {}); - const secret = "secret"; - const rawBody = JSON.stringify({ events: [{ type: "follow" }] }); - const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); - - const req = { - headers: { "x-line-signature": sign(rawBody, secret) }, - body: Buffer.from(rawBody, "utf-8"), - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); + const headers = { ...params.headers }; + const autoSign = params.autoSign ?? true; + if (autoSign && !headers["x-line-signature"]) { + if (typeof params.body === "string") { + headers["x-line-signature"] = sign(params.body, SECRET); + } else if (Buffer.isBuffer(params.body)) { + headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET); + } + } + const req = { + headers, + body: params.body, // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); + } as any; + const res = createRes(); + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + return { res, onEvents: onEventsMock }; +} +describe("createLineWebhookMiddleware", () => { + it.each([ + ["raw string body", JSON.stringify({ events: [{ type: "message" }] })], + ["raw buffer body", Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8")], + ])("parses JSON from %s", async (_label, body) => { + const { res, onEvents } = await invokeWebhook({ body }); expect(res.status).toHaveBeenCalledWith(200); expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) })); }); it("rejects invalid JSON payloads", async () => { - const onEvents = vi.fn(async () => {}); - const secret = "secret"; - const rawBody = "not json"; - const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); - - const req = { - headers: { "x-line-signature": sign(rawBody, secret) }, - body: rawBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - + const { res, onEvents } = await invokeWebhook({ body: "not json" }); expect(res.status).toHaveBeenCalledWith(400); expect(onEvents).not.toHaveBeenCalled(); }); it("rejects webhooks with invalid signatures", async () => { - const onEvents = vi.fn(async () => {}); - const secret = "secret"; - const rawBody = JSON.stringify({ events: [{ type: "message" }] }); - const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); - - const req = { + const { res, onEvents } = await invokeWebhook({ + body: JSON.stringify({ events: [{ type: "message" }] }), headers: { "x-line-signature": "invalid-signature" }, - body: rawBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - + }); expect(res.status).toHaveBeenCalledWith(401); expect(onEvents).not.toHaveBeenCalled(); }); it("returns 200 for verification request (empty events, no signature)", async () => { - const onEvents = vi.fn(async () => {}); - const secret = "secret"; - const rawBody = JSON.stringify({ events: [] }); - const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); - - const req = { + const { res, onEvents } = await invokeWebhook({ + body: JSON.stringify({ events: [] }), headers: {}, - body: rawBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - + autoSign: false, + }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ status: "ok" }); expect(onEvents).not.toHaveBeenCalled(); }); it("rejects missing signature when events are non-empty", async () => { - const onEvents = vi.fn(async () => {}); - const secret = "secret"; - const rawBody = JSON.stringify({ events: [{ type: "message" }] }); - const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); - - const req = { + const { res, onEvents } = await invokeWebhook({ + body: JSON.stringify({ events: [{ type: "message" }] }), headers: {}, - body: rawBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - + autoSign: false, + }); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" }); expect(onEvents).not.toHaveBeenCalled(); }); + + it("rejects signed requests when raw body is missing", async () => { + const { res, onEvents } = await invokeWebhook({ + body: { events: [{ type: "message" }] }, + headers: { "x-line-signature": "signed" }, + }); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Missing raw request body for signature verification", + }); + expect(onEvents).not.toHaveBeenCalled(); + }); }); diff --git a/src/whatsapp/resolve-outbound-target.test.ts b/src/whatsapp/resolve-outbound-target.test.ts index e2546315e2d..18fe322bcde 100644 --- a/src/whatsapp/resolve-outbound-target.test.ts +++ b/src/whatsapp/resolve-outbound-target.test.ts @@ -7,73 +7,46 @@ vi.mock("../infra/outbound/target-errors.js", () => ({ missingTargetError: (platform: string, format: string) => new Error(`${platform}: ${format}`), })); +type ResolveParams = Parameters[0]; + +function expectResolutionError(params: ResolveParams) { + const result = resolveWhatsAppOutboundTarget(params); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected resolution to fail"); + } + expect(result.error.message).toContain("WhatsApp"); +} + +function expectResolutionOk(params: ResolveParams, expectedTarget: string) { + const result = resolveWhatsAppOutboundTarget(params); + expect(result).toEqual({ ok: true, to: expectedTarget }); +} + describe("resolveWhatsAppOutboundTarget", () => { beforeEach(() => { vi.resetAllMocks(); }); describe("empty/missing to parameter", () => { - it("returns error when to is null", () => { - const result = resolveWhatsAppOutboundTarget({ - to: null, - allowFrom: undefined, - mode: undefined, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } - }); - - it("returns error when to is undefined", () => { - const result = resolveWhatsAppOutboundTarget({ - to: undefined, - allowFrom: undefined, - mode: undefined, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } - }); - - it("returns error when to is empty string", () => { - const result = resolveWhatsAppOutboundTarget({ - to: "", - allowFrom: undefined, - mode: undefined, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } - }); - - it("returns error when to is whitespace only", () => { - const result = resolveWhatsAppOutboundTarget({ - to: " ", - allowFrom: undefined, - mode: undefined, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } + it.each([ + ["null", null], + ["undefined", undefined], + ["empty string", ""], + ["whitespace only", " "], + ])("returns error when to is %s", (_label, to) => { + expectResolutionError({ to, allowFrom: undefined, mode: undefined }); }); }); describe("normalization failures", () => { it("returns error when normalizeWhatsAppTarget returns null/undefined", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce(null); - const result = resolveWhatsAppOutboundTarget({ + expectResolutionError({ to: "+1234567890", allowFrom: undefined, mode: undefined, }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } }); }); @@ -82,30 +55,28 @@ describe("resolveWhatsAppOutboundTarget", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("120363123456789@g.us"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(true); - const result = resolveWhatsAppOutboundTarget({ - to: "120363123456789@g.us", - allowFrom: undefined, - mode: "implicit", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("120363123456789@g.us"); - } + expectResolutionOk( + { + to: "120363123456789@g.us", + allowFrom: undefined, + mode: "implicit", + }, + "120363123456789@g.us", + ); }); it("returns success for group JID in heartbeat mode", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("120363999888777@g.us"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(true); - const result = resolveWhatsAppOutboundTarget({ - to: "120363999888777@g.us", - allowFrom: undefined, - mode: "heartbeat", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("120363999888777@g.us"); - } + expectResolutionOk( + { + to: "120363999888777@g.us", + allowFrom: undefined, + mode: "heartbeat", + }, + "120363999888777@g.us", + ); }); }); @@ -116,15 +87,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: ["*"], - mode: "implicit", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: ["*"], + mode: "implicit", + }, + "+11234567890", + ); }); it("allows message when allowList is empty", () => { @@ -133,15 +103,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: [], - mode: "implicit", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: [], + mode: "implicit", + }, + "+11234567890", + ); }); it("allows message when target is in allowList", () => { @@ -150,15 +119,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: ["+11234567890"], - mode: "implicit", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: ["+11234567890"], + mode: "implicit", + }, + "+11234567890", + ); }); it("denies message when target is not in allowList", () => { @@ -167,15 +135,11 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+19876543210"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ + expectResolutionError({ to: "+11234567890", allowFrom: ["+19876543210"], mode: "implicit", }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } }); it("handles mixed numeric and string allowList entries", () => { @@ -185,15 +149,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); // for allowFrom[1] vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: [1234567890, "+11234567890"], - mode: "implicit", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: [1234567890, "+11234567890"], + mode: "implicit", + }, + "+11234567890", + ); }); it("filters out invalid normalized entries from allowList", () => { @@ -203,15 +166,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); // for 'to' param (processed last) vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: ["invalid", "+11234567890"], - mode: "implicit", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: ["invalid", "+11234567890"], + mode: "implicit", + }, + "+11234567890", + ); }); }); @@ -222,15 +184,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: ["+11234567890"], - mode: "heartbeat", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: ["+11234567890"], + mode: "heartbeat", + }, + "+11234567890", + ); }); it("denies message when target is not in allowList in heartbeat mode", () => { @@ -239,15 +200,11 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+19876543210"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ + expectResolutionError({ to: "+11234567890", allowFrom: ["+19876543210"], mode: "heartbeat", }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("WhatsApp"); - } }); }); @@ -256,30 +213,28 @@ describe("resolveWhatsAppOutboundTarget", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: undefined, - mode: null, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: undefined, + mode: null, + }, + "+11234567890", + ); }); it("allows message in undefined mode", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: undefined, - mode: undefined, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: undefined, + mode: undefined, + }, + "+11234567890", + ); }); it("allows message in custom mode string", () => { @@ -288,15 +243,14 @@ describe("resolveWhatsAppOutboundTarget", () => { .mockReturnValueOnce("+11234567890"); // for 'to' param (happens second) vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: "+11234567890", - allowFrom: ["+19876543210"], - mode: "broadcast", - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.to).toBe("+11234567890"); - } + expectResolutionOk( + { + to: "+11234567890", + allowFrom: ["+19876543210"], + mode: "broadcast", + }, + "+11234567890", + ); }); }); @@ -305,12 +259,14 @@ describe("resolveWhatsAppOutboundTarget", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); - const result = resolveWhatsAppOutboundTarget({ - to: " +11234567890 ", - allowFrom: undefined, - mode: undefined, - }); - expect(result.ok).toBe(true); + expectResolutionOk( + { + to: " +11234567890 ", + allowFrom: undefined, + mode: undefined, + }, + "+11234567890", + ); expect(vi.mocked(normalize.normalizeWhatsAppTarget)).toHaveBeenCalledWith("+11234567890"); });