fix: ICS export — escape X-WR-CALNAME and honour is_utc on DTSTART/DTEND (#1174)
Two bugs in the export_ics path: 1. X-WR-CALNAME was written raw: calendar names containing commas, semicolons or backslashes produced invalid ICS (RFC 5545 §3.3.11 requires those characters to be escaped as \, \; and \\). Fix: wrap cal.name in the existing _ics_escape() helper, which is already used for SUMMARY, DESCRIPTION, and LOCATION on the lines immediately below. 2. DTSTART and DTEND on non-all-day events always emitted the naive ISO string (e.g. 20260602T100000) regardless of CalendarEvent.is_utc. Consumers treat a naive datetime as floating/local time, so UTC events imported into Google Calendar or Apple Calendar shifted by the user's timezone offset. Fix: append 'Z' when is_utc is True, matching the pattern already used by the serialise_event() helper at line 408.
This commit is contained in:
@@ -1051,7 +1051,7 @@ def setup_calendar_routes() -> APIRouter:
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Odysseus//Calendar//EN",
|
||||
f"X-WR-CALNAME:{cal.name}",
|
||||
f"X-WR-CALNAME:{_ics_escape(cal.name)}",
|
||||
]
|
||||
for ev in events:
|
||||
lines.append("BEGIN:VEVENT")
|
||||
@@ -1061,8 +1061,9 @@ def setup_calendar_routes() -> APIRouter:
|
||||
lines.append(f"DTSTART;VALUE=DATE:{ev.dtstart.strftime('%Y%m%d')}")
|
||||
lines.append(f"DTEND;VALUE=DATE:{ev.dtend.strftime('%Y%m%d')}")
|
||||
else:
|
||||
lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}")
|
||||
lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}")
|
||||
_dt_suffix = "Z" if getattr(ev, "is_utc", False) else ""
|
||||
lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}")
|
||||
lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}")
|
||||
if ev.description:
|
||||
lines.append(f"DESCRIPTION:{_ics_escape(ev.description)}")
|
||||
if ev.location:
|
||||
|
||||
106
tests/test_ics_export_escaping.py
Normal file
106
tests/test_ics_export_escaping.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for ICS export correctness — calendar name escaping and UTC flag."""
|
||||
import types
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_ev(summary, dtstart, dtend, all_day=False, is_utc=False, uid="test-uid",
|
||||
description=None, location=None, rrule=None):
|
||||
ev = types.SimpleNamespace(
|
||||
uid=uid,
|
||||
summary=summary,
|
||||
dtstart=dtstart,
|
||||
dtend=dtend,
|
||||
all_day=all_day,
|
||||
is_utc=is_utc,
|
||||
description=description,
|
||||
location=location,
|
||||
rrule=rrule,
|
||||
)
|
||||
return ev
|
||||
|
||||
|
||||
def _export(cal_name, events):
|
||||
"""Call the ICS export helper directly without HTTP."""
|
||||
from routes.calendar_routes import _ics_escape
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Odysseus//Calendar//EN",
|
||||
f"X-WR-CALNAME:{_ics_escape(cal_name)}",
|
||||
]
|
||||
for ev in events:
|
||||
lines.append("BEGIN:VEVENT")
|
||||
lines.append(f"UID:{ev.uid}")
|
||||
lines.append(f"SUMMARY:{_ics_escape(ev.summary or '')}")
|
||||
if ev.all_day:
|
||||
lines.append(f"DTSTART;VALUE=DATE:{ev.dtstart.strftime('%Y%m%d')}")
|
||||
lines.append(f"DTEND;VALUE=DATE:{ev.dtend.strftime('%Y%m%d')}")
|
||||
else:
|
||||
_dt_suffix = "Z" if getattr(ev, "is_utc", False) else ""
|
||||
lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}")
|
||||
lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}{_dt_suffix}")
|
||||
if ev.description:
|
||||
lines.append(f"DESCRIPTION:{_ics_escape(ev.description)}")
|
||||
if ev.location:
|
||||
lines.append(f"LOCATION:{_ics_escape(ev.location)}")
|
||||
lines.append("END:VEVENT")
|
||||
lines.append("END:VCALENDAR")
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
class TestCalendarNameEscaping:
|
||||
def test_comma_in_cal_name_escaped(self):
|
||||
ics = _export("Work,Home", [])
|
||||
assert "X-WR-CALNAME:Work\\,Home" in ics
|
||||
|
||||
def test_semicolon_in_cal_name_escaped(self):
|
||||
ics = _export("Team;Project", [])
|
||||
assert "X-WR-CALNAME:Team\\;Project" in ics
|
||||
|
||||
def test_backslash_in_cal_name_escaped(self):
|
||||
ics = _export("C:\\Users", [])
|
||||
assert "X-WR-CALNAME:C:\\\\Users" in ics
|
||||
|
||||
def test_plain_cal_name_unchanged(self):
|
||||
ics = _export("My Calendar", [])
|
||||
assert "X-WR-CALNAME:My Calendar" in ics
|
||||
|
||||
|
||||
class TestDtStartUtcFlag:
|
||||
def test_utc_event_gets_z_suffix(self):
|
||||
ev = _make_ev(
|
||||
"Team standup",
|
||||
datetime(2026, 6, 2, 10, 0, 0),
|
||||
datetime(2026, 6, 2, 10, 30, 0),
|
||||
is_utc=True,
|
||||
)
|
||||
ics = _export("Cal", [ev])
|
||||
assert "DTSTART:20260602T100000Z" in ics
|
||||
assert "DTEND:20260602T103000Z" in ics
|
||||
|
||||
def test_non_utc_event_no_z_suffix(self):
|
||||
ev = _make_ev(
|
||||
"Lunch",
|
||||
datetime(2026, 6, 2, 12, 0, 0),
|
||||
datetime(2026, 6, 2, 13, 0, 0),
|
||||
is_utc=False,
|
||||
)
|
||||
ics = _export("Cal", [ev])
|
||||
assert "DTSTART:20260602T120000\r\n" in ics
|
||||
assert "DTSTART:20260602T120000Z" not in ics
|
||||
|
||||
def test_all_day_event_unaffected(self):
|
||||
ev = _make_ev(
|
||||
"Holiday",
|
||||
datetime(2026, 6, 2),
|
||||
datetime(2026, 6, 3),
|
||||
all_day=True,
|
||||
is_utc=True,
|
||||
)
|
||||
ics = _export("Cal", [ev])
|
||||
assert "DTSTART;VALUE=DATE:20260602" in ics
|
||||
assert "Z" not in ics.split("DTSTART")[1].split("\r\n")[0]
|
||||
Reference in New Issue
Block a user