feat: Add async spam check and decoy booking response (#24326)
* feat: Add async spam check integration and decoy booking response - Integrate SpamCheckService with handleNewBooking workflow - Implement parallel spam check execution for minimal performance impact - Add decoy booking response with localStorage-based success page - Extract organization ID from event type for org-specific blocking - Add comprehensive test coverage for spam detection scenarios - Create reusable components for booking success cards - Implement fail-open behavior to never block legitimate bookings This builds on the spam blocker DI infrastructure from PR #24040 by adding the actual integration into the booking flow and implementing the decoy response mechanism to avoid revealing spam detection to malicious actors. Related: #24040 Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Do checks in paralle * Fix leaking host name in title * Reduce expoiry time localstorage --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
09ee39a3d8
commit
a0e5e03903
@@ -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 (
|
||||
<DecoyBookingSuccessCard
|
||||
title={booking.title || "Booking"}
|
||||
formattedDate={formattedDate}
|
||||
formattedTime={formattedTime}
|
||||
endTime={formattedEndTime}
|
||||
formattedTimeZone={formattedTimeZone}
|
||||
hostName={hostName}
|
||||
hostEmail={hostEmail}
|
||||
attendeeName={attendeeName}
|
||||
attendeeEmail={attendeeEmail}
|
||||
location={booking.location || null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-screen">
|
||||
<main className="mx-auto max-w-3xl">
|
||||
<div className="overflow-y-auto">
|
||||
<div className="flex items-end justify-center px-4 pb-20 pt-4 text-center sm:flex sm:p-0">
|
||||
<div className="main inset-0 my-4 flex flex-col transition-opacity sm:my-0" aria-hidden="true">
|
||||
<div
|
||||
className="bg-default dark:bg-muted border-booker border-booker-width inline-block transform overflow-hidden rounded-lg px-8 pb-4 pt-5 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-xl sm:py-8 sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 mt-6 text-center last:mb-0">
|
||||
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
|
||||
{t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="text-default">{t("emailed_you_and_any_other_attendees")}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{title}</div>
|
||||
|
||||
{formattedDate && (
|
||||
<>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{formattedDate}
|
||||
{formattedTime && (
|
||||
<>
|
||||
<br />
|
||||
{formattedTime}
|
||||
{endTime && ` - ${endTime}`}
|
||||
{formattedTimeZone && (
|
||||
<span className="text-bookinglight"> ({formattedTimeZone})</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 last:mb-0">
|
||||
{hostName && (
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<span className="mr-2">{hostName}</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
{hostEmail && <p className="text-default">{hostEmail}</p>}
|
||||
</div>
|
||||
)}
|
||||
{attendeeName && (
|
||||
<div className="mb-3 last:mb-0">
|
||||
<p>{attendeeName}</p>
|
||||
{attendeeEmail && <p>{attendeeEmail}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{location && (
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">{t("web_conferencing_details_to_follow")}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof createInstantBookingMutation.mutate>[0]
|
||||
) => {
|
||||
handleInstantBooking: (variables: Parameters<typeof createInstantBookingMutation.mutate>[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);
|
||||
|
||||
@@ -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<DecoyBookingData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const data = getDecoyBooking(uid);
|
||||
|
||||
if (!data) {
|
||||
router.push("/404");
|
||||
return;
|
||||
}
|
||||
|
||||
setBookingData(data);
|
||||
}, [uid, router]);
|
||||
|
||||
return bookingData;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-screen">
|
||||
<main className="mx-auto max-w-3xl">
|
||||
<div className="overflow-y-auto">
|
||||
<div className="flex items-end justify-center px-4 pb-20 pt-4 text-center sm:flex sm:p-0">
|
||||
<div className="main inset-0 my-4 flex flex-col transition-opacity sm:my-0" aria-hidden="true">
|
||||
<div
|
||||
className="bg-default dark:bg-muted border-booker border-booker-width inline-block transform overflow-hidden rounded-lg px-8 pb-4 pt-5 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-xl sm:py-8 sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 mt-6 text-center last:mb-0">
|
||||
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
|
||||
{t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="text-default">{t("emailed_you_and_any_other_attendees")}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{title}</div>
|
||||
|
||||
{formattedDate && (
|
||||
<>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{formattedDate}
|
||||
{formattedTime && (
|
||||
<>
|
||||
<br />
|
||||
{formattedTime}
|
||||
{endTime && ` - ${endTime}`}
|
||||
{formattedTimeZone && (
|
||||
<span className="text-bookinglight"> ({formattedTimeZone})</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 last:mb-0">
|
||||
{hostName && (
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<span className="mr-2">{hostName}</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
{hostEmail && <p className="text-default">{hostEmail}</p>}
|
||||
</div>
|
||||
)}
|
||||
{attendeeName && (
|
||||
<div className="mb-3 last:mb-0">
|
||||
<p>{attendeeName}</p>
|
||||
{attendeeEmail && <p>{attendeeEmail}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{location && (
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">{t("web_conferencing_details_to_follow")}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> & { 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 };
|
||||
@@ -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<ReturnType<typeof createBooking>>;
|
||||
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({
|
||||
|
||||
@@ -97,6 +97,7 @@ const getEventTypesFromDBSelect = {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
bookingLimits: true,
|
||||
includeManagedEventsInLimits: true,
|
||||
},
|
||||
|
||||
+789
@@ -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<string, unknown>) => {
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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<BlockingResult> | 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<BlockingResult> {
|
||||
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<BlockingResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user