Files
calendar/packages/features/ee/workflows/lib/test/urlScanner.test.ts
T
Peer RichelsenGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Volnei Munhozcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
cfb1db45b2 feat: add Cloudflare URL Scanner for malicious URL detection (#26387)
* feat: add Cloudflare URL Scanner integration for malicious URL detection

- Add MALICIOUS_URL_IN_WORKFLOW to LockReason enum
- Add URL_SCANNING_ENABLED constant for feature flag
- Create urlScanner.ts utility for Cloudflare Radar URL Scanner API
- Create scanWorkflowUrls task for async URL scanning with polling
- Integrate URL scanning into scanWorkflowBody task
- Add URL scanning for event type redirect URLs
- Lock user accounts when malicious URLs are detected
- Fix pre-existing lint issues (parseInt radix, optional chaining)

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: address biome lint warnings and TypeScript iterator errors

- Wrap iterators with Array.from() to fix TS2802 errors
- Add biome-ignore comments for process.env usage
- Extract helper functions to reduce function length
- Move exports to end of file per useExportsLast rule
- Remove problematic imports that cause TypeScript errors

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: address cubic-dev-ai review comments for URL scanning

- Fix P0: Re-fetch workflow steps before scheduling notifications to use actual verifiedAt values from database instead of overriding with new Date()
- Fix P1: Mark workflow step as verified in submitWorkflowStepForUrlScanning when URL scanning is disabled or no URLs found
- Fix P1: Add whitelistWorkflows parameter to submitUrlForUrlScanning for consistency
- Fix P2: Preserve URL context in error results in urlScanner.ts scanUrls function

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: add select clause to Prisma query for workflow steps

Address cubic-dev-ai P2 comment: Use select to fetch only the required
fields (id, action, sendTo, emailSubject, reminderBody, template, sender,
verifiedAt) instead of fetching all columns from workflowStep table.

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: add select clause to Prisma query in scanWorkflowBody.ts

Address cubic-dev-ai P2 review comment: Use select to fetch only the
required fields (id, action, sendTo, emailSubject, reminderBody,
template, sender, verifiedAt) instead of fetching all columns.

Co-Authored-By: peer@cal.com <peer@cal.com>

* test: add unit tests for URL scanning functionality

- Add tests for urlScanner.ts (extractUrlsFromHtml, isUrlScanningEnabled)
- Add tests for scanWorkflowUrls.ts (happy/unhappy paths for URL scanning task)
- Add tests for scanWorkflowBody.ts (happy/unhappy paths for workflow body scanning)

Tests cover:
- URL extraction from HTML content
- URL normalization and deduplication
- Handling of malicious URLs and user locking
- Fail-open behavior for API errors
- Whitelisted user handling
- Iffy spam detection integration

Co-Authored-By: peer@cal.com <peer@cal.com>

* test: remove incomplete test that provides no value

Removed the 'should mark all steps as verified when neither Iffy nor URL scanning is enabled' test as it used vi.doMock() which doesn't work after module import, had no assertions, and gave false confidence in test coverage.

Co-Authored-By: peer@cal.com <peer@cal.com>

* refactor: use Cloudflare bulk scanning endpoint to reduce API quota usage

- Added submitUrlsForBulkScanning function that uses /urlscanner/v2/bulk endpoint
- Updated scanUrls to use bulk submission instead of individual URL submissions
- Bulk endpoint accepts up to 100 URLs per request, batching is handled automatically
- Reduces API quota usage as suggested by keithwillcode

Co-Authored-By: peer@cal.com <peer@cal.com>

* Update packages/features/tasker/tasks/scanWorkflowUrls.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: address cubic-dev-ai review comments

- P1: Sanitize URLs before logging to prevent exposing sensitive query parameters
- P2: Extract handleUrlScanningForStep helper function to reduce code duplication
- P2: Use vi.stubGlobal for fetch mock in tests for proper cleanup

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: address volnei review comments on PR #26387

- Move vi.unstubAllGlobals() to afterEach hook in iffyScanBody tests
- Restore updateMany optimization when URL scanning is disabled

Co-Authored-By: peer@cal.com <peer@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-21 12:28:57 -03:00

179 lines
6.4 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { extractUrlsFromHtml, isUrlScanningEnabled } from "../urlScanner";
// Mock the constants module
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,
};
});
describe("urlScanner", () => {
describe("extractUrlsFromHtml", () => {
describe("happy paths", () => {
test("should extract URLs from href attributes", () => {
const html = '<a href="https://example.com">Click here</a>';
const result = extractUrlsFromHtml(html);
// URLs are normalized - root URLs get trailing slash removed but become normalized form
expect(result).toEqual(["https://example.com/"]);
});
test("should extract multiple URLs from href attributes", () => {
const html = `
<a href="https://example.com">Link 1</a>
<a href="https://another.com/page">Link 2</a>
`;
const result = extractUrlsFromHtml(html);
expect(result).toContain("https://example.com/");
expect(result).toContain("https://another.com/page");
expect(result.length).toBe(2);
});
test("should extract bare URLs from text content", () => {
const html = "<p>Visit https://example.com for more info</p>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should extract both href and bare URLs", () => {
const html = `
<a href="https://link.com">Click</a>
<p>Also visit https://bare.com</p>
`;
const result = extractUrlsFromHtml(html);
expect(result).toContain("https://link.com/");
expect(result).toContain("https://bare.com/");
});
test("should deduplicate URLs", () => {
const html = `
<a href="https://example.com">Link 1</a>
<a href="https://example.com">Link 2</a>
<p>https://example.com</p>
`;
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should handle URLs with paths and query strings", () => {
const html = '<a href="https://example.com/path?query=value&foo=bar">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/path?query=value&foo=bar"]);
});
test("should handle http URLs", () => {
const html = '<a href="http://example.com">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["http://example.com/"]);
});
test("should handle single-quoted href attributes", () => {
const html = "<a href='https://example.com'>Link</a>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should clean trailing punctuation from bare URLs", () => {
const html = "<p>Visit https://example.com. More text here.</p>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should handle URLs with ports", () => {
const html = '<a href="https://example.com:8080/path">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com:8080/path"]);
});
test("should handle URLs with fragments", () => {
const html = '<a href="https://example.com/page#section">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/page#section"]);
});
});
describe("unhappy paths", () => {
test("should return empty array for empty string", () => {
const result = extractUrlsFromHtml("");
expect(result).toEqual([]);
});
test("should return empty array for HTML without URLs", () => {
const html = "<p>No links here</p>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore mailto links", () => {
const html = '<a href="mailto:test@example.com">Email us</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore tel links", () => {
const html = '<a href="tel:+1234567890">Call us</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore javascript links", () => {
const html = '<a href="javascript:void(0)">Click</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore relative URLs", () => {
const html = '<a href="/relative/path">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore anchor-only links", () => {
const html = '<a href="#section">Jump to section</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should handle malformed HTML gracefully", () => {
const html = '<a href="https://example.com">Unclosed link';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should handle empty href attributes", () => {
const html = '<a href="">Empty link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
});
describe("URL normalization", () => {
test("should normalize URLs with trailing slash", () => {
const html = `
<a href="https://example.com/">Link 1</a>
<a href="https://example.com">Link 2</a>
`;
const result = extractUrlsFromHtml(html);
// Both should normalize to the same URL
expect(result.length).toBe(1);
});
test("should preserve path when normalizing", () => {
const html = '<a href="https://example.com/path/">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/path/"]);
});
});
});
describe("isUrlScanningEnabled", () => {
test("should return true when URL_SCANNING_ENABLED is true", () => {
const result = isUrlScanningEnabled();
expect(result).toBe(true);
});
});
});