diff --git a/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx b/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx new file mode 100644 index 00000000000000..357918cb63b0fc --- /dev/null +++ b/apps/web/app/(booking-page-wrapper)/booking-successful/[uid]/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import dayjs from "@calcom/dayjs"; +import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard"; +import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking"; + +export default function BookingSuccessful() { + const params = useParams(); + + const uid = params?.uid as string; + const bookingData = useDecoyBooking(uid); + + if (!bookingData) { + return null; + } + + const { booking } = bookingData; + + // Format the data for the BookingSuccessCard + const startTime = booking.startTime ? dayjs(booking.startTime) : null; + const endTime = booking.endTime ? dayjs(booking.endTime) : null; + const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess(); + + const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : ""; + const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : ""; + const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : ""; + const formattedTimeZone = timeZone; + + const hostName = booking.host?.name || null; + const hostEmail = null; // Email not stored for spam decoy bookings + const attendeeName = booking.booker?.name || null; + const attendeeEmail = booking.booker?.email || null; + + return ( + + ); +} diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index d261c3bc63b841..0b74837371cfaa 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2001,6 +2001,10 @@ export async function mockCalendarToHaveNoBusySlots( return await mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] }); } +export async function mockCalendarToCrashOnGetAvailability(metadataLookupKey: keyof typeof appStoreMetadata) { + return await mockCalendar(metadataLookupKey, { getAvailabilityCrash: true }); +} + export async function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { return await mockCalendar(metadataLookupKey, { creationCrash: true }); } diff --git a/packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx b/packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx new file mode 100644 index 00000000000000..81021b47ce548d --- /dev/null +++ b/packages/features/bookings/Booker/components/DecoyBookingSuccessCard.tsx @@ -0,0 +1,115 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Badge } from "@calcom/ui/components/badge"; +import { Icon } from "@calcom/ui/components/icon"; + +export interface DecoyBookingSuccessCardProps { + title: string; + formattedDate: string; + formattedTime: string; + endTime: string; + formattedTimeZone: string; + hostName: string | null; + hostEmail: string | null; + attendeeName: string | null; + attendeeEmail: string | null; + location: string | null; +} + +export function DecoyBookingSuccessCard({ + title, + formattedDate, + formattedTime, + endTime, + formattedTimeZone, + hostName, + hostEmail, + attendeeName, + attendeeEmail, + location, +}: DecoyBookingSuccessCardProps) { + const { t } = useLocale(); + + return ( +
+
+
+
+ +
+
+
+
+ ); +} + diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 3069ff5a113886..87915e4b624a8e 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -11,11 +11,14 @@ import dayjs from "@calcom/dayjs"; import { sdkActionManager } from "@calcom/embed-core/embed-iframe"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; -import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib"; +import { storeDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore"; +import { createBooking } from "@calcom/features/bookings/lib/create-booking"; +import { createInstantBooking } from "@calcom/features/bookings/lib/create-instant-booking"; +import { createRecurringBooking } from "@calcom/features/bookings/lib/create-recurring-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { getFullName } from "@calcom/features/form-builder/utils"; -import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect"; +import { useBookingSuccessRedirect } from "../../../lib/bookingSuccessRedirect"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; @@ -165,13 +168,7 @@ const storeInLocalStorage = ({ localStorage.setItem(STORAGE_KEY, value); }; -export const useBookings = ({ - event, - hashedLink, - bookingForm, - metadata, - isBookingDryRun, -}: IUseBookings) => { +export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookingDryRun }: IUseBookings) => { const router = useRouter(); const eventSlug = useBookerStoreContext((state) => state.eventSlug); const eventTypeId = useBookerStoreContext((state) => state.eventId); @@ -248,7 +245,7 @@ export const useBookings = ({ } else { showToast(t("something_went_wrong_on_our_end"), "error"); } - } catch (err) { + } catch { showToast(t("something_went_wrong_on_our_end"), "error"); } }, @@ -259,12 +256,6 @@ export const useBookings = ({ mutationFn: createBooking, onSuccess: (booking) => { if (booking.isDryRun) { - const validDuration = event.data?.isDynamic - ? duration || event.data?.length - : duration && event.data?.metadata?.multipleDuration?.includes(duration) - ? duration - : event.data?.length; - if (isRescheduling) { sdkActionManager?.fire( "dryRunRescheduleBookingSuccessfulV2", @@ -286,6 +277,28 @@ export const useBookings = ({ router.push("/booking/dry-run-successful"); return; } + + if ("isShortCircuitedBooking" in booking && booking.isShortCircuitedBooking) { + if (!booking.uid) { + console.error("Decoy booking missing uid"); + return; + } + + const bookingData = { + uid: booking.uid, + title: booking.title ?? null, + startTime: booking.startTime, + endTime: booking.endTime, + booker: booking.attendees?.[0] ?? null, + host: booking.user ?? null, + location: booking.location ?? null, + }; + + storeDecoyBooking(bookingData); + router.push(`/booking-successful/${booking.uid}`); + return; + } + const { uid, paymentUid } = booking; const fullName = getFullName(bookingForm.getValues("responses.name")); @@ -380,9 +393,10 @@ export const useBookings = ({ : event?.data?.forwardParamsSuccessRedirect, }); }, - onError: (err, _, ctx) => { - // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed - bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" }); + onError: (err) => { + if (bookerFormErrorRef?.current) { + bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" }); + } const error = err as Error & { data: { rescheduleUid: string; startTime: string; attendees: string[] }; @@ -414,10 +428,11 @@ export const useBookings = ({ updateQueryParam("bookingId", responseData.bookingId); setExpiryTime(responseData.expires); }, - onError: (err, _, ctx) => { + onError: (err) => { console.error("Error creating instant booking", err); - // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed - bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" }); + if (bookerFormErrorRef?.current) { + bookerFormErrorRef.current.scrollIntoView({ behavior: "smooth" }); + } }, }); @@ -513,15 +528,10 @@ export const useBookings = ({ bookingForm, hashedLink, metadata, - handleInstantBooking: ( - variables: Parameters[0] - ) => { + handleInstantBooking: (variables: Parameters[0]) => { const remaining = getInstantCooldownRemainingMs(eventTypeId); if (remaining > 0) { - showToast( - t("please_try_again_later_or_book_another_slot"), - "error" - ); + showToast(t("please_try_again_later_or_book_another_slot"), "error"); return; } createInstantBookingMutation.mutate(variables); diff --git a/packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts b/packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts new file mode 100644 index 00000000000000..39356f04ba243a --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useDecoyBooking.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { getDecoyBooking } from "@calcom/features/bookings/lib/client/decoyBookingStore"; +import type { DecoyBookingData } from "@calcom/features/bookings/lib/client/decoyBookingStore"; + +/** + * Hook to retrieve and manage decoy booking data from localStorage + * @param uid - The booking uid + * @returns The booking data or null if not found/expired + */ +export function useDecoyBooking(uid: string) { + const router = useRouter(); + const [bookingData, setBookingData] = useState(null); + + useEffect(() => { + const data = getDecoyBooking(uid); + + if (!data) { + router.push("/404"); + return; + } + + setBookingData(data); + }, [uid, router]); + + return bookingData; +} + diff --git a/packages/features/bookings/components/BookingSuccessCard.tsx b/packages/features/bookings/components/BookingSuccessCard.tsx new file mode 100644 index 00000000000000..1e14079b48a18c --- /dev/null +++ b/packages/features/bookings/components/BookingSuccessCard.tsx @@ -0,0 +1,114 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Badge } from "@calcom/ui/components/badge"; +import { Icon } from "@calcom/ui/components/icon"; + +export interface BookingSuccessCardProps { + title: string; + formattedDate: string; + formattedTime: string; + endTime: string; + formattedTimeZone: string; + hostName: string | null; + hostEmail: string | null; + attendeeName: string | null; + attendeeEmail: string | null; + location: string | null; +} + +export function BookingSuccessCard({ + title, + formattedDate, + formattedTime, + endTime, + formattedTimeZone, + hostName, + hostEmail, + attendeeName, + attendeeEmail, + location, +}: BookingSuccessCardProps) { + const { t } = useLocale(); + + return ( +
+
+
+
+ +
+
+
+
+ ); +} diff --git a/packages/features/bookings/lib/client/decoyBookingStore.ts b/packages/features/bookings/lib/client/decoyBookingStore.ts new file mode 100644 index 00000000000000..88a387f0fc7129 --- /dev/null +++ b/packages/features/bookings/lib/client/decoyBookingStore.ts @@ -0,0 +1,86 @@ +import { localStorage } from "@calcom/lib/webstorage"; + +const BOOKING_SUCCESS_STORAGE_KEY_PREFIX = "cal.successfulBooking"; + +interface DecoyBookingData { + booking: { + uid: string; + title: string | null; + startTime: string; + endTime: string; + booker: { name: string; timeZone?: string; email: string } | null; + host: { name: string; timeZone?: string } | null; + location: string | null; + }; + timestamp: number; +} + +function getStorageKey(uid: string): string { + return `${BOOKING_SUCCESS_STORAGE_KEY_PREFIX}.${uid}`; +} + +/** + * Stores decoy booking data in localStorage using the booking's uid + * @param booking - The booking data to store (must include uid) + */ +export function storeDecoyBooking(booking: Record & { uid: string }): void { + const bookingSuccessData = { + booking, + timestamp: Date.now(), + }; + const storageKey = getStorageKey(booking.uid); + localStorage.setItem(storageKey, JSON.stringify(bookingSuccessData)); +} + +/** + * Retrieves decoy booking data from localStorage + * @param uid - The booking uid + * @returns The stored booking data or null if not found or expired + */ +export function getDecoyBooking(uid: string): DecoyBookingData | null { + if (!uid) { + return null; + } + + const storageKey = getStorageKey(uid); + const dataStr = localStorage.getItem(storageKey); + + if (!dataStr) { + return null; + } + + try { + const data: DecoyBookingData = JSON.parse(dataStr); + + // Check if the data is too old (5 min) + const dataAge = Date.now() - data.timestamp; + const maxAge = 5 * 60 * 1000; // 5 minutes in milliseconds + + if (dataAge > maxAge) { + // Remove the data from localStorage if expired + localStorage.removeItem(storageKey); + return null; + } + + return data; + } catch { + // If parsing fails, remove the corrupted data + localStorage.removeItem(storageKey); + return null; + } +} + +/** + * Removes decoy booking data from localStorage + * @param uid - The booking uid + */ +export function removeDecoyBooking(uid: string): void { + if (!uid) { + return; + } + + const storageKey = getStorageKey(uid); + localStorage.removeItem(storageKey); +} + +export type { DecoyBookingData }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 4a436f19146152..7a33982a92ac7f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -23,7 +23,6 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc import getICalUID from "@calcom/emails/lib/getICalUID"; import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager"; -import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits"; import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types"; import type { CreateRegularBookingData, @@ -34,16 +33,14 @@ import type { CheckBookingAndDurationLimitsService } from "@calcom/features/book import { handlePayment } from "@calcom/features/bookings/lib/handlePayment"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; -import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; +import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; -import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getFullName } from "@calcom/features/form-builder/utils"; import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents"; -import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { UsersRepository } from "@calcom/features/users/users.repository"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; @@ -63,13 +60,17 @@ import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBooke import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; +import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits"; import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { PrismaAttributeRepository as AttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; +import type { BookingRepository } from "../repositories/BookingRepository"; import type { HostRepository } from "@calcom/lib/server/repository/host"; import type { PrismaOOORepository as OooRepository } from "@calcom/lib/server/repository/ooo"; +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; import { WorkflowService } from "@calcom/lib/server/service/workflows"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; @@ -154,7 +155,9 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) { return originalRescheduledBooking.iCalSequence + 1; } -type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }; +type CreatedBooking = Booking & { + isShortCircuitedBooking?: boolean; +} & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }; type ReturnTypeCreateBooking = Awaited>; export const buildDryRunBooking = ({ eventTypeId, @@ -505,6 +508,12 @@ async function handler( await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); + const spamCheckService = getSpamCheckService(); + // Either it is a team event or a managed child event of a managed event + const team = eventType.team ?? eventType.parent?.team ?? null; + const eventOrganizationId = team?.parentId ?? null; + spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId }); + if (!rawBookingData.rescheduleUid) { await checkActiveBookingsLimitForBooker({ eventTypeId, @@ -1404,6 +1413,88 @@ async function handler( organizerUser.id ); + const spamCheckResult = await spamCheckService.waitForCheck(); + + if (spamCheckResult.isBlocked) { + const DECOY_ORGANIZER_NAMES = ["Alex Smith", "Jordan Taylor", "Sam Johnson", "Chris Morgan"]; + const randomOrganizerName = + DECOY_ORGANIZER_NAMES[Math.floor(Math.random() * DECOY_ORGANIZER_NAMES.length)]; + + const eventName = getEventName({ + ...eventNameObject, + host: randomOrganizerName, + }); + + return { + id: 0, + uid, + iCalUID: "", + status: BookingStatus.ACCEPTED, + eventTypeId: eventType.id, + user: { + name: randomOrganizerName, + timeZone: "UTC", + email: null, + }, + userId: null, + title: eventName, + startTime: new Date(reqBody.start), + endTime: new Date(reqBody.end), + createdAt: new Date(), + updatedAt: new Date(), + attendees: [ + { + id: 0, + email: bookerEmail, + name: fullName, + timeZone: reqBody.timeZone, + locale: null, + phoneNumber: null, + bookingId: null, + noShow: null, + }, + ], + oneTimePassword: null, + smsReminderNumber: null, + metadata: {}, + idempotencyKey: null, + userPrimaryEmail: null, + description: eventType.description || null, + customInputs: null, + responses: null, + location: bookingLocation, + paid: false, + cancellationReason: null, + rejectionReason: null, + dynamicEventSlugRef: null, + dynamicGroupSlugRef: null, + fromReschedule: null, + recurringEventId: null, + scheduledJobs: [], + rescheduledBy: null, + destinationCalendarId: null, + reassignReason: null, + reassignById: null, + rescheduled: false, + isRecorded: false, + iCalSequence: 0, + rating: null, + ratingFeedback: null, + noShowHost: null, + cancelledBy: null, + creationSource: CreationSource.WEBAPP, + references: [], + payment: [], + isDryRun: false, + paymentRequired: false, + paymentUid: undefined, + luckyUsers: [], + paymentId: undefined, + seatReferenceUid: undefined, + isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users + } + } + // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot) { const newBooking = await handleSeats({ diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 5c3507ce1d0fc2..26394861bf9ce5 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -97,6 +97,7 @@ const getEventTypesFromDBSelect = { team: { select: { id: true, + parentId: true, bookingLimits: true, includeManagedEventsInLimits: true, }, diff --git a/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts new file mode 100644 index 00000000000000..9f33cf0f080d6e --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/spam-booking.integration-test.ts @@ -0,0 +1,789 @@ +import { + createBookingScenario, + TestData, + getGoogleCalendarCredential, + getOrganizer, + getBooker, + getScenarioData, + mockCalendarToHaveNoBusySlots, + mockCalendarToCrashOnGetAvailability, + BookingLocations, + createOrganization, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { prisma } from "@calcom/prisma" +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, expect, vi } from "vitest"; + +import { WatchlistType, BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { getNewBookingHandler } from "./getNewBookingHandler"; + +const timeout = process.env.CI ? 5000 : 20000; + +const createTestWatchlistEntry = async (overrides: { + type: WatchlistType; + value: string; + organizationId: number | null; + action: "BLOCK" | "REPORT"; +}) => { + return prisma.watchlist.create({ + data: { + type: overrides.type, + value: overrides.value, + action: overrides.action, + createdById: 0, + organizationId: overrides.organizationId, + isGlobal: overrides.organizationId !== null ? false : true, + }, + }); +}; + +const createGlobalWatchlistEntry = async (overrides: { + type: WatchlistType; + value: string; + action: "BLOCK" | "REPORT"; +}) => { + return createTestWatchlistEntry({ + type: overrides.type, + value: overrides.value, + action: overrides.action, + organizationId: null, + }); +}; + +const createOrganizationWatchlistEntry = async ( + organizationId: number, + overrides: { + type: WatchlistType; + value: string; + action: "BLOCK" | "REPORT"; + } +) => { + return createTestWatchlistEntry({ + type: overrides.type, + value: overrides.value, + action: overrides.action, + organizationId, + }); +}; + +const expectDecoyBookingResponse = (booking: Record) => { + expect(booking).toHaveProperty("isShortCircuitedBooking", true); + expect(booking).toHaveProperty("uid"); + expect(booking.uid).toBeTruthy(); + expect(booking).toHaveProperty("status", BookingStatus.ACCEPTED); + expect(booking).toHaveProperty("id", 0); +}; + +const expectNoBookingInDatabase = async (bookerEmail: string) => { + const bookings = await prisma.booking.findMany({ + where: { + attendees: { + some: { + email: bookerEmail, + }, + }, + }, + }); + expect(bookings).toHaveLength(0); +}; + +describe("handleNewBooking - Spam Detection", () => { + setupAndTeardown(); + + describe("Global Watchlist Blocking:", () => { + test( + "should block booking when email is in global watchlist and return decoy response", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "spammer@example.com"; + + const booker = getBooker({ + email: blockedEmail, + name: "Blocked Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + await createGlobalWatchlistEntry({ + type: WatchlistType.EMAIL, + value: blockedEmail, + action: "BLOCK", + }); + + console.log("watchlists", await prisma.watchlist.findMany()); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expectDecoyBookingResponse(createdBooking); + expect(createdBooking.attendees[0].email).toBe(blockedEmail); + await expectNoBookingInDatabase(blockedEmail); + }, + timeout + ); + + test( + "should fail with there are no available users instead of returning decoy response because spam check happens in parallel with availability check", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "spammer@example.com"; + + const booker = getBooker({ + email: blockedEmail, + name: "Blocked Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + await createGlobalWatchlistEntry({ + type: WatchlistType.EMAIL, + value: blockedEmail, + action: "BLOCK", + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + await mockCalendarToCrashOnGetAvailability("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + await expect(async () => { + await handleNewBooking({ + bookingData: mockBookingData, + }); + }).rejects.toThrow("no_available_users_found_error"); + }, + timeout + ); + + test( + "should block booking when domain is in global watchlist and return decoy response", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedDomain = "@globalspammydomain.com"; + const blockedEmail = `user${blockedDomain}`; + + const booker = getBooker({ + email: blockedEmail, + name: "Global Domain Blocked Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + await createGlobalWatchlistEntry({ + type: WatchlistType.DOMAIN, + value: blockedDomain, + action: "BLOCK", + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expectDecoyBookingResponse(createdBooking); + expect(createdBooking.attendees[0].email).toBe(blockedEmail); + await expectNoBookingInDatabase(blockedEmail); + }, + timeout + ); + + test( + "should allow booking when spamCheckService.startCheck throws error (fail-open behavior)", + async () => { + const handleNewBooking = getNewBookingHandler(); + const bookerEmail = "failopen@example.com"; + + const booker = getBooker({ + email: bookerEmail, + name: "Fail Open Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + // Mock the SpamCheckService to throw an error during isBlocked check + const { getSpamCheckService } = await import( + "@calcom/features/di/watchlist/containers/SpamCheckService.container" + ); + const spamCheckService = getSpamCheckService(); + const originalIsBlocked = spamCheckService["isBlocked"].bind(spamCheckService); + + // Use vi.spyOn to mock the private method + const isBlockedSpy = vi.spyOn(spamCheckService as never, "isBlocked"); + isBlockedSpy.mockRejectedValue(new Error("Database connection failed")); + + try { + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + // Should NOT be a decoy response - booking should succeed due to fail-open behavior + expect(createdBooking).not.toHaveProperty("isShortCircuitedBooking"); + expect(createdBooking.status).toBe(BookingStatus.ACCEPTED); + expect(createdBooking.id).not.toBe(0); + expect(createdBooking.attendees[0].email).toBe(bookerEmail); + } finally { + // Restore original implementation and clean up spy + isBlockedSpy.mockRestore(); + spamCheckService["isBlocked"] = originalIsBlocked; + } + }, + timeout + ); + }); + + describe("Organization Watchlist Blocking:", () => { + test( + "should block booking when email is in organization watchlist and return decoy response", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "org-spammer@example.com"; + + // Create organization with a team + const org = await createOrganization({ + name: "Test Org", + slug: "test-org", + withTeam: true, + }); + + const booker = getBooker({ + email: blockedEmail, + name: "Org Blocked Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + organizationId: org.id, + }); + + await createOrganizationWatchlistEntry(org.id, { + type: WatchlistType.EMAIL, + value: blockedEmail, + action: "BLOCK", + }); + + // Use the child team ID for the eventType + const teamId = org.children && org.children.length > 0 ? org.children[0].id : null; + + await createBookingScenario( + getScenarioData( + { + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + teamId, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expectDecoyBookingResponse(createdBooking); + expect(createdBooking.attendees[0].email).toBe(blockedEmail); + await expectNoBookingInDatabase(blockedEmail); + }, + timeout + ); + + test( + "should block booking when domain is in organization watchlist and return decoy response", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedDomain = "@spammydomain.com"; + const blockedEmail = `user${blockedDomain}`; + + // Create organization with a team + const org = await createOrganization({ + name: "Test Org", + slug: "test-org", + withTeam: true, + }); + + const booker = getBooker({ + email: blockedEmail, + name: "Domain Blocked Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + organizationId: org.id, + }); + + await createOrganizationWatchlistEntry(org.id, { + type: WatchlistType.DOMAIN, + value: blockedDomain, + action: "BLOCK", + }); + + // Use the child team ID for the eventType + const teamId = org.children && org.children.length > 0 ? org.children[0].id : null; + + await createBookingScenario( + getScenarioData( + { + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + teamId, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expectDecoyBookingResponse(createdBooking); + expect(createdBooking.attendees[0].email).toBe(blockedEmail); + await expectNoBookingInDatabase(blockedEmail); + }, + timeout + ); + + test( + "should NOT block booking when email is in a different organization's watchlist", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "different-org-user@example.com"; + + // Create two different organizations with teams + const organizerOrg = await createOrganization({ + name: "Organizer Org", + slug: "organizer-org", + withTeam: true, + }); + + const differentOrg = await createOrganization({ + name: "Different Org", + slug: "different-org", + withTeam: true, + }); + + const booker = getBooker({ + email: blockedEmail, + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + organizationId: organizerOrg.id, + }); + + // Block email in a DIFFERENT organization + await createOrganizationWatchlistEntry(differentOrg.id, { + type: WatchlistType.EMAIL, + value: blockedEmail, + action: "BLOCK", + }); + + // Use the child team ID for the eventType + const teamId = + organizerOrg.children && organizerOrg.children.length > 0 ? organizerOrg.children[0].id : null; + + await createBookingScenario( + getScenarioData( + { + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + teamId, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: organizerOrg.id } + ) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + // Should NOT be a decoy response - booking should succeed + expect(createdBooking).not.toHaveProperty("isShortCircuitedBooking"); + expect(createdBooking.status).toBe(BookingStatus.ACCEPTED); + expect(createdBooking.id).not.toBe(0); + expect(createdBooking.attendees[0].email).toBe(blockedEmail); + }, + timeout + ); + + test( + "should block booking for managed event when email is in organization watchlist", + async () => { + const handleNewBooking = getNewBookingHandler(); + const blockedEmail = "managed-event-spammer@example.com"; + + // Create organization with a team + const org = await createOrganization({ + name: "Managed Event Org", + slug: "managed-event-org", + withTeam: true, + }); + + const booker = getBooker({ + email: blockedEmail, + name: "Managed Event Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + organizationId: org.id, + }); + + await createOrganizationWatchlistEntry(org.id, { + type: WatchlistType.EMAIL, + value: blockedEmail, + action: "BLOCK", + }); + + // Get the child team ID + const teamId = org.children && org.children.length > 0 ? org.children[0].id : null; + + // Create a parent event type and a managed (child) event type + await createBookingScenario( + getScenarioData( + { + eventTypes: [ + // Parent event type + { + id: 1, + slotInterval: 30, + length: 30, + teamId, + users: [ + { + id: 101, + }, + ], + }, + // Managed event type (child) - has parent but no teamId + { + id: 2, + slotInterval: 30, + length: 30, + parent: { id: 1 }, // References parent event type + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }, + { id: org.id } + ) + ); + + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + // Try to book the managed event (id: 2) + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 2, // Booking the managed event + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + // Should return a decoy response since email is blocked in the organization + expectDecoyBookingResponse(createdBooking); + expect(createdBooking.attendees[0].email).toBe(blockedEmail); + await expectNoBookingInDatabase(blockedEmail); + }, + timeout + ); + }); +}); diff --git a/packages/features/di/watchlist/containers/SpamCheckService.container.ts b/packages/features/di/watchlist/containers/SpamCheckService.container.ts new file mode 100644 index 00000000000000..72664598d96640 --- /dev/null +++ b/packages/features/di/watchlist/containers/SpamCheckService.container.ts @@ -0,0 +1,11 @@ +import type { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService"; +import type { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService"; +import { SpamCheckService } from "@calcom/features/watchlist/lib/service/SpamCheckService"; + +import { getGlobalBlockingService, getOrganizationBlockingService } from "./watchlist"; + +export const getSpamCheckService = (): SpamCheckService => { + const globalBlockingService = getGlobalBlockingService() as GlobalBlockingService; + const organizationBlockingService = getOrganizationBlockingService() as OrganizationBlockingService; + return new SpamCheckService(globalBlockingService, organizationBlockingService); +}; diff --git a/packages/features/di/watchlist/modules/Watchlist.module.ts b/packages/features/di/watchlist/modules/Watchlist.module.ts index 983355d386ed2a..243c01eabba81b 100644 --- a/packages/features/di/watchlist/modules/Watchlist.module.ts +++ b/packages/features/di/watchlist/modules/Watchlist.module.ts @@ -6,9 +6,19 @@ import { OrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/ import { PrismaWatchlistAuditRepository } from "@calcom/features/watchlist/lib/repository/PrismaWatchlistAuditRepository"; import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens"; +import { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService"; +import { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService"; export const watchlistModule = createModule(); +watchlistModule.bind(WATCHLIST_DI_TOKENS.GLOBAL_BLOCKING_SERVICE).toClass(GlobalBlockingService, { + globalRepo: DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY, +}); + +watchlistModule.bind(WATCHLIST_DI_TOKENS.ORGANIZATION_BLOCKING_SERVICE).toClass(OrganizationBlockingService,{ + orgRepo: DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY, +}); + // Bind specialized repositories watchlistModule .bind(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY) diff --git a/packages/features/watchlist/lib/service/SpamCheckService.ts b/packages/features/watchlist/lib/service/SpamCheckService.ts new file mode 100644 index 00000000000000..7b2d6a0bf390da --- /dev/null +++ b/packages/features/watchlist/lib/service/SpamCheckService.ts @@ -0,0 +1,63 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +import type { BlockingResult } from "../interface/IBlockingService"; +import type { GlobalBlockingService } from "./GlobalBlockingService"; +import type { OrganizationBlockingService } from "./OrganizationBlockingService"; + +/** + * Spam Check Service - Orchestrates spam checking by coordinating blocking checks + * + * Checks both global watchlist entries (via GlobalBlockingService) and organization-specific + * watchlist entries (via OrganizationBlockingService) when an organizationId is provided. + */ +export class SpamCheckService { + private spamCheckPromise: Promise | null = null; + + constructor( + private readonly globalBlockingService: GlobalBlockingService, + private readonly organizationBlockingService: OrganizationBlockingService + ) { } + + startCheck({ email, organizationId }: { email: string, organizationId: number | null }): void { + this.spamCheckPromise = this.isBlocked(email, organizationId ?? undefined).catch((error) => { + logger.error("Error starting spam check", safeStringify(error)); + return { isBlocked: false }; + }); + } + + async waitForCheck(): Promise { + if (!this.spamCheckPromise) { + throw new Error( + "waitForCheck() called before startCheck(). You must call startCheck() first to initialize spam checking." + ); + } + return await this.spamCheckPromise; + } + + /** + * Checks if an email is blocked by global or organization-specific watchlist rules + * Runs both checks in parallel for better performance + */ + private async isBlocked(email: string, organizationId?: number): Promise { + const checks = [this.globalBlockingService.isBlocked(email)]; + + if (organizationId) { + checks.push(this.organizationBlockingService.isBlocked(email, organizationId)); + } + + const [globalResult, orgResult] = await Promise.all(checks); + + // Global blocking takes precedence + if (globalResult.isBlocked) { + return globalResult; + } + + // Check organization blocking if it was performed + if (orgResult?.isBlocked) { + return orgResult; + } + + return { isBlocked: false }; + } +}