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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
ec951cacad
commit
c28a0d7db0
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user