From c0aa97178c8e72ae5b2d8a2eb3a4d31df2113ed6 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 27 Mar 2025 21:11:10 -0400 Subject: [PATCH] feat: Prevent watchlisted entities from creating bookings (#19107) * Add `searchForAllBlockedRecords` to watchlist repository * Create `checkIfUsersAreBlocked` * Call `checkIfUsersAreBlocked` in `loadAndValidateUsers` * Draft test * Add tests * Type fix * Type fix --- .../utils/bookingScenario/bookingScenario.ts | 3 + .../handleNewBooking/loadAndValidateUsers.ts | 7 + .../test/fresh-booking.test.ts | 246 +++++++++++++++++- packages/features/watchlist/lib/testUtils.ts | 18 ++ .../check-if-users-are-blocked.controller.ts | 35 +++ .../watchlist/watchlist.repository.ts | 56 ++++ packages/lib/defaultEvents.ts | 1 + packages/prisma/selects/user.ts | 1 + 8 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 packages/features/watchlist/lib/testUtils.ts create mode 100644 packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 190edfbaa2..d363c660f5 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -1433,6 +1433,7 @@ export function getOrganizer({ smsLockState, completedOnboarding, username, + locked, }: { name: string; email: string; @@ -1449,6 +1450,7 @@ export function getOrganizer({ smsLockState?: SMSLockState; completedOnboarding?: boolean; username?: string; + locked?: boolean; }) { username = username ?? TestData.users.example.username; return { @@ -1469,6 +1471,7 @@ export function getOrganizer({ metadata, smsLockState, completedOnboarding, + locked, }; } diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index 438c28f72d..445562af61 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { IncomingMessage } from "http"; import type { Logger } from "tslog"; +import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller"; import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials"; import { enrichUsersWithDelegationCredentials } from "@calcom/lib/delegationCredential/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; @@ -120,6 +121,12 @@ export async function loadAndValidateUsers({ } if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); + + // Determine if users are locked + const containsBlockedUser = await checkIfUsersAreBlocked(users); + + if (containsBlockedUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); + // map fixed users users = users.map((user) => ({ ...user, diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 6aa2a4f32c..e21648924a 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -54,10 +54,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { describe, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { createWatchlistEntry } from "@calcom/features/watchlist/lib/testUtils"; import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { resetTestEmails } from "@calcom/lib/testEmails"; -import { CreationSource } from "@calcom/prisma/enums"; +import { CreationSource, WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -3333,5 +3334,248 @@ describe("handleNewBooking", () => { }); }); + describe("Blocked users", () => { + test("user is locked", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "user@lockeduser.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + locked: true, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound"); + }); + + test("username is critical in watchlist", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + username: "spammer", + email: "spam@spammer.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + locked: true, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + await createWatchlistEntry({ + type: WatchlistType.USERNAME, + severity: WatchlistSeverity.CRITICAL, + value: organizer.username, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound"); + }); + + test("domain is critical in watchlist", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + username: "spammer", + email: "spam@spammer.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + locked: true, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + await createWatchlistEntry({ + type: WatchlistType.DOMAIN, + severity: WatchlistSeverity.CRITICAL, + value: "spammer.com", + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound"); + }); + + test("domain is critical in watchlist", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + username: "spammer", + email: "spam@spammer.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + locked: true, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + await createWatchlistEntry({ + type: WatchlistType.EMAIL, + severity: WatchlistSeverity.CRITICAL, + value: "spam@spammer.com", + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("eventTypeUser.notFound"); + }); + }); + test.todo("CRM calendar events creation verification"); }); diff --git a/packages/features/watchlist/lib/testUtils.ts b/packages/features/watchlist/lib/testUtils.ts new file mode 100644 index 0000000000..84a92916b0 --- /dev/null +++ b/packages/features/watchlist/lib/testUtils.ts @@ -0,0 +1,18 @@ +import prismock from "../../../../tests/libs/__mocks__/prisma"; + +import type { WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums"; + +interface WatchlistInput { + severity: WatchlistSeverity; + type: WatchlistType; + value: string; +} + +export const createWatchlistEntry = async (input: WatchlistInput) => { + await prismock.watchlist.create({ + data: { + ...input, + createdById: 0, + }, + }); +}; diff --git a/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts b/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts new file mode 100644 index 0000000000..62b8e34e13 --- /dev/null +++ b/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts @@ -0,0 +1,35 @@ +import { startSpan } from "@sentry/nextjs"; + +import { WatchlistRepository } from "../watchlist.repository"; + +function presenter(containsBlockedUser: boolean) { + return startSpan({ name: "checkIfUsersAreBlocked Presenter", op: "serialize" }, () => { + return !!containsBlockedUser; + }); +} + +export async function checkIfUsersAreBlocked( + users: { email: string; username: string | null; locked: boolean }[] +): Promise> { + const usernamesToCheck = [], + emailsToCheck = [], + domainsToCheck = []; + + for (const user of users) { + // If any user is locked, then return true + if (user.locked) return true; + const emailDomain = user.email.split("@")[1]; + if (user.username) usernamesToCheck.push(user.username); + emailsToCheck.push(user.email); + domainsToCheck.push(emailDomain); + } + + const watchlistRepository = new WatchlistRepository(); + const blockedRecords = await watchlistRepository.searchForAllBlockedRecords({ + usernames: usernamesToCheck, + emails: emailsToCheck, + domains: domainsToCheck, + }); + + return presenter(blockedRecords.length > 0); +} diff --git a/packages/features/watchlist/watchlist.repository.ts b/packages/features/watchlist/watchlist.repository.ts index bb6e0cbb4f..d224de57d4 100644 --- a/packages/features/watchlist/watchlist.repository.ts +++ b/packages/features/watchlist/watchlist.repository.ts @@ -39,4 +39,60 @@ export class WatchlistRepository implements IWatchlistRepository { throw err; } } + + /** Returns a boolean if any of the users passed are blocked */ + async searchForAllBlockedRecords({ + usernames, + emails, + domains, + }: { + usernames: string[]; + emails: string[]; + domains: string[]; + }) { + try { + const blockedRecords = await db.watchlist.findMany({ + where: { + severity: WatchlistSeverity.CRITICAL, + OR: [ + ...(usernames.length > 0 + ? [ + { + type: WatchlistType.USERNAME, + value: { + in: usernames, + }, + }, + ] + : []), + ...(emails.length > 0 + ? [ + { + type: WatchlistType.EMAIL, + value: { + in: emails, + }, + }, + ] + : []), + ...(domains.length > 0 + ? [ + { + type: WatchlistType.DOMAIN, + value: { + in: domains, + }, + }, + ] + : []), + ], + }, + }); + return blockedRecords; + } catch (err) { + const captureException = (await import("@sentry/nextjs")).captureException; + captureException(err); + throw err; + } + } } diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index db42fb6f85..4e5af9f15b 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -55,6 +55,7 @@ const user: User & { credentials: CredentialPayload[] } = { allowDynamicBooking: true, timeFormat: 12, travelSchedules: [], + locked: false, }; const customInputs: CustomInputSchema[] = []; diff --git a/packages/prisma/selects/user.ts b/packages/prisma/selects/user.ts index f5564d7835..af9468e907 100644 --- a/packages/prisma/selects/user.ts +++ b/packages/prisma/selects/user.ts @@ -52,6 +52,7 @@ export const userSelect = Prisma.validator()({ brandColor: true, darkBrandColor: true, metadata: true, + locked: true, ...availabilityUserSelect, }, });