fix: handle invalid timezones gracefully in calendar events (#27042)

* fix: handle invalid timezones gracefully in getLuckyUser

Co-Authored-By: [email protected] <[email protected]>

* refactor: move timezone validation to getCalendarsEvents with offset conversion

- Move invalid timezone handling from getLuckyUser.ts to getCalendarsEvents.ts
- Add convertOffsetToEtcGmt() to convert offset formats (GMT-05:00, UTC+08:00) to valid IANA Etc/GMT timezones
- Preserves timezone precision for fairness calculations instead of falling back to UTC
- Add unit tests for timezone conversion scenarios

* refactor: extract timezone conversion to dedicated file

- Create timezone-conversion.ts with concise convertOffsetToIanaTimezone and normalizeTimezone functions
- Update getCalendarsEvents.ts to import from the new file
- Follow kebab-case naming convention for utility files

* test: add unit tests for timezone conversion proving Intl.DateTimeFormat behavior

- Add tests proving Intl.DateTimeFormat throws RangeError for offset formats (GMT-05:00, UTC+08:00)
- Add tests for convertOffsetToIanaTimezone function
- Add tests for normalizeTimezone function

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Benny Joo
2026-01-21 02:51:52 -03:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent ec951cacad
commit c28a0d7db0
4 changed files with 311 additions and 1 deletions
@@ -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
@@ -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);
@@ -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");
});
});
});
@@ -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";
}
}