diff --git a/apps/api/v1/lib/helpers/verifyApiKey.test.ts b/apps/api/v1/lib/helpers/verifyApiKey.test.ts index fc296f24c3..8cb47d561f 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.test.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.test.ts @@ -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, }; diff --git a/apps/api/v1/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts index 75f24f01e3..e7b92d0ba2 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.ts @@ -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); diff --git a/apps/api/v1/lib/utils/isLockedOrBlocked.ts b/apps/api/v1/lib/utils/isLockedOrBlocked.ts index 0786678a24..e0f3d423b0 100644 --- a/apps/api/v1/lib/utils/isLockedOrBlocked.ts +++ b/apps/api/v1/lib/utils/isLockedOrBlocked.ts @@ -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, + })) + ); } diff --git a/apps/api/v1/test/lib/users/_post.test.ts b/apps/api/v1/test/lib/users/_post.test.ts index 7aabe2fd03..09a9c34a1b 100644 --- a/apps/api/v1/test/lib/users/_post.test.ts +++ b/apps/api/v1/test/lib/users/_post.test.ts @@ -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({ 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({ 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({ 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({ 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({ - 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, }) ); diff --git a/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts index b67f45ca19..935073d652 100644 --- a/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts +++ b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts @@ -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"); }); }); diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index 06f9230af8..8efb084fac 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -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; } diff --git a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts index fd6fbb092d..30e0960d2c 100644 --- a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts +++ b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts @@ -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", diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index 192b7ba463..4163d5423e 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -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 }; } diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index a70896dde0..eb73e3dcfe 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -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 }); diff --git a/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts b/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts index 426dcfe2b2..c3c354209c 100644 --- a/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts +++ b/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts @@ -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) { diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index c57bfc17dc..bb9f1d4767 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -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" }); diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index b03267131f..673e520890 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -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, }; diff --git a/packages/features/di/watchlist/Watchlist.tokens.ts b/packages/features/di/watchlist/Watchlist.tokens.ts new file mode 100644 index 0000000000..9321e9c408 --- /dev/null +++ b/packages/features/di/watchlist/Watchlist.tokens.ts @@ -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"), +}; diff --git a/packages/features/di/watchlist/containers/watchlist.ts b/packages/features/di/watchlist/containers/watchlist.ts new file mode 100644 index 0000000000..718824662d --- /dev/null +++ b/packages/features/di/watchlist/containers/watchlist.ts @@ -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(WATCHLIST_DI_TOKENS.WATCHLIST_SERVICE); +} + +export function getGlobalBlockingService() { + return watchlistContainer.get(WATCHLIST_DI_TOKENS.GLOBAL_BLOCKING_SERVICE); +} + +export function getOrganizationBlockingService() { + return watchlistContainer.get( + WATCHLIST_DI_TOKENS.ORGANIZATION_BLOCKING_SERVICE + ); +} + +export function getAuditService() { + return watchlistContainer.get(WATCHLIST_DI_TOKENS.AUDIT_SERVICE); +} + +export function getGlobalWatchlistRepository() { + return watchlistContainer.get(WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY); +} + +export function getOrganizationWatchlistRepository() { + return watchlistContainer.get( + WATCHLIST_DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY + ); +} + +export async function getWatchlistFeature(): Promise { + return createWatchlistFeature(watchlistContainer); +} diff --git a/packages/features/di/watchlist/modules/Watchlist.module.ts b/packages/features/di/watchlist/modules/Watchlist.module.ts new file mode 100644 index 0000000000..983355d386 --- /dev/null +++ b/packages/features/di/watchlist/modules/Watchlist.module.ts @@ -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 diff --git a/packages/features/users/services/userCreationService.test.ts b/packages/features/users/services/userCreationService.test.ts index 3cdb18bcd4..1eb3085006 100644 --- a/packages/features/users/services/userCreationService.test.ts +++ b/packages/features/users/services/userCreationService.test.ts @@ -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 }, diff --git a/packages/features/users/services/userCreationService.ts b/packages/features/users/services/userCreationService.ts index b4955a08d3..59ac593e19 100644 --- a/packages/features/users/services/userCreationService.ts +++ b/packages/features/users/services/userCreationService.ts @@ -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; } diff --git a/packages/features/watchlist/lib/checkIfFreeEmailDomain.test.ts b/packages/features/watchlist/lib/checkIfFreeEmailDomain.test.ts deleted file mode 100644 index 5345262085..0000000000 --- a/packages/features/watchlist/lib/checkIfFreeEmailDomain.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/features/watchlist/lib/checkIfFreeEmailDomain.ts b/packages/features/watchlist/lib/checkIfFreeEmailDomain.ts deleted file mode 100644 index 11571b40e6..0000000000 --- a/packages/features/watchlist/lib/checkIfFreeEmailDomain.ts +++ /dev/null @@ -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)); -}; diff --git a/packages/features/watchlist/lib/dto/index.ts b/packages/features/watchlist/lib/dto/index.ts new file mode 100644 index 0000000000..9c912ae4f5 --- /dev/null +++ b/packages/features/watchlist/lib/dto/index.ts @@ -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"; diff --git a/packages/features/watchlist/lib/dto/mappers.ts b/packages/features/watchlist/lib/dto/mappers.ts new file mode 100644 index 0000000000..d4ca47bcef --- /dev/null +++ b/packages/features/watchlist/lib/dto/mappers.ts @@ -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(); + } +} diff --git a/packages/features/watchlist/lib/dto/types.ts b/packages/features/watchlist/lib/dto/types.ts new file mode 100644 index 0000000000..4e912f01c1 --- /dev/null +++ b/packages/features/watchlist/lib/dto/types.ts @@ -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; +} + +// 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; + }; +} diff --git a/packages/features/watchlist/lib/facade/WatchlistFeature.ts b/packages/features/watchlist/lib/facade/WatchlistFeature.ts new file mode 100644 index 0000000000..ed8ad9997e --- /dev/null +++ b/packages/features/watchlist/lib/facade/WatchlistFeature.ts @@ -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( + WATCHLIST_DI_TOKENS.GLOBAL_WATCHLIST_REPOSITORY + ); + const orgRepo = container.get( + WATCHLIST_DI_TOKENS.ORGANIZATION_WATCHLIST_REPOSITORY + ); + const auditRepo = container.get(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 }), + }; +} diff --git a/packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.test.ts b/packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.test.ts new file mode 100644 index 0000000000..f3d430b718 --- /dev/null +++ b/packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.test.ts @@ -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(); + }); +}); diff --git a/packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.ts b/packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.ts new file mode 100644 index 0000000000..43dba2381f --- /dev/null +++ b/packages/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain.ts @@ -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; +} + +export const checkIfFreeEmailDomain = async (params: CheckFreeEmailDomainParams): Promise => { + 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; + } +}; diff --git a/packages/lib/freeEmailDomainCheck/freeEmailDomains.ts b/packages/features/watchlist/lib/freeEmailDomainCheck/freeEmailDomains.ts similarity index 100% rename from packages/lib/freeEmailDomainCheck/freeEmailDomains.ts rename to packages/features/watchlist/lib/freeEmailDomainCheck/freeEmailDomains.ts diff --git a/packages/features/watchlist/lib/interface/IAuditRepository.ts b/packages/features/watchlist/lib/interface/IAuditRepository.ts new file mode 100644 index 0000000000..fada8936e2 --- /dev/null +++ b/packages/features/watchlist/lib/interface/IAuditRepository.ts @@ -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; + findById(id: string): Promise; + findByWatchlistId(watchlistId: string): Promise; + update(id: string, data: UpdateWatchlistAuditInput): Promise; + delete(id: string): Promise; + findMany(filters?: { + watchlistId?: string; + changedByUserId?: number; + limit?: number; + offset?: number; + }): Promise; +} diff --git a/packages/features/watchlist/lib/interface/IAuditService.ts b/packages/features/watchlist/lib/interface/IAuditService.ts new file mode 100644 index 0000000000..7103a5ee69 --- /dev/null +++ b/packages/features/watchlist/lib/interface/IAuditService.ts @@ -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; + getAuditEntry(id: string): Promise; + getAuditHistory(watchlistId: string): Promise; + updateAuditEntry(id: string, data: UpdateWatchlistAuditInput): Promise; + deleteAuditEntry(id: string): Promise; + getAuditEntries(filters?: { + watchlistId?: string; + changedByUserId?: number; + limit?: number; + offset?: number; + }): Promise; +} diff --git a/packages/features/watchlist/lib/interface/IBlockingService.ts b/packages/features/watchlist/lib/interface/IBlockingService.ts new file mode 100644 index 0000000000..dabf67ab96 --- /dev/null +++ b/packages/features/watchlist/lib/interface/IBlockingService.ts @@ -0,0 +1,11 @@ +import type { WatchlistType } from "../types"; + +export interface BlockingResult { + isBlocked: boolean; + reason?: WatchlistType; + watchlistEntry?: Record | null; +} + +export interface IBlockingService { + isBlocked(email: string, organizationId?: number | null): Promise; +} diff --git a/packages/features/watchlist/lib/interface/IWatchlistRepositories.ts b/packages/features/watchlist/lib/interface/IWatchlistRepositories.ts new file mode 100644 index 0000000000..b2baccfddb --- /dev/null +++ b/packages/features/watchlist/lib/interface/IWatchlistRepositories.ts @@ -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; + findBlockedDomain(domain: string): Promise; + findFreeEmailDomain(domain: string): Promise; + findById(id: string): Promise; + listBlockedEntries(): Promise; + + // Write operations + createEntry(data: { + type: WatchlistType; + value: string; + description?: string; + action: WatchlistAction; + source?: WatchlistSource; + }): Promise; + + updateEntry( + id: string, + data: { + value?: string; + description?: string; + action?: WatchlistAction; + source?: WatchlistSource; + } + ): Promise; + + deleteEntry(id: string): Promise; +} + +/** + * Interface for Organization Watchlist Repository + * Follows Dependency Inversion Principle + */ +export interface IOrganizationWatchlistRepository { + findBlockedEmail({ + email, + organizationId, + }: { + email: string; + organizationId: number; + }): Promise; + findBlockedDomain(domain: string, organizationId: number): Promise; + findById(id: string, organizationId: number): Promise; + listBlockedEntries(organizationId: number): Promise; + listAllOrganizationEntries(): Promise; + + // Write operations + createEntry( + organizationId: number, + data: { + type: WatchlistType; + value: string; + description?: string; + action: WatchlistAction; + source?: WatchlistSource; + } + ): Promise; + + updateEntry( + id: string, + organizationId: number, + data: { + value?: string; + description?: string; + action?: WatchlistAction; + source?: WatchlistSource; + } + ): Promise; + + deleteEntry(id: string, organizationId: number): Promise; +} diff --git a/packages/features/watchlist/lib/interface/IWatchlistService.ts b/packages/features/watchlist/lib/interface/IWatchlistService.ts new file mode 100644 index 0000000000..4526bbbee4 --- /dev/null +++ b/packages/features/watchlist/lib/interface/IWatchlistService.ts @@ -0,0 +1,9 @@ +import type { WatchlistEntry, CreateWatchlistEntryData, UpdateWatchlistEntryData } from "../types"; + +export interface IWatchlistService { + createEntry(data: CreateWatchlistEntryData): Promise; + updateEntry(id: string, data: UpdateWatchlistEntryData): Promise; + deleteEntry(id: string): Promise; + getEntry(id: string): Promise; + listAllSystemEntries(): Promise; +} diff --git a/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts b/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts new file mode 100644 index 0000000000..a6c64343f9 --- /dev/null +++ b/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts @@ -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 { + 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 { + 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 { + 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 { + return this.prisma.watchlist.findFirst({ + select: this.selectFields, + where: { + id, + organizationId: null, + isGlobal: true, + }, + }); + } + + async listBlockedEntries(): Promise { + 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 { + 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 { + 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 { + this.prisma.watchlist.delete({ + where: { id }, + }); + } +} diff --git a/packages/features/watchlist/lib/repository/OrganizationWatchlistRepository.ts b/packages/features/watchlist/lib/repository/OrganizationWatchlistRepository.ts new file mode 100644 index 0000000000..43cd6add53 --- /dev/null +++ b/packages/features/watchlist/lib/repository/OrganizationWatchlistRepository.ts @@ -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 { + 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 { + return this.prisma.watchlist.findFirst({ + select: this.selectFields, + where: { + type: WatchlistType.DOMAIN, + value: domain, + action: WatchlistAction.BLOCK, + organizationId, + }, + }); + } + + async listBlockedEntries(organizationId: number): Promise { + return this.prisma.watchlist.findMany({ + select: this.selectFields, + where: { + organizationId, + action: WatchlistAction.BLOCK, + }, + }); + } + + async listAllOrganizationEntries(): Promise { + return this.prisma.watchlist.findMany({ + select: this.selectFields, + where: { + organizationId: { not: null }, + isGlobal: false, + action: WatchlistAction.BLOCK, + }, + }); + } + + async findById(id: string, organizationId: number): Promise { + 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 { + 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 { + 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 { + return this.prisma.watchlist + .delete({ + where: { id, organizationId }, + }) + .then(() => {}); + } +} diff --git a/packages/features/watchlist/lib/repository/PrismaWatchlistAuditRepository.ts b/packages/features/watchlist/lib/repository/PrismaWatchlistAuditRepository.ts new file mode 100644 index 0000000000..e1c1c25149 --- /dev/null +++ b/packages/features/watchlist/lib/repository/PrismaWatchlistAuditRepository.ts @@ -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 { + 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 { + return this.prisma.watchlistAudit.findUnique({ + select: this.selectFields, + where: { id }, + }); + } + + async findByWatchlistId(watchlistId: string): Promise { + return this.prisma.watchlistAudit.findMany({ + select: this.selectFields, + where: { watchlistId }, + orderBy: { changedAt: "desc" }, + }); + } + + async update(id: string, data: UpdateWatchlistAuditInput): Promise { + 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 { + return this.prisma.watchlistAudit + .delete({ + where: { id }, + }) + .then(() => {}); + } + + async findMany(filters?: { + watchlistId?: string; + changedByUserId?: number; + limit?: number; + offset?: number; + }): Promise { + 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 }), + }); + } +} diff --git a/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts b/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts new file mode 100644 index 0000000000..516bb18377 --- /dev/null +++ b/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/features/watchlist/lib/service/GlobalBlockingService.ts b/packages/features/watchlist/lib/service/GlobalBlockingService.ts new file mode 100644 index 0000000000..13ba8f28d6 --- /dev/null +++ b/packages/features/watchlist/lib/service/GlobalBlockingService.ts @@ -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 { + 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 { + const normalizedDomain = normalizeDomain(domain); + return this.deps.globalRepo.findFreeEmailDomain(normalizedDomain).then((entry) => !!entry); + } +} diff --git a/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts b/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts new file mode 100644 index 0000000000..129e00b35d --- /dev/null +++ b/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts @@ -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, + }); + }); + }); +}); diff --git a/packages/features/watchlist/lib/service/OrganizationBlockingService.ts b/packages/features/watchlist/lib/service/OrganizationBlockingService.ts new file mode 100644 index 0000000000..2321aab4f9 --- /dev/null +++ b/packages/features/watchlist/lib/service/OrganizationBlockingService.ts @@ -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 { + 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 { + 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 }; + }); + } +} diff --git a/packages/features/watchlist/lib/service/WatchlistAuditService.ts b/packages/features/watchlist/lib/service/WatchlistAuditService.ts new file mode 100644 index 0000000000..ee2cb45393 --- /dev/null +++ b/packages/features/watchlist/lib/service/WatchlistAuditService.ts @@ -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 { + return this.deps.auditRepository.create(data); + } + + async getAuditEntry(id: string): Promise { + return this.deps.auditRepository.findById(id); + } + + async getAuditHistory(watchlistId: string): Promise { + return this.deps.auditRepository.findByWatchlistId(watchlistId); + } + + async updateAuditEntry(id: string, data: UpdateWatchlistAuditInput): Promise { + return this.deps.auditRepository.update(id, data); + } + + async deleteAuditEntry(id: string): Promise { + return this.deps.auditRepository.delete(id); + } + + async getAuditEntries(filters?: { + watchlistId?: string; + changedByUserId?: number; + limit?: number; + offset?: number; + }): Promise { + return this.deps.auditRepository.findMany(filters); + } +} diff --git a/packages/features/watchlist/lib/service/WatchlistService.test.ts b/packages/features/watchlist/lib/service/WatchlistService.test.ts new file mode 100644 index 0000000000..1910a48a39 --- /dev/null +++ b/packages/features/watchlist/lib/service/WatchlistService.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/features/watchlist/lib/service/WatchlistService.ts b/packages/features/watchlist/lib/service/WatchlistService.ts new file mode 100644 index 0000000000..6812cae9a4 --- /dev/null +++ b/packages/features/watchlist/lib/service/WatchlistService.ts @@ -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; +}; + +export class WatchlistService implements IWatchlistService { + private readonly log: ReturnType; + + constructor(private readonly deps: Deps) { + this.log = deps.logger; + } + + async createEntry(data: CreateWatchlistEntryData): Promise { + 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 { + // 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 { + return this.deps.globalRepo.deleteEntry(id); + } + + async getEntry(id: string): Promise { + 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 { + const [globalEntries, allOrgEntries] = await Promise.all([ + this.deps.globalRepo.listBlockedEntries(), + this.deps.orgRepo.listAllOrganizationEntries(), + ]); + + return [...globalEntries, ...allOrgEntries]; + } +} diff --git a/packages/features/watchlist/lib/telemetry/index.ts b/packages/features/watchlist/lib/telemetry/index.ts new file mode 100644 index 0000000000..eb7a8816a4 --- /dev/null +++ b/packages/features/watchlist/lib/telemetry/index.ts @@ -0,0 +1,3 @@ +export type { SpanOptions, SpanFn } from "./types"; +export { sentrySpan } from "./sentry-span"; +export { noOpSpan } from "./no-op-span"; diff --git a/packages/features/watchlist/lib/telemetry/no-op-span.ts b/packages/features/watchlist/lib/telemetry/no-op-span.ts new file mode 100644 index 0000000000..45997315e9 --- /dev/null +++ b/packages/features/watchlist/lib/telemetry/no-op-span.ts @@ -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()); +}; diff --git a/packages/features/watchlist/lib/telemetry/sentry-span.ts b/packages/features/watchlist/lib/telemetry/sentry-span.ts new file mode 100644 index 0000000000..a311e55cff --- /dev/null +++ b/packages/features/watchlist/lib/telemetry/sentry-span.ts @@ -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); +}; diff --git a/packages/features/watchlist/lib/telemetry/types.ts b/packages/features/watchlist/lib/telemetry/types.ts new file mode 100644 index 0000000000..1e215b10fe --- /dev/null +++ b/packages/features/watchlist/lib/telemetry/types.ts @@ -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 = (options: SpanOptions, callback: () => T | Promise) => Promise; diff --git a/packages/features/watchlist/lib/types.ts b/packages/features/watchlist/lib/types.ts new file mode 100644 index 0000000000..a230299c0c --- /dev/null +++ b/packages/features/watchlist/lib/types.ts @@ -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; diff --git a/packages/features/watchlist/lib/utils/normalization.test.ts b/packages/features/watchlist/lib/utils/normalization.test.ts new file mode 100644 index 0000000000..b5524d2aa4 --- /dev/null +++ b/packages/features/watchlist/lib/utils/normalization.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/features/watchlist/lib/utils/normalization.ts b/packages/features/watchlist/lib/utils/normalization.ts new file mode 100644 index 0000000000..851a6b93cf --- /dev/null +++ b/packages/features/watchlist/lib/utils/normalization.ts @@ -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(); +} diff --git a/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.test.ts b/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.test.ts new file mode 100644 index 0000000000..8d6337fedb --- /dev/null +++ b/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts b/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts index e945f08ec2..2b9ee6ef23 100644 --- a/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts +++ b/packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts @@ -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 { + 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> { - 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 { + 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); } diff --git a/packages/features/watchlist/operations/check-if-users-are-blocked.controller.test.ts b/packages/features/watchlist/operations/check-if-users-are-blocked.controller.test.ts new file mode 100644 index 0000000000..c4a9c86d4d --- /dev/null +++ b/packages/features/watchlist/operations/check-if-users-are-blocked.controller.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts b/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts index 62b8e34e13..11aec8ea31 100644 --- a/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts +++ b/packages/features/watchlist/operations/check-if-users-are-blocked.controller.ts @@ -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 { + 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> { - 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 { + 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); } diff --git a/packages/features/watchlist/operations/list-all-system-entries.controller.test.ts b/packages/features/watchlist/operations/list-all-system-entries.controller.test.ts new file mode 100644 index 0000000000..7e67c33c77 --- /dev/null +++ b/packages/features/watchlist/operations/list-all-system-entries.controller.test.ts @@ -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 +> = { + 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 + ); + }); + + 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(); + }); + }); +}); diff --git a/packages/features/watchlist/operations/list-all-system-entries.controller.ts b/packages/features/watchlist/operations/list-all-system-entries.controller.ts new file mode 100644 index 0000000000..43bd224019 --- /dev/null +++ b/packages/features/watchlist/operations/list-all-system-entries.controller.ts @@ -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 { + const { span } = params; + + const execute = async () => { + const watchlist = await getWatchlistFeature(); + return watchlist.watchlist.listAllSystemEntries(); + }; + + if (!span) { + return execute(); + } + + return span({ name: "listAllSystemEntries Controller" }, execute); +} diff --git a/packages/features/watchlist/watchlist.model.ts b/packages/features/watchlist/watchlist.model.ts deleted file mode 100644 index 8b23eddb1e..0000000000 --- a/packages/features/watchlist/watchlist.model.ts +++ /dev/null @@ -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; - -export const insertWatchlistSchema = WatchlistModelSchema.pick({ - type: true, - value: true, - description: true, -}); diff --git a/packages/features/watchlist/watchlist.repository.interface.ts b/packages/features/watchlist/watchlist.repository.interface.ts deleted file mode 100644 index 7ba722454b..0000000000 --- a/packages/features/watchlist/watchlist.repository.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Watchlist } from "./watchlist.model"; - -export interface IWatchlistRepository { - getBlockedEmailInWatchlist(email: string): Promise; -} diff --git a/packages/features/watchlist/watchlist.repository.mock.ts b/packages/features/watchlist/watchlist.repository.mock.ts deleted file mode 100644 index b41c215ed8..0000000000 --- a/packages/features/watchlist/watchlist.repository.mock.ts +++ /dev/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, - }; - } -} diff --git a/packages/features/watchlist/watchlist.repository.ts b/packages/features/watchlist/watchlist.repository.ts deleted file mode 100644 index b986bf9b9a..0000000000 --- a/packages/features/watchlist/watchlist.repository.ts +++ /dev/null @@ -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; - } - } -} diff --git a/packages/lib/server/repository/PrismaApiKeyRepository.ts b/packages/lib/server/repository/PrismaApiKeyRepository.ts index be4aae05b2..fc4440f6f6 100644 --- a/packages/lib/server/repository/PrismaApiKeyRepository.ts +++ b/packages/lib/server/repository/PrismaApiKeyRepository.ts @@ -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: { diff --git a/packages/lib/server/service/ApiKeyService.ts b/packages/lib/server/service/ApiKeyService.ts new file mode 100644 index 0000000000..8585501bdb --- /dev/null +++ b/packages/lib/server/service/ApiKeyService.ts @@ -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 { + 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); + } +}