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