chore: Return webhook version in the header (#26139)
* init * add tests * fix type * type fix * fix * fix tests * fix test
This commit is contained in:
1 parent
18017b5320
commit
8b7497b8cd
19 files changed
+506
-18
No files matched your search
@@ -59,6 +59,11 @@ All webhook payloads are wrapped in the following structure:
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
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
|
||||
</Note>
|
||||
|
||||
Select a version and trigger event to view the example payload:
|
||||
|
||||
<Tabs>
|
||||
|
||||
+7
-1
@@ -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",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
);
|
||||
await sendPayload(secretKey, triggerEvent, createdAt, webhook, data);
|
||||
} catch (error) {
|
||||
// ... handle error
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
@@ -33,14 +33,19 @@ export function sendWebhookPayload(
|
||||
participants: ParticipantsWithEmail,
|
||||
originalRescheduledBooking?: OriginalRescheduledBooking,
|
||||
hostEmail?: string
|
||||
): Promise<any> {
|
||||
): 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,
|
||||
|
||||
@@ -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<typeof ZWebhook>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<any>[] = [];
|
||||
const fetchPromises: Promise<Response | void>[] = [];
|
||||
|
||||
// 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<string, string> = {
|
||||
"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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<typeof sendPayload>[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<typeof sendPayload>[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<typeof sendPayload>[4]);
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0];
|
||||
expect(options.headers["X-Cal-Webhook-Version"]).toBe("2021-10-20");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WebhookSubscriber, "subscriberUrl" | "appId" | "payloadTemplate">;
|
||||
type WebhookForPayload = Pick<WebhookSubscriber, "subscriberUrl" | "appId" | "payloadTemplate" | "version">;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
let mockLogger: ReturnType<typeof createMockLogger>;
|
||||
let mockRepository: ReturnType<typeof createMockRepository>;
|
||||
let mockTasker: ReturnType<typeof createMockTasker>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -386,6 +386,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
appId: subscriber.appId,
|
||||
subscriberUrl: subscriber.subscriberUrl,
|
||||
payloadTemplate: subscriber.payloadTemplate,
|
||||
version: subscriber.version,
|
||||
},
|
||||
payload
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user