Files
calendar/packages/features/tasker/tasks/test/scanWorkflowBody.test.ts
T
98b6d63164 refactor: apply biome formatting to packages/features (#27844)
* refactor: apply biome formatting to packages/features (batch 1 - small subdirs)

Format small subdirectories in packages/features: di, flags, holidays, oauth,
settings, users, assignment-reason, selectedCalendar, hashedLink, host, form,
form-builder, availability, data-table, pbac, schedules, troubleshooter,
eventtypes, calendar-subscription, and root-level files.

Also includes straggler apps/web BookEventForm.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 2 - medium subdirs)

Format medium subdirectories in packages/features: auth, credentials,
calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone,
tasker, and webhooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 3 - bookings + insights)

Format bookings and insights subdirectories in packages/features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 4 - ee)

Format packages/features/ee subdirectory covering billing, workflows,
organizations, teams, managed-event-types, round-robin, dsync,
integration-attribute-sync, and payments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1)

Format booking-audit di, actions, common, dto, repository, and types
subdirectories in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2)

Format booking-audit service subdirectory in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:47:14 +01:00

501 lines
14 KiB
TypeScript

import process from "node:process";
import prismock from "@calcom/testing/lib/__mocks__/prisma";
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents, TimeUnit } from "@calcom/prisma/enums";
import { scanWorkflowBody, iffyScanBody } from "../scanWorkflowBody";
// Mock the submitWorkflowStepForUrlScanning function
vi.mock("../scanWorkflowUrls", () => ({
submitWorkflowStepForUrlScanning: vi.fn().mockResolvedValue(undefined),
}));
// Mock the scheduleWorkflowNotifications function
vi.mock("@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications", () => ({
scheduleWorkflowNotifications: vi.fn().mockResolvedValue(undefined),
}));
// Mock the Task repository
vi.mock("@calcom/features/tasker/repository", () => ({
Task: {
hasNewerScanTaskForStepId: vi.fn().mockResolvedValue(false),
},
}));
// Mock the actionHelperFunctions
vi.mock("@calcom/features/ee/workflows/lib/actionHelperFunctions", () => ({
getTemplateBodyForAction: vi.fn().mockReturnValue("Default template body"),
}));
// Mock the compareReminderBodyToTemplate
vi.mock("@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate", () => ({
default: vi.fn().mockReturnValue(false),
}));
// Mock the i18n
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
// Mock the timeFormat
vi.mock("@calcom/lib/timeFormat", () => ({
getTimeFormatStringFromUserTimeFormat: vi.fn().mockReturnValue("h:mma"),
}));
// Mock the constants
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
...actual,
URL_SCANNING_ENABLED: true,
};
});
// Import mocked modules for assertions
import { submitWorkflowStepForUrlScanning } from "../scanWorkflowUrls";
import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications";
import { Task } from "@calcom/features/tasker/repository";
import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate";
describe("scanWorkflowBody", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset environment variables
delete process.env.IFFY_API_KEY;
});
describe("happy paths", () => {
test("should process workflow step and submit for URL scanning when IFFY is not configured", async () => {
// Create test data in prismock
const user = await prismock.user.create({
data: {
id: 1,
email: "test@example.com",
locale: "en",
timeFormat: 12,
whitelistWorkflows: false,
},
});
const workflow = await prismock.workflow.create({
data: {
id: 1,
name: "Test Workflow",
userId: user.id,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
},
});
await prismock.workflowStep.create({
data: {
id: 200,
stepNumber: 1,
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
reminderBody: '<p>Hello <a href="https://example.com">click here</a></p>',
},
});
const eventType = await prismock.eventType.create({
data: {
id: 1,
title: "Test Event",
slug: "test-event",
length: 30,
userId: user.id,
},
});
await prismock.workflowsOnEventTypes.create({
data: {
workflowId: workflow.id,
eventTypeId: eventType.id,
},
});
const payload = JSON.stringify({
userId: 1,
workflowStepId: 200,
});
await scanWorkflowBody(payload);
// Should submit for URL scanning
expect(submitWorkflowStepForUrlScanning).toHaveBeenCalledWith(
200,
'<p>Hello <a href="https://example.com">click here</a></p>',
1,
false
);
// Should schedule workflow notifications
expect(scheduleWorkflowNotifications).toHaveBeenCalled();
});
test("should mark workflow step as verified when no reminder body", async () => {
// Set IFFY_API_KEY to test the Iffy path
process.env.IFFY_API_KEY = "test-api-key";
const user = await prismock.user.create({
data: {
id: 2,
email: "test2@example.com",
locale: "en",
timeFormat: 12,
whitelistWorkflows: false,
},
});
const workflow = await prismock.workflow.create({
data: {
id: 2,
name: "Test Workflow 2",
userId: user.id,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
},
});
await prismock.workflowStep.create({
data: {
id: 201,
stepNumber: 1,
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
reminderBody: null, // No reminder body
},
});
const eventType = await prismock.eventType.create({
data: {
id: 2,
title: "Test Event 2",
slug: "test-event-2",
length: 30,
userId: user.id,
},
});
await prismock.workflowsOnEventTypes.create({
data: {
workflowId: workflow.id,
eventTypeId: eventType.id,
},
});
const payload = JSON.stringify({
userId: 2,
workflowStepId: 201,
});
await scanWorkflowBody(payload);
// Should mark as verified
const updatedStep = await prismock.workflowStep.findUnique({
where: { id: 201 },
});
expect(updatedStep?.verifiedAt).toBeTruthy();
});
test("should mark workflow step as verified when body matches template", async () => {
process.env.IFFY_API_KEY = "test-api-key";
vi.mocked(compareReminderBodyToTemplate).mockReturnValueOnce(true);
const user = await prismock.user.create({
data: {
id: 3,
email: "test3@example.com",
locale: "en",
timeFormat: 12,
whitelistWorkflows: false,
},
});
const workflow = await prismock.workflow.create({
data: {
id: 3,
name: "Test Workflow 3",
userId: user.id,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
},
});
await prismock.workflowStep.create({
data: {
id: 202,
stepNumber: 1,
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
reminderBody: "Default template body",
},
});
const eventType = await prismock.eventType.create({
data: {
id: 3,
title: "Test Event 3",
slug: "test-event-3",
length: 30,
userId: user.id,
},
});
await prismock.workflowsOnEventTypes.create({
data: {
workflowId: workflow.id,
eventTypeId: eventType.id,
},
});
const payload = JSON.stringify({
userId: 3,
workflowStepId: 202,
});
await scanWorkflowBody(payload);
// Should mark as verified since body matches template
const updatedStep = await prismock.workflowStep.findUnique({
where: { id: 202 },
});
expect(updatedStep?.verifiedAt).toBeTruthy();
});
test("should skip processing when newer task exists", async () => {
vi.mocked(Task.hasNewerScanTaskForStepId).mockResolvedValueOnce(true);
const payload = JSON.stringify({
userId: 1,
workflowStepId: 999,
createdAt: new Date().toISOString(),
});
await scanWorkflowBody(payload);
// Should not submit for URL scanning
expect(submitWorkflowStepForUrlScanning).not.toHaveBeenCalled();
});
test("should handle deprecated workflowStepIds array", async () => {
const user = await prismock.user.create({
data: {
id: 4,
email: "test4@example.com",
locale: "en",
timeFormat: 12,
whitelistWorkflows: false,
},
});
const workflow = await prismock.workflow.create({
data: {
id: 4,
name: "Test Workflow 4",
userId: user.id,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
},
});
await prismock.workflowStep.create({
data: {
id: 203,
stepNumber: 1,
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
reminderBody: '<p>Hello <a href="https://test.com">link</a></p>',
},
});
const eventType = await prismock.eventType.create({
data: {
id: 4,
title: "Test Event 4",
slug: "test-event-4",
length: 30,
userId: user.id,
},
});
await prismock.workflowsOnEventTypes.create({
data: {
workflowId: workflow.id,
eventTypeId: eventType.id,
},
});
const payload = JSON.stringify({
userId: 4,
workflowStepIds: [203], // Using deprecated array format
});
await scanWorkflowBody(payload);
// Should submit for URL scanning
expect(submitWorkflowStepForUrlScanning).toHaveBeenCalled();
});
});
describe("unhappy paths", () => {
test("should return early when no step IDs provided", async () => {
const payload = JSON.stringify({
userId: 1,
});
await scanWorkflowBody(payload);
// Should not submit for URL scanning
expect(submitWorkflowStepForUrlScanning).not.toHaveBeenCalled();
expect(scheduleWorkflowNotifications).not.toHaveBeenCalled();
});
test("should return early when workflow not found after processing steps", async () => {
// This test verifies that the function returns early when the workflow
// associated with the step IDs is not found (after the initial step processing)
// Note: In practice, this scenario is rare since workflow steps are always
// associated with a workflow, but the code handles it gracefully by logging
// a warning and returning early.
const payload = JSON.stringify({
userId: 5,
workflowStepIds: [999], // Non-existent step IDs
});
// Should not throw - the function returns early when no steps are found
await expect(scanWorkflowBody(payload)).resolves.not.toThrow();
// Should not schedule notifications since no workflow was found
expect(scheduleWorkflowNotifications).not.toHaveBeenCalled();
});
test("should pass whitelistWorkflows flag when user is whitelisted", async () => {
const user = await prismock.user.create({
data: {
id: 6,
email: "test6@example.com",
locale: "en",
timeFormat: 12,
whitelistWorkflows: true, // Whitelisted user
},
});
const workflow = await prismock.workflow.create({
data: {
id: 6,
name: "Test Workflow 6",
userId: user.id,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 1,
timeUnit: TimeUnit.HOUR,
},
});
await prismock.workflowStep.create({
data: {
id: 205,
stepNumber: 1,
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
reminderBody: '<p>Hello <a href="https://example.com">link</a></p>',
},
});
const eventType = await prismock.eventType.create({
data: {
id: 6,
title: "Test Event 6",
slug: "test-event-6",
length: 30,
userId: user.id,
},
});
await prismock.workflowsOnEventTypes.create({
data: {
workflowId: workflow.id,
eventTypeId: eventType.id,
},
});
const payload = JSON.stringify({
userId: 6,
workflowStepId: 205,
});
await scanWorkflowBody(payload);
// Should pass whitelistWorkflows=true
expect(submitWorkflowStepForUrlScanning).toHaveBeenCalledWith(205, expect.any(String), 6, true);
});
});
describe("iffyScanBody", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
test("should return flagged status from Iffy API", async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ flagged: true }),
});
vi.stubGlobal("fetch", mockFetch);
process.env.IFFY_API_KEY = "test-api-key";
const result = await iffyScanBody("spam content", 100);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.iffy.com/api/v1/moderate",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
}),
body: expect.stringContaining("spam content"),
})
);
});
test("should return false when content is not flagged", async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ flagged: false }),
});
vi.stubGlobal("fetch", mockFetch);
process.env.IFFY_API_KEY = "test-api-key";
const result = await iffyScanBody("normal content", 100);
expect(result).toBe(false);
});
test("should handle API errors gracefully", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("API error"));
vi.stubGlobal("fetch", mockFetch);
process.env.IFFY_API_KEY = "test-api-key";
const result = await iffyScanBody("content", 100);
// Should return undefined on error (fail-open)
expect(result).toBeUndefined();
});
});
});