Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0

This library is tightly dependent on the CalDAV-library, particularly the `compatibility_hints.py`-file. This file is not (yet) considered to be part of the "core" business logic in the CalDAV library and can be changed in patch-releases in the CalDAV library - so this library is usually released in lock-steps with the CalDAV-library. I've considered to bump the version number to be follow the CalDAV version number.

## Unreleased

### Fixed

- A test calendar left by an earlier run is no longer reused when it is a ghost. Zimbra 10 relocates a calendar created with a display name, and for a while after the run deletes it the old address still answers as an empty calendar that refuses every PUT with 500, so the next run failed from the start. A leftover the server does not list, and that refuses a test write, is now created anew; this covers the task and journal calendars too. A calendar that accepts writes is always reused.

## 1.7.0 - 2026-10-23

This release works with caldav 3.6.0. 1.6.0 was accidentally released without two probes and a grading fix that caldav 3.6.0's server hints were based on.
Expand Down
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ When you have an existing `compatibility_hints` configuration for a server
and want to see whether the server behaviour has changed:

```
caldav-server-tester --caldav-url … --caldav-features zimbra --diff
caldav-server-tester --caldav-url … --caldav-features zimbra_10_1 --diff
```

The `--diff` flag appends a section to the report listing every feature where
Expand Down
2 changes: 1 addition & 1 deletion src/caldav_server_tester/caldav_server_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def _check_server(
)
@click.option(
"--caldav-features",
help="Server compatibility features preset (e.g., 'stalwart', 'zimbra', 'sogo')",
help="Server compatibility features preset (e.g., 'stalwart', 'zimbra_10_1', 'sogo')",
metavar="FEATURES",
)
@click.option(
Expand Down
69 changes: 64 additions & 5 deletions src/caldav_server_tester/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1431,8 +1431,8 @@ def _check_set_displayname(self):
* ``propfind.displayname`` - can DAV:displayname be read back via PROPFIND?

Zimbra quirks deliberately NOT probed here (each could merit its own
dedicated probe one day; mirrored in the ``zimbra`` entry of caldav's
``compatibility_hints.py``):
dedicated probe one day; mirrored in the ``zimbra_8_8`` and
``zimbra_10_1`` entries of caldav's ``compatibility_hints.py``):

* The calendar URL only becomes unstable when a display name is supplied
at creation - a nameless MKCALENDAR stays put at the requested cal_id.
Expand Down Expand Up @@ -1872,7 +1872,7 @@ def _find_or_create_calendar(self, cal_id, name, test_cal_info):
if user_specified:
calendar = self.checker.principal.calendar(name=name)
else:
calendar = self.checker.principal.calendar(cal_id=cal_id)
calendar = self._own_calendar(cal_id)
## At least one out of those two will raise NotFoundError if calendar doesn't exist
calendar.get_display_name()
calendar.events()
Expand Down Expand Up @@ -1910,6 +1910,65 @@ def _find_or_create_calendar(self, cal_id, name, test_cal_info):
self.checker.tasklist = calendar
self.checker.journallist = calendar

## UID of the object _own_calendar writes to tell a ghost from a calendar;
## the csc_ prefix keeps it inside the cleanup sweep should the run die.
GHOST_PROBE_UID = "csc_ghost_probe"

def _own_calendar(self, cal_id, component=Event):
"""The calendar an earlier run left under one of our cal_ids.

Raises NotFoundError when there is none, and also for a ghost. Zimbra
10 relocates a calendar created with a display name, and for a while
after the relocated calendar is deleted the old path still resolves -
to an empty calendar, absent from the listing, answering 500 to every
PUT. A DELETE of it changes nothing, but a MKCALENDAR at the cal_id
replaces it.

A calendar the principal lists, by path or by display name, is used as
listed: where the cal_id is a ghost, the listed one is the live one.
One that is not listed - or whose listing fails - is put to a write of
``component``, and is a ghost only when that fails, so a calendar that
works is never replaced, whatever the server's listing says.
"""
calendar = self.checker.principal.calendar(cal_id=cal_id)
name = calendar.get_display_name()
listed = self._listed_calendar(calendar, name)
if listed is not None:
return listed
try:
probe = calendar.save_object(
component, uid=self.GHOST_PROBE_UID, summary="ghost probe", dtstart=datetime.now(utc)
)
except Exception as e:
raise NotFoundError(f"{calendar.url} answers, but is not listed and refuses a write: {e}") from e
with contextlib.suppress(Exception):
probe.delete()
return calendar

def _listed_calendar(self, calendar, name):
"""The calendar the principal lists at ``calendar``'s path, or else
under display name ``name``; None when there is none or the listing
fails. A listed calendar whose name cannot be read is skipped."""
try:
listed = list(self.checker.principal.calendars())
except Exception:
return None

def path(cal):
return unquote(urlsplit(str(cal.url)).path).rstrip("/")

for other in listed:
if path(other) == path(calendar):
return calendar
if name:
for other in listed:
try:
if other.get_display_name() == name:
return other
except Exception:
continue
return None

def _prepare_task_calendar(self, cal_id, name, add_if_not_existing):
"""Handle task calendar setup and save-load.todo / save-load.todo.mixed-calendar features."""
base = self.checker.fixture_base_year
Expand All @@ -1925,7 +1984,7 @@ def _prepare_task_calendar(self, cal_id, name, add_if_not_existing):
self.set_feature("save-load.todo.mixed-calendar")
except Exception:
try:
tasklist = self.checker.principal.calendar(cal_id=f"{cal_id}_tasks")
tasklist = self._own_calendar(f"{cal_id}_tasks", Todo)
tasklist.todos()
except Exception:
tasklist = self.checker.principal.make_calendar(
Expand Down Expand Up @@ -1966,7 +2025,7 @@ def _prepare_journal_calendar(self, cal_id, name, add_if_not_existing):
except Exception:
journallist = None
try:
journallist = self.checker.principal.calendar(cal_id=f"{cal_id}_journals")
journallist = self._own_calendar(f"{cal_id}_journals", Journal)
journallist.journals()
except Exception:
try:
Expand Down
121 changes: 121 additions & 0 deletions tests/test_checks_with_mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@ def save_object(*args, **kwargs):

mock_calendar.save_object = save_object
principal.calendar.return_value = mock_calendar
principal.calendars.return_value = [mock_calendar]

check = PrepareCalendar(checker)
check.run_check()
Expand Down Expand Up @@ -810,6 +811,7 @@ def save_object(*args, **kwargs):

mock_calendar.save_object = save_object
principal.calendar.return_value = mock_calendar
principal.calendars.return_value = [mock_calendar]

check = PrepareCalendar(checker)
check.run_check()
Expand All @@ -818,6 +820,125 @@ def save_object(*args, **kwargs):
assert checker.features_checked.is_supported("save-load.event")


class TestGhostCalendar:
"""A calendar this tool deleted may linger at its cal_id.

Zimbra 10 relocates a calendar created with a display name, and after the
relocated calendar is deleted the old path keeps answering PROPFIND and
REPORT for a while - with no objects, absent from the calendar listing,
and answering 500 to every PUT. A DELETE of it is answered 204 and changes
nothing, but a MKCALENDAR at the cal_id replaces it. A leftover found under
one of our cal_ids is replaced only when the principal does not list it and
a write into it fails, so a calendar that works is never replaced, whatever
the server's listing says.
"""

CAL_ID = "caldav-server-checker-calendar"
NAME = "Calendar for checking server feature support"
HOME = "https://caldav.example.com/dav/user%40example.com/"

def _calendar(self, path, name, writable=True, home=None):
cal = Mock()
cal.url = (home or self.HOME) + path
cal.get_display_name.return_value = name
if not writable:
cal.save_object.side_effect = DAVError("500 Internal Server Error")
return cal

def _check(self, found, listed):
checker, _client, principal = TestPrepareCalendar().create_checker_with_calendar()
checker.fixture_base_year = 2027
principal.calendar.return_value = found
if isinstance(listed, Exception):
principal.calendars.side_effect = listed
else:
principal.calendars.return_value = listed
principal.make_calendar.return_value = self._calendar("new/", self.NAME)
return PrepareCalendar(checker), checker, principal

def _ghost(self, suffix=""):
return self._calendar(self.CAL_ID + suffix + "/", self.NAME, writable=False)

def test_unlisted_unwritable_calendar_is_replaced(self) -> None:
check, checker, principal = self._check(self._ghost(), [self._calendar("Calendar/", "Calendar")])
check._find_or_create_calendar(self.CAL_ID, self.NAME, {})
principal.make_calendar.assert_called_once()
assert principal.make_calendar.call_args.kwargs["cal_id"] == self.CAL_ID
assert checker.calendar is principal.make_calendar.return_value
assert checker.calendar_was_created

def test_unlisted_writable_calendar_is_reused(self) -> None:
"""A server whose listing leaves the calendar out keeps its leftover;
the write probe is removed again."""
found = self._calendar(self.CAL_ID + "/", self.NAME)
check, checker, principal = self._check(found, [])
check._find_or_create_calendar(self.CAL_ID, self.NAME, {})
principal.make_calendar.assert_not_called()
assert checker.calendar is found
found.save_object.return_value.delete.assert_called_once()

@pytest.mark.parametrize("error", [DAVError("no listing"), ConnectionError("reset"), ValueError("bad XML")])
def test_failed_listing_falls_back_to_the_write_probe(self, error) -> None:
found = self._calendar(self.CAL_ID + "/", self.NAME)
check, checker, principal = self._check(found, error)
check._find_or_create_calendar(self.CAL_ID, self.NAME, {})
principal.make_calendar.assert_not_called()
assert checker.calendar is found

@pytest.mark.parametrize(
"home",
[
"https://caldav.example.com/dav/user@example.com/",
"http://backend.example.com:8080/dav/user%40example.com/",
"https://CalDAV.example.com:443/dav/user%40example.com/",
],
)
def test_listed_under_another_spelling_is_reused(self, home) -> None:
found = self._ghost()
listed = self._calendar(self.CAL_ID, None, home=home)
check, checker, principal = self._check(found, [listed])
check._find_or_create_calendar(self.CAL_ID, self.NAME, {})
principal.make_calendar.assert_not_called()
found.save_object.assert_not_called()
assert checker.calendar is found

def test_relocated_leftover_is_used_where_it_is_listed(self) -> None:
"""Zimbra lists a live relocated calendar under its display-name path,
while its cal_id may resolve to a ghost - the listed one is used."""
relocated = self._calendar("Calendar%20for%20checking%20server%20feature%20support/", self.NAME)
check, checker, principal = self._check(self._ghost(), [relocated])
check._find_or_create_calendar(self.CAL_ID, self.NAME, {})
principal.make_calendar.assert_not_called()
assert checker.calendar is relocated

def test_unreadable_listed_name_is_skipped(self) -> None:
shared = self._calendar("shared/", None)
shared.get_display_name.side_effect = DAVError("403 Forbidden")
relocated = self._calendar("relocated/", self.NAME)
check, checker, principal = self._check(self._ghost(), [shared, relocated])
check._find_or_create_calendar(self.CAL_ID, self.NAME, {})
assert checker.calendar is relocated

@pytest.mark.parametrize(
"suffix, prepare, attribute",
[("_tasks", "_prepare_task_calendar", "tasklist"), ("_journals", "_prepare_journal_calendar", "journallist")],
)
def test_unlisted_unwritable_component_calendar_is_replaced(self, suffix, prepare, attribute) -> None:
check, checker, principal = self._check(self._ghost(suffix), [])
calls = []

def add_if_not_existing(*args, **kwargs):
calls.append(kwargs)
if len(calls) == 1:
raise DAVError("refused in the main calendar")
return Mock()

getattr(check, prepare)(self.CAL_ID, self.NAME, add_if_not_existing)
principal.make_calendar.assert_called_once()
assert principal.make_calendar.call_args.kwargs["cal_id"] == self.CAL_ID + suffix
assert getattr(checker, attribute) is principal.make_calendar.return_value


class TestCheckSearch:
"""Test CheckSearch with mocked server responses"""

Expand Down
Loading