diff --git a/packages/features/calendars/lib/getCalendarsEvents.test.ts b/packages/features/calendars/lib/getCalendarsEvents.test.ts index 915425a001..03ebf5ce66 100644 --- a/packages/features/calendars/lib/getCalendarsEvents.test.ts +++ b/packages/features/calendars/lib/getCalendarsEvents.test.ts @@ -542,6 +542,162 @@ describe("getCalendarsEventsWithTimezones", () => { expect(result).toEqual([[]]); }); }); + + describe("Invalid timezone handling", () => { + it("should convert GMT-05:00 offset format to Etc/GMT+5", async () => { + const availability = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "GMT-05:00", + }, + { + start: new Date(2010, 11, 2, 4), + end: new Date(2010, 11, 2, 16), + timeZone: "America/New_York", + }, + ]; + + mockGoogleGetAvailabilityWithTimeZones.mockResolvedValueOnce(availability); + + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEventsWithTimezones( + [buildRegularCredential(credential)], + "2010-12-01", + "2010-12-04", + [selectedCalendar] + ); + + expect(result).toEqual([ + [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "Etc/GMT+5", + }, + { + start: new Date(2010, 11, 2, 4), + end: new Date(2010, 11, 2, 16), + timeZone: "America/New_York", + }, + ], + ]); + }); + + it("should convert UTC+08:00 offset format to Etc/GMT-8", async () => { + const availability = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "UTC+08:00", + }, + ]; + + mockGoogleGetAvailabilityWithTimeZones.mockResolvedValueOnce(availability); + + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEventsWithTimezones( + [buildRegularCredential(credential)], + "2010-12-01", + "2010-12-04", + [selectedCalendar] + ); + + expect(result).toEqual([ + [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "Etc/GMT-8", + }, + ], + ]); + }); + + it("should fallback to UTC when timezone is undefined", async () => { + const availability = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: undefined, + }, + ]; + + mockGoogleGetAvailabilityWithTimeZones.mockResolvedValueOnce(availability); + + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEventsWithTimezones( + [buildRegularCredential(credential)], + "2010-12-01", + "2010-12-04", + [selectedCalendar] + ); + + expect(result).toEqual([ + [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "UTC", + }, + ], + ]); + }); + + it("should fallback to UTC for completely invalid timezone formats", async () => { + const availability = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "InvalidTimezone", + }, + ]; + + mockGoogleGetAvailabilityWithTimeZones.mockResolvedValueOnce(availability); + + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEventsWithTimezones( + [buildRegularCredential(credential)], + "2010-12-01", + "2010-12-04", + [selectedCalendar] + ); + + expect(result).toEqual([ + [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "UTC", + }, + ], + ]); + }); + }); }); // CalDAV Credential Leak Prevention Tests diff --git a/packages/features/calendars/lib/getCalendarsEvents.ts b/packages/features/calendars/lib/getCalendarsEvents.ts index 76d023ddfe..5277a74d28 100644 --- a/packages/features/calendars/lib/getCalendarsEvents.ts +++ b/packages/features/calendars/lib/getCalendarsEvents.ts @@ -8,9 +8,12 @@ import { performance } from "@calcom/lib/server/perfObserver"; import type { CalendarFetchMode, EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; +import { normalizeTimezone } from "./timezone-conversion"; + const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] }); const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; + // only for Google Calendar for now export const getCalendarsEventsWithTimezones = async ( withCredentials: CredentialForCalendarService[], @@ -77,7 +80,7 @@ export const getCalendarsEventsWithTimezones = async ( return eventBusyDates.map((event) => ({ ...event, - timeZone: event.timeZone || "UTC", + timeZone: normalizeTimezone(event.timeZone), })); }); const awaitedResults = await Promise.all(results); diff --git a/packages/features/calendars/lib/timezone-conversion.test.ts b/packages/features/calendars/lib/timezone-conversion.test.ts new file mode 100644 index 0000000000..a04def7d8e --- /dev/null +++ b/packages/features/calendars/lib/timezone-conversion.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; + +import { convertOffsetToIanaTimezone, normalizeTimezone } from "./timezone-conversion"; + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + }), + }, +})); + +describe("timezone-conversion", () => { + describe("Intl.DateTimeFormat behavior with offset formats", () => { + it("throws RangeError for GMT-05:00 offset format", () => { + expect(() => Intl.DateTimeFormat(undefined, { timeZone: "GMT-05:00" })).toThrow(RangeError); + }); + + it("throws RangeError for UTC+08:00 offset format", () => { + expect(() => Intl.DateTimeFormat(undefined, { timeZone: "UTC+08:00" })).toThrow(RangeError); + }); + + it("throws RangeError for GMT+5 offset format without minutes", () => { + expect(() => Intl.DateTimeFormat(undefined, { timeZone: "GMT+5" })).toThrow(RangeError); + }); + + it("accepts valid IANA timezone America/New_York", () => { + expect(() => Intl.DateTimeFormat(undefined, { timeZone: "America/New_York" })).not.toThrow(); + }); + + it("accepts Etc/GMT+5 as valid IANA timezone", () => { + expect(() => Intl.DateTimeFormat(undefined, { timeZone: "Etc/GMT+5" })).not.toThrow(); + }); + + it("accepts UTC as valid timezone", () => { + expect(() => Intl.DateTimeFormat(undefined, { timeZone: "UTC" })).not.toThrow(); + }); + }); + + describe("convertOffsetToIanaTimezone", () => { + it("converts GMT-05:00 to Etc/GMT+5 (inverted sign)", () => { + expect(convertOffsetToIanaTimezone("GMT-05:00")).toBe("Etc/GMT+5"); + }); + + it("converts UTC+08:00 to Etc/GMT-8 (inverted sign)", () => { + expect(convertOffsetToIanaTimezone("UTC+08:00")).toBe("Etc/GMT-8"); + }); + + it("converts GMT+0 to Etc/GMT", () => { + expect(convertOffsetToIanaTimezone("GMT+0")).toBe("Etc/GMT"); + }); + + it("converts GMT-00:00 to Etc/GMT", () => { + expect(convertOffsetToIanaTimezone("GMT-00:00")).toBe("Etc/GMT"); + }); + + it("handles case insensitivity (gmt-05:00)", () => { + expect(convertOffsetToIanaTimezone("gmt-05:00")).toBe("Etc/GMT+5"); + }); + + it("handles format without minutes (GMT+5)", () => { + expect(convertOffsetToIanaTimezone("GMT+5")).toBe("Etc/GMT-5"); + }); + + it("returns null for non-zero minutes (cannot be represented in Etc/GMT)", () => { + expect(convertOffsetToIanaTimezone("GMT+05:30")).toBeNull(); + }); + + it("returns null for hours > 14", () => { + expect(convertOffsetToIanaTimezone("GMT+15:00")).toBeNull(); + }); + + it("returns null for invalid format", () => { + expect(convertOffsetToIanaTimezone("America/New_York")).toBeNull(); + }); + + it("returns null for completely invalid string", () => { + expect(convertOffsetToIanaTimezone("InvalidTimezone")).toBeNull(); + }); + }); + + describe("normalizeTimezone", () => { + it("returns UTC for undefined timezone", () => { + expect(normalizeTimezone(undefined)).toBe("UTC"); + }); + + it("returns valid IANA timezone as-is", () => { + expect(normalizeTimezone("America/New_York")).toBe("America/New_York"); + }); + + it("converts GMT-05:00 to Etc/GMT+5", () => { + expect(normalizeTimezone("GMT-05:00")).toBe("Etc/GMT+5"); + }); + + it("converts UTC+08:00 to Etc/GMT-8", () => { + expect(normalizeTimezone("UTC+08:00")).toBe("Etc/GMT-8"); + }); + + it("falls back to UTC for completely invalid timezone", () => { + expect(normalizeTimezone("InvalidTimezone")).toBe("UTC"); + }); + + it("falls back to UTC for non-zero minutes offset", () => { + expect(normalizeTimezone("GMT+05:30")).toBe("UTC"); + }); + }); +}); diff --git a/packages/features/calendars/lib/timezone-conversion.ts b/packages/features/calendars/lib/timezone-conversion.ts new file mode 100644 index 0000000000..34ed422bf8 --- /dev/null +++ b/packages/features/calendars/lib/timezone-conversion.ts @@ -0,0 +1,43 @@ +import logger from "@calcom/lib/logger"; + +const log = logger.getSubLogger({ prefix: ["timezoneConversion"] }); + +/** + * Converts offset-based timezone formats (GMT±HH:MM, UTC±HH:MM) to IANA Etc/GMT timezones. + * Etc/GMT uses inverted signs: Etc/GMT+5 = UTC-5, Etc/GMT-8 = UTC+8. + */ +export function convertOffsetToIanaTimezone(timezone: string): string | null { + const match = timezone.match(/^(?:GMT|UTC)([+-])(\d{1,2})(?::(\d{2}))?$/i); + if (!match) return null; + + const [, sign, hoursStr, minutesStr] = match; + const hours = parseInt(hoursStr, 10); + const minutes = minutesStr ? parseInt(minutesStr, 10) : 0; + + // Etc/GMT only supports whole hours up to 14 + if (minutes !== 0 || hours > 14) return null; + + if (hours === 0) return "Etc/GMT"; + return `Etc/GMT${sign === "+" ? "-" : "+"}${hours}`; +} + +/** + * Validates and normalizes a timezone string to a valid IANA timezone. + * Converts offset formats to Etc/GMT, falls back to UTC if invalid. + */ +export function normalizeTimezone(timezone: string | undefined): string { + if (!timezone) return "UTC"; + + try { + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return timezone; + } catch { + const converted = convertOffsetToIanaTimezone(timezone); + if (converted) { + log.info(`Converted offset timezone to IANA format`, { original: timezone, converted }); + return converted; + } + log.warn(`Invalid timezone format, falling back to UTC`, { invalidTimezone: timezone }); + return "UTC"; + } +}