From 8b7497b8cd382b0787d8a1b66ac1681c39b3fae7 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Sat, 27 Dec 2025 04:31:20 +0400 Subject: [PATCH] chore: Return webhook version in the header (#26139) * init * add tests * fix type * type fix * fix * fix tests * fix test --- .../developing/guides/automation/webhooks.mdx | 5 + ...scheduleNoShowTriggers.integration-test.ts | 8 +- .../handleSeats/cancel/cancelAttendeeSeat.ts | 2 + .../service/InstantBookingCreateService.ts | 7 +- packages/features/tasker/tasks/sendWebook.ts | 3 +- .../triggerFormSubmittedNoEventWebhook.ts | 9 +- .../tasker/tasks/triggerNoShow/common.ts | 9 +- .../tasker/tasks/triggerNoShow/schema.ts | 2 + .../triggerNoShow/triggerGuestNoShow.test.ts | 6 + .../triggerNoShow/triggerHostNoShow.test.ts | 5 + .../handleWebhookScheduledTriggers.test.ts | 157 ++++++++++++++++ .../lib/handleWebhookScheduledTriggers.ts | 12 +- .../features/webhooks/lib/scheduleTrigger.ts | 4 +- .../features/webhooks/lib/sendPayload.test.ts | 111 +++++++++++ packages/features/webhooks/lib/sendPayload.ts | 5 +- .../lib/service/WebhookService.test.ts | 172 ++++++++++++++++++ .../webhooks/lib/service/WebhookService.ts | 1 + .../ooo/outOfOfficeCreateOrUpdate.handler.ts | 1 + .../viewer/webhook/testTrigger.handler.ts | 5 +- 19 files changed, 506 insertions(+), 18 deletions(-) create mode 100644 packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts create mode 100644 packages/features/webhooks/lib/sendPayload.test.ts create mode 100644 packages/features/webhooks/lib/service/WebhookService.test.ts diff --git a/docs/developing/guides/automation/webhooks.mdx b/docs/developing/guides/automation/webhooks.mdx index 9476661cf2..d4d4a8952d 100644 --- a/docs/developing/guides/automation/webhooks.mdx +++ b/docs/developing/guides/automation/webhooks.mdx @@ -59,6 +59,11 @@ All webhook payloads are wrapped in the following structure: } ``` + +Webhook payloads are versioned. The version of the payload sent is included in the x-cal-webhook-version HTTP header. +Example: x-cal-webhook-version: 2021-10-20 + + Select a version and trigger event to view the example payload: diff --git a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts index b9e6c31b81..23ef45e057 100644 --- a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts +++ b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts @@ -265,6 +265,7 @@ describe("scheduleNoShowTriggers Integration", () => { payloadTemplate: null, secret: null, appId: null, + version: "2021-10-20", }, }); @@ -273,8 +274,8 @@ describe("scheduleNoShowTriggers Integration", () => { expect(sendGenericWebhookPayload).toHaveBeenCalledWith( expect.objectContaining({ webhook: expect.objectContaining({ - id: hostWebhook.id, subscriberUrl: "https://example.com/host-webhook", + version: "2021-10-20", }), triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, data: expect.objectContaining({ @@ -287,6 +288,11 @@ describe("scheduleNoShowTriggers Integration", () => { user_name: "Guest User", }), ]), + webhook: expect.objectContaining({ + id: hostWebhook.id, + subscriberUrl: "https://example.com/host-webhook", + version: "2021-10-20", + }), }), }) ); diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index a8a42070ad..93f1c14c1d 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -4,6 +4,7 @@ import { getDelegationCredentialOrFindRegularCredential } from "@calcom/app-stor import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails/email-manager"; import { updateMeeting } from "@calcom/features/conferencing/lib/videoClient"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; +import type { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import { getRichDescription } from "@calcom/lib/CalEventParser"; @@ -31,6 +32,7 @@ async function cancelAttendeeSeat( payloadTemplate: string | null; appId: string | null; secret: string | null; + version: WebhookVersion; }[]; evt: CalendarEvent; eventTypeInfo: EventTypeInfo; diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts index e6bda9c41d..768cdfeb7a 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts @@ -27,6 +27,7 @@ import { Prisma } from "@calcom/prisma/client"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { instantMeetingSubscriptionSchema as subscriptionSchema } from "../dto/schema"; +import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; interface IInstantBookingCreateServiceDependencies { prismaClient: PrismaClient; @@ -70,6 +71,7 @@ const handleInstantMeetingWebhookTrigger = async (args: { payloadTemplate: true, appId: true, secret: true, + version: true, }, }); @@ -80,7 +82,10 @@ const handleInstantMeetingWebhookTrigger = async (args: { secretKey: sub.secret, triggerEvent: eventTrigger, createdAt: new Date().toISOString(), - webhook: sub, + webhook: { + ...sub, + version: sub.version as WebhookVersion, + }, data: webhookData, }).catch((e) => { console.error( diff --git a/packages/features/tasker/tasks/sendWebook.ts b/packages/features/tasker/tasks/sendWebook.ts index b8bdb69954..e9a5a1bdda 100644 --- a/packages/features/tasker/tasks/sendWebook.ts +++ b/packages/features/tasker/tasks/sendWebook.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; const sendWebhookPayloadSchema = z.object({ @@ -10,6 +11,7 @@ const sendWebhookPayloadSchema = z.object({ subscriberUrl: z.string().url(), appId: z.string().nullable(), payloadTemplate: z.string().nullable(), + version: z.nativeEnum(WebhookVersion), }), // TODO: Define the data schema data: z.any(), @@ -22,7 +24,6 @@ export async function sendWebhook(payload: string): Promise { ); await sendPayload(secretKey, triggerEvent, createdAt, webhook, data); } catch (error) { - // ... handle error console.error(error); throw error; } diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts index ac89a89be7..67e4c06510 100644 --- a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import incompleteBookingActionFunctions from "@calcom/app-store/routing-forms/lib/incompleteBooking/actionFunctions"; +import { DEFAULT_WEBHOOK_VERSION, WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import prisma from "@calcom/prisma"; @@ -23,6 +24,7 @@ export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({ appId: z.string().nullable(), payloadTemplate: z.string().nullable(), secret: z.string().nullable(), + version: z.nativeEnum(WebhookVersion).optional(), }), responseId: z.number(), responses: z.any(), @@ -55,7 +57,12 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi secretKey: webhook.secret, triggerEvent: "FORM_SUBMITTED_NO_EVENT", createdAt: new Date().toISOString(), - webhook, + webhook: { + subscriberUrl: webhook.subscriberUrl, + appId: webhook.appId, + payloadTemplate: webhook.payloadTemplate, + version: webhook.version ?? DEFAULT_WEBHOOK_VERSION, + }, data: { formId: form.id, formName: form.name, diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 5fef3c95f0..31f3608d00 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -33,14 +33,19 @@ export function sendWebhookPayload( participants: ParticipantsWithEmail, originalRescheduledBooking?: OriginalRescheduledBooking, hostEmail?: string -): Promise { +): Promise<{ ok: boolean; status: number } | void> { const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z"); return sendGenericWebhookPayload({ secretKey: webhook.secret, triggerEvent, createdAt: new Date().toISOString(), - webhook, + webhook: { + subscriberUrl: webhook.subscriberUrl, + appId: webhook.appId, + payloadTemplate: webhook.payloadTemplate, + version: webhook.version, + }, data: { title: booking.title, bookingId: booking.id, diff --git a/packages/features/tasker/tasks/triggerNoShow/schema.ts b/packages/features/tasker/tasks/triggerNoShow/schema.ts index 355b6cbd0c..1a9858f474 100644 --- a/packages/features/tasker/tasks/triggerNoShow/schema.ts +++ b/packages/features/tasker/tasks/triggerNoShow/schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; const commonSchema = z.object({ triggerEvent: z.enum([ @@ -20,6 +21,7 @@ export const ZWebhook = z.object({ timeUnit: z.enum(TIME_UNIT), eventTriggers: z.array(z.string()), payloadTemplate: z.string().nullable(), + version: z.nativeEnum(WebhookVersion), }); export type TWebhook = z.infer; diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts index d84f639e81..12443c869e 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts @@ -21,6 +21,7 @@ import { calculateMaxStartTime } from "./common"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; import type { TSendNoShowWebhookPayloadSchema } from "./schema"; import { triggerGuestNoShow } from "./triggerGuestNoShow"; +import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({ getMeetingSessionsFromRoomName: vi.fn(), @@ -141,6 +142,7 @@ describe("Trigger Guest No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -327,6 +329,7 @@ describe("Trigger Guest No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -552,6 +555,7 @@ describe("Trigger Guest No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -737,6 +741,7 @@ describe("Trigger Guest No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -889,6 +894,7 @@ describe("Trigger Guest No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts index 408e19c2c9..2cd9dc845f 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts @@ -21,6 +21,7 @@ import { calculateMaxStartTime } from "./common"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; import type { TSendNoShowWebhookPayloadSchema } from "./schema"; import { triggerHostNoShow } from "./triggerHostNoShow"; +import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository"; vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({ getMeetingSessionsFromRoomName: vi.fn(), @@ -133,6 +134,7 @@ describe("Trigger Host No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -298,6 +300,7 @@ describe("Trigger Host No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -503,6 +506,7 @@ describe("Trigger Host No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ @@ -653,6 +657,7 @@ describe("Trigger Host No Show:", () => { timeUnit: TimeUnit.MINUTE, payloadTemplate: null, secret: null, + version: WebhookVersion.V_2021_10_20, }; const payload = JSON.stringify({ diff --git a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts new file mode 100644 index 0000000000..4e4f8ef333 --- /dev/null +++ b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { DEFAULT_WEBHOOK_VERSION } from "./interface/IWebhookRepository"; +import { handleWebhookScheduledTriggers } from "./handleWebhookScheduledTriggers"; +import type { PrismaClient } from "@calcom/prisma"; + +describe("handleWebhookScheduledTriggers - X-Cal-Webhook-Version header", () => { + const mockFetch = vi.fn(); + const now = new Date(); + + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); + }); + + it("should include X-Cal-Webhook-Version header with webhook version from database", async () => { + const webhookVersion = "2021-10-20"; + const mockPrisma = { + webhookScheduledTriggers: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([ + { + id: 1, + jobName: null, + subscriberUrl: "https://example.com/webhook", + payload: JSON.stringify({ triggerEvent: "MEETING_ENDED" }), + startAfter: new Date(now.getTime() - 60000), // 1 minute ago + webhook: { + secret: "test-secret", + version: webhookVersion, + }, + }, + ]), + delete: vi.fn().mockResolvedValue({}), + }, + webhook: { + findUniqueOrThrow: vi.fn(), + }, + }; + + await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + expect(url).toBe("https://example.com/webhook"); + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", webhookVersion); + expect(options.headers).toHaveProperty("X-Cal-Signature-256"); + }); + + it("should use DEFAULT_WEBHOOK_VERSION when webhook has no version", async () => { + const mockPrisma = { + webhookScheduledTriggers: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([ + { + id: 1, + jobName: null, + subscriberUrl: "https://example.com/webhook", + payload: JSON.stringify({ triggerEvent: "MEETING_STARTED" }), + startAfter: new Date(now.getTime() - 60000), + webhook: { + secret: "test-secret", + version: null, // No version set + }, + }, + ]), + delete: vi.fn().mockResolvedValue({}), + }, + webhook: { + findUniqueOrThrow: vi.fn(), + }, + }; + + await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, options] = mockFetch.mock.calls[0]; + + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", DEFAULT_WEBHOOK_VERSION); + }); + + it("should use DEFAULT_WEBHOOK_VERSION when webhook relationship is null", async () => { + const mockPrisma = { + webhookScheduledTriggers: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([ + { + id: 1, + jobName: null, + subscriberUrl: "https://example.com/webhook", + payload: JSON.stringify({ triggerEvent: "MEETING_STARTED" }), + startAfter: new Date(now.getTime() - 60000), + webhook: null, // No webhook relationship + }, + ]), + delete: vi.fn().mockResolvedValue({}), + }, + webhook: { + findUniqueOrThrow: vi.fn(), + }, + }; + + await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, options] = mockFetch.mock.calls[0]; + + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", DEFAULT_WEBHOOK_VERSION); + }); + + it("should fetch webhook version from database for legacy jobs using jobName", async () => { + const webhookVersion = "2021-10-20"; + const mockPrisma = { + webhookScheduledTriggers: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findMany: vi.fn().mockResolvedValue([ + { + id: 1, + jobName: "appId_webhookId123", // Legacy format + subscriberUrl: "https://example.com/webhook", + payload: JSON.stringify({ triggerEvent: "MEETING_ENDED" }), + startAfter: new Date(now.getTime() - 60000), + webhook: null, // No webhook relationship for legacy jobs + }, + ]), + delete: vi.fn().mockResolvedValue({}), + }, + webhook: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + secret: "fetched-secret", + version: webhookVersion, + }), + }, + }; + + await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient); + + expect(mockPrisma.webhook.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: "webhookId123", appId: "appId" }, + select: { secret: true, version: true }, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, options] = mockFetch.mock.calls[0]; + + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", webhookVersion); + }); +}); diff --git a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts index b93791f937..b32fc28ab4 100644 --- a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts +++ b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts @@ -2,6 +2,7 @@ import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { PrismaClient } from "@calcom/prisma"; +import { DEFAULT_WEBHOOK_VERSION } from "./interface/IWebhookRepository"; import { createWebhookSignature, jsonParse } from "./sendPayload"; export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { @@ -27,12 +28,13 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { webhook: { select: { secret: true, + version: true, }, }, }, }); - const fetchPromises: Promise[] = []; + const fetchPromises: Promise[] = []; // run jobs for (const job of jobsToRun) { @@ -45,6 +47,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { try { webhook = await prisma.webhook.findUniqueOrThrow({ where: { id: subscriberId, appId: appId !== "null" ? appId : null }, + select: { secret: true, version: true }, }); } catch { logger.error(`Error finding webhook for subscriberId: ${subscriberId}, appId: ${appId}`); @@ -54,6 +57,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { const headers: Record = { "Content-Type": !job.payload || jsonParse(job.payload) ? "application/json" : "application/x-www-form-urlencoded", + "X-Cal-Webhook-Version": webhook?.version ?? DEFAULT_WEBHOOK_VERSION, }; if (webhook) { @@ -71,12 +75,6 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) { }) ); - const parsedJobPayload = JSON.parse(job.payload) as { - id: number; // booking id - endTime: string; - triggerEvent: string; - }; - // clean finished job await prisma.webhookScheduledTriggers.delete({ where: { diff --git a/packages/features/webhooks/lib/scheduleTrigger.ts b/packages/features/webhooks/lib/scheduleTrigger.ts index 3d60ec3b56..13b5ee5bd3 100644 --- a/packages/features/webhooks/lib/scheduleTrigger.ts +++ b/packages/features/webhooks/lib/scheduleTrigger.ts @@ -13,6 +13,7 @@ import { prisma } from "@calcom/prisma"; import type { Prisma, Webhook, Booking, ApiKey } from "@calcom/prisma/client"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import { DEFAULT_WEBHOOK_VERSION, type WebhookVersion } from "./interface/IWebhookRepository"; const SCHEDULING_TRIGGER: WebhookTriggerEvents[] = [ WebhookTriggerEvents.MEETING_ENDED, @@ -657,7 +658,7 @@ export async function scheduleNoShowTaskForBooking( triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ? "triggerHostNoShowWebhook" : "triggerGuestNoShowWebhook"; - + const version = (webhook.version as WebhookVersion) ?? DEFAULT_WEBHOOK_VERSION; await tasker.create( taskType, { @@ -667,6 +668,7 @@ export async function scheduleNoShowTaskForBooking( ...webhook, time: webhook.time ?? 0, timeUnit: webhook.timeUnit ?? "HOUR", + version, }, }, { diff --git a/packages/features/webhooks/lib/sendPayload.test.ts b/packages/features/webhooks/lib/sendPayload.test.ts new file mode 100644 index 0000000000..788d5b658c --- /dev/null +++ b/packages/features/webhooks/lib/sendPayload.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { WebhookVersion } from "./interface/IWebhookRepository"; +import sendPayload from "./sendPayload"; + +describe("sendPayload", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); + }); + + describe("X-Cal-Webhook-Version header", () => { + it("should include X-Cal-Webhook-Version header with the webhook version", async () => { + const webhook = { + subscriberUrl: "https://example.com/webhook", + appId: null, + payloadTemplate: null, + version: WebhookVersion.V_2021_10_20, + }; + + await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, { + title: "Test Booking", + startTime: "2024-01-01T10:00:00Z", + endTime: "2024-01-01T11:00:00Z", + organizer: { + email: "organizer@example.com", + name: "Organizer", + timeZone: "UTC", + language: { locale: "en" }, + }, + attendees: [], + type: "test-event", + description: "", + } as unknown as Parameters[4]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + expect(url).toBe("https://example.com/webhook"); + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20"); + }); + + it("should include X-Cal-Signature-256 header alongside version header", async () => { + const webhook = { + subscriberUrl: "https://example.com/webhook", + appId: null, + payloadTemplate: null, + version: WebhookVersion.V_2021_10_20, + }; + + await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, { + title: "Test Booking", + startTime: "2024-01-01T10:00:00Z", + endTime: "2024-01-01T11:00:00Z", + organizer: { + email: "organizer@example.com", + name: "Organizer", + timeZone: "UTC", + language: { locale: "en" }, + }, + attendees: [], + type: "test-event", + description: "", + } as unknown as Parameters[4]); + + const [, options] = mockFetch.mock.calls[0]; + + expect(options.headers).toHaveProperty("X-Cal-Signature-256"); + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version"); + expect(options.headers).toHaveProperty("Content-Type", "application/json"); + }); + + it("should send correct version for different webhook versions", async () => { + // Test with the current version + const webhook = { + subscriberUrl: "https://example.com/webhook", + appId: null, + payloadTemplate: null, + version: WebhookVersion.V_2021_10_20, + }; + + await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, { + title: "Test", + startTime: "2024-01-01T10:00:00Z", + endTime: "2024-01-01T11:00:00Z", + organizer: { + email: "test@example.com", + name: "Test", + timeZone: "UTC", + language: { locale: "en" }, + }, + attendees: [], + type: "test", + description: "", + } as unknown as Parameters[4]); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers["X-Cal-Webhook-Version"]).toBe("2021-10-20"); + }); + }); +}); diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index 7dc096df52..faed63a257 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -9,7 +9,7 @@ import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; // Minimal webhook shape for sending payloads (subset of WebhookSubscriber) -type WebhookForPayload = Pick; +type WebhookForPayload = Pick; type ContentType = "application/json" | "application/x-www-form-urlencoded"; @@ -309,7 +309,7 @@ const _sendPayload = async ( body: string, contentType: "application/json" | "application/x-www-form-urlencoded" ) => { - const { subscriberUrl } = webhook; + const { subscriberUrl, version } = webhook; if (!subscriberUrl || !body) { throw new Error("Missing required elements to send webhook payload."); } @@ -319,6 +319,7 @@ const _sendPayload = async ( headers: { "Content-Type": contentType, "X-Cal-Signature-256": createWebhookSignature({ secret: secretKey, body }), + "X-Cal-Webhook-Version": version, }, redirect: "manual", body, diff --git a/packages/features/webhooks/lib/service/WebhookService.test.ts b/packages/features/webhooks/lib/service/WebhookService.test.ts new file mode 100644 index 0000000000..87db5f2bfc --- /dev/null +++ b/packages/features/webhooks/lib/service/WebhookService.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; + +import type { WebhookSubscriber } from "../dto/types"; +import { IWebhookRepository, WebhookVersion } from "../interface/IWebhookRepository"; +import { WebhookService } from "./WebhookService"; +import { ILogger, ITasker } from "../interface/infrastructure"; +import { WebhookPayload } from "../factory/types"; + +describe("WebhookService", () => { + let mockFetch: ReturnType; + let mockLogger: ReturnType; + let mockRepository: ReturnType; + let mockTasker: ReturnType; + + function createMockLogger() { + const subLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + getSubLogger: vi.fn(), + }; + subLogger.getSubLogger.mockReturnValue(subLogger); + + return { + getSubLogger: vi.fn().mockReturnValue(subLogger), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + } + + function createMockRepository() { + return { + getSubscribers: vi.fn(), + getWebhookById: vi.fn(), + findByWebhookId: vi.fn(), + getFilteredWebhooksForUser: vi.fn(), + listWebhooks: vi.fn(), + }; + } + + function createMockTasker() { + return { + create: vi.fn(), + cancelWithReference: vi.fn(), + }; + } + + beforeEach(() => { + mockFetch = vi.fn(); + mockLogger = createMockLogger(); + mockRepository = createMockRepository(); + mockTasker = createMockTasker(); + + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(""), + }); + // Ensure TASKER_ENABLE_WEBHOOKS is not set so we use direct sending + delete process.env.TASKER_ENABLE_WEBHOOKS; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("X-Cal-Webhook-Version header", () => { + it("should include X-Cal-Webhook-Version header when sending webhook directly", async () => { + const service = new WebhookService(mockRepository as unknown as IWebhookRepository, mockTasker as unknown as ITasker, mockLogger as unknown as ILogger); + + const subscriber: WebhookSubscriber = { + id: "webhook-1", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + secret: "test-secret", + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED], + version: WebhookVersion.V_2021_10_20, + }; + + const payload = { + createdAt: new Date().toISOString(), + payload: { test: "data", triggerEvent: WebhookTriggerEvents.BOOKING_CREATED }, + } as unknown as WebhookPayload; + + await service.processWebhooks(WebhookTriggerEvents.BOOKING_CREATED, payload, [subscriber]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + expect(url).toBe("https://example.com/webhook"); + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20"); + expect(options.headers).toHaveProperty("X-Cal-Signature-256"); + expect(options.headers).toHaveProperty("Content-Type", "application/json"); + }); + + it("should include correct version for each subscriber", async () => { + const service = new WebhookService(mockRepository as unknown as IWebhookRepository, mockTasker as unknown as ITasker, mockLogger as unknown as ILogger); + + const subscriber1: WebhookSubscriber = { + id: "webhook-1", + subscriberUrl: "https://example1.com/webhook", + payloadTemplate: null, + appId: null, + secret: "secret-1", + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED], + version: WebhookVersion.V_2021_10_20, + }; + + const subscriber2: WebhookSubscriber = { + id: "webhook-2", + subscriberUrl: "https://example2.com/webhook", + payloadTemplate: null, + appId: null, + secret: "secret-2", + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED], + version: WebhookVersion.V_2021_10_20, + }; + + const payload = { + createdAt: new Date().toISOString(), + payload: { test: "data", triggerEvent: WebhookTriggerEvents.BOOKING_CREATED }, + } as unknown as WebhookPayload; + + await service.processWebhooks(WebhookTriggerEvents.BOOKING_CREATED, payload, [subscriber1, subscriber2]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Check both calls have the version header + mockFetch.mock.calls.forEach((call) => { + const [, options] = call; + expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20"); + }); + }); + + it("should schedule webhook with version when TASKER_ENABLE_WEBHOOKS is enabled", async () => { + process.env.TASKER_ENABLE_WEBHOOKS = "1"; + + const service = new WebhookService(mockRepository as unknown as IWebhookRepository, mockTasker as unknown as ITasker, mockLogger as unknown as ILogger); + + const subscriber: WebhookSubscriber = { + id: "webhook-1", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + secret: "test-secret", + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED], + version: WebhookVersion.V_2021_10_20, + }; + + const payload = { + createdAt: new Date().toISOString(), + payload: { test: "data", triggerEvent: WebhookTriggerEvents.BOOKING_CREATED }, + } as unknown as WebhookPayload; + + await service.processWebhooks(WebhookTriggerEvents.BOOKING_CREATED, payload, [subscriber]); + + expect(mockTasker.create).toHaveBeenCalledTimes(1); + const [taskName, taskPayload] = mockTasker.create.mock.calls[0]; + + expect(taskName).toBe("sendWebhook"); + const parsedPayload = JSON.parse(taskPayload); + expect(parsedPayload.webhook.version).toBe("2021-10-20"); + }); + }); +}); diff --git a/packages/features/webhooks/lib/service/WebhookService.ts b/packages/features/webhooks/lib/service/WebhookService.ts index 159410c0b1..b559e5920a 100644 --- a/packages/features/webhooks/lib/service/WebhookService.ts +++ b/packages/features/webhooks/lib/service/WebhookService.ts @@ -100,6 +100,7 @@ export class WebhookService implements IWebhookService { headers: { "Content-Type": contentType, "X-Cal-Signature-256": signature, + "X-Cal-Webhook-Version": subscriber.version, }, redirect: "manual", body, diff --git a/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts b/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts index a1668ccc07..cb3b8c8616 100644 --- a/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts +++ b/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts @@ -386,6 +386,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect appId: subscriber.appId, subscriberUrl: subscriber.subscriberUrl, payloadTemplate: subscriber.payloadTemplate, + version: subscriber.version, }, payload ); diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts index f3b7c66c98..d19cd8e736 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -1,6 +1,7 @@ +import { DEFAULT_WEBHOOK_VERSION } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { getErrorFromUnknown } from "@calcom/lib/errors"; + import { getTranslation } from "@calcom/lib/server/i18n"; import type { TTestTriggerInputSchema } from "./testTrigger.schema"; @@ -41,7 +42,7 @@ export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOption }; try { - const webhook = { subscriberUrl: url, appId: null, payloadTemplate }; + const webhook = { subscriberUrl: url, appId: null, payloadTemplate, version: DEFAULT_WEBHOOK_VERSION }; return await sendPayload(secret, type, new Date().toISOString(), webhook, data); } catch { return {