feat: Add spam blocker DI structure (#24040)

* --init

* --

* replace old structure with new DI

* fix type

* --

* moving stuff around

* moving stuff around again

* minor clean up

* --

* resolve conflict

* clea up

* old schema clean up

* further clean up

* removing unwanted merged responsibilities

* --

* improve DI and SOLID

* some type fixes

* --1

* clean up --cont

* fix DI in facade for test

* more fix

* fix

* checking

* o.o

* fix import

* fix failing test --2

* fix failing test --3

* fix failing test --4

* normalization use

* introduce facade container injection

* uniform async telemetry spans

* further improvements

* replace prismock with repo mocks

* ensure we don't pass prisma outside of repo

* fix import

* remove try catch from repo calls

* async await fixes

* using deps pattern

* more clean up and fixes

* more clean up

* address feedback --1

* separation of concern

* clean up

* test clean up

* feedback --2

* remove extra fetch

* remove await

* migrate _post test from prismock

* fix type

* --

* fix tokens path

* rename AuditRepo

* test --1

* update _post to integration test

* fix test

* fix test

* test fix maybe?

* --

* feedback

* feedback

* fixes

* more feedback

* NIT

* use sentry and logger imports as planned

* assertion in test

* add missing test case

* add tests for controllers and services

* NITs

* fix domain normalisation
This commit is contained in:
Syed Ali Shahbaz
2025-10-14 10:16:18 +03:00
committed by GitHub
parent 52ecea964d
commit a2bee76da6
60 changed files with 3599 additions and 461 deletions
+89 -63
View File
@@ -1,19 +1,42 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
/**
* Unit Tests for verifyApiKey middleware
*
* These tests verify the middleware logic without touching the database.
* All dependencies (repositories, utilities) are mocked.
*/
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { isAdminGuard } from "../utils/isAdmin";
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
import { verifyApiKey } from "./verifyApiKey";
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
ApiKeyService: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
PrismaApiKeyRepository: vi.fn(),
}));
vi.mock("../utils/isAdmin", () => ({
isAdminGuard: vi.fn(),
}));
vi.mock("../utils/isLockedOrBlocked", () => ({
isLockedOrBlocked: vi.fn(),
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
@@ -32,17 +55,29 @@ afterEach(() => {
});
const mockDeploymentRepository: IDeploymentRepository = {
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"),
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
};
describe("Verify API key", () => {
describe("Verify API key - Unit Tests", () => {
let service: ILicenseKeyService;
let mockApiKeyService: ApiKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create(mockDeploymentRepository);
vi.spyOn(service, "checkLicense");
vi.spyOn(LicenseKeySingleton, "getInstance").mockResolvedValue(service as LicenseKeyService);
mockApiKeyService = {
verifyKeyByHashedKey: vi.fn(),
} as unknown as ApiKeyService;
vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService);
vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository));
vi.mocked(isAdminGuard).mockReset();
vi.mocked(isLockedOrBlocked).mockReset();
});
it("should throw an error if the api key is not valid", async () => {
@@ -98,22 +133,25 @@ describe("Verify API key", () => {
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
locked: false,
},
},
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
valid: true,
userId: 1,
user: {
role: UserPermissionRole.ADMIN,
locked: false,
email: "admin@example.com",
},
});
vi.mocked(isAdminGuard).mockResolvedValue({
isAdmin: true,
scope: ScopeOfAdmin.SystemWide,
});
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
const middleware = {
fn: verifyApiKey,
};
@@ -139,40 +177,25 @@ describe("Verify API key", () => {
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "org-admin@acme.com",
role: UserPermissionRole.USER,
locked: false,
teams: {
create: {
accepted: true,
role: MembershipRole.OWNER,
team: {
create: {
name: "ACME",
isOrganization: true,
organizationSettings: {
create: {
isAdminAPIEnabled: true,
orgAutoAcceptEmail: "acme.com",
},
},
},
},
},
},
},
},
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
valid: true,
userId: 2,
user: {
role: UserPermissionRole.USER,
locked: false,
email: "org-admin@acme.com",
},
});
vi.mocked(isAdminGuard).mockResolvedValue({
isAdmin: true,
scope: ScopeOfAdmin.OrgOwnerOrAdmin,
});
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
const middleware = {
fn: verifyApiKey,
};
@@ -198,22 +221,25 @@ describe("Verify API key", () => {
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "locked@example.com",
role: UserPermissionRole.USER,
locked: true,
},
},
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
valid: true,
userId: 3,
user: {
role: UserPermissionRole.USER,
locked: true,
email: "locked@example.com",
},
});
vi.mocked(isAdminGuard).mockResolvedValue({
isAdmin: false,
scope: ScopeOfAdmin.SystemWide,
});
vi.mocked(isLockedOrBlocked).mockResolvedValue(true);
const middleware = {
fn: verifyApiKey,
};
+13 -24
View File
@@ -3,21 +3,15 @@ import type { NextMiddleware } from "next-api-middleware";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import prisma from "@calcom/prisma";
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
import { prisma } from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {
const now = new Date();
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
return true;
}
};
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const deploymentRepo = new DeploymentRepository(prisma);
@@ -32,24 +26,19 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
const hashedKey = hashAPIKey(strippedApiKey);
const apiKey = await prisma.apiKey.findUnique({
where: { hashedKey },
include: {
user: {
select: { role: true, locked: true, email: true },
},
},
});
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
return res.status(401).json({ error: "This API key is expired." });
// Use service layer for API key verification
const apiKeyRepo = new PrismaApiKeyRepository(prisma);
const apiKeyService = new ApiKeyService({ apiKeyRepo });
const result = await apiKeyService.verifyKeyByHashedKey(hashedKey);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
if (!apiKey.userId || !apiKey.user)
return res.status(404).json({ error: "No user found for this API key." });
// save the user id in the request for later use
req.userId = apiKey.userId;
req.user = apiKey.user;
req.userId = result.userId!;
req.user = result.user!;
const { isAdmin, scope } = await isAdminGuard(req);
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
+9 -1
View File
@@ -1,9 +1,17 @@
import type { NextApiRequest } from "next";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
export async function isLockedOrBlocked(req: NextApiRequest) {
const user = req.user;
if (!user?.email) return false;
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
return (
user.locked ||
(await checkIfEmailIsBlockedInWatchlistController({
email: user.email,
organizationId: null,
span: sentrySpan,
}))
);
}
+60 -49
View File
@@ -1,9 +1,13 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
/**
* Unit Tests for POST /api/users
*
* These tests verify the API endpoint logic without touching the database.
* All dependencies (UserCreationService) are mocked.
*/
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, test, expect, vi } from "vitest";
import { describe, test, expect, vi, beforeEach } from "vitest";
import handler from "../../../pages/api/users/_post";
@@ -18,9 +22,41 @@ vi.mock("@calcom/lib/server/i18n", () => {
};
});
vi.mock("@calcom/features/profile/lib/checkUsername", () => ({
checkUsername: vi.fn().mockResolvedValue({
available: true,
premium: false,
}),
}));
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({
checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false),
}));
const mockCreate = vi.fn();
vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
UserRepository: vi.fn().mockImplementation(() => ({
create: mockCreate,
})),
}));
vi.mock("@calcom/lib/auth/hashPassword", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
}));
vi.stubEnv("CALCOM_LICENSE_KEY", undefined);
describe("POST /api/users", () => {
describe("POST /api/users - Unit Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreate.mockResolvedValue({
id: 1,
email: "test@example.com",
username: "test",
locked: false,
});
});
test("should throw 401 if not system-wide admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -34,7 +70,9 @@ describe("POST /api/users", () => {
await handler(req, res);
expect(res.statusCode).toBe(401);
expect(mockCreate).not.toHaveBeenCalled();
});
test("should throw a 400 if no email is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -47,7 +85,9 @@ describe("POST /api/users", () => {
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(mockCreate).not.toHaveBeenCalled();
});
test("should throw a 400 if no username is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -60,15 +100,24 @@ describe("POST /api/users", () => {
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(mockCreate).not.toHaveBeenCalled();
});
test("should create user successfully", async () => {
mockCreate.mockResolvedValue({
id: 1,
email: "test@example.com",
username: "testuser123",
locked: false,
organizationId: null,
});
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "test@example.com",
username: "test",
username: "testuser123",
},
prisma: prismock,
});
req.isSystemWideAdmin = true;
@@ -76,57 +125,19 @@ describe("POST /api/users", () => {
expect(res.statusCode).toBe(200);
const userQuery = await prismock.user.findFirst({
where: {
email: "test@example.com",
},
});
expect(userQuery).toEqual(
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
email: "test@example.com",
username: "test",
username: "testuser123",
locked: false,
organizationId: null,
})
);
});
test("should auto lock user if email is in watchlist", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "test@example.com",
username: "test",
},
prisma: prismock,
});
req.isSystemWideAdmin = true;
await prismock.watchlist.create({
data: {
type: "EMAIL",
value: "test@example.com",
severity: "CRITICAL",
createdById: 1,
},
});
await handler(req, res);
expect(res.statusCode).toBe(200);
const userQuery = await prismock.user.findFirst({
where: {
email: "test@example.com",
},
});
expect(userQuery).toEqual(
const responseData = JSON.parse(res._getData());
expect(responseData.user).toEqual(
expect.objectContaining({
email: "test@example.com",
username: "test",
locked: true,
username: "testuser123",
organizationId: null,
})
);
@@ -1,38 +1,52 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { NextApiRequest } from "next";
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import type { WatchlistFeature } from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
describe("isLockedOrBlocked", () => {
beforeEach(async () => {
await prismock.watchlist.createMany({
data: [
{
type: "DOMAIN",
value: "spam.com",
createdById: 1,
},
{
type: "DOMAIN",
value: "blocked.com",
createdById: 1,
},
],
});
let mockWatchlistFeature: WatchlistFeature;
beforeEach(() => {
const mockGlobalBlocking = {
isBlocked: vi.fn(),
};
const mockOrgBlocking = {
isBlocked: vi.fn(),
};
mockWatchlistFeature = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
} as unknown as WatchlistFeature;
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature);
});
afterEach(() => {
vi.resetAllMocks();
});
it("should return false if no user in request", async () => {
const req = { userId: null, user: null } as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
});
it("should return false if user has no email", async () => {
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
});
it("should return true if user is locked", async () => {
@@ -46,6 +60,8 @@ describe("isLockedOrBlocked", () => {
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
});
it("should return true if user email domain is watchlisted", async () => {
@@ -57,8 +73,12 @@ describe("isLockedOrBlocked", () => {
},
} as unknown as NextApiRequest;
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
});
it("should return false if user is not locked and email domain is not watchlisted", async () => {
@@ -70,8 +90,12 @@ describe("isLockedOrBlocked", () => {
},
} as unknown as NextApiRequest;
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: false });
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@example.com");
});
it("should handle email domains case-insensitively", async () => {
@@ -83,7 +107,11 @@ describe("isLockedOrBlocked", () => {
},
} as unknown as NextApiRequest;
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
});
});
@@ -4,7 +4,7 @@ import { RRule } from "rrule";
import { z } from "zod";
import { RoutingFormResponseDataFactory } from "@calcom/app-store/routing-forms/lib/RoutingFormResponseDataFactory";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/checkIfFreeEmailDomain";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { RetryableError } from "@calcom/lib/crmManager/errors";
@@ -1599,7 +1599,7 @@ export default class SalesforceCRMService implements CRM {
const appOptions = this.getAppOptions();
if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false;
const response = await checkIfFreeEmailDomain(attendeeEmail);
const response = await checkIfFreeEmailDomain({ email: attendeeEmail });
return response;
}
@@ -6,8 +6,6 @@
*
* CrmService.test.ts could still focus on testing detailed edge cases as needed.
*/
import "../../../../../tests/libs/__mocks__/prisma";
import jsforce from "@jsforce/jsforce-node";
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -46,7 +44,7 @@ vi.mock("@jsforce/jsforce-node", () => {
};
});
vi.mock("@calcom/features/watchlist/lib/checkIfFreeEmailDomain", () => ({
vi.mock("@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain", () => ({
checkIfFreeEmailDomain: vi.fn().mockResolvedValue(false),
}));
@@ -210,7 +208,7 @@ describe("SalesforceCRMService", () => {
Website: "https://anything",
});
const contact = salesforceMock.addContact({
const _contact = salesforceMock.addContact({
// Contact doesn't have the matching email
Email: contactEmail,
FirstName: "Test",
@@ -250,7 +248,7 @@ describe("SalesforceCRMService", () => {
const contactOwnerEmail = "contact-owner@acme.com";
const leadOwnerEmail = "lead-owner@acme.com";
const lookingForEmail = "test1@example.com";
const contactEmail = "test2@example.com";
const _contactEmail = "test2@example.com";
const account = salesforceMock.addAccount({
Id: "test-account-id",
@@ -271,7 +269,7 @@ describe("SalesforceCRMService", () => {
},
});
const lead = salesforceMock.addLead({
const _lead = salesforceMock.addLead({
// Lead has the matching email
Email: lookingForEmail,
FirstName: "Test",
+11 -4
View File
@@ -7,13 +7,14 @@ import {
sendChangeOfEmailVerificationLink,
} from "@calcom/emails/email-manager";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
@@ -43,7 +44,7 @@ export const sendEmailVerification = async ({
return { ok: true, skipped: true };
}
if (await checkIfEmailIsBlockedInWatchlistController(email)) {
if (await checkIfEmailIsBlockedInWatchlistController({ email, organizationId: null, span: sentrySpan })) {
log.warn("Email is blocked - not sending verification email", email);
return { ok: false, skipped: false };
}
@@ -90,7 +91,7 @@ export const sendEmailVerificationByCode = async ({
username,
isVerifyingEmail,
}: VerifyEmailType) => {
if (await checkIfEmailIsBlockedInWatchlistController(email)) {
if (await checkIfEmailIsBlockedInWatchlistController({ email, organizationId: null, span: sentrySpan })) {
log.warn("Email is blocked - not sending verification email", email);
return { ok: false, skipped: false };
}
@@ -136,7 +137,13 @@ export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOf
return { ok: true, skipped: true };
}
if (await checkIfEmailIsBlockedInWatchlistController(user.emailFrom)) {
if (
await checkIfEmailIsBlockedInWatchlistController({
email: user.emailFrom,
organizationId: null,
span: sentrySpan,
})
) {
log.warn("Email is blocked - not sending verification email", user.emailFrom);
return { ok: false, skipped: false };
}
@@ -7,6 +7,7 @@ import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -45,7 +46,11 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => {
const billingService = new StripeBillingService();
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(_email);
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController({
email: _email,
organizationId: null,
span: sentrySpan,
});
log.debug("handler", { email: _email });
@@ -1,5 +1,5 @@
import dayjs from "@calcom/dayjs";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/checkIfFreeEmailDomain";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { getEventTypeResponse } from "./getEventTypesFromDB";
@@ -56,7 +56,7 @@ const _determineRequiresConfirmation = async (
const requiresConfirmationForFreeEmail = eventType?.requiresConfirmationForFreeEmail;
if (requiresConfirmationForFreeEmail) {
requiresConfirmation = await checkIfFreeEmailDomain(bookerEmail);
requiresConfirmation = await checkIfFreeEmailDomain({ email: bookerEmail });
}
if (rcThreshold) {
@@ -3,6 +3,7 @@ import type { Logger } from "tslog";
import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import type { RoutingFormResponse } from "@calcom/features/bookings/lib/getLuckyUser";
import { getQualifiedHostsService } from "@calcom/features/di/containers/QualifiedHosts";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
@@ -130,7 +131,11 @@ const _loadAndValidateUsers = async ({
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
// Determine if users are locked
const containsBlockedUser = await checkIfUsersAreBlocked(users);
const containsBlockedUser = await checkIfUsersAreBlocked({
users,
organizationId: null,
span: sentrySpan,
});
if (containsBlockedUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
+4
View File
@@ -1,5 +1,7 @@
import { BOOKING_DI_TOKENS } from "@calcom/features/bookings/di/tokens";
import { WATCHLIST_DI_TOKENS } from "./watchlist/Watchlist.tokens";
export const DI_TOKENS = {
PRISMA_CLIENT: Symbol("PrismaClient"),
READ_ONLY_PRISMA_CLIENT: Symbol("ReadOnlyPrismaClient"),
@@ -55,4 +57,6 @@ export const DI_TOKENS = {
ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"),
// Booking service tokens
...BOOKING_DI_TOKENS,
// Watchlist service tokens
...WATCHLIST_DI_TOKENS,
};
@@ -0,0 +1,12 @@
export const WATCHLIST_DI_TOKENS = {
// Core services
WATCHLIST_SERVICE: Symbol("WatchlistService"),
GLOBAL_BLOCKING_SERVICE: Symbol("GlobalBlockingService"),
ORGANIZATION_BLOCKING_SERVICE: Symbol("OrganizationBlockingService"),
AUDIT_SERVICE: Symbol("WatchlistAuditService"),
// Repositories
GLOBAL_WATCHLIST_REPOSITORY: Symbol("GlobalWatchlistRepository"),
ORGANIZATION_WATCHLIST_REPOSITORY: Symbol("OrganizationWatchlistRepository"),
AUDIT_REPOSITORY: Symbol("PrismaWatchlistAuditRepository"),
};
@@ -0,0 +1,66 @@
import { createContainer } from "@evyweb/ioctopus";
import { loggerServiceModule } from "@calcom/features/di/shared/services/logger.service";
import { taskerServiceModule } from "@calcom/features/di/shared/services/tasker.service";
import { SHARED_TOKENS } from "@calcom/features/di/shared/shared.tokens";
import {
createWatchlistFeature,
type WatchlistFeature,
} from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
import type { IGlobalWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
import type { IOrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
import type { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
import type { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
import type { WatchlistAuditService } from "@calcom/features/watchlist/lib/service/WatchlistAuditService";
import type { WatchlistService } from "@calcom/features/watchlist/lib/service/WatchlistService";
import { moduleLoader as prismaModuleLoader } from "@calcom/prisma/prisma.module";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
import { watchlistModule } from "../modules/Watchlist.module";
export const watchlistContainer = createContainer();
prismaModuleLoader.loadModule(watchlistContainer);
watchlistContainer.load(SHARED_TOKENS.LOGGER, loggerServiceModule);
watchlistContainer.load(SHARED_TOKENS.TASKER, taskerServiceModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY, watchlistModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY, watchlistModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.AUDIT_REPOSITORY, watchlistModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.WATCHLIST_SERVICE, watchlistModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.AUDIT_SERVICE, watchlistModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.GLOBAL_BLOCKING_SERVICE, watchlistModule);
watchlistContainer.load(WATCHLIST_DI_TOKENS.ORGANIZATION_BLOCKING_SERVICE, watchlistModule);
export function getWatchlistService() {
return watchlistContainer.get<WatchlistService>(WATCHLIST_DI_TOKENS.WATCHLIST_SERVICE);
}
export function getGlobalBlockingService() {
return watchlistContainer.get<GlobalBlockingService>(WATCHLIST_DI_TOKENS.GLOBAL_BLOCKING_SERVICE);
}
export function getOrganizationBlockingService() {
return watchlistContainer.get<OrganizationBlockingService>(
WATCHLIST_DI_TOKENS.ORGANIZATION_BLOCKING_SERVICE
);
}
export function getAuditService() {
return watchlistContainer.get<WatchlistAuditService>(WATCHLIST_DI_TOKENS.AUDIT_SERVICE);
}
export function getGlobalWatchlistRepository() {
return watchlistContainer.get<IGlobalWatchlistRepository>(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY);
}
export function getOrganizationWatchlistRepository() {
return watchlistContainer.get<IOrganizationWatchlistRepository>(
WATCHLIST_DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY
);
}
export async function getWatchlistFeature(): Promise<WatchlistFeature> {
return createWatchlistFeature(watchlistContainer);
}
@@ -0,0 +1,26 @@
import { createModule } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
import { OrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/repository/OrganizationWatchlistRepository";
import { PrismaWatchlistAuditRepository } from "@calcom/features/watchlist/lib/repository/PrismaWatchlistAuditRepository";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
export const watchlistModule = createModule();
// Bind specialized repositories
watchlistModule
.bind(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY)
.toClass(GlobalWatchlistRepository, [DI_TOKENS.PRISMA_CLIENT]);
watchlistModule
.bind(WATCHLIST_DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY)
.toClass(OrganizationWatchlistRepository, [DI_TOKENS.PRISMA_CLIENT]);
// Bind remaining repositories
watchlistModule
.bind(WATCHLIST_DI_TOKENS.AUDIT_REPOSITORY)
.toClass(PrismaWatchlistAuditRepository, [DI_TOKENS.PRISMA_CLIENT]);
// Services are created in the facade to handle Deps pattern properly
@@ -1,8 +1,5 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { CreationSource } from "@calcom/prisma/enums";
@@ -24,16 +21,18 @@ vi.mock("@calcom/lib/auth/hashPassword", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
}));
const mockUserRepository = {
create: vi.fn(),
};
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
return {
UserRepository: vi.fn().mockImplementation(() => ({
create: vi.fn(),
})),
UserRepository: vi.fn().mockImplementation(() => mockUserRepository),
};
});
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", (async) => ({
checkIfEmailIsBlockedInWatchlistController: vi.fn(() => false),
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({
checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false),
}));
const mockUserData = {
@@ -46,7 +45,6 @@ vi.stubEnv("CALCOM_LICENSE_KEY", undefined);
describe("UserCreationService", () => {
beforeEach(() => {
prismock;
vi.clearAllMocks();
});
@@ -55,17 +53,9 @@ describe("UserCreationService", () => {
username: "test",
locked: false,
organizationId: null,
} as any);
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
create: mockCreate,
} as any)
);
}
mockUserRepository.create = mockCreate;
const user = await UserCreationService.createUser({ data: mockUserData });
@@ -87,17 +77,9 @@ describe("UserCreationService", () => {
username: "test",
locked: true,
organizationId: null,
} as any);
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
create: mockCreate,
} as any)
);
}
mockUserRepository.create = mockCreate;
const user = await UserCreationService.createUser({ data: mockUserData });
@@ -118,17 +100,9 @@ describe("UserCreationService", () => {
username: "test",
locked: false,
organizationId: null,
} as any);
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
create: mockCreate,
} as any)
);
}
mockUserRepository.create = mockCreate;
const user = await UserCreationService.createUser({
data: { ...mockUserData, password: mockPassword },
@@ -1,3 +1,4 @@
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import logger from "@calcom/lib/logger";
@@ -33,7 +34,11 @@ export class UserCreationService {
static async createUser({ data }: { data: CreateUserInput }) {
const { email, password, username } = data;
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(email);
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController({
email,
organizationId: data.organizationId ?? undefined,
span: sentrySpan,
});
const hashedPassword = password ? await hashPassword(password) : null;
@@ -48,7 +53,7 @@ export class UserCreationService {
log.info(`Created user: ${user.id} with locked status of ${user.locked}`);
const { locked, ...restUser } = user;
const { locked: _locked, ...restUser } = user;
return restUser;
}
@@ -1,41 +0,0 @@
import type { Mock } from "vitest";
import { describe, expect, test, vi, afterEach } from "vitest";
import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository";
import { checkIfFreeEmailDomain } from "./checkIfFreeEmailDomain";
vi.mock("@calcom/features/watchlist/watchlist.repository", () => {
return {
WatchlistRepository: vi.fn().mockImplementation(() => ({
getFreeEmailDomainInWatchlist: vi.fn().mockResolvedValue(undefined),
})),
};
});
describe("checkIfFreeEmailDomain", () => {
test("If gmail should return true", async () => {
expect(await checkIfFreeEmailDomain("test@gmail.com")).toBe(true);
});
test("If outlook should return true", async () => {
expect(await checkIfFreeEmailDomain("test@outlook.com")).toBe(true);
});
test("If there's no email domain return as if it was a free email domain", async () => {
expect(await checkIfFreeEmailDomain("test@")).toBe(true);
});
test("If free email domain in watchlist, should return true", async () => {
const WatchlistRepoMock = WatchlistRepository as Mock;
await checkIfFreeEmailDomain("test@freedomain.com");
expect(WatchlistRepoMock).toHaveBeenCalled();
const mockInstance = WatchlistRepoMock.mock.results.at(-1)?.value;
expect(mockInstance.getFreeEmailDomainInWatchlist).toHaveBeenCalledWith("freedomain.com");
});
afterEach(() => {
vi.clearAllMocks();
});
});
@@ -1,14 +0,0 @@
import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository";
export const checkIfFreeEmailDomain = async (email: string) => {
const emailDomain = email.split("@")[1].toLowerCase();
// If there's no email domain return as if it was a free email domain
if (!emailDomain) return true;
// Gmail and Outlook are one of the most common email domains so we don't need to check the domains list
if (emailDomain === "gmail.com" || emailDomain === "outlook.com") return true;
// Check if email domain is in the watchlist
const watchlistRepository = new WatchlistRepository();
return !!(await watchlistRepository.getFreeEmailDomainInWatchlist(emailDomain));
};
@@ -0,0 +1,22 @@
// Export all DTO types
export type {
WatchlistEntryDTO,
CreateWatchlistEntryDTO,
UpdateWatchlistEntryDTO,
WatchlistListResponseDTO,
WatchlistSearchDTO,
BlockingCheckResultDTO,
UsersBlockedCheckResponseDTO,
WatchlistErrorDTO,
BulkWatchlistOperationDTO,
BulkWatchlistResultDTO,
} from "./types";
// Export all mappers
export {
mapWatchlistToDTO,
mapWatchlistListToDTO,
mapBlockingResultToDTO,
sanitizeWatchlistEntryDTO,
sanitizeWatchlistValue,
} from "./mappers";
@@ -0,0 +1,119 @@
import type { User } from "@calcom/prisma/client";
import { WatchlistType } from "@calcom/prisma/enums";
import type { Watchlist } from "../types";
import { normalizeEmail, normalizeDomain, normalizeUsername } from "../utils/normalization";
import type { WatchlistEntryDTO, WatchlistListResponseDTO, BlockingCheckResultDTO } from "./types";
export function mapWatchlistToDTO(
watchlist: Watchlist & {
createdBy?: User | null;
updatedBy?: User | null;
}
): WatchlistEntryDTO {
return {
id: watchlist.id,
type: watchlist.type,
value: watchlist.value,
description: watchlist.description,
action: watchlist.action,
source: watchlist.source,
isGlobal: watchlist.isGlobal,
lastUpdatedAt: watchlist.lastUpdatedAt.toISOString(),
organizationId: watchlist.organizationId,
createdBy: watchlist.createdBy
? {
id: watchlist.createdBy.id,
name: watchlist.createdBy.name,
email: watchlist.createdBy.email,
avatarUrl: watchlist.createdBy.avatarUrl,
}
: null,
updatedBy: watchlist.updatedBy
? {
id: watchlist.updatedBy.id,
name: watchlist.updatedBy.name,
email: watchlist.updatedBy.email,
avatarUrl: watchlist.updatedBy.avatarUrl,
}
: null,
};
}
export function mapWatchlistListToDTO(
watchlists: Array<
Watchlist & {
createdBy?: User | null;
updatedBy?: User | null;
}
>,
pagination?: {
total: number;
page: number;
limit: number;
}
): WatchlistListResponseDTO {
return {
entries: watchlists.map(mapWatchlistToDTO),
pagination: pagination
? {
...pagination,
hasMore: pagination.page * pagination.limit < pagination.total,
}
: undefined,
};
}
export function mapBlockingResultToDTO(result: {
isBlocked: boolean;
reason?: WatchlistType;
watchlistEntry?: WatchlistEntryDTO | null;
}): BlockingCheckResultDTO {
return {
isBlocked: result.isBlocked,
reason: result.reason,
matchedEntry: result.watchlistEntry
? {
id: result.watchlistEntry.id,
type: result.watchlistEntry.type,
value: result.watchlistEntry.value,
action: result.watchlistEntry.action,
}
: undefined,
};
}
export function sanitizeWatchlistEntryDTO(dto: WatchlistEntryDTO): WatchlistEntryDTO {
return {
...dto,
createdBy: dto.createdBy
? {
id: dto.createdBy.id,
name: dto.createdBy.name,
email: "", // Hide email in public responses
avatarUrl: dto.createdBy.avatarUrl,
}
: null,
updatedBy: dto.updatedBy
? {
id: dto.updatedBy.id,
name: dto.updatedBy.name,
email: "", // Hide email in public responses
avatarUrl: dto.updatedBy.avatarUrl,
}
: null,
};
}
export function sanitizeWatchlistValue(type: WatchlistType, value: string): string {
switch (type) {
case WatchlistType.EMAIL:
return normalizeEmail(value);
case WatchlistType.DOMAIN:
return normalizeDomain(value);
case WatchlistType.USERNAME:
return normalizeUsername(value);
default:
return value.trim();
}
}
@@ -0,0 +1,106 @@
import type { WatchlistAction, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
// Base DTOs for API responses
export interface WatchlistEntryDTO {
id: string;
type: WatchlistType;
value: string;
description?: string | null;
action: WatchlistAction;
source: WatchlistSource;
isGlobal: boolean;
lastUpdatedAt: string;
organizationId?: number | null;
createdBy?: {
id: number;
name: string | null;
email: string;
avatarUrl?: string | null;
} | null;
updatedBy?: {
id: number;
name: string | null;
email: string;
avatarUrl?: string | null;
} | null;
}
// Request DTOs for creating/updating entries
export interface CreateWatchlistEntryDTO {
type: WatchlistType;
value: string;
description?: string;
action?: WatchlistAction;
organizationId?: number;
}
export interface UpdateWatchlistEntryDTO {
value?: string;
description?: string;
action?: WatchlistAction;
}
// Response DTOs for API operations
export interface WatchlistListResponseDTO {
entries: WatchlistEntryDTO[];
pagination?: {
total: number;
page: number;
limit: number;
hasMore: boolean;
};
}
// Search and filter DTOs
export interface WatchlistSearchDTO {
query?: string;
type?: WatchlistType;
action?: WatchlistAction;
source?: WatchlistSource;
isGlobal?: boolean;
organizationId?: number;
page?: number;
limit?: number;
}
export interface BlockingCheckResultDTO {
isBlocked: boolean;
reason?: WatchlistType;
matchedEntry?: {
id: string;
type: WatchlistType;
value: string;
action: WatchlistAction;
};
}
export interface UsersBlockedCheckResponseDTO {
containsBlockedUser: boolean;
}
// Error DTOs
export interface WatchlistErrorDTO {
code: string;
message: string;
field?: string;
details?: Record<string, unknown>;
}
// Bulk operations DTOs
export interface BulkWatchlistOperationDTO {
entries: CreateWatchlistEntryDTO[];
organizationId?: number;
}
export interface BulkWatchlistResultDTO {
created: WatchlistEntryDTO[];
failed: Array<{
entry: CreateWatchlistEntryDTO;
error: WatchlistErrorDTO;
}>;
summary: {
total: number;
created: number;
failed: number;
};
}
@@ -0,0 +1,47 @@
import type { Container } from "@evyweb/ioctopus";
import { WATCHLIST_DI_TOKENS } from "@calcom/features/di/watchlist/Watchlist.tokens";
import logger from "@calcom/lib/logger";
import type { IAuditRepository } from "../interface/IAuditRepository";
import type {
IGlobalWatchlistRepository,
IOrganizationWatchlistRepository,
} from "../interface/IWatchlistRepositories";
import { GlobalBlockingService } from "../service/GlobalBlockingService";
import { OrganizationBlockingService } from "../service/OrganizationBlockingService";
import { WatchlistAuditService } from "../service/WatchlistAuditService";
import { WatchlistService } from "../service/WatchlistService";
export interface WatchlistFeature {
/** Global blocking service - handles global watchlist entries only */
globalBlocking: GlobalBlockingService;
/** Organization blocking service - handles org-specific entries only */
orgBlocking: OrganizationBlockingService;
/** Watchlist CRUD service - manages watchlist entries */
watchlist: WatchlistService;
/** Audit service - logs blocking attempts and decisions */
audit: WatchlistAuditService;
}
export function createWatchlistFeature(container: Container): WatchlistFeature {
// Get repositories from container
const globalRepo = container.get<IGlobalWatchlistRepository>(
WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY
);
const orgRepo = container.get<IOrganizationWatchlistRepository>(
WATCHLIST_DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY
);
const auditRepo = container.get<IAuditRepository>(WATCHLIST_DI_TOKENS.AUDIT_REPOSITORY);
// Create sub-loggers for each service
const watchlistLogger = logger.getSubLogger({ prefix: ["[WatchlistService]"] });
// Create services with Deps pattern
return {
globalBlocking: new GlobalBlockingService({ globalRepo }),
orgBlocking: new OrganizationBlockingService({ orgRepo }),
watchlist: new WatchlistService({ globalRepo, orgRepo, logger: watchlistLogger }),
audit: new WatchlistAuditService({ auditRepository: auditRepo }),
};
}
@@ -0,0 +1,63 @@
import type { Mock } from "vitest";
import { describe, expect, test, vi, afterEach } from "vitest";
import { checkIfFreeEmailDomain } from "./checkIfFreeEmailDomain";
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => {
return {
getWatchlistFeature: vi.fn().mockReturnValue({
globalBlocking: {
isFreeEmailDomain: vi.fn().mockResolvedValue(true),
},
}),
};
});
describe("checkIfFreeEmailDomain", () => {
test("If gmail should return true", async () => {
expect(await checkIfFreeEmailDomain({ email: "test@gmail.com" })).toBe(true);
});
test("If outlook should return true", async () => {
expect(await checkIfFreeEmailDomain({ email: "test@outlook.com" })).toBe(true);
});
test("If there's no email domain return as if it was a free email domain", async () => {
expect(await checkIfFreeEmailDomain({ email: "test@" })).toBe(true);
});
test("If free email domain in watchlist, should return true", async () => {
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
const getWatchlistFeatureMock = getWatchlistFeature as Mock;
const result = await checkIfFreeEmailDomain({ email: "test@freedomain.com" });
expect(getWatchlistFeatureMock).toHaveBeenCalled();
const mockInstance = getWatchlistFeatureMock.mock.results.at(-1)?.value;
expect(mockInstance.globalBlocking.isFreeEmailDomain).toHaveBeenCalledWith("freedomain.com");
expect(result).toBe(true);
});
test("If non-free email domain, should return false", async () => {
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
const getWatchlistFeatureMock = getWatchlistFeature as Mock;
getWatchlistFeatureMock.mockReturnValueOnce({
globalBlocking: {
isFreeEmailDomain: vi.fn().mockResolvedValue(false),
},
});
const result = await checkIfFreeEmailDomain({ email: "test@corporatedomain.com" });
expect(getWatchlistFeatureMock).toHaveBeenCalled();
const mockInstance = getWatchlistFeatureMock.mock.results.at(-1)?.value;
expect(mockInstance.globalBlocking.isFreeEmailDomain).toHaveBeenCalledWith("corporatedomain.com");
expect(result).toBe(false);
});
afterEach(() => {
vi.clearAllMocks();
});
});
@@ -0,0 +1,31 @@
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import type logger from "@calcom/lib/logger";
import { extractDomainFromEmail } from "../utils/normalization";
interface CheckFreeEmailDomainParams {
email: string;
logger?: ReturnType<typeof logger.getSubLogger>;
}
export const checkIfFreeEmailDomain = async (params: CheckFreeEmailDomainParams): Promise<boolean> => {
const { email, logger: log } = params;
try {
const emailDomain = extractDomainFromEmail(email);
const domainWithoutAt = emailDomain.slice(1);
// If there's no email domain return as if it was a free email domain
if (!domainWithoutAt) return true;
// Gmail and Outlook are one of the most common email domains so we don't need to check the domains list
if (domainWithoutAt === "gmail.com" || domainWithoutAt === "outlook.com") return true;
const watchlist = await getWatchlistFeature();
return await watchlist.globalBlocking.isFreeEmailDomain(domainWithoutAt);
} catch (err) {
log?.error(err);
// If normalization fails, treat as free email domain for safety
return true;
}
};
@@ -0,0 +1,33 @@
import type { WatchlistAction, WatchlistAudit, WatchlistType } from "../types";
export interface CreateWatchlistAuditInput {
type: WatchlistType;
value: string;
description?: string | null;
action: WatchlistAction;
changedByUserId?: number | null;
watchlistId: string;
}
export interface UpdateWatchlistAuditInput {
type?: WatchlistType;
value?: string;
description?: string | null;
action?: WatchlistAction;
changedByUserId?: number | null;
}
export interface IAuditRepository {
// Basic CRUD operations for WatchlistAudit table
create(data: CreateWatchlistAuditInput): Promise<WatchlistAudit>;
findById(id: string): Promise<WatchlistAudit | null>;
findByWatchlistId(watchlistId: string): Promise<WatchlistAudit[]>;
update(id: string, data: UpdateWatchlistAuditInput): Promise<WatchlistAudit>;
delete(id: string): Promise<void>;
findMany(filters?: {
watchlistId?: string;
changedByUserId?: number;
limit?: number;
offset?: number;
}): Promise<WatchlistAudit[]>;
}
@@ -0,0 +1,17 @@
import type { WatchlistAudit } from "../types";
import type { CreateWatchlistAuditInput, UpdateWatchlistAuditInput } from "./IAuditRepository";
export interface IWatchlistAuditService {
// Basic CRUD operations for WatchlistAudit
createAuditEntry(data: CreateWatchlistAuditInput): Promise<WatchlistAudit>;
getAuditEntry(id: string): Promise<WatchlistAudit | null>;
getAuditHistory(watchlistId: string): Promise<WatchlistAudit[]>;
updateAuditEntry(id: string, data: UpdateWatchlistAuditInput): Promise<WatchlistAudit>;
deleteAuditEntry(id: string): Promise<void>;
getAuditEntries(filters?: {
watchlistId?: string;
changedByUserId?: number;
limit?: number;
offset?: number;
}): Promise<WatchlistAudit[]>;
}
@@ -0,0 +1,11 @@
import type { WatchlistType } from "../types";
export interface BlockingResult {
isBlocked: boolean;
reason?: WatchlistType;
watchlistEntry?: Record<string, unknown> | null;
}
export interface IBlockingService {
isBlocked(email: string, organizationId?: number | null): Promise<BlockingResult>;
}
@@ -0,0 +1,79 @@
import type { Watchlist } from "@calcom/prisma/client";
import type { WatchlistAction, WatchlistSource, WatchlistType } from "../types";
/**
* Interface for Global Watchlist Repository
* Follows Dependency Inversion Principle
*/
export interface IGlobalWatchlistRepository {
findBlockedEmail(email: string): Promise<Watchlist | null>;
findBlockedDomain(domain: string): Promise<Watchlist | null>;
findFreeEmailDomain(domain: string): Promise<Watchlist | null>;
findById(id: string): Promise<Watchlist | null>;
listBlockedEntries(): Promise<Watchlist[]>;
// Write operations
createEntry(data: {
type: WatchlistType;
value: string;
description?: string;
action: WatchlistAction;
source?: WatchlistSource;
}): Promise<Watchlist>;
updateEntry(
id: string,
data: {
value?: string;
description?: string;
action?: WatchlistAction;
source?: WatchlistSource;
}
): Promise<Watchlist>;
deleteEntry(id: string): Promise<void>;
}
/**
* Interface for Organization Watchlist Repository
* Follows Dependency Inversion Principle
*/
export interface IOrganizationWatchlistRepository {
findBlockedEmail({
email,
organizationId,
}: {
email: string;
organizationId: number;
}): Promise<Watchlist | null>;
findBlockedDomain(domain: string, organizationId: number): Promise<Watchlist | null>;
findById(id: string, organizationId: number): Promise<Watchlist | null>;
listBlockedEntries(organizationId: number): Promise<Watchlist[]>;
listAllOrganizationEntries(): Promise<Watchlist[]>;
// Write operations
createEntry(
organizationId: number,
data: {
type: WatchlistType;
value: string;
description?: string;
action: WatchlistAction;
source?: WatchlistSource;
}
): Promise<Watchlist>;
updateEntry(
id: string,
organizationId: number,
data: {
value?: string;
description?: string;
action?: WatchlistAction;
source?: WatchlistSource;
}
): Promise<Watchlist>;
deleteEntry(id: string, organizationId: number): Promise<void>;
}
@@ -0,0 +1,9 @@
import type { WatchlistEntry, CreateWatchlistEntryData, UpdateWatchlistEntryData } from "../types";
export interface IWatchlistService {
createEntry(data: CreateWatchlistEntryData): Promise<WatchlistEntry>;
updateEntry(id: string, data: UpdateWatchlistEntryData): Promise<WatchlistEntry>;
deleteEntry(id: string): Promise<void>;
getEntry(id: string): Promise<WatchlistEntry | null>;
listAllSystemEntries(): Promise<WatchlistEntry[]>;
}
@@ -0,0 +1,136 @@
import type { PrismaClient, Watchlist } from "@calcom/prisma/client";
import { WatchlistAction, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories";
/**
* Repository for global watchlist operations (organizationId = null)
* Handles system-wide blocking rules that apply to all organizations
*
* Note: Expects normalized values from the service layer
*/
export class GlobalWatchlistRepository implements IGlobalWatchlistRepository {
constructor(private readonly prisma: PrismaClient) {}
private readonly selectFields = {
id: true,
type: true,
value: true,
description: true,
isGlobal: true,
organizationId: true,
action: true,
source: true,
lastUpdatedAt: true,
} as const;
async findBlockedEmail(email: string): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
type: WatchlistType.EMAIL,
value: email,
action: WatchlistAction.BLOCK,
organizationId: null,
isGlobal: true,
},
});
}
async findBlockedDomain(domain: string): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
type: WatchlistType.DOMAIN,
value: domain,
action: WatchlistAction.BLOCK,
organizationId: null,
isGlobal: true,
},
});
}
async findFreeEmailDomain(domain: string): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
type: WatchlistType.DOMAIN,
value: domain,
source: WatchlistSource.FREE_DOMAIN_POLICY,
organizationId: null,
isGlobal: true,
},
});
}
async findById(id: string): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
id,
organizationId: null,
isGlobal: true,
},
});
}
async listBlockedEntries(): Promise<Watchlist[]> {
return this.prisma.watchlist.findMany({
select: this.selectFields,
where: {
organizationId: null,
isGlobal: true,
action: WatchlistAction.BLOCK,
},
});
}
// Write operations for global entries
async createEntry(data: {
type: WatchlistType;
value: string;
description?: string | null;
action: WatchlistAction;
source?: WatchlistSource;
}): Promise<Watchlist> {
return this.prisma.watchlist.create({
select: this.selectFields,
data: {
type: data.type,
value: data.value,
description: data.description,
isGlobal: true,
organizationId: null,
action: data.action,
source: data.source || WatchlistSource.MANUAL,
},
});
}
async updateEntry(
id: string,
data: {
value?: string;
description?: string | null;
action?: WatchlistAction;
source?: WatchlistSource;
}
): Promise<Watchlist> {
return this.prisma.watchlist.update({
select: this.selectFields,
where: { id },
data: {
...(data.value !== undefined && { value: data.value }),
...(data.description !== undefined && { description: data.description }),
...(data.action && { action: data.action }),
...(data.source && { source: data.source }),
},
});
}
async deleteEntry(id: string): Promise<void> {
this.prisma.watchlist.delete({
where: { id },
});
}
}
@@ -0,0 +1,143 @@
import type { PrismaClient, Watchlist } from "@calcom/prisma/client";
import { WatchlistAction, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
import type { IOrganizationWatchlistRepository } from "../interface/IWatchlistRepositories";
/**
* Repository for organization-specific watchlist operations
* Handles blocking rules that apply only to a specific organization,
* or to all organizations
*
* Note: Expects normalized values from the service layer
*/
export class OrganizationWatchlistRepository implements IOrganizationWatchlistRepository {
constructor(private readonly prisma: PrismaClient) {}
private readonly selectFields = {
id: true,
type: true,
value: true,
description: true,
isGlobal: true,
organizationId: true,
action: true,
source: true,
lastUpdatedAt: true,
} as const;
async findBlockedEmail({
email,
organizationId,
}: {
email: string;
organizationId: number;
}): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
type: WatchlistType.EMAIL,
value: email,
action: WatchlistAction.BLOCK,
organizationId,
},
});
}
async findBlockedDomain(domain: string, organizationId: number): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
type: WatchlistType.DOMAIN,
value: domain,
action: WatchlistAction.BLOCK,
organizationId,
},
});
}
async listBlockedEntries(organizationId: number): Promise<Watchlist[]> {
return this.prisma.watchlist.findMany({
select: this.selectFields,
where: {
organizationId,
action: WatchlistAction.BLOCK,
},
});
}
async listAllOrganizationEntries(): Promise<Watchlist[]> {
return this.prisma.watchlist.findMany({
select: this.selectFields,
where: {
organizationId: { not: null },
isGlobal: false,
action: WatchlistAction.BLOCK,
},
});
}
async findById(id: string, organizationId: number): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
where: {
id,
organizationId,
},
});
}
// Write operations for organization-specific entries
async createEntry(
organizationId: number,
data: {
type: WatchlistType;
value: string;
description?: string;
action: WatchlistAction;
source?: WatchlistSource;
}
): Promise<Watchlist> {
return this.prisma.watchlist.create({
select: this.selectFields,
data: {
type: data.type,
value: data.value,
description: data.description,
isGlobal: false,
organizationId,
action: data.action,
source: data.source || WatchlistSource.MANUAL,
},
});
}
async updateEntry(
id: string,
organizationId: number,
data: {
value?: string;
description?: string | null;
action?: WatchlistAction;
source?: WatchlistSource;
}
): Promise<Watchlist> {
return this.prisma.watchlist.update({
select: this.selectFields,
where: { id, organizationId },
data: {
...(data.value !== undefined && { value: data.value }),
...(data.description !== undefined && { description: data.description }),
...(data.action !== undefined && { action: data.action }),
...(data.source !== undefined && { source: data.source }),
},
});
}
async deleteEntry(id: string, organizationId: number): Promise<void> {
return this.prisma.watchlist
.delete({
where: { id, organizationId },
})
.then(() => {});
}
}
@@ -0,0 +1,93 @@
import type { PrismaClient } from "@calcom/prisma/client";
import { WatchlistAction, WatchlistType } from "@calcom/prisma/enums";
import type {
IAuditRepository,
CreateWatchlistAuditInput,
UpdateWatchlistAuditInput,
} from "../interface/IAuditRepository";
import type { WatchlistAudit } from "../types";
export class PrismaWatchlistAuditRepository implements IAuditRepository {
constructor(private readonly prisma: PrismaClient) {}
private readonly selectFields = {
id: true,
type: true,
value: true,
description: true,
action: true,
changedAt: true,
changedByUserId: true,
watchlistId: true,
} as const;
async create(data: CreateWatchlistAuditInput): Promise<WatchlistAudit> {
return this.prisma.watchlistAudit.create({
select: this.selectFields,
data: {
type: data.type as WatchlistType,
value: data.value,
description: data.description,
action: data.action as WatchlistAction,
changedByUserId: data.changedByUserId,
watchlistId: data.watchlistId,
},
});
}
async findById(id: string): Promise<WatchlistAudit | null> {
return this.prisma.watchlistAudit.findUnique({
select: this.selectFields,
where: { id },
});
}
async findByWatchlistId(watchlistId: string): Promise<WatchlistAudit[]> {
return this.prisma.watchlistAudit.findMany({
select: this.selectFields,
where: { watchlistId },
orderBy: { changedAt: "desc" },
});
}
async update(id: string, data: UpdateWatchlistAuditInput): Promise<WatchlistAudit> {
return this.prisma.watchlistAudit.update({
select: this.selectFields,
where: { id },
data: {
...(data.type && { type: data.type as WatchlistType }),
...(data.value && { value: data.value }),
...(data.description !== undefined && { description: data.description }),
...(data.action && { action: data.action as WatchlistAction }),
...(data.changedByUserId !== undefined && { changedByUserId: data.changedByUserId }),
},
});
}
async delete(id: string): Promise<void> {
return this.prisma.watchlistAudit
.delete({
where: { id },
})
.then(() => {});
}
async findMany(filters?: {
watchlistId?: string;
changedByUserId?: number;
limit?: number;
offset?: number;
}): Promise<WatchlistAudit[]> {
return this.prisma.watchlistAudit.findMany({
select: this.selectFields,
where: {
...(filters?.watchlistId && { watchlistId: filters.watchlistId }),
...(filters?.changedByUserId && { changedByUserId: filters.changedByUserId }),
},
orderBy: { changedAt: "desc" },
...(filters?.limit && { take: filters.limit }),
...(filters?.offset && { skip: filters.offset }),
});
}
}
@@ -0,0 +1,196 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { WatchlistType, WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories";
import { GlobalBlockingService } from "./GlobalBlockingService";
const mockGlobalRepo: IGlobalWatchlistRepository = {
findBlockedEmail: vi.fn(),
findBlockedDomain: vi.fn(),
findFreeEmailDomain: vi.fn(),
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
findById: vi.fn(),
listBlockedEntries: vi.fn(),
};
describe("GlobalBlockingService", () => {
let service: GlobalBlockingService;
beforeEach(() => {
vi.clearAllMocks();
service = new GlobalBlockingService({ globalRepo: mockGlobalRepo });
});
describe("isBlocked", () => {
test("should return blocked when email matches", async () => {
const mockEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "blocked@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(mockEntry);
vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(null);
const result = await service.isBlocked("blocked@example.com");
expect(result.isBlocked).toBe(true);
expect(result.reason).toBe(WatchlistType.EMAIL);
expect(result.watchlistEntry).toEqual(mockEntry);
expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalledWith("blocked@example.com");
expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalledWith("@example.com");
});
test("should return blocked when domain matches", async () => {
const mockEntry = {
id: "456",
type: WatchlistType.DOMAIN,
value: "@spam.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(mockEntry);
const result = await service.isBlocked("user@spam.com");
expect(result.isBlocked).toBe(true);
expect(result.reason).toBe(WatchlistType.DOMAIN);
expect(result.watchlistEntry).toEqual(mockEntry);
expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalledWith("user@spam.com");
expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalledWith("@spam.com");
});
test("should return not blocked when no matches", async () => {
vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(null);
const result = await service.isBlocked("clean@example.com");
expect(result.isBlocked).toBe(false);
expect(result.reason).toBeUndefined();
expect(result.watchlistEntry).toBeUndefined();
});
test("should normalize email before checking", async () => {
vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(null);
await service.isBlocked("USER@EXAMPLE.COM");
expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalledWith("user@example.com");
expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalledWith("@example.com");
});
test("should check both email and domain in parallel", async () => {
const emailPromise = Promise.resolve(null);
const domainPromise = Promise.resolve(null);
vi.mocked(mockGlobalRepo.findBlockedEmail).mockReturnValue(emailPromise);
vi.mocked(mockGlobalRepo.findBlockedDomain).mockReturnValue(domainPromise);
await service.isBlocked("test@example.com");
// Both should be called before awaiting (parallel execution)
expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalled();
expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalled();
});
test("should return email match over domain match", async () => {
const emailEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "specific@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
const domainEntry = {
id: "456",
type: WatchlistType.DOMAIN,
value: "@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(emailEntry);
vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(domainEntry);
const result = await service.isBlocked("specific@example.com");
expect(result.isBlocked).toBe(true);
expect(result.reason).toBe(WatchlistType.EMAIL); // Email takes precedence
expect(result.watchlistEntry).toEqual(emailEntry);
});
});
describe("isFreeEmailDomain", () => {
test("should return true when domain is in free email list", async () => {
const mockEntry = {
id: "789",
type: WatchlistType.DOMAIN,
value: "@yahoo.com",
description: null,
action: WatchlistAction.REPORT,
isGlobal: true,
organizationId: null,
source: WatchlistSource.FREE_DOMAIN_POLICY,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.findFreeEmailDomain).mockResolvedValue(mockEntry);
const result = await service.isFreeEmailDomain("yahoo.com");
expect(result).toBe(true);
expect(mockGlobalRepo.findFreeEmailDomain).toHaveBeenCalledWith("@yahoo.com");
});
test("should return false when domain is not in free email list", async () => {
vi.mocked(mockGlobalRepo.findFreeEmailDomain).mockResolvedValue(null);
const result = await service.isFreeEmailDomain("corporatedomain.com");
expect(result).toBe(false);
expect(mockGlobalRepo.findFreeEmailDomain).toHaveBeenCalledWith("@corporatedomain.com");
});
test("should normalize domain before checking", async () => {
vi.mocked(mockGlobalRepo.findFreeEmailDomain).mockResolvedValue(null);
await service.isFreeEmailDomain("GMAIL.COM");
expect(mockGlobalRepo.findFreeEmailDomain).toHaveBeenCalledWith("@gmail.com");
});
test("should handle domain with @ prefix", async () => {
vi.mocked(mockGlobalRepo.findFreeEmailDomain).mockResolvedValue(null);
await service.isFreeEmailDomain("@hotmail.com");
expect(mockGlobalRepo.findFreeEmailDomain).toHaveBeenCalledWith("@hotmail.com");
});
});
});
@@ -0,0 +1,49 @@
import type { IBlockingService, BlockingResult } from "../interface/IBlockingService";
import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories";
import { WatchlistType } from "../types";
import { normalizeEmail, extractDomainFromEmail, normalizeDomain } from "../utils/normalization";
type Deps = {
globalRepo: IGlobalWatchlistRepository;
};
/**
* Global Blocking Service - handles only global watchlist entries
* For organization-specific blocking, use OrganizationBlockingService
*/
export class GlobalBlockingService implements IBlockingService {
constructor(private readonly deps: Deps) {}
async isBlocked(email: string): Promise<BlockingResult> {
const normalizedEmail = normalizeEmail(email);
const normalizedDomain = extractDomainFromEmail(normalizedEmail);
const [globalEmailEntry, globalDomainEntry] = await Promise.all([
this.deps.globalRepo.findBlockedEmail(normalizedEmail),
this.deps.globalRepo.findBlockedDomain(normalizedDomain),
]);
if (globalEmailEntry) {
return {
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: globalEmailEntry,
};
}
if (globalDomainEntry) {
return {
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: globalDomainEntry,
};
}
return { isBlocked: false };
}
async isFreeEmailDomain(domain: string): Promise<boolean> {
const normalizedDomain = normalizeDomain(domain);
return this.deps.globalRepo.findFreeEmailDomain(normalizedDomain).then((entry) => !!entry);
}
}
@@ -0,0 +1,175 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { WatchlistType, WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
import type { IOrganizationWatchlistRepository } from "../interface/IWatchlistRepositories";
import { OrganizationBlockingService } from "./OrganizationBlockingService";
const mockOrgRepo: IOrganizationWatchlistRepository = {
findBlockedEmail: vi.fn(),
findBlockedDomain: vi.fn(),
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
findById: vi.fn(),
listBlockedEntries: vi.fn(),
listAllOrganizationEntries: vi.fn(),
};
describe("OrganizationBlockingService", () => {
let service: OrganizationBlockingService;
beforeEach(() => {
vi.clearAllMocks();
service = new OrganizationBlockingService({ orgRepo: mockOrgRepo });
});
describe("isBlocked", () => {
const ORGANIZATION_ID = 123;
test("should return blocked when email matches for organization", async () => {
const mockEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "blocked@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: ORGANIZATION_ID,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(mockEntry);
vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null);
const result = await service.isBlocked("blocked@example.com", ORGANIZATION_ID);
expect(result.isBlocked).toBe(true);
expect(result.reason).toBe(WatchlistType.EMAIL);
expect(result.watchlistEntry).toEqual(mockEntry);
expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalledWith({
email: "blocked@example.com",
organizationId: ORGANIZATION_ID,
});
expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalledWith("@example.com", ORGANIZATION_ID);
});
test("should return blocked when domain matches for organization", async () => {
const mockEntry = {
id: "456",
type: WatchlistType.DOMAIN,
value: "@competitor.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: ORGANIZATION_ID,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(mockEntry);
const result = await service.isBlocked("user@competitor.com", ORGANIZATION_ID);
expect(result.isBlocked).toBe(true);
expect(result.reason).toBe(WatchlistType.DOMAIN);
expect(result.watchlistEntry).toEqual(mockEntry);
});
test("should return not blocked when no matches for organization", async () => {
vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null);
const result = await service.isBlocked("clean@example.com", ORGANIZATION_ID);
expect(result.isBlocked).toBe(false);
expect(result.reason).toBeUndefined();
expect(result.watchlistEntry).toBeUndefined();
});
test("should normalize email before checking", async () => {
vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null);
await service.isBlocked("USER@EXAMPLE.COM", ORGANIZATION_ID);
expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalledWith({
email: "user@example.com",
organizationId: ORGANIZATION_ID,
});
expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalledWith("@example.com", ORGANIZATION_ID);
});
test("should check both email and domain in parallel", async () => {
const emailPromise = Promise.resolve(null);
const domainPromise = Promise.resolve(null);
vi.mocked(mockOrgRepo.findBlockedEmail).mockReturnValue(emailPromise);
vi.mocked(mockOrgRepo.findBlockedDomain).mockReturnValue(domainPromise);
await service.isBlocked("test@example.com", ORGANIZATION_ID);
// Both should be called before awaiting (parallel execution)
expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalled();
expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalled();
});
test("should return email match over domain match", async () => {
const emailEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "specific@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: ORGANIZATION_ID,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
const domainEntry = {
id: "456",
type: WatchlistType.DOMAIN,
value: "@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: ORGANIZATION_ID,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(emailEntry);
vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(domainEntry);
const result = await service.isBlocked("specific@example.com", ORGANIZATION_ID);
expect(result.isBlocked).toBe(true);
expect(result.reason).toBe(WatchlistType.EMAIL); // Email takes precedence
expect(result.watchlistEntry).toEqual(emailEntry);
});
test("should only check specified organization", async () => {
const ORG_A = 100;
const ORG_B = 200;
vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null);
vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null);
await service.isBlocked("test@example.com", ORG_A);
// Should only query for ORG_A, not ORG_B
expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalledWith({
email: "test@example.com",
organizationId: ORG_A,
});
expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalledWith("@example.com", ORG_A);
expect(mockOrgRepo.findBlockedEmail).not.toHaveBeenCalledWith({
email: "test@example.com",
organizationId: ORG_B,
});
});
});
});
@@ -0,0 +1,59 @@
import type { IBlockingService, BlockingResult } from "../interface/IBlockingService";
import type { IOrganizationWatchlistRepository } from "../interface/IWatchlistRepositories";
import { WatchlistType } from "../types";
import { normalizeEmail, extractDomainFromEmail, normalizeDomain } from "../utils/normalization";
type Deps = {
orgRepo: IOrganizationWatchlistRepository;
};
/**
* Service for organization-specific blocking operations
* Handles blocking rules that apply only to a specific organization
*/
export class OrganizationBlockingService implements IBlockingService {
constructor(private readonly deps: Deps) {}
async isBlocked(email: string, organizationId: number): Promise<BlockingResult> {
const normalizedEmail = normalizeEmail(email);
const normalizedDomain = extractDomainFromEmail(normalizedEmail);
const emailPromise = this.deps.orgRepo.findBlockedEmail({ email: normalizedEmail, organizationId });
const domainPromise = this.deps.orgRepo.findBlockedDomain(normalizedDomain, organizationId);
const [emailEntry, domainEntry] = await Promise.all([emailPromise, domainPromise]);
if (emailEntry) {
return {
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: emailEntry,
};
}
if (domainEntry) {
return {
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: domainEntry,
};
}
return { isBlocked: false };
}
async isDomainBlocked(domain: string, organizationId: number): Promise<BlockingResult> {
const normalizedDomain = normalizeDomain(domain);
return this.deps.orgRepo.findBlockedDomain(normalizedDomain, organizationId).then((domainEntry) => {
if (domainEntry) {
return {
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: domainEntry,
};
}
return { isBlocked: false };
});
}
}
@@ -0,0 +1,44 @@
import type {
IAuditRepository,
CreateWatchlistAuditInput,
UpdateWatchlistAuditInput,
} from "../interface/IAuditRepository";
import type { IWatchlistAuditService } from "../interface/IAuditService";
import type { WatchlistAudit } from "../types";
type Deps = {
auditRepository: IAuditRepository;
};
export class WatchlistAuditService implements IWatchlistAuditService {
constructor(private readonly deps: Deps) {}
async createAuditEntry(data: CreateWatchlistAuditInput): Promise<WatchlistAudit> {
return this.deps.auditRepository.create(data);
}
async getAuditEntry(id: string): Promise<WatchlistAudit | null> {
return this.deps.auditRepository.findById(id);
}
async getAuditHistory(watchlistId: string): Promise<WatchlistAudit[]> {
return this.deps.auditRepository.findByWatchlistId(watchlistId);
}
async updateAuditEntry(id: string, data: UpdateWatchlistAuditInput): Promise<WatchlistAudit> {
return this.deps.auditRepository.update(id, data);
}
async deleteAuditEntry(id: string): Promise<void> {
return this.deps.auditRepository.delete(id);
}
async getAuditEntries(filters?: {
watchlistId?: string;
changedByUserId?: number;
limit?: number;
offset?: number;
}): Promise<WatchlistAudit[]> {
return this.deps.auditRepository.findMany(filters);
}
}
@@ -0,0 +1,373 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { WatchlistType, WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
import type {
IGlobalWatchlistRepository,
IOrganizationWatchlistRepository,
} from "../interface/IWatchlistRepositories";
import { WatchlistService } from "./WatchlistService";
const mockGlobalRepo: IGlobalWatchlistRepository = {
findBlockedEmail: vi.fn(),
findBlockedDomain: vi.fn(),
findFreeEmailDomain: vi.fn(),
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
findById: vi.fn(),
listBlockedEntries: vi.fn(),
};
const mockOrgRepo: IOrganizationWatchlistRepository = {
findBlockedEmail: vi.fn(),
findBlockedDomain: vi.fn(),
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
findById: vi.fn(),
listBlockedEntries: vi.fn(),
listAllOrganizationEntries: vi.fn(),
};
const mockLogger = {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
describe("WatchlistService", () => {
let service: WatchlistService;
beforeEach(() => {
vi.clearAllMocks();
service = new WatchlistService({
globalRepo: mockGlobalRepo,
orgRepo: mockOrgRepo,
logger: mockLogger as never,
});
});
describe("createEntry", () => {
test("should create global entry with normalized email", async () => {
const mockEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "test@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.createEntry).mockResolvedValue(mockEntry);
const result = await service.createEntry({
type: WatchlistType.EMAIL,
value: "TEST@EXAMPLE.COM", // Not normalized
action: WatchlistAction.BLOCK,
isGlobal: true,
});
expect(mockGlobalRepo.createEntry).toHaveBeenCalledWith({
type: WatchlistType.EMAIL,
value: "test@example.com", // Normalized by service
description: undefined,
action: WatchlistAction.BLOCK,
source: undefined,
});
expect(result).toEqual(mockEntry);
});
test("should create global entry with normalized domain", async () => {
const mockEntry = {
id: "456",
type: WatchlistType.DOMAIN,
value: "@spam.com",
description: "Spam domain",
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.createEntry).mockResolvedValue(mockEntry);
const result = await service.createEntry({
type: WatchlistType.DOMAIN,
value: "SPAM.COM", // Not normalized, no @ prefix
description: "Spam domain",
action: WatchlistAction.BLOCK,
isGlobal: true,
});
expect(mockGlobalRepo.createEntry).toHaveBeenCalledWith({
type: WatchlistType.DOMAIN,
value: "@spam.com", // Normalized with @ prefix
description: "Spam domain",
action: WatchlistAction.BLOCK,
source: undefined,
});
expect(result).toEqual(mockEntry);
});
test("should create organization entry", async () => {
const mockEntry = {
id: "789",
type: WatchlistType.EMAIL,
value: "competitor@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 123,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockOrgRepo.createEntry).mockResolvedValue(mockEntry);
const result = await service.createEntry({
type: WatchlistType.EMAIL,
value: "COMPETITOR@EXAMPLE.COM",
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 123,
});
expect(mockOrgRepo.createEntry).toHaveBeenCalledWith(123, {
type: WatchlistType.EMAIL,
value: "competitor@example.com",
description: undefined,
action: WatchlistAction.BLOCK,
source: undefined,
});
expect(result).toEqual(mockEntry);
});
test("should throw when organizationId missing for non-global entry", async () => {
await expect(
service.createEntry({
type: WatchlistType.EMAIL,
value: "test@example.com",
action: WatchlistAction.BLOCK,
isGlobal: false,
// organizationId missing!
})
).rejects.toThrow("organizationId is required for organization-scoped entries");
expect(mockLogger.error).toHaveBeenCalledWith(
"organizationId required for non-global entry",
expect.objectContaining({
type: WatchlistType.EMAIL,
value: "test@example.com",
})
);
});
test("should create entry with FREE_DOMAIN_POLICY source", async () => {
const mockEntry = {
id: "free-1",
type: WatchlistType.DOMAIN,
value: "@gmail.com",
description: null,
action: WatchlistAction.REPORT,
isGlobal: true,
organizationId: null,
source: WatchlistSource.FREE_DOMAIN_POLICY,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.createEntry).mockResolvedValue(mockEntry);
const result = await service.createEntry({
type: WatchlistType.DOMAIN,
value: "gmail.com",
action: WatchlistAction.REPORT,
isGlobal: true,
source: WatchlistSource.FREE_DOMAIN_POLICY,
});
expect(mockGlobalRepo.createEntry).toHaveBeenCalledWith({
type: WatchlistType.DOMAIN,
value: "@gmail.com",
description: undefined,
action: WatchlistAction.REPORT,
source: WatchlistSource.FREE_DOMAIN_POLICY,
});
expect(result).toEqual(mockEntry);
});
});
describe("updateEntry", () => {
test("should update entry with normalized email", async () => {
const mockEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "updated@example.com",
description: "Updated description",
action: WatchlistAction.ALERT,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.updateEntry).mockResolvedValue(mockEntry);
const result = await service.updateEntry("123", {
type: WatchlistType.EMAIL,
value: "UPDATED@EXAMPLE.COM", // Not normalized
description: "Updated description",
action: WatchlistAction.ALERT,
});
expect(mockGlobalRepo.updateEntry).toHaveBeenCalledWith("123", {
type: WatchlistType.EMAIL,
value: "updated@example.com", // Normalized
description: "Updated description",
action: WatchlistAction.ALERT,
source: undefined,
});
expect(result).toEqual(mockEntry);
});
test("should update entry without value change", async () => {
const mockEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "test@example.com",
description: "New description",
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.updateEntry).mockResolvedValue(mockEntry);
const result = await service.updateEntry("123", {
type: WatchlistType.EMAIL, // Required for normalization
description: "New description",
});
expect(mockGlobalRepo.updateEntry).toHaveBeenCalledWith("123", {
type: WatchlistType.EMAIL,
description: "New description",
action: undefined,
source: undefined,
});
expect(result).toEqual(mockEntry);
});
});
describe("deleteEntry", () => {
test("should delete entry by id", async () => {
vi.mocked(mockGlobalRepo.deleteEntry).mockResolvedValue();
await service.deleteEntry("123");
expect(mockGlobalRepo.deleteEntry).toHaveBeenCalledWith("123");
});
});
describe("getEntry", () => {
test("should get entry by id", async () => {
const mockEntry = {
id: "123",
type: WatchlistType.EMAIL,
value: "test@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
vi.mocked(mockGlobalRepo.findById).mockResolvedValue(mockEntry);
const result = await service.getEntry("123");
expect(mockGlobalRepo.findById).toHaveBeenCalledWith("123");
expect(result).toEqual(mockEntry);
});
test("should return null when entry not found", async () => {
vi.mocked(mockGlobalRepo.findById).mockResolvedValue(null);
const result = await service.getEntry("nonexistent");
expect(result).toBeNull();
});
});
describe("listAllSystemEntries", () => {
test("should return combined global and org entries", async () => {
const globalEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "global@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
const orgEntries = [
{
id: "2",
type: WatchlistType.DOMAIN,
value: "@competitor.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 123,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
vi.mocked(mockGlobalRepo.listBlockedEntries).mockResolvedValue(globalEntries);
vi.mocked(mockOrgRepo.listAllOrganizationEntries).mockResolvedValue(orgEntries);
const result = await service.listAllSystemEntries();
expect(mockGlobalRepo.listBlockedEntries).toHaveBeenCalled();
expect(mockOrgRepo.listAllOrganizationEntries).toHaveBeenCalled();
expect(result).toHaveLength(2);
expect(result).toEqual([...globalEntries, ...orgEntries]);
});
test("should return empty array when no entries exist", async () => {
vi.mocked(mockGlobalRepo.listBlockedEntries).mockResolvedValue([]);
vi.mocked(mockOrgRepo.listAllOrganizationEntries).mockResolvedValue([]);
const result = await service.listAllSystemEntries();
expect(result).toEqual([]);
});
test("should fetch global and org entries in parallel", async () => {
const globalPromise = Promise.resolve([]);
const orgPromise = Promise.resolve([]);
vi.mocked(mockGlobalRepo.listBlockedEntries).mockReturnValue(globalPromise);
vi.mocked(mockOrgRepo.listAllOrganizationEntries).mockReturnValue(orgPromise);
await service.listAllSystemEntries();
// Verify both repository methods are invoked (implementation uses Promise.all)
expect(mockGlobalRepo.listBlockedEntries).toHaveBeenCalled();
expect(mockOrgRepo.listAllOrganizationEntries).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,87 @@
import type logger from "@calcom/lib/logger";
import { WatchlistType } from "@calcom/prisma/enums";
import type {
IGlobalWatchlistRepository,
IOrganizationWatchlistRepository,
} from "../interface/IWatchlistRepositories";
import type { IWatchlistService } from "../interface/IWatchlistService";
import type { WatchlistEntry, CreateWatchlistEntryData, UpdateWatchlistEntryData } from "../types";
import { normalizeEmail, normalizeDomain } from "../utils/normalization";
type Deps = {
globalRepo: IGlobalWatchlistRepository;
orgRepo: IOrganizationWatchlistRepository;
logger: ReturnType<typeof logger.getSubLogger>;
};
export class WatchlistService implements IWatchlistService {
private readonly log: ReturnType<typeof logger.getSubLogger>;
constructor(private readonly deps: Deps) {
this.log = deps.logger;
}
async createEntry(data: CreateWatchlistEntryData): Promise<WatchlistEntry> {
const isGlobal = data.isGlobal ?? false;
// Normalize value based on type (service layer responsibility)
const normalizedValue =
data.type === WatchlistType.EMAIL ? normalizeEmail(data.value) : normalizeDomain(data.value);
const payload = {
type: data.type,
value: normalizedValue,
description: data.description,
action: data.action,
source: data.source,
};
// Global path
if (isGlobal) {
return this.deps.globalRepo.createEntry(payload);
}
// Org path (validate, then narrow)
const orgId = data.organizationId;
if (orgId == null) {
this.log.error("organizationId required for non-global entry", { type: data.type, value: data.value });
throw new Error("organizationId is required for organization-scoped entries");
}
return this.deps.orgRepo.createEntry(orgId, payload);
}
async updateEntry(id: string, data: UpdateWatchlistEntryData): Promise<WatchlistEntry> {
// Normalize value if provided (service layer responsibility)
const payload = {
...data,
...(data.value && {
value: data.type === WatchlistType.EMAIL ? normalizeEmail(data.value) : normalizeDomain(data.value),
}),
};
return this.deps.globalRepo.updateEntry(id, payload);
}
async deleteEntry(id: string): Promise<void> {
return this.deps.globalRepo.deleteEntry(id);
}
async getEntry(id: string): Promise<WatchlistEntry | null> {
return this.deps.globalRepo.findById(id);
}
/**
* System admin method to get ALL watchlist entries across the entire system
* Returns both global entries and all organization-specific entries from every organization
*/
async listAllSystemEntries(): Promise<WatchlistEntry[]> {
const [globalEntries, allOrgEntries] = await Promise.all([
this.deps.globalRepo.listBlockedEntries(),
this.deps.orgRepo.listAllOrganizationEntries(),
]);
return [...globalEntries, ...allOrgEntries];
}
}
@@ -0,0 +1,3 @@
export type { SpanOptions, SpanFn } from "./types";
export { sentrySpan } from "./sentry-span";
export { noOpSpan } from "./no-op-span";
@@ -0,0 +1,9 @@
import type { SpanFn } from "./types";
/**
* No-op implementation of telemetry spans
* Useful for testing or when telemetry is disabled
*/
export const noOpSpan: SpanFn = async (_options, callback) => {
return Promise.resolve(callback());
};
@@ -0,0 +1,11 @@
import { startSpan } from "@sentry/nextjs";
import type { SpanFn } from "./types";
/**
* Sentry implementation of telemetry spans
* Wraps Sentry's startSpan for production use
*/
export const sentrySpan: SpanFn = async (options, callback) => {
return startSpan({ name: options.name, op: options.op }, callback);
};
@@ -0,0 +1,11 @@
/**
* Telemetry abstraction for tracing operations
* Allows injecting different implementations (Sentry, no-op, test mocks)
*/
export interface SpanOptions {
name: string;
op?: string;
}
export type SpanFn = <T>(options: SpanOptions, callback: () => T | Promise<T>) => Promise<T>;
+61
View File
@@ -0,0 +1,61 @@
import { WatchlistAction, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
export { WatchlistAction, WatchlistType, WatchlistSource };
export interface Watchlist {
id: string;
type: WatchlistType;
value: string;
description?: string | null;
isGlobal: boolean;
organizationId?: number | null;
action: WatchlistAction;
source: WatchlistSource;
lastUpdatedAt: Date;
}
export interface WatchlistAudit {
id: string;
type: WatchlistType;
value: string;
description?: string | null;
action: WatchlistAction;
changedAt: Date;
changedByUserId?: number | null;
watchlistId: string;
}
export interface WatchlistEventAudit {
id: string;
watchlistId: string;
eventTypeId: number;
actionTaken: WatchlistAction;
timestamp: Date;
}
// Input types for creating/updating watchlist entries
export interface CreateWatchlistInput {
type: WatchlistType;
value: string;
description?: string;
isGlobal?: boolean;
organizationId?: number;
action: WatchlistAction;
source?: WatchlistSource;
}
export interface UpdateWatchlistInput {
type: WatchlistType; // Required to normalize value correctly without fetching
value?: string;
description?: string;
action?: WatchlistAction;
source?: WatchlistSource;
}
// Alias types for the service interface
export type WatchlistEntry = Watchlist;
export type CreateWatchlistEntryData = CreateWatchlistInput;
export type UpdateWatchlistEntryData = UpdateWatchlistInput;
// Type aliases for convenience
export type WatchlistEvent = WatchlistEventAudit;
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import { normalizeEmail, normalizeDomain, extractDomainFromEmail, normalizeUsername } from "./normalization";
describe("normalization", () => {
describe("normalizeEmail", () => {
test("should normalize basic email", () => {
expect(normalizeEmail("Test@Example.COM")).toBe("test@example.com");
expect(normalizeEmail(" user@domain.org ")).toBe("user@domain.org");
});
test("should throw on invalid emails", () => {
expect(() => normalizeEmail("invalid")).toThrow("Invalid email format");
expect(() => normalizeEmail("@domain.com")).toThrow("Invalid email format");
expect(() => normalizeEmail("user@")).toThrow("Invalid email format");
});
});
describe("normalizeDomain", () => {
test("should normalize basic domain", () => {
expect(normalizeDomain("Example.COM")).toBe("@example.com");
expect(normalizeDomain("@Domain.ORG")).toBe("@domain.org");
expect(normalizeDomain(" sub.domain.net ")).toBe("@sub.domain.net");
});
test("should preserve full domain including subdomains", () => {
expect(normalizeDomain("mail.google.com")).toBe("@mail.google.com");
expect(normalizeDomain("sub.domain.example.org")).toBe("@sub.domain.example.org");
});
test("should handle multi-level TLDs correctly", () => {
expect(normalizeDomain("example.co.uk")).toBe("@example.co.uk");
expect(normalizeDomain("mail.example.co.uk")).toBe("@mail.example.co.uk");
expect(normalizeDomain("company.com.au")).toBe("@company.com.au");
});
test("should throw on invalid domains", () => {
expect(() => normalizeDomain("invalid..domain")).toThrow("Invalid domain format");
expect(() => normalizeDomain(".domain.com")).toThrow("Invalid domain format");
expect(() => normalizeDomain("domain.")).toThrow("Invalid domain format");
});
});
describe("extractDomainFromEmail", () => {
test("should extract and normalize domain from email", () => {
expect(extractDomainFromEmail("user@Example.COM")).toBe("@example.com");
expect(extractDomainFromEmail("test@sub.domain.org")).toBe("@sub.domain.org");
expect(extractDomainFromEmail("user@mail.google.com")).toBe("@mail.google.com");
});
test("should handle multi-level TLDs", () => {
expect(extractDomainFromEmail("user@example.co.uk")).toBe("@example.co.uk");
expect(extractDomainFromEmail("admin@mail.company.com.au")).toBe("@mail.company.com.au");
});
test("should throw on invalid emails", () => {
expect(() => extractDomainFromEmail("invalid")).toThrow("Invalid email format");
});
});
describe("normalizeUsername", () => {
test("should normalize basic username", () => {
expect(normalizeUsername("TestUser")).toBe("testuser");
expect(normalizeUsername(" user_name ")).toBe("user_name");
expect(normalizeUsername("User.Name-123")).toBe("user.name-123");
});
test("should preserve special characters", () => {
expect(normalizeUsername("user@name!")).toBe("user@name!");
expect(normalizeUsername("test.user-123")).toBe("test.user-123");
});
test("should throw on invalid usernames", () => {
expect(() => normalizeUsername("")).toThrow("Invalid username: must be a non-empty string");
});
});
describe("Edge cases and consistency", () => {
test("should handle international domains", () => {
// Note: In a real implementation, you might want to handle punycode
expect(normalizeDomain("münchen.de")).toBe("@münchen.de");
});
test("should be consistent across multiple calls", () => {
const email = "Test.User+Tag@GMAIL.COM";
const result1 = normalizeEmail(email);
const result2 = normalizeEmail(email);
expect(result1).toBe(result2);
});
test("should handle empty strings gracefully", () => {
expect(() => normalizeEmail("")).toThrow();
expect(() => normalizeDomain("")).toThrow();
expect(() => normalizeUsername("")).toThrow();
});
});
});
@@ -0,0 +1,93 @@
import { emailRegex } from "@calcom/lib/emailSchema";
/**
* Centralized normalization utilities for emails and domains
*
*/
/**
* Normalizes an email address for consistent comparison
*
* Rules applied:
* 1. Convert to lowercase
* 2. Trim whitespace
* 3. Validate basic email format
*
* @param email - Raw email address
* @returns Normalized email address
* @throws Error if email format is invalid
*/
export function normalizeEmail(email: string): string {
const normalized = email.trim().toLowerCase();
if (!emailRegex.test(normalized)) {
throw new Error(`Invalid email format: ${email}`);
}
return normalized;
}
/**
* Normalizes a domain for consistent comparison
*
* Rules applied:
* 1. Convert to lowercase
* 2. Trim whitespace
* 3. Ensure proper @ prefix for domain entries
*
* Note: Domains are stored AS-IS with @ prefix (e.g., @mail.google.com, @example.co.uk)
* No subdomain stripping is performed to avoid multi-level TLD issues.
* If you want to block subdomains separately, create separate entries.
*
* @param domain - Raw domain (with or without @ prefix)
* @returns Normalized domain with @ prefix
*/
export function normalizeDomain(domain: string): string {
let normalized = domain.trim().toLowerCase();
if (normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
const domainRegex =
/^[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?(\.[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?)*$/;
if (!domainRegex.test(normalized)) {
throw new Error(`Invalid domain format: ${domain}`);
}
return `@${normalized}`;
}
/**
* Extracts and normalizes domain from an email address
*
* @param email - Email address
* @returns Normalized domain with @ prefix
*/
export function extractDomainFromEmail(email: string): string {
const normalizedEmail = normalizeEmail(email);
const domain = normalizedEmail.split("@")[1];
if (!domain) {
throw new Error(`Could not extract domain from email: ${email}`);
}
return normalizeDomain(domain);
}
/**
* Normalizes a username for consistent comparison
*
* Rules applied:
* 1. Convert to lowercase
* 2. Trim whitespace
*
* @param username - Raw username
* @returns Normalized username
*/
export function normalizeUsername(username: string): string {
if (!username || typeof username !== "string") {
throw new Error("Invalid username: must be a non-empty string");
}
return username.trim().toLowerCase();
}
@@ -0,0 +1,247 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { WatchlistType } from "@calcom/prisma/enums";
import { WatchlistFeature } from "../lib/facade/WatchlistFeature";
import type { SpanFn } from "../lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "./check-if-email-in-watchlist.controller";
// Mock the DI container
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
const mockGlobalBlocking = {
isBlocked: vi.fn(),
};
const mockOrgBlocking = {
isBlocked: vi.fn(),
};
const mockWatchlistFeature = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
watchlist: {},
audit: {},
};
describe("checkIfEmailIsBlockedInWatchlistController", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature as WatchlistFeature);
});
describe("Global blocking only", () => {
test("should return false when email not blocked globally", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "clean@example.com",
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("clean@example.com");
expect(mockOrgBlocking.isBlocked).not.toHaveBeenCalled();
});
test("should return true when email blocked globally", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: { id: "123" },
});
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "blocked@example.com",
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("blocked@example.com");
expect(mockOrgBlocking.isBlocked).not.toHaveBeenCalled(); // Short-circuit
});
test("should return true when domain blocked globally", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: { id: "456" },
});
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "user@spam.com",
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@spam.com");
});
test("should normalize email before checking", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
await checkIfEmailIsBlockedInWatchlistController({
email: "USER@EXAMPLE.COM",
});
// Email should be normalized by the time it reaches the service
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@example.com");
});
});
describe("Organization-specific blocking", () => {
test("should check org blocklist when organizationId provided", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
mockOrgBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "user@example.com",
organizationId: 123,
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@example.com");
expect(mockOrgBlocking.isBlocked).toHaveBeenCalledWith("user@example.com", 123);
});
test("should return true when blocked in org blocklist", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
mockOrgBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: { id: "org-123" },
});
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "competitor@example.com",
organizationId: 123,
});
expect(result).toBe(true);
expect(mockOrgBlocking.isBlocked).toHaveBeenCalledWith("competitor@example.com", 123);
});
test("should short-circuit on global block (not check org)", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: { id: "global-1" },
});
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "spam@example.com",
organizationId: 123,
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalled();
expect(mockOrgBlocking.isBlocked).not.toHaveBeenCalled(); // Global takes precedence
});
test("should handle organizationId as null (explicit no-org)", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "user@example.com",
organizationId: null,
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalled();
expect(mockOrgBlocking.isBlocked).not.toHaveBeenCalled();
});
});
describe("Telemetry (span)", () => {
test("should call span when provided", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const mockSpan: SpanFn = vi.fn((options, callback) => {
return Promise.resolve(callback());
});
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "test@example.com",
span: mockSpan,
});
expect(result).toBe(false);
expect(mockSpan).toHaveBeenCalledWith(
{ name: "checkIfEmailInWatchlist Controller" },
expect.any(Function)
);
expect(mockSpan).toHaveBeenCalledWith(
{ name: "checkIfEmailInWatchlist Presenter", op: "serialize" },
expect.any(Function)
);
});
test("should not call span when not provided", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "test@example.com",
// No span provided
});
expect(result).toBe(false);
// No span to verify - just ensure it doesn't crash
});
test("should execute callback inside span", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.EMAIL,
});
let callbackExecuted = false;
const mockSpan: SpanFn = vi.fn(async (options, callback) => {
const result = await callback();
callbackExecuted = true;
return result;
});
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "blocked@example.com",
span: mockSpan,
});
expect(result).toBe(true);
expect(callbackExecuted).toBe(true);
});
});
describe("Edge cases", () => {
test("should handle whitespace in email", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
await checkIfEmailIsBlockedInWatchlistController({
email: " user@example.com ",
});
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@example.com");
});
test("should handle case-insensitive email", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
await checkIfEmailIsBlockedInWatchlistController({
email: "UsEr@ExAmPlE.CoM",
});
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@example.com");
});
test("should handle organizationId as undefined", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfEmailIsBlockedInWatchlistController({
email: "user@example.com",
organizationId: undefined,
});
expect(result).toBe(false);
expect(mockOrgBlocking.isBlocked).not.toHaveBeenCalled();
});
});
});
@@ -1,33 +1,53 @@
import { startSpan } from "@sentry/nextjs";
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import type { Watchlist } from "../watchlist.model";
import { WatchlistRepository } from "../watchlist.repository";
import type { SpanFn } from "../lib/telemetry";
import { normalizeEmail } from "../lib/utils/normalization";
/**
* Controllers use Presenters to convert the data to a UI-friendly format just before
* returning it to the "consumer". This helps us ship less JavaScript to the client (logic
* and libraries to convert the data), helps prevent leaking any sensitive properties, like
* emails or hashed passwords, and also helps us slim down the amount of data we're sending
* back to the client.
*/
function presenter(watchlistedEmail: Watchlist | null) {
return startSpan({ name: "checkIfEmailInWatchlist Presenter", op: "serialize" }, () => {
return !!watchlistedEmail;
});
interface CheckEmailBlockedParams {
email: string;
organizationId?: number | null;
span?: SpanFn;
}
function presenter(isBlocked: boolean, span?: SpanFn): Promise<boolean> {
if (!span) {
return Promise.resolve(isBlocked);
}
return span({ name: "checkIfEmailInWatchlist Presenter", op: "serialize" }, () => isBlocked);
}
/**
* Controllers perform authentication checks and input validation before passing the input
* to the specific use cases. Controllers orchestrate Use Cases. They don't implement any
* logic, but define the whole operations using use cases.
* Controllers perform auth/validation and orchestrate use-cases.
* Uses DI container for proper dependency management.
*/
export async function checkIfEmailIsBlockedInWatchlistController(
email: string
): Promise<ReturnType<typeof presenter>> {
return await startSpan({ name: "checkIfEmailInWatchlist Controller" }, async () => {
const lowercasedEmail = email.toLowerCase();
const watchlistRepository = new WatchlistRepository();
const watchlistedEmail = await watchlistRepository.getBlockedEmailInWatchlist(lowercasedEmail);
return presenter(watchlistedEmail);
});
params: CheckEmailBlockedParams
): Promise<boolean> {
const { email, organizationId, span } = params;
const execute = async () => {
const normalizedEmail = normalizeEmail(email);
const watchlist = await getWatchlistFeature();
const globalResult = await watchlist.globalBlocking.isBlocked(normalizedEmail);
if (globalResult.isBlocked) {
return presenter(true, span);
}
if (organizationId != null) {
const orgResult = await watchlist.orgBlocking.isBlocked(normalizedEmail, organizationId);
if (orgResult.isBlocked) {
return presenter(true, span);
}
}
return presenter(false, span);
};
if (!span) {
return execute();
}
return span({ name: "checkIfEmailInWatchlist Controller" }, execute);
}
@@ -0,0 +1,284 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { WatchlistType } from "@calcom/prisma/enums";
import type { SpanFn } from "../lib/telemetry";
import { checkIfUsersAreBlocked } from "./check-if-users-are-blocked.controller";
// Mock the DI container
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
const mockGlobalBlocking = {
isBlocked: vi.fn(),
};
const mockOrgBlocking = {
isBlocked: vi.fn(),
};
const mockWatchlistFeature = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
watchlist: {},
audit: {},
};
describe("checkIfUsersAreBlocked", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature as never);
});
describe("Basic functionality", () => {
test("should return false when no users are blocked", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfUsersAreBlocked({
users: [
{ email: "user1@example.com", username: "user1", locked: false },
{ email: "user2@example.com", username: "user2", locked: false },
],
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledTimes(2);
});
test("should return true when any user email is blocked globally", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValueOnce({ isBlocked: false }).mockResolvedValueOnce({
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: { id: "123" },
});
const result = await checkIfUsersAreBlocked({
users: [
{ email: "clean@example.com", username: "user1", locked: false },
{ email: "blocked@spam.com", username: "user2", locked: false },
],
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("clean@example.com");
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("blocked@spam.com");
});
test("should return true when any user domain is blocked", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValueOnce({ isBlocked: false }).mockResolvedValueOnce({
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: { id: "456" },
});
const result = await checkIfUsersAreBlocked({
users: [
{ email: "user1@clean.com", username: "user1", locked: false },
{ email: "user2@blocked.com", username: "user2", locked: false },
],
});
expect(result).toBe(true);
});
});
describe("Early exit on locked user", () => {
test("should return true immediately if user is already locked", async () => {
const result = await checkIfUsersAreBlocked({
users: [
{ email: "user1@example.com", username: "user1", locked: true }, // Already locked
{ email: "user2@example.com", username: "user2", locked: false },
],
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).not.toHaveBeenCalled(); // Should short-circuit
});
test("should short-circuit even if locked user is not first", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfUsersAreBlocked({
users: [
{ email: "user1@example.com", username: "user1", locked: false },
{ email: "user2@example.com", username: "user2", locked: true }, // Locked user second
],
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).not.toHaveBeenCalled(); // Still short-circuits with .some()
});
test("should not call watchlist if all users are locked", async () => {
const result = await checkIfUsersAreBlocked({
users: [
{ email: "user1@example.com", username: "user1", locked: true },
{ email: "user2@example.com", username: "user2", locked: true },
],
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).not.toHaveBeenCalled();
});
});
describe("Organization-specific blocking", () => {
test("should check both global and org blocklists", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
mockOrgBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfUsersAreBlocked({
users: [{ email: "user@example.com", username: "user1", locked: false }],
organizationId: 123,
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@example.com");
expect(mockOrgBlocking.isBlocked).toHaveBeenCalledWith("user@example.com", 123);
});
test("should return true when blocked in org blocklist", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
mockOrgBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.EMAIL,
});
const result = await checkIfUsersAreBlocked({
users: [{ email: "competitor@example.com", username: "user1", locked: false }],
organizationId: 123,
});
expect(result).toBe(true);
});
test("should short-circuit on global block (not check org)", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({
isBlocked: true,
reason: WatchlistType.EMAIL,
});
const result = await checkIfUsersAreBlocked({
users: [{ email: "spam@example.com", username: "user1", locked: false }],
organizationId: 123,
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalled();
expect(mockOrgBlocking.isBlocked).not.toHaveBeenCalled(); // Global block short-circuits
});
});
describe("Email normalization", () => {
test("should normalize emails before checking", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
await checkIfUsersAreBlocked({
users: [
{ email: "USER1@EXAMPLE.COM", username: "user1", locked: false },
{ email: " user2@example.com ", username: "user2", locked: false },
],
});
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user1@example.com");
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user2@example.com");
});
});
describe("Edge cases", () => {
test("should skip users with no email", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfUsersAreBlocked({
users: [
{ email: "", username: "user1", locked: false }, // Empty email
{ email: "user2@example.com", username: "user2", locked: false },
],
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledTimes(1); // Only checks user2
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user2@example.com");
});
test("should return false for empty users array", async () => {
const result = await checkIfUsersAreBlocked({
users: [],
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).not.toHaveBeenCalled();
});
test("should handle users with null username", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfUsersAreBlocked({
users: [{ email: "user@example.com", username: null, locked: false }],
});
expect(result).toBe(false);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledWith("user@example.com");
});
});
describe("Telemetry (span)", () => {
test("should call span when provided", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const mockSpan: SpanFn = vi.fn((options, callback) => {
return Promise.resolve(callback());
});
const result = await checkIfUsersAreBlocked({
users: [{ email: "test@example.com", username: "test", locked: false }],
span: mockSpan,
});
expect(result).toBe(false);
expect(mockSpan).toHaveBeenCalledWith(
{ name: "checkIfUsersAreBlocked Controller" },
expect.any(Function)
);
expect(mockSpan).toHaveBeenCalledWith(
{ name: "checkIfUsersAreBlocked Presenter", op: "serialize" },
expect.any(Function)
);
});
test("should not call span when not provided", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValue({ isBlocked: false });
const result = await checkIfUsersAreBlocked({
users: [{ email: "test@example.com", username: "test", locked: false }],
// No span provided
});
expect(result).toBe(false);
// No span to verify - just ensure it doesn't crash
});
});
describe("Performance optimization", () => {
test("should stop checking after finding blocked user", async () => {
mockGlobalBlocking.isBlocked.mockResolvedValueOnce({ isBlocked: false }).mockResolvedValueOnce({
isBlocked: true,
reason: WatchlistType.EMAIL,
});
const result = await checkIfUsersAreBlocked({
users: [
{ email: "user1@example.com", username: "user1", locked: false },
{ email: "blocked@example.com", username: "user2", locked: false },
{ email: "user3@example.com", username: "user3", locked: false }, // Should not check this
],
});
expect(result).toBe(true);
expect(mockGlobalBlocking.isBlocked).toHaveBeenCalledTimes(2); // Stops after 2nd user
expect(mockGlobalBlocking.isBlocked).not.toHaveBeenCalledWith("user3@example.com");
});
});
});
@@ -1,35 +1,53 @@
import { startSpan } from "@sentry/nextjs";
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import type { SpanFn } from "@calcom/features/watchlist/lib/telemetry";
import { normalizeEmail } from "@calcom/features/watchlist/lib/utils/normalization";
import { WatchlistRepository } from "../watchlist.repository";
function presenter(containsBlockedUser: boolean) {
return startSpan({ name: "checkIfUsersAreBlocked Presenter", op: "serialize" }, () => {
return !!containsBlockedUser;
});
function presenter(containsBlockedUser: boolean, span?: SpanFn): Promise<boolean> {
const result = !!containsBlockedUser;
if (!span) {
return Promise.resolve(result);
}
return span({ name: "checkIfUsersAreBlocked Presenter", op: "serialize" }, () => result);
}
export async function checkIfUsersAreBlocked(
users: { email: string; username: string | null; locked: boolean }[]
): Promise<ReturnType<typeof presenter>> {
const usernamesToCheck = [],
emailsToCheck = [],
domainsToCheck = [];
interface CheckUsersBlockedParams {
users: { email: string; username: string | null; locked: boolean }[];
organizationId?: number | null;
span?: SpanFn;
}
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);
export async function checkIfUsersAreBlocked(params: CheckUsersBlockedParams): Promise<boolean> {
const { users, organizationId, span } = params;
const execute = async () => {
if (users.some((user) => user.locked)) {
return presenter(true, span);
}
const watchlist = await getWatchlistFeature();
for (const user of users) {
if (!user.email) continue;
const normalizedEmail = normalizeEmail(user.email);
const globalResult = await watchlist.globalBlocking.isBlocked(normalizedEmail);
if (globalResult.isBlocked) {
return presenter(true, span);
}
if (organizationId) {
const orgResult = await watchlist.orgBlocking.isBlocked(normalizedEmail, organizationId);
if (orgResult.isBlocked) {
return presenter(true, span);
}
}
}
return presenter(false, span);
};
if (!span) {
return execute();
}
const watchlistRepository = new WatchlistRepository();
const blockedRecords = await watchlistRepository.searchForAllBlockedRecords({
usernames: usernamesToCheck,
emails: emailsToCheck,
domains: domainsToCheck,
});
return presenter(blockedRecords.length > 0);
return span({ name: "checkIfUsersAreBlocked Controller" }, execute);
}
@@ -0,0 +1,327 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { WatchlistType, WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
import type { SpanFn } from "../lib/telemetry";
import { listAllSystemEntriesController } from "./list-all-system-entries.controller";
// Mock the DI container
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
const mockWatchlistService = {
listAllSystemEntries: vi.fn(),
};
const mockWatchlistFeature: Partial<
ReturnType<typeof import("../lib/facade/WatchlistFeature").createWatchlistFeature>
> = {
watchlist: mockWatchlistService as never,
};
describe("listAllSystemEntriesController", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
vi.mocked(getWatchlistFeature).mockResolvedValue(
mockWatchlistFeature as ReturnType<typeof getWatchlistFeature>
);
});
describe("Basic functionality", () => {
test("should return all system entries (global + org)", async () => {
const mockEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "global@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "2",
type: WatchlistType.DOMAIN,
value: "@competitor.com",
description: "Competitor domain",
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 123,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
mockWatchlistService.listAllSystemEntries.mockResolvedValue(mockEntries);
const result = await listAllSystemEntriesController({});
expect(result).toEqual(mockEntries);
expect(result).toHaveLength(2);
expect(mockWatchlistService.listAllSystemEntries).toHaveBeenCalledTimes(1);
});
test("should return empty array when no entries exist", async () => {
mockWatchlistService.listAllSystemEntries.mockResolvedValue([]);
const result = await listAllSystemEntriesController({});
expect(result).toEqual([]);
expect(mockWatchlistService.listAllSystemEntries).toHaveBeenCalled();
});
});
describe("Entry types", () => {
test("should return entries of all types (EMAIL, DOMAIN, USERNAME)", async () => {
const mockEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "email@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "2",
type: WatchlistType.DOMAIN,
value: "@domain.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "3",
type: WatchlistType.USERNAME,
value: "spammer123",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
mockWatchlistService.listAllSystemEntries.mockResolvedValue(mockEntries);
const result = await listAllSystemEntriesController({});
expect(result).toHaveLength(3);
expect(result.map((e) => e.type)).toEqual([
WatchlistType.EMAIL,
WatchlistType.DOMAIN,
WatchlistType.USERNAME,
]);
});
test("should return entries with different actions (BLOCK, REPORT, ALERT)", async () => {
const mockEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "blocked@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "2",
type: WatchlistType.EMAIL,
value: "reported@example.com",
description: null,
action: WatchlistAction.REPORT,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "3",
type: WatchlistType.EMAIL,
value: "alert@example.com",
description: null,
action: WatchlistAction.ALERT,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
mockWatchlistService.listAllSystemEntries.mockResolvedValue(mockEntries);
const result = await listAllSystemEntriesController({});
expect(result.map((e) => e.action)).toEqual([
WatchlistAction.BLOCK,
WatchlistAction.REPORT,
WatchlistAction.ALERT,
]);
});
test("should return entries with different sources (MANUAL, FREE_DOMAIN_POLICY)", async () => {
const mockEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "manual@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "2",
type: WatchlistType.DOMAIN,
value: "@gmail.com",
description: null,
action: WatchlistAction.REPORT,
isGlobal: true,
organizationId: null,
source: WatchlistSource.FREE_DOMAIN_POLICY,
lastUpdatedAt: new Date(),
},
];
mockWatchlistService.listAllSystemEntries.mockResolvedValue(mockEntries);
const result = await listAllSystemEntriesController({});
expect(result.map((e) => e.source)).toEqual([
WatchlistSource.MANUAL,
WatchlistSource.FREE_DOMAIN_POLICY,
]);
});
});
describe("Multiple organizations", () => {
test("should return entries from multiple organizations", async () => {
const mockEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "org1@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 100,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "2",
type: WatchlistType.EMAIL,
value: "org2@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 200,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
{
id: "3",
type: WatchlistType.EMAIL,
value: "org3@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: false,
organizationId: 300,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
mockWatchlistService.listAllSystemEntries.mockResolvedValue(mockEntries);
const result = await listAllSystemEntriesController({});
expect(result).toHaveLength(3);
const orgIds = result.map((e) => e.organizationId);
expect(orgIds).toEqual([100, 200, 300]);
expect(new Set(orgIds).size).toBe(3); // All different orgs
});
});
describe("Telemetry (span)", () => {
test("should call span when provided", async () => {
const mockEntries = [
{
id: "1",
type: WatchlistType.EMAIL,
value: "test@example.com",
description: null,
action: WatchlistAction.BLOCK,
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
},
];
mockWatchlistService.listAllSystemEntries.mockResolvedValue(mockEntries);
const mockSpan: SpanFn = vi.fn((options, callback) => {
return Promise.resolve(callback());
});
const result = await listAllSystemEntriesController({
span: mockSpan,
});
expect(result).toEqual(mockEntries);
expect(mockSpan).toHaveBeenCalledWith(
{ name: "listAllSystemEntries Controller" },
expect.any(Function)
);
});
test("should not call span when not provided", async () => {
mockWatchlistService.listAllSystemEntries.mockResolvedValue([]);
const result = await listAllSystemEntriesController({
// No span provided
});
expect(result).toEqual([]);
// No span to verify - just ensure it doesn't crash
});
});
describe("Error handling", () => {
test("should propagate errors from service", async () => {
const error = new Error("Service error");
mockWatchlistService.listAllSystemEntries.mockRejectedValue(error);
await expect(listAllSystemEntriesController({})).rejects.toThrow("Service error");
});
test("should propagate errors from service when span is provided", async () => {
const error = new Error("Service error");
mockWatchlistService.listAllSystemEntries.mockRejectedValue(error);
const mockSpan: SpanFn = vi.fn((options, callback) => {
return Promise.resolve(callback());
});
await expect(listAllSystemEntriesController({ span: mockSpan })).rejects.toThrow("Service error");
expect(mockSpan).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,31 @@
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import type { SpanFn } from "../lib/telemetry";
import type { WatchlistEntry } from "../lib/types";
interface ListAllSystemEntriesParams {
span?: SpanFn;
}
/**
* System admin controller to get ALL watchlist entries across the entire system
* Returns both global entries and all organization-specific entries from every organization
*
* This should only be accessible to system administrators
*/
export async function listAllSystemEntriesController(
params: ListAllSystemEntriesParams = {}
): Promise<WatchlistEntry[]> {
const { span } = params;
const execute = async () => {
const watchlist = await getWatchlistFeature();
return watchlist.watchlist.listAllSystemEntries();
};
if (!span) {
return execute();
}
return span({ name: "listAllSystemEntries Controller" }, execute);
}
@@ -1,22 +0,0 @@
import type { z } from "zod";
import { WatchlistSchema } from "@calcom/prisma/zod/modelSchema/WatchlistSchema";
export const WatchlistModelSchema = WatchlistSchema.pick({
id: true,
type: true,
value: true,
description: true,
createdAt: true,
createdById: true,
updatedAt: true,
updatedById: true,
});
export type Watchlist = z.infer<typeof WatchlistModelSchema>;
export const insertWatchlistSchema = WatchlistModelSchema.pick({
type: true,
value: true,
description: true,
});
@@ -1,5 +0,0 @@
import type { Watchlist } from "./watchlist.model";
export interface IWatchlistRepository {
getBlockedEmailInWatchlist(email: string): Promise<Watchlist | null>;
}
@@ -1,19 +0,0 @@
import { WatchlistType } from "@calcom/prisma/enums";
import type { IWatchlistRepository } from "./watchlist.repository.interface";
export class MockFeaturesRepository implements IWatchlistRepository {
async getEmailInWatchlist(email: string) {
if (email !== "watchlisted@example.com") return null;
return {
id: "1",
type: WatchlistType.EMAIL,
value: "watchlisted@example.com",
description: "This is a watchlisted email",
createdAt: new Date(),
createdById: 1,
updatedAt: new Date(),
updatedById: 1,
};
}
}
@@ -1,95 +0,0 @@
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import { WatchlistType } from "@calcom/prisma/enums";
import type { IWatchlistRepository } from "./watchlist.repository.interface";
export class WatchlistRepository implements IWatchlistRepository {
async getBlockedEmailInWatchlist(email: string) {
const [, domain] = email.split("@");
try {
const emailInWatchlist = await db.watchlist.findFirst({
where: {
OR: [
{ type: WatchlistType.EMAIL, value: email },
{ type: WatchlistType.DOMAIN, value: domain },
],
},
});
return emailInWatchlist;
} catch (err) {
captureException(err);
throw err;
}
}
async getFreeEmailDomainInWatchlist(emailDomain: string) {
try {
const domainInWatchWatchlist = await db.watchlist.findFirst({
where: {
type: WatchlistType.DOMAIN,
value: emailDomain,
},
});
return domainInWatchWatchlist;
} catch (err) {
captureException(err);
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: {
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) {
captureException(err);
throw err;
}
}
}
@@ -10,6 +10,25 @@ export class PrismaApiKeyRepository {
return new PrismaApiKeyRepository((await import("@calcom/prisma")).prisma);
}
async findByHashedKey(hashedKey: string) {
return this.prismaClient.apiKey.findUnique({
where: { hashedKey },
select: {
id: true,
hashedKey: true,
userId: true,
expiresAt: true,
user: {
select: {
role: true,
locked: true,
email: true,
},
},
},
});
}
async findApiKeysFromUserId({ userId }: { userId: number }) {
const apiKeys = await this.prismaClient.apiKey.findMany({
where: {
@@ -0,0 +1,47 @@
import type { PrismaApiKeyRepository } from "../repository/PrismaApiKeyRepository";
type Deps = {
apiKeyRepo: PrismaApiKeyRepository;
};
interface VerifyKeyResult {
valid: boolean;
error?: string;
userId?: number;
user?: {
role: string;
locked: boolean;
email: string;
};
}
export class ApiKeyService {
constructor(private readonly deps: Deps) {}
async verifyKeyByHashedKey(hashedKey: string): Promise<VerifyKeyResult> {
const apiKey = await this.deps.apiKeyRepo.findByHashedKey(hashedKey);
if (!apiKey) {
return { valid: false, error: "Your API key is not valid." };
}
if (apiKey.expiresAt && this.isExpired(apiKey.expiresAt)) {
return { valid: false, error: "This API key is expired." };
}
if (!apiKey.userId || !apiKey.user) {
return { valid: false, error: "No user found for this API key." };
}
return {
valid: true,
userId: apiKey.userId,
user: apiKey.user,
};
}
private isExpired(expiresAt: Date): boolean {
const now = new Date();
return now.setHours(0, 0, 0, 0) > expiresAt.setHours(0, 0, 0, 0);
}
}