feat: publish Hermes Nextcloud calendar MCP under AGPL-3.0
Some checks failed
Offline checks / test (3.11) (push) Has been cancelled
Offline checks / test (3.13) (push) Has been cancelled

This commit is contained in:
xeno
2026-09-30 20:27:25 +00:00
commit 93b9c9f913
44 changed files with 8193 additions and 0 deletions

316
tests/test_events.py Normal file
View File

@@ -0,0 +1,316 @@
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)