From a4346ac63322f256ee3f4bfb999735ce2867a08b Mon Sep 17 00:00:00 2001 From: Muhammadcodes112 Date: Sat, 11 Jul 2026 02:12:58 -0700 Subject: [PATCH] feat: validate WhatsApp webhook payload shape with Zod schema (closes #8) --- apps/backend/src/routes/webhook.ts | 46 +++++++++++-- apps/backend/test/webhook.test.ts | 100 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 apps/backend/test/webhook.test.ts diff --git a/apps/backend/src/routes/webhook.ts b/apps/backend/src/routes/webhook.ts index dc43ecd..cd9d067 100644 --- a/apps/backend/src/routes/webhook.ts +++ b/apps/backend/src/routes/webhook.ts @@ -1,10 +1,48 @@ import type { FastifyInstance } from "fastify"; +import { z } from "zod"; import { env } from "../config/env.js"; import { verifyWebhookSignature } from "../lib/signature.js"; import { parseCommand } from "../lib/commands.js"; import { sendText } from "../services/whatsapp.js"; import { handleCommand } from "../services/router.js"; -import type { WhatsAppWebhookPayload } from "../types/index.js"; + +const whatsappInboundMessageSchema = z.object({ + from: z.string(), + id: z.string(), + timestamp: z.string(), + type: z.string(), + text: z + .object({ + body: z.string(), + }) + .optional(), +}); + +export const whatsappWebhookPayloadSchema = z.object({ + object: z.string(), + entry: z + .array( + z.object({ + id: z.string(), + changes: z + .array( + z.object({ + field: z.string(), + value: z.object({ + messaging_product: z.string(), + metadata: z.object({ + display_phone_number: z.string(), + phone_number_id: z.string(), + }), + messages: z.array(whatsappInboundMessageSchema).optional(), + }), + }), + ) + .optional(), + }), + ) + .optional(), +}); /** * WhatsApp Cloud API webhook. @@ -37,12 +75,12 @@ export async function webhookRoutes(app: FastifyInstance) { return reply.code(401).send({ error: "bad signature" }); } - const payload = request.body as WhatsAppWebhookPayload; + const payload = whatsappWebhookPayloadSchema.parse(request.body); + const messages = payload.entry?.flatMap( (entry) => - entry.changes?.flatMap((change) => change.value.messages ?? []) ?? - [], + entry.changes?.flatMap((change) => change.value.messages ?? []) ?? [], ) ?? []; for (const message of messages) { diff --git a/apps/backend/test/webhook.test.ts b/apps/backend/test/webhook.test.ts new file mode 100644 index 0000000..acfb1e6 --- /dev/null +++ b/apps/backend/test/webhook.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; + +describe("POST /webhooks/whatsapp payload validation", () => { + it("accepts a valid WhatsAppWebhookPayload", async () => { + const app = buildApp(); + try { + const response = await app.inject({ + method: "POST", + url: "/webhooks/whatsapp", + payload: { + object: "whatsapp_business_account", + entry: [ + { + id: "123", + changes: [ + { + field: "messages", + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "123456789", + phone_number_id: "987654321", + }, + messages: [ + { + from: "15555555555", + id: "wamid.HBgLMTU1NTU1NTU1NTU=", + timestamp: "1672531199", + type: "text", + text: { + body: "balance", + }, + }, + ], + }, + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ received: true }); + } finally { + await app.close(); + } + }); + + it("returns 400 when object field is missing", async () => { + const app = buildApp(); + try { + const response = await app.inject({ + method: "POST", + url: "/webhooks/whatsapp", + payload: { + entry: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBeDefined(); + } finally { + await app.close(); + } + }); + + it("returns 400 when metadata fields are missing in changes", async () => { + const app = buildApp(); + try { + const response = await app.inject({ + method: "POST", + url: "/webhooks/whatsapp", + payload: { + object: "whatsapp_business_account", + entry: [ + { + id: "123", + changes: [ + { + field: "messages", + value: { + messaging_product: "whatsapp", + // metadata is missing + }, + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBeDefined(); + } finally { + await app.close(); + } + }); +});