* fix: Support domains without @ prefix in watchlist * fix: free email domain check
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
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 without @ 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();
|
|
});
|
|
});
|
|
});
|