diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2e55d..e693713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/USAGE.md b/USAGE.md index b6f41b4..d34aa26 100644 --- a/USAGE.md +++ b/USAGE.md @@ -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 diff --git a/src/caldav_server_tester/caldav_server_tester.py b/src/caldav_server_tester/caldav_server_tester.py index 98cec8f..fd64c5e 100755 --- a/src/caldav_server_tester/caldav_server_tester.py +++ b/src/caldav_server_tester/caldav_server_tester.py @@ -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( diff --git a/src/caldav_server_tester/checks.py b/src/caldav_server_tester/checks.py index 7c9becd..75fa8a5 100644 --- a/src/caldav_server_tester/checks.py +++ b/src/caldav_server_tester/checks.py @@ -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. @@ -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() @@ -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 @@ -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( @@ -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: diff --git a/tests/test_checks_with_mocks.py b/tests/test_checks_with_mocks.py index a93c48c..e51674e 100644 --- a/tests/test_checks_with_mocks.py +++ b/tests/test_checks_with_mocks.py @@ -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() @@ -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() @@ -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"""