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
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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<ReturnType<typeof presenter>> {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const user: User & { credentials: CredentialPayload[] } = {
|
||||
allowDynamicBooking: true,
|
||||
timeFormat: 12,
|
||||
travelSchedules: [],
|
||||
locked: false,
|
||||
};
|
||||
|
||||
const customInputs: CustomInputSchema[] = [];
|
||||
|
||||
@@ -52,6 +52,7 @@ export const userSelect = Prisma.validator<Prisma.UserArgs>()({
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
metadata: true,
|
||||
locked: true,
|
||||
...availabilityUserSelect,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user