317 lines
9.9 KiB
Python
317 lines
9.9 KiB
Python
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from icalendar import Calendar
|
|
|
|
from hermes_caldav_mcp.events import (
|
|
build_event,
|
|
event_views,
|
|
patch_event,
|
|
validate_interval,
|
|
)
|
|
from hermes_caldav_mcp.types import CalendarResource, EventFields, EventPatch, SafeError
|
|
|
|
SIMPLE = (Path(__file__).parent / "fixtures/simple.ics").read_bytes()
|
|
RECURRING = (Path(__file__).parent / "fixtures/recurring.ics").read_bytes()
|
|
|
|
|
|
def test_edit_utc_event_keeps_utc_when_profile_default_differs():
|
|
data = patch_event(
|
|
SIMPLE,
|
|
EventPatch(start="2025-03-29T12:00:00Z", end="2025-03-29T13:00:00Z"),
|
|
default_timezone="Europe/Berlin",
|
|
whole_series=False,
|
|
)
|
|
assert (
|
|
event_views(resource(data), "Europe/Berlin", minimal=True)[0]["start"]
|
|
== "2025-03-29T12:00:00+00:00"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"stamp", ["2025-10-26T02:30:00+02:00", "2025-10-26T02:30:00+01:00"]
|
|
)
|
|
def test_ambiguous_iana_write_is_refused_instead_of_losing_fold(stamp):
|
|
with pytest.raises(SafeError):
|
|
build_event(
|
|
EventFields(
|
|
"Fold", stamp, "2025-10-26T04:00:00+01:00", timezone="Europe/Berlin"
|
|
),
|
|
"UTC",
|
|
)
|
|
|
|
|
|
def test_fractional_time_is_not_silently_truncated():
|
|
with pytest.raises(SafeError):
|
|
validate_interval("2025-01-01T00:00:00.5Z", "2025-01-02T00:00:00Z")
|
|
with pytest.raises(SafeError):
|
|
build_event(
|
|
EventFields("Fraction", "2025-01-01T00:00:00.5Z", "2025-01-01T01:00:00Z"),
|
|
"UTC",
|
|
)
|
|
|
|
|
|
def test_date_event_does_not_ignore_timezone_patch():
|
|
_, data = build_event(
|
|
EventFields("Day", "2025-01-01", "2025-01-02", all_day=True), "UTC"
|
|
)
|
|
with pytest.raises(SafeError):
|
|
patch_event(
|
|
data,
|
|
EventPatch(timezone="Europe/Berlin"),
|
|
default_timezone="UTC",
|
|
whole_series=False,
|
|
)
|
|
|
|
|
|
def test_timezone_definition_generation_has_bounded_range():
|
|
with pytest.raises(SafeError) as caught:
|
|
build_event(
|
|
EventFields(
|
|
"Too long",
|
|
"2000-01-01T10:00:00+01:00",
|
|
"2050-01-01T10:00:00+01:00",
|
|
timezone="Europe/Berlin",
|
|
),
|
|
"UTC",
|
|
)
|
|
assert caught.value.code.value == "LIMIT_EXCEEDED"
|
|
|
|
|
|
def test_create_unique_uid_and_literal_property_text():
|
|
fields = EventFields(
|
|
"Test\\nATTENDEE:evil",
|
|
"2025-03-29T10:00:00Z",
|
|
"2025-03-29T11:00:00Z",
|
|
description="text\nATTENDEE:mailto:evil@example.test\nEND:VEVENT",
|
|
)
|
|
uid, data = build_event(fields, "UTC")
|
|
other_uid, _ = build_event(fields, "UTC")
|
|
assert uid != other_uid
|
|
event = Calendar.from_ical(data).walk("VEVENT")[0]
|
|
assert str(event["UID"]) == uid
|
|
assert "ATTENDEE" not in event
|
|
assert str(event["DESCRIPTION"]) == fields.description
|
|
assert len(Calendar.from_ical(data).walk("VEVENT")) == 1
|
|
|
|
|
|
def test_create_all_day_and_explicit_iana_time():
|
|
_, data = build_event(
|
|
EventFields("Day", "2025-03-29", "2025-03-30", all_day=True), "Europe/Berlin"
|
|
)
|
|
assert (
|
|
event_views(resource(data), "Europe/Berlin", minimal=True)[0]["end"]
|
|
== "2025-03-30"
|
|
)
|
|
_, data = build_event(
|
|
EventFields(
|
|
"Timed",
|
|
"2025-03-30T03:30:00+02:00",
|
|
"2025-03-30T04:30:00+02:00",
|
|
timezone="Europe/Berlin",
|
|
),
|
|
"UTC",
|
|
)
|
|
view = event_views(resource(data), "UTC", minimal=True)[0]
|
|
assert view["start"] == "2025-03-30T03:30:00+02:00"
|
|
assert view["timezone"] == "Europe/Berlin"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fields",
|
|
[
|
|
EventFields("", "2025-01-01", "2025-01-02", all_day=True),
|
|
EventFields("x" * 201, "2025-01-01", "2025-01-02", all_day=True),
|
|
EventFields("x", "2025-01-01", "2025-01-02"),
|
|
EventFields("x", "2025-01-01", "2025-01-01", all_day=True),
|
|
EventFields(
|
|
"x",
|
|
"2025-03-30T02:30:00+01:00",
|
|
"2025-03-30T04:30:00+02:00",
|
|
timezone="Europe/Berlin",
|
|
),
|
|
EventFields("x", "2025-01-01T10:00:00", "2025-01-01T11:00:00"),
|
|
EventFields(
|
|
"x", "2025-01-01", "2025-01-02", all_day=True, description="x" * 4097
|
|
),
|
|
],
|
|
)
|
|
def test_create_invalid_fields_fail(fields):
|
|
with pytest.raises(SafeError):
|
|
build_event(fields, "UTC")
|
|
|
|
|
|
def test_patch_retains_complete_calendar_semantics():
|
|
before = Calendar.from_ical(RECURRING)
|
|
data = patch_event(
|
|
RECURRING,
|
|
EventPatch(title="Changed", description=""),
|
|
default_timezone="UTC",
|
|
whole_series=True,
|
|
)
|
|
after = Calendar.from_ical(data)
|
|
a, b = before.walk("VEVENT")[0], after.walk("VEVENT")[0]
|
|
for key in (
|
|
"UID",
|
|
"DTSTART",
|
|
"DTEND",
|
|
"RRULE",
|
|
"EXDATE",
|
|
"RDATE",
|
|
"X-CUSTOM-EVENT",
|
|
):
|
|
assert a[key].to_ical() == b[key].to_ical()
|
|
assert a[key].params == b[key].params
|
|
assert a.subcomponents[0].to_ical() == b.subcomponents[0].to_ical()
|
|
assert before.walk("VTIMEZONE")[0].to_ical() == after.walk("VTIMEZONE")[0].to_ical()
|
|
assert before.walk("VEVENT")[1].to_ical() == after.walk("VEVENT")[1].to_ical()
|
|
assert before["X-CUSTOM-CALENDAR"] == after["X-CUSTOM-CALENDAR"]
|
|
assert b["SUMMARY"] == "Changed"
|
|
assert not str(b.get("DESCRIPTION", ""))
|
|
assert "LAST-MODIFIED" in b
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"original,patch,whole",
|
|
[
|
|
(RECURRING, EventPatch(title="Changed"), False),
|
|
(
|
|
RECURRING,
|
|
EventPatch(
|
|
start="2025-03-01T11:00:00+02:00", end="2025-03-01T12:00:00+02:00"
|
|
),
|
|
True,
|
|
),
|
|
(
|
|
SIMPLE.replace(b"SUMMARY:", b"ATTENDEE:mailto:test@example.test\nSUMMARY:"),
|
|
EventPatch(title="Changed"),
|
|
False,
|
|
),
|
|
(
|
|
SIMPLE.replace(b"VERSION:2.0", b"VERSION:2.0\nMETHOD:REQUEST"),
|
|
EventPatch(title="Changed"),
|
|
False,
|
|
),
|
|
(SIMPLE, EventPatch(), False),
|
|
],
|
|
)
|
|
def test_unsafe_edits_fail(original, patch, whole):
|
|
with pytest.raises(SafeError):
|
|
patch_event(original, patch, default_timezone="UTC", whole_series=whole)
|
|
|
|
|
|
def test_timing_edit_retains_uid_and_untouched_text():
|
|
data = patch_event(
|
|
SIMPLE,
|
|
EventPatch(start="2025-03-29T12:00:00Z", end="2025-03-29T13:00:00Z"),
|
|
default_timezone="UTC",
|
|
whole_series=False,
|
|
)
|
|
view = event_views(resource(data), "UTC", minimal=False)[0]
|
|
assert view["uid"] == "synthetic-event"
|
|
assert view["start"] == "2025-03-29T12:00:00+00:00"
|
|
assert view["description"].startswith("Untrusted text")
|
|
|
|
|
|
def resource(data):
|
|
return CalendarResource("event-1", "/selected/a.ics", '"rev1"', data)
|
|
|
|
|
|
def test_minimal_reads_do_not_disclose_descriptions_or_links(monkeypatch):
|
|
def forbidden(*args, **kwargs):
|
|
raise AssertionError("side effect while parsing text")
|
|
|
|
monkeypatch.setattr("builtins.open", forbidden)
|
|
views = event_views(resource(SIMPLE), "UTC", minimal=True)
|
|
assert views == [
|
|
{
|
|
"event_id": "event-1",
|
|
"revision": '"rev1"',
|
|
"title": "Synthetic meeting",
|
|
"start": "2025-03-29T10:00:00+00:00",
|
|
"end": "2025-03-29T11:00:00+00:00",
|
|
"all_day": False,
|
|
"timezone": "UTC",
|
|
"recurring": False,
|
|
"occurrence": None,
|
|
}
|
|
]
|
|
details = event_views(resource(SIMPLE), "UTC", minimal=False)[0]
|
|
assert "ignore previous instructions" in details["description"]
|
|
assert "raw_ics" not in details
|
|
|
|
|
|
def test_all_day_end_is_exclusive():
|
|
data = (
|
|
SIMPLE.replace(b"20250329T100000Z", b"20250329")
|
|
.replace(b"20250329T110000Z", b"20250330")
|
|
.replace(b"DTSTART:", b"DTSTART;VALUE=DATE:")
|
|
.replace(b"DTEND:", b"DTEND;VALUE=DATE:")
|
|
)
|
|
view = event_views(resource(data), "UTC", minimal=True)[0]
|
|
assert view["start"] == "2025-03-29"
|
|
assert view["end"] == "2025-03-30"
|
|
assert view["all_day"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"start,end",
|
|
[
|
|
("2025-01-01", "2025-01-02"),
|
|
("2025-01-01T00:00:00", "2025-01-02T00:00:00"),
|
|
("bad", "bad"),
|
|
("2025-01-02T00:00:00Z", "2025-01-01T00:00:00Z"),
|
|
("2025-01-01T00:00:00Z", "2025-04-04T00:00:01Z"),
|
|
],
|
|
)
|
|
def test_invalid_query_intervals_fail(start, end):
|
|
with pytest.raises(SafeError):
|
|
validate_interval(start, end)
|
|
|
|
|
|
def test_exact_query_limit():
|
|
start, end = validate_interval("2025-01-01T01:00:00+01:00", "2025-04-04T00:00:00Z")
|
|
assert start == datetime(2025, 1, 1, tzinfo=timezone.utc)
|
|
assert (end - start).days == 93
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"stamp,expected",
|
|
[
|
|
(b"20250330T013000", "2025-03-30T01:30:00+01:00"),
|
|
(b"20251026T033000", "2025-10-26T03:30:00+01:00"),
|
|
],
|
|
)
|
|
def test_iana_zone_retains_historical_dst_offset(stamp, expected):
|
|
data = SIMPLE.replace(
|
|
b"DTSTART:20250329T100000Z", b"DTSTART;TZID=Europe/Berlin:" + stamp
|
|
).replace(b"DTEND:20250329T110000Z", b"DURATION:PT1H")
|
|
assert (
|
|
event_views(resource(data), "Europe/Berlin", minimal=True)[0]["start"]
|
|
== expected
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("stamp", [b"20250330T023000", b"20251026T023000"])
|
|
def test_ambiguous_or_nonexistent_floating_local_time_fails(stamp):
|
|
data = SIMPLE.replace(b"20250329T100000Z", stamp).replace(
|
|
b"DTEND:20250329T110000Z", b"DURATION:PT1H"
|
|
)
|
|
with pytest.raises(SafeError):
|
|
event_views(resource(data), "Europe/Berlin", minimal=True)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data",
|
|
[
|
|
b"garbage",
|
|
SIMPLE.replace(b"UID:synthetic-event", b"UID:a\nUID:b"),
|
|
SIMPLE.replace(b"DTSTART:20250329T100000Z", b"DTSTART:invalid"),
|
|
SIMPLE.replace(b"END:VCALENDAR", b"END:INVALID"),
|
|
],
|
|
)
|
|
def test_malformed_event_is_not_silently_accepted(data):
|
|
with pytest.raises(SafeError):
|
|
event_views(resource(data), "UTC", minimal=True)
|