test: dedupe line and whatsapp target resolution tests

This commit is contained in:
Peter Steinberger
2026-02-18 05:31:13 +00:00
parent a9cce800df
commit 639d0221ff
2 changed files with 182 additions and 254 deletions

View File

@@ -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<string, string>;
onEvents?: ReturnType<typeof vi.fn>;
autoSign?: boolean;
}) {
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents: onEventsMock as unknown as (body: WebhookRequestBody) => Promise<void>,
});
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();
});
});

View File

@@ -7,73 +7,46 @@ vi.mock("../infra/outbound/target-errors.js", () => ({
missingTargetError: (platform: string, format: string) => new Error(`${platform}: ${format}`),
}));
type ResolveParams = Parameters<typeof resolveWhatsAppOutboundTarget>[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");
});