Files
calendar/packages/lib/CalendarService.test.ts
T
45ea59ceb8 fix: prevent CalDAV duplicate invitations with RFC-compliant SCHEDULE-AGENT (#22434)
* fix: prevent CalDAV duplicate invitations with RFC-compliant SCHEDULE-AGENT

Fixes #9485

Adds SCHEDULE-AGENT=CLIENT to ATTENDEE lines in CalDAV createEvent and
updateEvent operations per RFC 6638 Section 7.1. This tells CalDAV servers
(Fastmail, NextCloud, etc.) that the client handles scheduling, preventing
duplicate invitation emails.

Implementation includes:
- RFC 5545 compliant line folding (max 75 octets per line)
- UTF-8 aware byte counting for international names
- Proper handling of :mailto: format in ATTENDEE lines
- Skips lines that already have SCHEDULE-AGENT parameter

* fix: address Cubic review - unfold lines and case-insensitive matching

- Add unfoldLines() to handle RFC 5545 folded lines before processing
- Use case-insensitive regex (gim flag) per RFC 5545 spec
- Simplify regex to properly capture params and value separately

* fix: only unfold/refold ATTENDEE lines, preserve other lines

- Match ATTENDEE lines including folded continuations
- Unfold only the matched ATTENDEE line
- Re-fold after adding SCHEDULE-AGENT=CLIENT
- Other iCal lines remain untouched (no RFC 5545 violation)

* fix: check SCHEDULE-AGENT only in params, not value

Only check for existing SCHEDULE-AGENT in the params portion,
not the value (which contains email/CN). This prevents false
positives when email contains "schedule-agent" substring.

* fix: handle quoted colons and exact SCHEDULE-AGENT matching

1. Colon parsing: Match :mailto:/:http:/:urn: patterns, or fallback
   to finding colon not inside quotes
2. SCHEDULE-AGENT check: Use regex to match exact parameter name
   (preceded by ; or at start), not substring match

* add tests

* Update .env.example

---------

Co-authored-by: Anik Dhabal Babu <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-22 16:23:06 +05:30

401 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { createEvent as createIcsEvent } from "ics";
import { createCalendarObject, updateCalendarObject } from "tsdav";
vi.mock("ics", () => ({
createEvent: vi.fn(),
}));
vi.mock("tsdav", () => ({
createAccount: vi.fn(),
fetchCalendars: vi.fn(),
fetchCalendarObjects: vi.fn(),
createCalendarObject: vi.fn().mockResolvedValue({ ok: true }),
updateCalendarObject: vi.fn().mockResolvedValue({ status: 200 }),
deleteCalendarObject: vi.fn(),
getBasicAuthHeaders: vi.fn().mockReturnValue({}),
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn().mockImplementation((text) => {
if (typeof text === "object") {
return JSON.stringify(text);
}
return text;
}),
}));
vi.mock("./CalEventParser", () => ({
getLocation: vi.fn().mockReturnValue("Test Location"),
getRichDescription: vi.fn().mockReturnValue("Test Description"),
}));
import type { CalendarServiceEvent } from "@calcom/types/Calendar";
import BaseCalendarService from "./CalendarService";
const createMockEvent = (overrides: Partial<CalendarServiceEvent> = {}): CalendarServiceEvent => ({
type: "caldav",
title: "Test Event",
startTime: "2023-01-01T10:00:00Z",
endTime: "2023-01-01T11:00:00Z",
organizer: {
name: "Test",
email: "test@example.com",
timeZone: "UTC",
language: { translate: ((key: string) => key) as never, locale: "en" },
},
attendees: [],
calendarDescription: "Test Description",
...overrides,
});
class TestCalendarService extends BaseCalendarService {
constructor() {
super(
{
id: 1,
type: "caldav_calendar",
delegationCredentialId: null,
user: { email: "test@example.com" },
userId: 1,
teamId: null,
appId: "caldav",
invalid: false,
key: {
username: "test",
password: "test",
url: "https://caldav.example.com",
},
},
"caldav",
"https://caldav.example.com"
);
}
async listCalendars() {
return [
{
externalId: "https://caldav.example.com/calendar/",
name: "Test Calendar",
primary: true,
readOnly: false,
email: "test@example.com",
integrationName: "caldav",
credentialId: 1,
},
];
}
async testUpdateEvent(uid: string, event: CalendarServiceEvent, externalCalendarId: string) {
return this.updateEvent(uid, event);
}
}
describe("CalendarService - SCHEDULE-AGENT injection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("injectScheduleAgent", () => {
it("should inject SCHEDULE-AGENT=CLIENT into ATTENDEE lines", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("ATTENDEE;CN=Guest;SCHEDULE-AGENT=CLIENT:mailto:guest@example.com");
expect(unfolded).not.toContain("METHOD:PUBLISH");
});
it("should inject SCHEDULE-AGENT=CLIENT into ORGANIZER lines", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nORGANIZER;CN=Test:mailto:test@example.com\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("ORGANIZER;CN=Test;SCHEDULE-AGENT=CLIENT:mailto:test@example.com");
expect(unfolded).toContain("ATTENDEE;CN=Guest;SCHEDULE-AGENT=CLIENT:mailto:guest@example.com");
});
it("should not duplicate SCHEDULE-AGENT if already present", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;SCHEDULE-AGENT=SERVER;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const scheduleAgentCount = (iCalString.match(/SCHEDULE-AGENT/g) || []).length;
expect(scheduleAgentCount).toBe(1);
});
it("should handle quoted colons in CN parameter", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN="John: CEO":mailto:john@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain('ATTENDEE;CN="John: CEO";SCHEDULE-AGENT=CLIENT:mailto:john@example.com');
});
it("should remove METHOD:PUBLISH per RFC 4791", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
expect(iCalString).not.toContain("METHOD:PUBLISH");
});
it("should handle case-insensitive ATTENDEE matching", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nattendee;cn=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
});
describe("RFC 5545 Line Folding", () => {
it("should fold long lines at 75 octets", async () => {
const service = new TestCalendarService();
const longName = "A".repeat(60);
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=${longName}:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const lines = iCalString.split("\r\n");
lines.forEach((line) => {
const byteLength = new TextEncoder().encode(line).length;
expect(byteLength).toBeLessThanOrEqual(75);
});
});
it("should handle UTF-8 multi-byte characters correctly", async () => {
const service = new TestCalendarService();
const unicodeName = "\u4E2D".repeat(30);
const mockIcsOutput = `BEGIN:VCALENDAR\r\nORGANIZER;CN=${unicodeName}:mailto:test@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const lines = iCalString.split("\r\n");
lines.forEach((line) => {
const byteLength = new TextEncoder().encode(line).length;
expect(byteLength).toBeLessThanOrEqual(75);
});
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
it("should handle folded input lines correctly", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=Very Long Name That Will Be\r\n Folded:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
});
describe("updateEvent", () => {
it("should inject SCHEDULE-AGENT=CLIENT on update", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
(service as unknown as Record<string, unknown>).getEventsByUID = vi.fn().mockResolvedValue([
{
uid: "test-uid",
url: "https://caldav.example.com/calendar/test.ics",
etag: '"etag123"',
},
]);
const event = createMockEvent({ uid: "test-uid" });
await service.testUpdateEvent("test-uid", event, "https://caldav.example.com/calendar/");
const calledArg = vi.mocked(updateCalendarObject).mock.calls[0][0];
const data = calledArg.calendarObject.data;
const unfolded = data.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
});
describe("Edge cases", () => {
it("should handle ATTENDEE with multiple parameters", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=Guest:mailto:guest@example.com\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("PARTSTAT=NEEDS-ACTION");
expect(unfolded).toContain("RSVP=TRUE");
expect(unfolded).toContain("SCHEDULE-AGENT=CLIENT");
});
it("should handle ATTENDEE with http URI", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nATTENDEE;CN=Guest:http://example.com/user\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
const unfolded = iCalString.replace(/\r?\n[ \t]/g, "");
expect(unfolded).toContain("ATTENDEE;CN=Guest;SCHEDULE-AGENT=CLIENT:http://example.com/user");
});
it("should preserve other iCal properties unchanged", async () => {
const service = new TestCalendarService();
const mockIcsOutput = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Cal.com//NONSGML//EN\r\nATTENDEE;CN=Guest:mailto:guest@example.com\r\nDTSTART:20230101T100000Z\r\nDTEND:20230101T110000Z\r\nEND:VCALENDAR`;
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: mockIcsOutput,
});
const event = createMockEvent();
await service.createEvent(event, 1);
const calledArg = vi.mocked(createCalendarObject).mock.calls[0][0];
const iCalString = calledArg.iCalString;
expect(iCalString).toContain("VERSION:2.0");
expect(iCalString).toContain("PRODID:-//Cal.com//NONSGML//EN");
expect(iCalString).toContain("DTSTART:20230101T100000Z");
expect(iCalString).toContain("DTEND:20230101T110000Z");
});
it("should handle empty iCalString gracefully", async () => {
const service = new TestCalendarService();
vi.mocked(createIcsEvent).mockReturnValue({
error: null as unknown as Error,
value: "",
});
const event = createMockEvent();
await expect(service.createEvent(event, 1)).rejects.toThrow();
});
});
});