diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index ba1d39c..9e9d88d 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -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: diff --git a/tests/test_ics_export_escaping.py b/tests/test_ics_export_escaping.py new file mode 100644 index 0000000..96032c8 --- /dev/null +++ b/tests/test_ics_export_escaping.py @@ -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]