From 5c7147747a7e911ae4df98bd59f24b16989cd476 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 29 Oct 2026 12:01:56 +0100 Subject: [PATCH 1/3] fix: replace a ghost of a deleted test calendar Zimbra 10 relocates a calendar created with a display name. After the relocated calendar is deleted, its cal_id keeps answering PROPFIND and REPORT for a while, as an empty calendar that is not listed and answers 500 to every PUT; a DELETE of it is a 204 that changes nothing. The next run reused it and failed from the start. A leftover under one of our cal_ids (main, tasks, journals) is now used as the principal lists it, by path or display name; one not listed is put to a test write, and only if that fails is it replaced by a MKCALENDAR. Prompt: Clean-up of Zimbra: the tests has to be able to clean up from previous test runs, it should not be needed to do manual cleanup after aborted test runs ... Prompts (comments made inline in the document while reviewing it): > (on suggestions on how to fix "A server with an incomplete calendar listing loses its leftovers and aborts") I'm not much concerned about more requests ... it's important that the fix makes things robust and resilent (sic) on every server. Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- CHANGELOG.md | 6 ++ src/caldav_server_tester/checks.py | 65 +++++++++++++++- tests/test_checks_with_mocks.py | 121 +++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) 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/src/caldav_server_tester/checks.py b/src/caldav_server_tester/checks.py index 7c9becd..627e083 100644 --- a/src/caldav_server_tester/checks.py +++ b/src/caldav_server_tester/checks.py @@ -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""" From bcb16abc41d5725189d04b69aa9a18afa4229ac8 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 29 Oct 2026 13:14:19 +0100 Subject: [PATCH 2/3] docs: TODO on following the zimbra profile rename Prompt: [file the review finding on caldav's commit splitting the zimbra profile] Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- docs/TODO.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/TODO.md b/docs/TODO.md index af0ea9e..0d8a122 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -497,3 +497,13 @@ a production Zimbra, not the podman one): xandikos. Reproduce: is it the number of UIDs, Zimbra's UID search (it ignores text-match, so each lookup fetches the whole list), or plann's handling of several UIDs? + +# Follow caldav's rename of the `zimbra` profile + +caldav (after 3.6.0) renames the `zimbra` compatibility profile `zimbra_8_8` +and adds `zimbra_10_1`; the old name still resolves, with a warning. Once this +tool requires that caldav, update what still names `zimbra`: the +`--caldav-features zimbra --diff` example in USAGE.md, the `--caldav-features` +help text in caldav_server_tester.py, and the docstring in checks.py that +points at "the `zimbra` entry" of caldav's hints. Not before: CI tests +against the released caldav, which has no `zimbra_8_8`. From ae3a81ce73b36cc19f3ed51d8b05543d7276e576 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 29 Oct 2026 15:59:10 +0100 Subject: [PATCH 3/3] docs: name the version-stamped Zimbra profiles caldav renames its zimbra compatibility profile zimbra_8_8 and adds zimbra_10_1 (https://github.com/python-caldav/caldav/pull/839). The --caldav-features help text, a docstring and the example in USAGE.md still named the old one; the TODO on following the rename goes. Prompt: if (sic) the caldav-server-tester help text in master, without pushing [fix the help text and docstring that still name caldav's old `zimbra` profile] Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- USAGE.md | 2 +- docs/TODO.md | 10 ---------- src/caldav_server_tester/caldav_server_tester.py | 2 +- src/caldav_server_tester/checks.py | 4 ++-- 4 files changed, 4 insertions(+), 14 deletions(-) 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/docs/TODO.md b/docs/TODO.md index 0d8a122..af0ea9e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -497,13 +497,3 @@ a production Zimbra, not the podman one): xandikos. Reproduce: is it the number of UIDs, Zimbra's UID search (it ignores text-match, so each lookup fetches the whole list), or plann's handling of several UIDs? - -# Follow caldav's rename of the `zimbra` profile - -caldav (after 3.6.0) renames the `zimbra` compatibility profile `zimbra_8_8` -and adds `zimbra_10_1`; the old name still resolves, with a warning. Once this -tool requires that caldav, update what still names `zimbra`: the -`--caldav-features zimbra --diff` example in USAGE.md, the `--caldav-features` -help text in caldav_server_tester.py, and the docstring in checks.py that -points at "the `zimbra` entry" of caldav's hints. Not before: CI tests -against the released caldav, which has no `zimbra_8_8`. 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 627e083..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.