diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2e55d..2cd15fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ 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 + +Needs caldav 3.7.0, which has the features below in its `compatibility_hints.py`. + +### Changed + +- **`save.no-uid-conflict`** is graded by a plain PUT. 403 or 409 with `CALDAV:no-uid-conflict` (or Cyrus' `CALDAV:unique-scheduling-object-resource`) is `full`; another status with it (Xandikos, Stalwart: 412) is `quirk`. A refusal without it is `quirk` too, with the extra keys `status` and `precondition`, if nothing was stored at the URL and the same PUT with another UID goes through (SOGo: 409). A bare 403 stays `ungraceful`. + +### Added + +- **`supported-components.property-exists`** (the former `get-supported-components`, now probed), **`supported-components.honoured`** and **`create-calendar.with-supported-component-types.multiple`** - whether a calendar reports the component set, whether it takes exactly the types it lists (and every type where it lists none, RFC 4791 §5.2.3), and whether a MKCALENDAR naming several types gives a calendar taking all of them. A calendar that reports `[VTODO]` and still takes a VEVENT is `broken` on `.honoured` (Bedework 3.10, DAViCal, Stalwart); CCS refuses a set of several components the §5.3.1.1 way and is `unsupported` on `.multiple`. +- **`delete.if-match`** - whether a DELETE with an If-Match etag the object no longer has is refused with 412, and one with the current etag goes through. SOGo refuses a stale If-Match on PUT, but deletes the object on DELETE. +- **`save-load.event.floating`** - whether an event with a floating DTSTART and DTEND is stored floating. SOGo gives the times TZID=Etc/UTC (tasks it keeps floating). The probe reads the server's own text: the icalendar library writes a TZID=Etc/UTC time back with a `Z` after it, which RFC 5545 §3.3.5 forbids. +- **`save-load.icalendar.attendee-params`** - whether ATTENDEE parameters are stored as sent. An added RFC 5545 default (PARTSTAT=NEEDS-ACTION and the like) or SCHEDULE-STATUS is fine; SOGo also adds RSVP=TRUE, where the default is FALSE. +- **`save-load.icalendar.participant`** - whether an RFC 9073 PARTICIPANT inside a VEVENT is stored. CCS and Bedework 3 refuse the event with 403, robur with 400, GMX and Ox with 500. See https://github.com/python-caldav/caldav-server-tester/issues/38. +- **`save-load.icalendar.vtimezone`** - whether the VTIMEZONE sent with an event comes back with it. CCS returns the event's TZID without the VTIMEZONE. See https://github.com/python-caldav/caldav-server-tester/issues/39. +- **`save-load.icalendar.x-property`**, **`save-load.icalendar.unknown-property`**, **`save-load.todo.attendee`** and **`save-load.todo.completed-without-status`** - whether an X- property (in an event and in a task), a property RFC 5545 does not define (ESTIMATED-DURATION), an ATTENDEE on a task, and COMPLETED on a task without STATUS are kept. Ox drops all of them (the X- property only from a task); a task with an ATTENDEE is answered 201, then 404. See https://github.com/python-caldav/caldav-server-tester/issues/40. +- **`delete.missing`** and **`delete.missing.if-match`** - whether a DELETE of an object that does not exist answers 404, and one with If-Match answers as the plain one does, as RFC 9110 §13.2.1 has it. Baïkal, Davis, Nextcloud, CCS, OX, Robur, Purelymail and Infomaniak answer 412 with If-Match, so a delete of an object someone else already deleted reads as an etag mismatch. +- **`save.etag.matches-stored`** - whether a strong ETag in a PUT answer belongs to what was stored: RFC 4791 §5.3.4 lets a server rewrite the object, but not answer with a strong ETag then. The behaviour says what the server changed. +- **`sync-token.initial-sync`**, **`sync-token.delete.created-and-deleted`** and **`sync-token.recreated`** - which members a sync-collection report lists: an initial sync with deleted members or the collection itself in it, a member created and deleted between two reports left out, and a re-created member reported twice, or only as removed (Nextcloud, Synology). +- **`multiget.missing-href`** - whether a calendar-multiget answers a missing href with a 404 entry; Baïkal, Nextcloud, DAViCal, Davis and Infomaniak leave it out, Posteo fails the whole multiget with a 404, and an entry claiming the object exists is broken. +- **`multiget.limit`**, **`propfind.limit`** and **`search.limit`** - whether a calendar-multiget, a Depth: 1 PROPFIND or a calendar-query answers every member, however many. Stalwart stops at its `maxResults` with a 507. These store as many objects as `CALDAV_SERVER_TESTER_LIMIT_OBJECTS` says, and are not probed without it. +- **`save.no-uid-conflict.if-none-match`** - the same, for a PUT carrying `If-None-Match: *`, as `save(no_overwrite=True)` sends it. Bedework answers it with a bare 412, but a plain PUT with 403 and the precondition. +- **`save.no-uid-conflict.href-given`** - whether the precondition names the object holding the UID, as RFC 4791 section 5.3.2.1 says it SHOULD. +- **`search.text.uid`** - whether a calendar-query with a UID filter answers the one object with that UID. iCloud answers it with 412 (`ungraceful`), so `get_object_by_uid()` and what is built on it fail there. See https://github.com/python-caldav/caldav/issues/783. + +The behaviours came from calendaring-sync's server compatibility notes: https://github.com/PyCalendar/calendaring-sync/blob/c40782bc7afe7b350fe63bf27ef13374c280ea8b/docs/reference/server-compatibility.rst + +### Fixed + +- **`create-calendar.with-supported-component-types`** - back to asking whether a MKCALENDAR naming `VTODO` gives a calendar with that set. Bedework 5 and SOGo report another set and are `unsupported`; a calendar that reports the set and ignores it is a matter for `.honoured`. + ## 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/docs/ROADMAP.md b/docs/ROADMAP.md index c415cd7..77cbb02 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -185,12 +185,12 @@ library *declares* but nothing measures, and quirks that predate the feature database entirely and still live in the old flat flag list. At the baseline nine features were defined in `compatibility_hints.py` and probed -by nothing. Eight still are: `get-supported-components`, `save-load.reuse-deleted-uid`, +by nothing. Seven still are: `save-load.reuse-deleted-uid`, `save-load.todo.recurrences.thisandfuture`, `search.time-range.accurate`, `search.time-range.journal`, `search.text.category.substring`, `wrong-password-check` and `http.multiplexing`. Every one of them is currently reported from its default rather than from evidence - which is exactly the -failure mode constraint 2 is about, at eight known sites. The ninth, +failure mode constraint 2 is about, at seven known sites. The ninth, `create-calendar.with-supported-component-types`, got its probe on 2026-09-13, after Bedework 5 turned out to ignore the component set - the first server where the default was wrong. diff --git a/docs/TODO.md b/docs/TODO.md index af0ea9e..da6793a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -230,8 +230,9 @@ cases apart in the probe. (Clean-context review 2026-09-14, filed rather than fixed.) -`CheckSupportedComponentSet._delete_probe_calendar` deletes -`principal.calendar(cal_id=CAL_ID)`, although the check itself notes that the +`CheckSupportedComponentSet._delete_probe_calendars` deletes +`principal.calendar(cal_id=...)` for both its probe calendars (`CAL_ID` and +`CAL_ID_MULTIPLE`), although the check itself notes that the `cal_id` is not the collection's address on a server that relocates it. The probe calendar has no display name either, so a prefix sweep may miss it too. No server is known to leak one - OX, the relocating server, also answers at the @@ -497,3 +498,54 @@ 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? + +# Expired sync tokens: worth a probe at all? + +(Noted 2026-10-24, from calendaring-sync's evidence branch. Undecided.) + +RFC 6578 section 3.2 has a server refuse a token whose change history it +discarded, with `DAV:valid-sync-token`. `sync-token.invalid-token` only +covers tokens the server never issued; a token that was valid and expired is +not probed, and a client that keeps tokens for days is the one that meets it. + +Stalwart can be made to expire tokens: calendaring-sync shrank +`DataRetention.maxChangesHistory` to 1 and then ran an `AccountMaintenance` +purge (https://github.com/PyCalendar/calendaring-sync/tree/evidence, under +`sync-design/servers/stalwart_admin.py`). + +Against it: + +* The purge is an admin JMAP call. The checker is a client; it would need a + server-side hook, i.e. a Stalwart special case, and the checker shouldn't + grow many of those. +* A low `maxChangesHistory` in caldav's shared Stalwart container expires + tokens under caldav's own sync tests whenever a purge runs. + +If it is done, the generic shape is a hook in the test-server definition +("expire the change history now"), with the probe `unknown` wherever no hook +is defined; Stalwart would be the only server with one. + +# Bedework refuses TZIDs that are tzdata link names + +Bedework answers 403 `unknownTimezone` for a TZID that is a tzdata link name +(Europe/Oslo, Europe/Stockholm, Europe/Amsterdam, Europe/Kiev), even with a +matching VTIMEZONE in the object. Canonical names (Europe/Berlin, +Europe/Paris, America/New_York) are accepted. Seen 2026-10-26 on the +Bedework3 docker server. + +No probe or hint covers it. The `save-load.icalendar.vtimezone` probe +already sidesteps it by using Europe/Berlin, which is deliberate; only +Bedework 3 was tried, Bedework 5 was not. A probe would save an event with a +link-name TZID (with and without VTIMEZONE) next to a canonical-name control, +and grade the refusal `ungraceful`; it belongs with that probe +(https://github.com/python-caldav/caldav-server-tester/issues/39). + +# `delete.if-match: broken` has no consumer + +The hint is set for Zimbra, SOGo, Bedework 3 and 5, and Posteo, but nothing +acts on it: caldav's `delete(if_match=)` does not read it, and neither, as far +as known, does https://github.com/PyCalendar/calendaring. `broken` means the +server deletes whatever the etag says, so skipping the header would change +nothing. Decide whether `delete(if_match=True)` should warn or raise when the +hint says `broken`, or emulate the check with a GET and an etag compare +before the DELETE (racy, but closer to what was asked for). diff --git a/pyproject.toml b/pyproject.toml index a06302f..34fc8f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] keywords = ["caldav", "calendar", "testing", "compatibility", "server"] dependencies = [ - "caldav>=3.6.0", + "caldav>=3.7.0", "click", "pyyaml", ] diff --git a/src/caldav_server_tester/checks.py b/src/caldav_server_tester/checks.py index 7c9becd..3dad96a 100644 --- a/src/caldav_server_tester/checks.py +++ b/src/caldav_server_tester/checks.py @@ -1,12 +1,14 @@ import contextlib import logging import math +import os import re import time import uuid from datetime import date, datetime, timedelta, timezone from typing import NamedTuple from urllib.parse import quote, unquote, urlsplit, urlunsplit +from xml.etree import ElementTree from xml.sax.saxutils import escape as xml_escape from zoneinfo import ZoneInfo @@ -23,6 +25,7 @@ NotFoundError, PutError, ReportError, + UidConflictError, parse_preconditions, ) from caldav.lib.http_sync import CaseInsensitiveDict @@ -1616,222 +1619,6 @@ def _fallback_displayname(reason): _cleanup() -class CheckSupportedComponentSet(Check): - """ - Checks whether a calendar created with a component-set restriction really - is restricted. - - RFC 4791 section 5.2.3 lets a client hand MKCALENDAR a - CALDAV:supported-calendar-component-set, and section 5.3.1 has a server - that cannot do what was asked answer 403 with a - CALDAV:supported-calendar-component-set precondition rather than create - something else. A server may legitimately not report the property at all - - 5.2.3 makes it optional, and an absent property means every component type - is welcome - so what the collection *advertises* cannot be the only - question asked. The restriction is therefore measured twice over, from a - calendar the probe asks to be VTODO-only: - - * advertised - does the created collection report the component set that - was requested? - * enforced - does it refuse a VEVENT, which the restriction excludes? - - Either one is enough for "full", which is what the feature's own - description says ("the server both advertises (or enforces) the - restriction"); the behaviour note says so when only one of them holds. - Neither is "unsupported": the restriction was silently ignored and - wrong-type objects can be saved to the calendar. A MKCALENDAR that is - refused only when it carries the component set is "ungraceful". - - Bedework 5 is the server this was written for: the component set of a - Bedework collection is a function of its internal calType, which nothing in - CalDAV can select, so the property is accepted with a "200 ok" propstat and - the collection is VEVENT-only regardless. - """ - - depends_on = {CheckMakeDeleteCalendar} - features_to_be_checked = {"create-calendar.with-supported-component-types"} - - FEATURE = "create-calendar.with-supported-component-types" - - ## The cal_id the probe uses. Prefixed so the cleanup sweep recognises it - ## (see checker.PROBE_CALENDAR_PREFIXES). - CAL_ID = "caldav-server-checker-compset-test" - - ## What the probe calendar is asked to hold, and the component type that - ## restriction excludes. VTODO rather than VJOURNAL because a server that - ## can only do one of them does VTODO (and save-load.todo is measured - ## anyway, so a failure here has a neighbour to be read against). - RESTRICTION = ["VTODO"] - WRONG_TYPE = "VEVENT" - - def _run_check(self) -> None: - ## No calendar can be created, so there is nothing to restrict: the - ## feature collapses under create-calendar's own verdict, the way the - ## set-displayname features do. takes_effect(), not is_supported(): - ## a creation that is merely fragile or rude still produces a calendar - ## to look at. - if not self.takes_effect("create-calendar"): - return - - ## A leftover from an interrupted run would answer the wrong question - - ## it may well be a calendar this probe created *unrestricted* as its - ## own control below. - self._delete_probe_calendar() - try: - self._probe() - finally: - self._delete_probe_calendar() - - def _probe(self) -> None: - cal, _attempts, error = self.make_calendar_with_retries( - self.CAL_ID, supported_calendar_component_set=self.RESTRICTION - ) - if cal is None: - self.set_feature(self.FEATURE, self._grade_refused_creation(error)) - return - - ## Some servers process MKCALENDAR asynchronously; poll on the calendar - ## object the library handed back rather than on the cal_id, which is - ## not the collection's address on a server that relocates it (see - ## create-calendar.stable-url). - cal, _waited = self._poll_calendar(cal=cal, timeout=self.checker.delay_probe_timeout) - if cal is None: - self.set_feature( - self.FEATURE, - { - "support": "unknown", - "behaviour": "the restricted calendar was accepted but never became queryable", - }, - ) - return - - advertised = self._advertised(cal) - enforced = self._enforced(cal) - self.set_feature(self.FEATURE, self._grade(advertised, enforced)) - - def _grade_refused_creation(self, error): - """The MKCALENDAR carrying the component set was refused - by this - server, or only because of the component set? - - The control is the same cal_id without the restriction: a server that - creates it then is refusing the component set specifically, which is - what "ungraceful" describes. One that refuses either way is refusing - to create calendars for a reason of its own, and create-calendar is the - feature that gets to say so - nothing is claimed here. - """ - control, _attempts, control_error = self.make_calendar_with_retries(self.CAL_ID) - if control is None: - return { - "support": "unknown", - "behaviour": f"cannot test, creating the probe calendar failed with and without the component set ({control_error})", - } - return { - "support": "ungraceful", - "behaviour": f"MKCALENDAR is refused when it carries a component set ({error})", - } - - def _advertised(self, cal): - """What the created collection reports, or ``None`` when it cannot be read. - - ``with_fallback=False``: the fallback answers with the RFC default set - for a server that reports nothing, which is the right answer for a - client and the wrong one for a probe - it would read as "advertises - everything" where nothing was advertised at all. - """ - try: - return cal.get_supported_components(with_fallback=False) - except (DAVError, AuthorizationError): - return None - - def _enforced(self, cal) -> bool | int | None: - """Does the collection refuse the component type the restriction excludes? - - The status, rather than a bool, where the save failed with a 5xx the - server software is to blame for: that is enforcement, ungracefully. - ``None`` where it failed without saying anything - a 502-504, or an - error carrying no status - since that says nothing about the - restriction either way. - """ - uid = f"csc_compset_{uuid.uuid4()}" - ## _base_year() rather than checker.fixture_base_year: that attribute is - ## set by PrepareCalendar, which this check does not depend on and may - ## well run after it. Same year either way - both read this function. - year = _base_year() - try: - obj = cal.save_event( - dtstart=datetime(year, 1, 8, 10, 0, tzinfo=utc), - dtend=datetime(year, 1, 8, 11, 0, tzinfo=utc), - summary="component set probe", - uid=uid, - ) - except AuthorizationError: - return True - except DAVError as exc: - status = dav_error_status(exc) - if status is not None and 400 <= status < 500: - return True - return status if is_server_failure(status) else None - ## It went in. The calendar is deleted at the end of the check either - ## way, but a server that cannot delete a calendar would keep the - ## object, so take it out again. - try: - obj.delete() - except (DAVError, AuthorizationError): - logging.warning("Could not delete the %s the component-set probe saved (uid %s)", self.WRONG_TYPE, uid) - return False - - def _grade(self, advertised, enforced): - """Grade the restriction from what the collection says and what it takes.""" - asked = f"asked for {self.RESTRICTION}" - if advertised is None: - advertises = "its component set cannot be read" - elif not advertised: - advertises = "it advertises no component set" - else: - advertises = f"it advertises {sorted(advertised)}" - ## A restriction is advertised when nothing outside it is: a server - ## reporting a subset of what was asked for restricts at least as much. - restricted = bool(advertised) and not set(advertised) - set(self.RESTRICTION) - - if enforced is not None and not isinstance(enforced, bool): - return { - "support": "ungraceful", - "behaviour": f"saving a {self.WRONG_TYPE} fails with {enforced} rather than a refusal; {advertises}", - } - if restricted and enforced: - return True - if restricted and enforced is None: - return { - "support": "full", - "behaviour": f"the restriction is advertised; saving a {self.WRONG_TYPE} failed without a refusal, so enforcement was not tested", - } - if restricted: - return { - "support": "full", - "behaviour": f"the restriction is advertised but not enforced; a {self.WRONG_TYPE} can be saved to the calendar", - } - if enforced is None: - return { - "support": "unknown", - "behaviour": f"cannot test, {advertises} and saving a {self.WRONG_TYPE} failed without a refusal", - } - if enforced: - return { - "support": "full", - "behaviour": f"the restriction is enforced but not advertised ({advertises})", - } - return { - "support": "unsupported", - "behaviour": f"the restriction is ignored: {asked}, {advertises}, and a {self.WRONG_TYPE} can be saved to the calendar", - } - - def _delete_probe_calendar(self) -> None: - try: - DAVObject.delete(self.checker.principal.calendar(cal_id=self.CAL_ID)) - except Exception: - pass - - class PrepareCalendar(Check): """ This "check" doesn't check anything, but ensures the calendar has some known events @@ -3382,6 +3169,424 @@ def add_if_not_existing(*largs, **kwargs): self._check_encode_at(calendar) +class CheckSupportedComponentSet(Check): + """ + Grades the supported-calendar-component-set property two ways, and the + MKCALENDAR that sets it two. + + RFC 4791 section 5.2.3 makes the property optional. Where a calendar has + it, storing a component type it does not list "MUST result in an error" + (the CALDAV:supported-calendar-component precondition of section 5.3.2.1); + where it has none, "the server MUST accept all component types". Section + 5.3.1.1 has a MKCALENDAR whose property cannot be set answer 207 with + 403/409 for that property and create nothing. So four questions, kept + apart because servers answer them independently: + + * ``supported-components.property-exists`` - does the *test* calendar + report the property? Absent is conformant, never a failure. + * ``supported-components.honoured`` - does a calendar take exactly the + types it lists, and every type where it lists none? Measured on a + calendar the probe has asked to be VTODO-only (a VEVENT, a VTODO and a + VJOURNAL saved into it), and, where the test calendar has no property, from + what PrepareCalendar found out about it. A calendar that reports + [VTODO] and still takes a VEVENT is "broken" (Bedework 3.10, DAViCal, + Stalwart); so is one that reports nothing and refuses a type, though + the restriction it was asked for is in force. + * ``create-calendar.with-supported-component-types`` - does MKCALENDAR + naming {VTODO} give a calendar with that set? Bedework 5 and SOGo + answer the request and then ignore it, which is "unsupported". + * ``...with-supported-component-types.multiple`` - the same naming + {VEVENT, VTODO}. CCS refuses it the section 5.3.1.1 way, which is + conformant but "unsupported"; Bedework 3.10 is said to report one + concatenated name, which is "broken". Independent of + ``save-load.todo.mixed-calendar``, which asks about the default + calendar. Measured whenever the {VTODO} calendar could be + created, even where the parent is graded negative. + + ``with_fallback=False`` when reading the property: the fallback answers + with the RFC default set for a server that reports nothing, which is the + right answer for a client and the wrong one for a probe. + + Like save.etag.matches-stored, the child features are conditions, not + modes, so "rewrites it or not" never became a feature of its own. + ``supported-components`` itself is a grouping node nothing may set, hence + both of its children are set explicitly on every path. + """ + + depends_on = {CheckMakeDeleteCalendar, PrepareCalendar} + FEATURE = "create-calendar.with-supported-component-types" + MULTIPLE = FEATURE + ".multiple" + EXISTS = "supported-components.property-exists" + HONOURED = "supported-components.honoured" + features_to_be_checked = {FEATURE, MULTIPLE, EXISTS, HONOURED} + + ## The cal_ids the probe uses. Prefixed so the cleanup sweep recognises + ## them (see checker.PROBE_CALENDAR_PREFIXES). + CAL_ID = "caldav-server-checker-compset-test" + CAL_ID_MULTIPLE = "caldav-server-checker-compset-multi-test" + + ## What the probe calendar is asked to hold, and the component type that + ## restriction excludes. VTODO rather than VJOURNAL because a server that + ## can only do one of them does VTODO (and save-load.todo is measured + ## anyway, so a failure here has a neighbour to be read against). + RESTRICTION = ["VTODO"] + WRONG_TYPE = "VEVENT" + SEVERAL = ["VEVENT", "VTODO"] + + ## A save-load.*.mixed-calendar verdict that says the test calendar + ## refused the type. + _REFUSED_BY_TEST_CALENDAR = ("unsupported", "ungraceful", "broken") + + def _run_check(self) -> None: + reported = self._reported(getattr(self.checker, "calendar", None)) + self._set_exists(reported) + + honoured = None + ## No calendar can be created, so there is nothing to restrict: the + ## creation features collapse under create-calendar's own verdict, the + ## way the set-displayname features do. takes_effect(), not + ## is_supported(): a creation that is merely fragile or rude still + ## produces a calendar to look at. + if self.takes_effect("create-calendar"): + ## A leftover from an interrupted run would answer the wrong + ## question - it may well be a calendar this probe created + ## *unrestricted* as its own control below. + self._delete_probe_calendars() + try: + honoured = self._probe() + finally: + self._delete_probe_calendars() + ## What PrepareCalendar found out about the test calendar is evidence + ## only where no probe calendar could be made to look at: its flag is + ## set by any failure of its first save, a gateway error included. + self._set_honoured(honoured, self._test_calendar_refuses(reported) if honoured is None else []) + + ## property-exists, on the test calendar + + def _reported(self, cal): + """The component set ``cal`` reports: a list, ``[]`` when the property + is absent, ``None`` when it could not be read.""" + if cal is None: + return None + try: + return list(cal.get_supported_components(with_fallback=False)) + except (DAVError, AuthorizationError) as exc: + ## The library raises a PropfindError carrying no status for an + ## absent property; a failed PROPFIND carries one. + return None if (dav_error_status(exc) or getattr(exc, "status", None)) else [] + + def _set_exists(self, reported) -> None: + if reported is None: + self.set_feature( + self.EXISTS, + {"support": "unknown", "behaviour": "the test calendar's component set could not be read"}, + ) + else: + self.set_feature(self.EXISTS, bool(reported)) + + ## honoured + + def _test_calendar_refuses(self, reported): + """The types PrepareCalendar found the test calendar refusing, where it + reports no property - and so has to take every type.""" + if reported is None or reported: + return [] + refused = [] + for component in ("todo", "journal"): + feature = f"save-load.{component}.mixed-calendar" + if self.checker.features_checked.is_supported(feature, str) in self._REFUSED_BY_TEST_CALENDAR: + refused.append(component.upper()) + return refused + + def _set_honoured(self, probed, refused_by_test_calendar) -> None: + """Set ``honoured`` from the probe calendar's verdict, or - where there + was none (``None``) - from the test calendar's, inferred.""" + if probed is None and refused_by_test_calendar: + probed = { + "support": "broken", + "behaviour": ( + "inferred from save-load.*.mixed-calendar: the test calendar reports no component set, " + f"which means every type is accepted, yet it refuses {', '.join(refused_by_test_calendar)}" + ), + } + if probed is None: + probed = { + "support": "unknown", + "behaviour": "cannot test, no calendar with a component set could be created", + } + self.set_feature(self.HONOURED, probed) + + def _grade_honoured(self, reported, stored): + """Does the probe calendar take exactly what it lists? + + ``stored`` maps a component type to what saving one did: ``"stored"``, + ``"refused"``, a 5xx status the server broke on, or ``None``. + """ + if reported is None: + return { + "support": "unknown", + "behaviour": "cannot test, the probe calendar's component set could not be read", + } + listed = set(reported) if reported else None + mismatches = [] + for component, outcome in stored.items(): + if outcome not in ("stored", "refused"): + continue + takes = listed is None or component in listed + if (outcome == "stored") == takes: + continue + if listed is None: + mismatches.append( + f"the calendar lists no component set, which means every type is accepted, " + f"yet a {component} was refused" + ) + elif outcome == "stored": + mismatches.append( + f"a {'-'.join(self.RESTRICTION)}-only calendar reports {sorted(listed)}, " + f"and a {component} can be saved to it" + ) + else: + mismatches.append(f"a {component} the calendar lists ({sorted(listed)}) was refused") + if mismatches: + return {"support": "broken", "behaviour": "; ".join(mismatches)} + failed = {c: o for c, o in stored.items() if isinstance(o, int) and not isinstance(o, bool)} + if failed: + what = ", ".join(f"{c} with {o}" for c, o in failed.items()) + return {"support": "ungraceful", "behaviour": f"saving fails rather than being refused: {what}"} + if any(outcome is None for outcome in stored.values()): + return {"support": "unknown", "behaviour": "saving failed without a refusal, so obedience was not tested"} + return True + + ## MKCALENDAR naming {VTODO}, and {VEVENT, VTODO} + + def _probe(self): + """The creation features are set here; returns the verdict on + ``honoured`` from the probe calendar, ``None`` where there was no + calendar to look at.""" + cal, _attempts, error = self.make_calendar_with_retries( + self.CAL_ID, supported_calendar_component_set=self.RESTRICTION + ) + if cal is None: + self.set_feature(self.FEATURE, self._grade_refused_creation(error)) + return None + + ## Some servers process MKCALENDAR asynchronously; poll on the calendar + ## object the library handed back rather than on the cal_id, which is + ## not the collection's address on a server that relocates it (see + ## create-calendar.stable-url). + cal, _waited = self._poll_calendar(cal=cal, timeout=self.checker.delay_probe_timeout) + if cal is None: + self.set_feature( + self.FEATURE, + { + "support": "unknown", + "behaviour": "the restricted calendar was accepted but never became queryable", + }, + ) + return None + + reported = self._reported(cal) + ## VJOURNAL as well: it is the type the most servers refuse outright, so + ## where the calendar reports no set (every type must then be taken) it + ## is the one that shows the property is missing for a reason. + stored = {component: self._store(cal, component) for component in ("VEVENT", "VTODO", "VJOURNAL")} + self.set_feature(self.FEATURE, self._grade_creation(reported, stored[self.WRONG_TYPE])) + ## Out of the way before the second: a provider with a calendar quota + ## would otherwise refuse that one for a reason of its own. + self._delete_probe_calendars() + self._probe_several() + return self._grade_honoured(reported, stored) + + def _grade_refused_creation(self, error): + """The MKCALENDAR carrying the component set was refused - by this + server, or only because of the component set? + + The control is the same cal_id without the restriction: a server that + creates it then is refusing the component set specifically, which is + what "ungraceful" describes. One that refuses either way is refusing + to create calendars for a reason of its own, and create-calendar is the + feature that gets to say so - nothing is claimed here. + """ + control, _attempts, control_error = self.make_calendar_with_retries(self.CAL_ID) + if control is None: + return { + "support": "unknown", + "behaviour": f"cannot test, creating the probe calendar failed with and without the component set ({control_error})", + } + return { + "support": "ungraceful", + "behaviour": f"MKCALENDAR is refused when it carries a component set ({error})", + } + + def _grade_creation(self, reported, wrong_type_outcome): + """Did MKCALENDAR naming {VTODO} give a VTODO-only calendar? + + What the calendar reports decides. Where it reports nothing (or could + not be read), whether the wrong type is refused does: the restriction + asked for is then in force, which is what a client needs to know, even + though an absent property is not what RFC 4791 section 5.2.3 has a + restricted calendar look like - that is ``honoured``'s business. + """ + asked = f"asked for {self.RESTRICTION}" + if reported: + ## VFREEBUSY is not a type anything is stored as - Zimbra lists it + ## next to VTODO - so it says nothing about the restriction. + if set(reported) - set(self.RESTRICTION) - {"VFREEBUSY"} or not set(self.RESTRICTION) <= set(reported): + return { + "support": "unsupported", + "behaviour": f"the component set is not honoured at creation: {asked}, the calendar reports {sorted(reported)}", + } + return True + advertises = "its component set cannot be read" if reported is None else "it reports no component set" + if wrong_type_outcome == "refused": + return { + "support": "full", + "behaviour": f"the restriction is in force but not reported ({advertises})", + } + if wrong_type_outcome == "stored": + return { + "support": "unsupported", + "behaviour": f"the restriction is ignored: {asked}, {advertises}, and a {self.WRONG_TYPE} can be saved to the calendar", + } + if wrong_type_outcome is None: + return { + "support": "unknown", + "behaviour": f"cannot test, {advertises} and saving a {self.WRONG_TYPE} failed without a refusal", + } + return { + "support": "ungraceful", + "behaviour": f"saving a {self.WRONG_TYPE} fails with {wrong_type_outcome} rather than a refusal; {advertises}", + } + + def _probe_several(self) -> None: + cal, _attempts, error = self.make_calendar_with_retries( + self.CAL_ID_MULTIPLE, supported_calendar_component_set=self.SEVERAL + ) + if cal is None: + self.set_feature(self.MULTIPLE, self._grade_refused_several(error)) + return + cal, _waited = self._poll_calendar(cal=cal, timeout=self.checker.delay_probe_timeout) + if cal is None: + self.set_feature( + self.MULTIPLE, + {"support": "unknown", "behaviour": "the calendar was accepted but never became queryable"}, + ) + return + reported = self._reported(cal) + stored = {component: self._store(cal, component) for component in self.SEVERAL} + self.set_feature(self.MULTIPLE, self._grade_several(reported, stored)) + + def _grade_refused_several(self, error): + """A MKCALENDAR naming several types was refused. 207 with 403/409 on + the property, nothing created, is what RFC 4791 section 5.3.1.1 has a + server do that cannot set it: conformant, but the feature is not + there. Anything else is a server failing on the request.""" + status = dav_error_status(error) or getattr(error, "status", None) + if status == 207: + refusal = self._component_set_refusal(getattr(error, "body", None)) + if refusal: + return { + "support": "unsupported", + "behaviour": ( + f"MKCALENDAR naming {self.SEVERAL} is refused as RFC 4791 section 5.3.1.1 has it: " + f"207, {refusal} on the property, no calendar created" + ), + } + return graded_refusal(error, f"MKCALENDAR naming {self.SEVERAL} fails with {{answer}}") + + @staticmethod + def _component_set_refusal(body): + """The 403 or 409 a 207 reply gives the supported-calendar-component-set + property, or ``None`` when the reply is unreadable or refuses something + else.""" + if not body: + return None + try: + root = ElementTree.fromstring(body) + except ElementTree.ParseError: + return None + for propstat in root.iter("{DAV:}propstat"): + props = [el.tag for el in propstat.iter() if el.tag.endswith("supported-calendar-component-set")] + status = propstat.findtext("{DAV:}status") or "" + refused = re.search(r"\s(403|409)\b", status) + if props and refused: + return refused.group(1) + return None + + def _grade_several(self, reported, stored): + if reported: + missing = [c for c in self.SEVERAL if c not in reported] + if missing: + return { + "support": "broken", + "behaviour": f"asked for {self.SEVERAL}, the calendar reports {sorted(reported)}", + } + refused = [c for c, o in stored.items() if o == "refused"] + if refused: + return { + "support": "broken", + "behaviour": f"the calendar was created, but a {' and a '.join(refused)} named in its set was refused", + } + failed = {c: o for c, o in stored.items() if isinstance(o, int) and not isinstance(o, bool)} + if failed: + what = ", ".join(f"{c} with {o}" for c, o in failed.items()) + return {"support": "ungraceful", "behaviour": f"saving a type named in the set fails: {what}"} + if any(outcome is None for outcome in stored.values()): + return {"support": "unknown", "behaviour": "saving failed without a refusal, so the set was not verified"} + return True + + ## Saving + + def _store(self, cal, component): + """What saving a ``component`` into ``cal`` did. + + ``"stored"`` (and the object is taken out again: the calendar is deleted + at the end of the check, but a server that cannot delete a calendar + would keep it), ``"refused"`` for an authorisation error or a 4xx, + the status for a 5xx the server software is to blame for, and ``None`` + where it failed without saying anything - a gateway, an error carrying + no status - since that says nothing about the restriction either way. + """ + uid = f"csc_compset_{uuid.uuid4()}" + ## _base_year() rather than checker.fixture_base_year: that attribute is + ## set by PrepareCalendar, but only for the checks that depend on it. + year = _base_year() + try: + if component == "VEVENT": + obj = cal.save_event( + dtstart=datetime(year, 1, 8, 10, 0, tzinfo=utc), + dtend=datetime(year, 1, 8, 11, 0, tzinfo=utc), + summary="component set probe", + uid=uid, + ) + elif component == "VTODO": + obj = cal.save_todo(summary="component set probe", uid=uid) + else: + obj = cal.save_journal(summary="component set probe", uid=uid) + except AuthorizationError: + return "refused" + except DAVError as exc: + status = dav_error_status(exc) + if status is not None and 400 <= status < 500: + return "refused" + return status if is_server_failure(status) else None + except Exception as exc: + logging.warning("The component-set probe could not save a %s (uid %s): %s", component, uid, exc) + return None + try: + obj.delete() + except (DAVError, AuthorizationError): + logging.warning("Could not delete the %s the component-set probe saved (uid %s)", component, uid) + return "stored" + + def _delete_probe_calendars(self) -> None: + for cal_id in (self.CAL_ID, self.CAL_ID_MULTIPLE): + try: + DAVObject.delete(self.checker.principal.calendar(cal_id=cal_id)) + except Exception: + pass + + class CheckTodoNoDtstartSearch(Check): """ Checks whether a VTODO without DTSTART (but with DUE) is returned by a @@ -4226,6 +4431,7 @@ class _StatusOnly(NamedTuple): status: int preconditions: tuple = () + body: bytes | None = None class _Unauthorized(int): @@ -4248,35 +4454,64 @@ class _RawPut: ## Names the probe in the objects it stores, should one be left behind LABEL = "put-etag" + ## The checker attribute holding the calendar the probe stores in + CALENDAR = "calendar" def _url(self, name): - return str(self.checker.calendar.url.join(f"{name}.ics")) + return str(getattr(self.checker, self.CALENDAR).url.join(f"{name}.ics")) - def _ical(self, uid): + def _ical(self, uid, summary=None, floating=False, extra="", vtimezone=None, todo=False): + """An event at the fixture year's 20 January 12:00 UTC, or at 12:00 + wherever the user is with ``floating``, or in the time zone of the + ``vtimezone`` text, which goes along; ``extra`` is more lines. With + ``todo``, a task starting then and due an hour later.""" year = self.checker.fixture_base_year + utc = "" if floating or vtimezone else "Z" + tzid = f";TZID={re.search(r'^TZID:(.*)$', vtimezone, re.MULTILINE)[1].strip()}" if vtimezone else "" + component = "VTODO" if todo else "VEVENT" return ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" f"PRODID:-//caldav-server-tester//{self.LABEL} probe//EN\r\n" - "BEGIN:VEVENT\r\n" + f"{vtimezone or ''}" + f"BEGIN:{component}\r\n" f"UID:{uid}\r\n" f"DTSTAMP:{year}0120T120000Z\r\n" - f"DTSTART:{year}0120T120000Z\r\n" - f"DTEND:{year}0120T130000Z\r\n" - f"SUMMARY:{self.LABEL} probe\r\n" - "END:VEVENT\r\n" + f"DTSTART{tzid}:{year}0120T120000{utc}\r\n" + f"{'DUE' if todo else 'DTEND'}{tzid}:{year}0120T130000{utc}\r\n" + f"SUMMARY:{summary or self.LABEL + ' probe'}\r\n" + f"{extra}" + f"END:{component}\r\n" "END:VCALENDAR\r\n" ) - def _get_etag(self, url): - """The ETag header a GET answers with, or None.""" + def _get(self, url): + """The answer to a GET, or None where it failed.""" try: response = self.client.request(url) except (DAVError, OSError): return None - if not self._ok(response): + return response if self._ok(response) else None + + def _get_etag(self, url): + """The ETag header a GET answers with, or None.""" + response = self._get(url) + return response.headers.get("Etag") if response is not None else None + + @staticmethod + def _answer(request, *args): + try: + return request(*args) + except AuthorizationError as exc: + ## 401/403 are raised before a response exists, but they are answers. + return _StatusOnly( + dav_error_status(exc) or _Unauthorized(), + tuple(getattr(exc, "preconditions", ())), + getattr(exc, "body", None), + ) + except (DAVError, OSError): + ## Rate limited for longer than the client waits, or no answer at all. return None - return response.headers.get("Etag") def _put(self, url, ical, if_match=None, if_none_match=None): headers = {"Content-Type": "text/calendar; charset=utf-8"} @@ -4284,14 +4519,10 @@ def _put(self, url, ical, if_match=None, if_none_match=None): headers["If-Match"] = if_match if if_none_match is not None: headers["If-None-Match"] = if_none_match - try: - return self.client.put(url, ical, headers) - except AuthorizationError as exc: - ## 401/403 are raised before a response exists, but they are answers. - return _StatusOnly(dav_error_status(exc) or _Unauthorized(), tuple(getattr(exc, "preconditions", ()))) - except (DAVError, OSError): - ## Rate limited for longer than the client waits, or no answer at all. - return None + return self._answer(self.client.put, url, ical, headers) + + def _delete_if_match(self, url, etag): + return self._answer(self.client.delete, url, {"If-Match": etag}) def _delete(self, url): try: @@ -4347,7 +4578,8 @@ class CheckPutEtag(_RawPut, Check): depends_on = {PrepareCalendar} WILDCARD = "save-load.mutable.if-match-wildcard" IF_NONE_MATCH = "save.if-none-match" - features_to_be_checked = {"save.etag", WILDCARD, IF_NONE_MATCH} + STORED = "save.etag.matches-stored" + features_to_be_checked = {"save.etag", STORED, WILDCARD, IF_NONE_MATCH} ## One missing object per condition: an If-Match: * that is ignored or ## backwards creates its object, which would then exist for the other. MISSING = ("csc_put_etag_missing_1", "csc_put_etag_missing_2") @@ -4361,7 +4593,14 @@ def _run_check(self) -> None: self.set_feature(feature, None) return try: + ## Read back before _grade_etag PUTs again: what an update adds + ## (LAST-MODIFIED, SEQUENCE) says nothing about the create's ETag + stored = self._get(url) self._grade_etag(url, ical, created.headers.get("Etag")) + ## Whether an unusable ETag matches the stored object is moot: + ## left to derive from save.etag + if self.checker._features_checked.is_supported("save.etag", str) != "broken": + self.set_feature(self.STORED, self._grade_stored(stored, ical, created.headers.get("Etag"))) ## A run that died half-way may have left a "missing" object behind. for name in self.MISSING: self._delete(self._url(name)) @@ -4371,6 +4610,53 @@ def _run_check(self) -> None: for probe_url in (url, *map(self._url, self.MISSING)): self._delete(probe_url) + def _grade_stored(self, response, ical, etag): + """RFC 4791 section 5.3.4 lets a server store an object otherwise than + sent, but then it MUST NOT answer the PUT with a strong ETag.""" + if response is None: + return self._graded("unknown", "cannot test, the object could not be read back") + ## The library turns CRLF into LF in .raw, and keeps the bytes as + ## received beside it; without them, line endings can't be compared + body = getattr(response, "_raw_body", None) + if isinstance(body, bytes) and body: + stored = body.decode("utf-8", "replace") + else: + stored = response.raw if isinstance(response.raw, str) else response.raw.decode("utf-8", "replace") + ical = ical.replace("\r\n", "\n") + stored = stored.replace("\r\n", "\n") + if stored == ical: + return "full" + what = self._difference(ical, stored) + if etag and not etag.startswith("W/"): + return self._graded("broken", f"stored with {what}, yet the PUT answered with a strong ETag") + return self._graded("full", f"stored with {what}, and the PUT answered without a strong ETag") + + @staticmethod + def _difference(sent, stored): + """What the server changed, in a few words.""" + + def names(lines): + return [re.split(r"[;:]", line, maxsplit=1)[0].upper() for line in lines] + + if sent.replace("\r\n", "\n") == stored.replace("\r\n", "\n"): + return "other line endings" + notes = ["other line endings"] if ("\r\n" in sent) != ("\r\n" in stored) else [] + sent_lines, stored_lines = unfolded_lines(sent), unfolded_lines(stored) + if sent_lines == stored_lines: + return ", ".join([*notes, "other folding"]) + added = [n for n in dict.fromkeys(names(stored_lines)) if n not in names(sent_lines)] + removed = [n for n in dict.fromkeys(names(sent_lines)) if n not in names(stored_lines)] + changed = [n for line in sent_lines if line not in stored_lines for n in names([line]) if n not in removed] + if added: + notes.append(f"{' and '.join(added)} added") + if removed: + notes.append(f"{' and '.join(removed)} removed") + if changed: + notes.append(f"{' and '.join(dict.fromkeys(changed))} changed") + if not (added or removed or changed): + notes.append("its properties reordered") + return ", ".join(notes) + def _grade_wildcard(self, url, ical): """``If-Match: *`` on the existing object grades; the missing object tells "never holds" from "holds backwards".""" @@ -4501,68 +4787,619 @@ def _grade_etag(self, url, ical, etag) -> None: self.set_feature("save.etag", {"support": "broken", "behaviour": f"malformed etag {etag!r}"}) -## RFC 4791 section 5.3.2.1 +## RFC 4791 section 5.3.2.1 + + +class CheckNoUidConflict(_RawPut, Check): + """ + Checks whether a PUT of an object whose UID another object resource in + the calendar already holds is refused. + + RFC 4791 section 5.3.2.1 requires it, with the CALDAV:no-uid-conflict + precondition (Cyrus names RFC 6638's CALDAV:unique-scheduling-object-resource + instead); 403 or 409 with it, the statuses RFC 4918 section 16 gives a + failed precondition, is 'full'. Xandikos and Stalwart answer 412 + with it: a 'quirk', since the library goes by the body. + + A refusal without the precondition is a 'quirk' only where it is + confirmed to be about the UID: nothing was stored at the URL, and the + same PUT with another UID goes through. The library does the first of + those checks itself before it raises UidConflictError. A bare 403 is + 'ungraceful' all the same - a read-only calendar answers it too - as is + any other bare status outside 4xx, and any refusal failing either check + (one of them getting no answer leaves it 'unknown'). The extra keys 'status' + and 'precondition' say what a 'quirk' is. + + The parent is graded by a plain PUT, save.no-uid-conflict.if-none-match + by one carrying If-None-Match: *, as save(no_overwrite=True) sends it + where save.if-none-match holds (Bedework answers that with a bare 412, + a plain PUT with 403 and the precondition). + save.no-uid-conflict.href-given says whether the precondition names the + object holding the UID. + """ + + depends_on = {PrepareCalendar, CheckPutEtag} + LABEL = "no-uid-conflict" + FEATURE = "save.no-uid-conflict" + IF_NONE_MATCH = "save.no-uid-conflict.if-none-match" + HREF = "save.no-uid-conflict.href-given" + features_to_be_checked = {FEATURE, IF_NONE_MATCH, HREF} + UID = "csc_no_uid_conflict" + NAMES = ("csc_no_uid_conflict_1", "csc_no_uid_conflict_2") + ## The statuses RFC 4918 section 16 gives a failed precondition + RFC_REFUSALS = frozenset({403, 409}) + + def _run_check(self) -> None: + first, second = map(self._url, self.NAMES) + ## A run that died half-way may have left the second object behind. + self._delete(second) + try: + if not self._ok(self._put(first, self._ical(self.UID))): + for feature in (self.FEATURE, self.IF_NONE_MATCH, self.HREF): + self.set_feature(feature, None) + return + hrefs = [] + self.set_feature(self.FEATURE, self._probe(second, None, hrefs)) + ## Only where the server handles If-None-Match: *, or a 412 may be about the + ## header. Elsewhere the library never sends it, and the child is left to + ## say what the parent says. + if self.checker._features_checked.is_supported("save.if-none-match", str) in ("full", "quirk"): + self.set_feature(self.IF_NONE_MATCH, self._probe(second, "*", hrefs)) + ## Explicitly unknown without a precondition: the parent's grade says nothing about it + self.set_feature(self.HREF, ("full" if all(hrefs) else "unsupported") if hrefs else None) + finally: + for url in (first, second): + self._delete(url) + + def _probe(self, url, if_none_match, hrefs): + """The grade of a PUT of a second object with the UID to ``url``. + Whether a refusal with the precondition names the holder is + appended to ``hrefs``.""" + try: + response = self._put(url, self._ical(self.UID), if_none_match=if_none_match) + return self._grade(response, url, if_none_match, hrefs) + finally: + self._delete(url) + + def _grade(self, response, url, if_none_match, hrefs): + status = self._status(response) + if status is None: + return self._graded("unknown", "cannot test, a second object with the same UID got no answer") + if 200 <= status < 300: + return self._graded("unsupported", f"a second object with the same UID was stored ({status})") + if UID_CONFLICT_PRECONDITIONS.intersection(self._preconditions(response)): + hrefs.append(UidConflictError(body=self._body(response)).href is not None) + if status in self.RFC_REFUSALS: + return "full" + return self._quirk(status, True, f"refused with {status} and the precondition") + if status == 403: + return self._graded("ungraceful", f"refused with {status}, without the precondition: may be anything") + if not 400 <= status < 500: + return self._graded("ungraceful", f"a second object with the same UID was answered with {status}") + stored = self._get_status(url) + if stored is None: + return self._graded("unknown", f"refused with a bare {status}, and a GET of the URL got no answer") + if stored != 404: + return self._graded("ungraceful", f"refused with a bare {status}, yet a GET of the URL answered {stored}") + other = self._put(url, self._ical(self.UID + "-other"), if_none_match=if_none_match) + if self._status(other) is None: + return self._graded( + "unknown", f"refused with a bare {status}, and the same PUT with another UID got no answer" + ) + if not self._ok(other): + return self._graded( + "ungraceful", + f"refused with a bare {status}, and so was the same PUT with another UID ({self._status(other)})", + ) + return self._quirk(status, False, f"refused with a bare {status}") + + @staticmethod + def _quirk(status, precondition, behaviour): + return {"support": "quirk", "status": int(status), "precondition": precondition, "behaviour": behaviour} + + def _get_status(self, url): + """The status a GET of ``url`` answers with, None for no answer.""" + try: + return self._status(self.client.request(url)) + except (DAVError, OSError): + return None + + @staticmethod + def _body(response): + return response.body if isinstance(response, _StatusOnly) else getattr(response, "raw", None) + + +class CheckDeleteIfMatch(_RawPut, Check): + """ + Checks whether a DELETE carrying an If-Match etag the object no longer + has is refused, and one carrying the current etag goes through. + + RFC 9110 section 13.1.1 has the server refuse it with 412. SOGo refuses + a stale If-Match on PUT, but on DELETE it deletes the object anyway. + The etags are read with GET, as delete(if_match=True) sends the one the + object was loaded with. + + On an object that does not exist, a DELETE answers 404, and RFC 9110 + section 13.2.1 has the server ignore an If-Match there, since the DELETE + fails anyway: 404, not 412. + Baikal and Robur answer 412, so delete(if_match=True) on an object + someone else deleted reads as an etag mismatch. + """ + + depends_on = {PrepareCalendar} + LABEL = "delete-if-match" + FEATURE = "delete.if-match" + PLAIN = "delete.missing" + MISSING = "delete.missing.if-match" + features_to_be_checked = {FEATURE, PLAIN, MISSING} + + def _run_check(self) -> None: + self._grade_missing() + url = self._url("csc_delete_if_match") + ical = self._ical("csc_delete_if_match") + try: + if not self._ok(self._put(url, ical)): + self.set_feature(self.FEATURE, None) + return + stale = self._get_etag(url) + changed = self._put(url, self._ical("csc_delete_if_match", summary=f"{self.LABEL} probe changed")) + current = self._get_etag(url) if self._ok(changed) else None + self.set_feature(self.FEATURE, self._grade(url, stale, current)) + finally: + self._delete(url) + + def _grade(self, url, stale, current): + for etag in (stale, current): + if not etag or not ENTITY_TAG.fullmatch(etag) or etag.startswith("W/"): + return self._graded("unknown", "cannot test, no strong etag for the object") + if stale == current: + return self._graded("unknown", "cannot test, the etag did not change when the object did") + status = self._status(self._delete_if_match(url, stale)) + if status is None: + return self._graded("unknown", "cannot test, a DELETE with a stale If-Match got no answer") + if 200 <= status < 300: + if self._get(url) is None: + return self._graded("broken", f"a DELETE with a stale If-Match deleted the object ({status})") + return self._graded("ungraceful", f"a DELETE with a stale If-Match answered {status}, but kept the object") + if status != 412: + return self._graded("ungraceful", f"a DELETE with a stale If-Match answered {status} rather than 412") + status = self._status(self._delete_if_match(url, current)) + if status is None: + return self._graded("unknown", "cannot test, a DELETE with the current If-Match got no answer") + if status == 412: + return self._graded("unsupported", "a DELETE with the current If-Match is refused with 412 too") + if not 200 <= status < 300: + return self._graded("ungraceful", f"a DELETE with the current If-Match answered {status}") + return "full" + + def _grade_missing(self) -> None: + """A DELETE of a missing object, plain and with If-Match.""" + url = self._url("csc_delete_if_match_missing") + status = self._status(self._answer(self.client.delete, url)) + if status is None: + self.set_feature( + self.PLAIN, self._graded("unknown", "cannot test, a DELETE of a missing object got no answer") + ) + elif status == 404: + self.set_feature(self.PLAIN) + elif 200 <= status < 300: + self.set_feature(self.PLAIN, self._graded("unsupported", f"a DELETE of a missing object answered {status}")) + else: + self.set_feature(self.PLAIN, self._graded("ungraceful", f"a DELETE of a missing object answered {status}")) + ## RFC 9110 section 13.2.1 has the condition ignored where the request + ## would fail without it, so the plain DELETE's failure is the answer + plain, status = status, self._status(self._delete_if_match(url, '"csc-never-issued"')) + if status is None: + verdict = self._graded("unknown", "cannot test, a DELETE with If-Match on a missing object got no answer") + elif plain is not None and 200 <= plain < 300: + verdict = self._graded("unknown", f"cannot test, a plain DELETE of a missing object answered {plain}") + elif status == 404: + verdict = "full" + elif status == plain: + verdict = self._graded("full", f"answered {status}, as a plain DELETE of a missing object is") + elif status == 412: + verdict = self._graded("unsupported", "a DELETE with If-Match on a missing object answered 412") + else: + verdict = self._graded("ungraceful", f"a DELETE with If-Match on a missing object answered {status}") + self.set_feature(self.MISSING, verdict) + + +def unfolded_lines(ical): + """The content lines of an iCalendar text, unfolded (RFC 5545 section 3.1).""" + return re.sub(r"\r?\n[ \t]", "", ical).splitlines() + + +def component_lines(ical, name): + """The content lines of the first ``name`` component, BEGIN and END left out.""" + lines = unfolded_lines(ical) + try: + start = lines.index(f"BEGIN:{name}") + return lines[start + 1 : lines.index(f"END:{name}", start)] + except ValueError: + return [] + + +class _RoundTrip(_RawPut): + """Stores the object ``_body()`` gives, reads it back, and has ``_grade()`` + judge the server's own text. Refused is "ungraceful". + + One object for each of ``COMPONENTS``, the first verdict that is not + "full" being the feature's. A task goes to the task calendar, which is + the event calendar where the two mix, and is not probed where tasks cannot + be stored: a save-load.todo feature probing tasks only is then left unset, + so save-load.todo's status propagates to it, and any other is "unknown", + as is every one where there is no task calendar at all.""" + + ## The probe object's UID and name, and what it is, for the behaviour notes + UID = "csc_round_trip" + WHAT = "the event" + ## More lines for the VEVENT or VTODO + EXTRA = "" + COMPONENTS = ("VEVENT",) + ## The one being probed + component = "VEVENT" + CANNOT_STORE = "tasks cannot be stored" + + @property + def uid(self): + """The UID and name of the object probed now: one per component, so a + task never lands where the event was just deleted""" + return self.UID if len(self.COMPONENTS) == 1 else f"{self.UID}_{self.component.lower()}" + + def _body(self): + return self._ical(self.uid, extra=self.EXTRA, todo=self.component == "VTODO") + + def _run_check(self) -> None: + verdict, skipped = None, None + for component in self.COMPONENTS: + if component == "VTODO": + skipped = self._tasks_unstorable() + if skipped: + continue + self.component = component + verdict = self._round_trip() + if verdict != "full": + if len(self.COMPONENTS) > 1 and isinstance(verdict, dict): + kind = "a task" if component == "VTODO" else "an event" + verdict = {**verdict, "behaviour": f"{verdict['behaviour']}, in {kind}"} + break + if verdict == "full" and skipped: + verdict = self._graded("full", f"probed in an event only, {skipped}") + elif verdict is None and (skipped != self.CANNOT_STORE or not self.FEATURE.startswith("save-load.todo.")): + verdict = self._graded("unknown", f"cannot test, {skipped}") + if verdict is not None: + self.set_feature(self.FEATURE, verdict) + + def _tasks_unstorable(self): + """Why no task can be probed, or None where one can""" + if getattr(self.checker, "tasklist", None) is None: + return "there is no task calendar" + if not self.checker.features_checked.is_supported("save-load.todo"): + return self.CANNOT_STORE + return None + + def _round_trip(self): + url = self._url(self.uid) + try: + status = self._status(self._put(url, self._body())) + if status is None: + return self._graded("unknown", f"cannot test, {self.WHAT} got no answer") + if not 200 <= status < 300: + return self._graded("ungraceful", f"{self.WHAT} is refused ({status})") + response = self._answer(self.client.request, url) + if self._ok(response): + return self._grade(response.raw) + return self._unreadable(self._status(response)) + finally: + self._delete(url) + + @property + def CALENDAR(self): + return "tasklist" if self.component == "VTODO" else "calendar" + + def _unreadable(self, status): + """The verdict where the stored object cannot be read back""" + answer = f" ({status})" if status else "" + return self._graded("unknown", f"cannot test, {self.WHAT} could not be read back{answer}") + + def _kept(self, raw, name): + """``_grade()`` for a probe asking whether property ``name`` is kept""" + if any(re.match(rf"{name}[;:]", line, re.IGNORECASE) for line in component_lines(raw, self.component)): + return "full" + return self._graded("unsupported", f"{name} is dropped") + + +class CheckFloatingEvent(_RoundTrip, Check): + """ + Checks whether an event with a floating DTSTART and DTEND (RFC 5545 + section 3.3.5: neither TZID nor 'Z') is stored floating. + + SOGo adds a VTIMEZONE for Etc/UTC and gives the times TZID=Etc/UTC. The + server's own text is read rather than a parsed object: the icalendar + library writes a TZID=Etc/UTC time back with a 'Z', which the section + forbids, so a re-serialised object would look broken where SOGo's is not. + """ + + depends_on = {PrepareCalendar} + LABEL = "floating" + FEATURE = "save-load.event.floating" + features_to_be_checked = {FEATURE} + UID = "csc_floating_event" + WHAT = "an event with a floating time" + PROPERTY = re.compile(r"(DTSTART|DTEND)((?:;[^:]*)?):(.*)") + + def _body(self): + return self._ical(self.uid, floating=True) + + def _grade(self, raw): + times = [match.groups() for match in map(self.PROPERTY.fullmatch, component_lines(raw, "VEVENT")) if match] + if not times: + return self._graded("unknown", "cannot test, the event came back without DTSTART") + for _, params, value in times: + tzid = re.search(r";TZID=([^;]*)", params, re.IGNORECASE) + if tzid and value.endswith("Z"): + return self._graded( + "broken", + f"a floating time is stored with TZID={tzid[1]} and a 'Z' both, which RFC 5545 section 3.3.5 forbids", + ) + if tzid: + return self._graded("unsupported", f"a floating time is stored with TZID={tzid[1]}") + if value.endswith("Z"): + return self._graded("unsupported", "a floating time is stored as UTC") + return "full" + + +class CheckAttendeeParams(_RoundTrip, Check): + """ + Checks whether the parameters of an ATTENDEE are stored as sent. + + A parameter added with RFC 5545's default value changes nothing, nor does + SCHEDULE-STATUS, which RFC 6638 has the server set. SOGo adds RSVP=TRUE, + where the default is FALSE. The event has no ORGANIZER, so it is not a + scheduling object resource and the server has no iTIP reason to touch it. + """ + + depends_on = {PrepareCalendar} + LABEL = "attendee-params" + FEATURE = "save-load.icalendar.attendee-params" + features_to_be_checked = {FEATURE} + UID = "csc_attendee_params" + WHAT = "an event with attendees" + ## RFC 5545 section 3.2 + DEFAULTS = {"PARTSTAT": "NEEDS-ACTION", "ROLE": "REQ-PARTICIPANT", "CUTYPE": "INDIVIDUAL", "RSVP": "FALSE"} + ## RFC 6638 section 7.3 + SERVER_SET = {"SCHEDULE-STATUS"} + ATTENDEES = { + "mailto:csc-bob@attendee.example.com": {"CN": "Bob"}, + "mailto:csc-ase@attendee.example.com": {"CN": "Åse", "PARTSTAT": "ACCEPTED"}, + } + + EXTRA = "".join( + "ATTENDEE" + "".join(f";{name}={value}" for name, value in params.items()) + f":{address}\r\n" + for address, params in ATTENDEES.items() + ) + + @staticmethod + def _parse(line): + """The address and parameters of an ATTENDEE line.""" + match = re.fullmatch(r"ATTENDEE((?:;(?:[^:;\"]|\"[^\"]*\")*)*):(.*)", line, re.IGNORECASE) + if not match: + return None, {} + params = {} + for param in re.findall(r';((?:[^;"]|"[^"]*")*)', match[1]): + name, _, value = param.partition("=") + params[name.upper()] = value.strip('"') + return match[2].lower(), params + + def _grade(self, raw): + stored = dict( + self._parse(line) for line in component_lines(raw, "VEVENT") if line.upper().startswith("ATTENDEE") + ) + changes, defaults = [], [] + for address, sent in self.ATTENDEES.items(): + if address not in stored: + changes.append(f"ATTENDEE {address} is dropped") + continue + got = stored[address] + for name, value in sent.items(): + if name not in got: + changes.append(f"{name}={value} is dropped") + elif got[name] != value and not (name != "CN" and got[name].upper() == value.upper()): + changes.append(f"{name}={value} is changed to {got[name]}") + for name, value in got.items(): + if name in sent: + continue + note = f"{name}={value} is added" + if name in self.SERVER_SET or self.DEFAULTS.get(name) == value.upper(): + defaults.append(f"{name}={value}" if name in self.SERVER_SET else note) + else: + changes.append(note) + ## Each attendee may get the same addition; say it once + changes, defaults = list(dict.fromkeys(changes)), list(dict.fromkeys(defaults)) + if changes: + return self._graded("unsupported", *changes, *defaults) + return self._graded("full", *defaults) + + +class CheckParticipant(_RoundTrip, Check): + """ + Checks whether a PARTICIPANT component (RFC 9073 section 7.1) inside a + VEVENT is stored. CCS and Bedework 3 refuse the event with 403, robur + with 400, GMX and Ox with 500 (and Zimbra, reportedly). + """ + + depends_on = {PrepareCalendar} + LABEL = "participant" + FEATURE = "save-load.icalendar.participant" + features_to_be_checked = {FEATURE} + UID = "csc_participant_event" + WHAT = "an event with a PARTICIPANT" + ## UID and PARTICIPANT-TYPE are the properties section 7.1 requires + EXTRA = ( + "BEGIN:PARTICIPANT\r\n" + "UID:csc_participant_1\r\n" + "PARTICIPANT-TYPE:ACTIVE\r\n" + "CALENDAR-ADDRESS:mailto:csc-participant@attendee.example.com\r\n" + "END:PARTICIPANT\r\n" + ) + def _grade(self, raw): + if "BEGIN:PARTICIPANT" in (line.upper() for line in component_lines(raw, "VEVENT")): + return "full" + return self._graded("unsupported", "the event is stored without its PARTICIPANT") -class CheckNoUidConflict(_RawPut, Check): + +class CheckVtimezone(_RoundTrip, Check): """ - Checks whether a PUT of an object whose UID another object resource in - the calendar already holds is refused. + Checks whether the VTIMEZONE sent with an event comes back with it. CCS + returns the event's TZID without the VTIMEZONE that defines it. - RFC 4791 section 5.3.2.1 requires it, with the CALDAV:no-uid-conflict - precondition. Radicale answers 409, Xandikos and Stalwart 412, Cyrus 403 - with RFC 6638's CALDAV:unique-scheduling-object-resource instead. Where - it holds, save(no_overwrite=True) leaves out the UID search before its - PUT: If-None-Match: * guards the URL, and this the UID under any other. - The second PUT carries If-None-Match: *, as the library's does where - save.if-none-match holds. A - refusal without a precondition the library knows (SOGo's bare 409, - Bedework's bare 412, Purelymail's no-uid-conflict in the CardDAV - namespace) keeps the duplicate out, but the library cannot tell it from - any other failed PUT, so it is ungraceful: there the search, and its - ConsistencyError, stays. + Europe/Berlin rather than a zone such as Europe/Oslo: Bedework refuses + TZIDs that are tzdata link names, which would hide what is asked here. """ - depends_on = {PrepareCalendar, CheckPutEtag} - LABEL = "no-uid-conflict" - FEATURE = "save.no-uid-conflict" + depends_on = {PrepareCalendar} + LABEL = "vtimezone" + FEATURE = "save-load.icalendar.vtimezone" features_to_be_checked = {FEATURE} - UID = "csc_no_uid_conflict" - NAMES = ("csc_no_uid_conflict_1", "csc_no_uid_conflict_2") - ## RFC 4791 section 5.3.2.1 names 403 and 409; Xandikos and Stalwart use 412. - REFUSALS = frozenset({403, 409, 412}) + UID = "csc_vtimezone_event" + WHAT = "an event with a VTIMEZONE" + VTIMEZONE = ( + "BEGIN:VTIMEZONE\r\n" + "TZID:Europe/Berlin\r\n" + "BEGIN:DAYLIGHT\r\n" + "TZOFFSETFROM:+0100\r\n" + "TZOFFSETTO:+0200\r\n" + "TZNAME:CEST\r\n" + "DTSTART:19700329T020000\r\n" + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\n" + "END:DAYLIGHT\r\n" + "BEGIN:STANDARD\r\n" + "TZOFFSETFROM:+0200\r\n" + "TZOFFSETTO:+0100\r\n" + "TZNAME:CET\r\n" + "DTSTART:19701025T030000\r\n" + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\n" + "END:STANDARD\r\n" + "END:VTIMEZONE\r\n" + ) - def _run_check(self) -> None: - first, second = map(self._url, self.NAMES) - ## A run that died half-way may have left the second object behind. - self._delete(second) + def _body(self): + return self._ical(self.uid, vtimezone=self.VTIMEZONE) + + def _grade(self, raw): + dtstart = next((line for line in component_lines(raw, "VEVENT") if line.upper().startswith("DTSTART")), "") + tzid = re.search(r";TZID=([^;:]*)", dtstart, re.IGNORECASE) + if not tzid: + return self._graded("unknown", "cannot test, the event came back without a TZID") + tzid = tzid[1].strip('"') + defined, inside = set(), False + for line in unfolded_lines(raw): + if line.upper() in ("BEGIN:VTIMEZONE", "END:VTIMEZONE"): + inside = line.upper() == "BEGIN:VTIMEZONE" + elif inside and line.upper().startswith("TZID:"): + defined.add(line[len("TZID:") :]) + if tzid in defined: + return "full" + return self._graded("unsupported", f"the event comes back with TZID={tzid} but no VTIMEZONE for it") + + +class CheckXProperty(_RoundTrip, Check): + """ + Checks whether an X- property (RFC 5545 section 3.8.8.2) is stored, in an + event and in a task. Ox keeps it in an event and drops it from a task. + """ + + depends_on = {PrepareCalendar} + LABEL = "x-property" + FEATURE = "save-load.icalendar.x-property" + features_to_be_checked = {FEATURE} + UID = "csc_x_property_event" + WHAT = "an object with an X- property" + EXTRA = "X-CALDAV-SERVER-TESTER:kept\r\n" + COMPONENTS = ("VEVENT", "VTODO") + + def _grade(self, raw): + return self._kept(raw, "X-CALDAV-SERVER-TESTER") + + +class CheckUnknownProperty(_RoundTrip, Check): + """ + Checks whether a property that is neither in RFC 5545 nor an X- one is + stored, using ESTIMATED-DURATION on a task. Ox drops it. + """ + + depends_on = {PrepareCalendar} + LABEL = "unknown-property" + FEATURE = "save-load.icalendar.unknown-property" + features_to_be_checked = {FEATURE} + UID = "csc_unknown_property_task" + WHAT = "a task with ESTIMATED-DURATION" + COMPONENTS = ("VTODO",) + EXTRA = "ESTIMATED-DURATION:PT2H\r\n" + + def _grade(self, raw): + return self._kept(raw, "ESTIMATED-DURATION") + + +class CheckTodoAttendee(_RoundTrip, Check): + """ + Checks whether a task with an ATTENDEE is stored with it. Ox answers the + PUT with 201 and the GET with 404: the task is gone, which is "broken", + since the server said it was stored. A 404 counts only where the same + task without the ATTENDEE reads back. There is no ORGANIZER, so the + server has no scheduling reason to touch it. + """ + + depends_on = {PrepareCalendar} + LABEL = "todo-attendee" + FEATURE = "save-load.todo.attendee" + features_to_be_checked = {FEATURE} + UID = "csc_attendee_task" + WHAT = "a task with an ATTENDEE" + COMPONENTS = ("VTODO",) + EXTRA = "ATTENDEE;CN=Bob:mailto:csc-bob@attendee.example.com\r\n" + + def _grade(self, raw): + return self._kept(raw, "ATTENDEE") + + def _unreadable(self, status): + if status == 404 and self._plain_task_kept(): + return self._graded("broken", "the task is accepted, then gone (404)") + return super()._unreadable(status) + + def _plain_task_kept(self): + """Whether the same task without the ATTENDEE can be read back""" + uid = f"{self.UID}_control" + url = self._url(uid) try: - if not self._ok(self._put(first, self._ical(self.UID))): - self.set_feature(self.FEATURE, None) - return - ## With If-None-Match: *, as save(no_overwrite=True) sends it - ## (Bedework then refuses with a bare 412) - but only where the - ## server handles it, or a 412 may be about the header. - inm = self.checker._features_checked.is_supported("save.if-none-match", str) in ("full", "quirk") - second_put = self._put(second, self._ical(self.UID), if_none_match="*" if inm else None) - self.set_feature(self.FEATURE, self._grade(second_put)) + if not self._ok(self._put(url, self._ical(uid, todo=True))): + return False + return self._ok(self._answer(self.client.request, url)) finally: - for url in (first, second): - self._delete(url) + self._delete(url) - def _grade(self, response): - status = self._status(response) - if status is None: - return self._graded("unknown", "cannot test, a second object with the same UID got no answer") - if 200 <= status < 300: - return self._graded("unsupported", f"a second object with the same UID was stored ({status})") - if status in self.REFUSALS: - if UID_CONFLICT_PRECONDITIONS.intersection(self._preconditions(response)): - return "full" - return self._graded( - "ungraceful", f"refused with {status}, but without the CALDAV:no-uid-conflict precondition" - ) - return self._graded("ungraceful", f"a second object with the same UID was answered with {status}") + +class CheckCompletedWithoutStatus(_RoundTrip, Check): + """ + Checks whether a task with COMPLETED but no STATUS keeps its COMPLETED. + Ox drops it. + """ + + depends_on = {PrepareCalendar} + LABEL = "completed-without-status" + FEATURE = "save-load.todo.completed-without-status" + features_to_be_checked = {FEATURE} + UID = "csc_completed_task" + WHAT = "a task with COMPLETED but no STATUS" + COMPONENTS = ("VTODO",) + + @property + def EXTRA(self): + return f"COMPLETED:{self.checker.fixture_base_year}0120T130000Z\r\n" + + def _grade(self, raw): + return self._kept(raw, "COMPLETED") class CheckAttendeePartstat(Check): @@ -4941,6 +5778,7 @@ class CheckSearch(Check): "search.time-range.event", "search.time-range.event.old-dates", "search.text.category", + "search.text.uid", "search.time-range.todo", "search.time-range.todo.old-dates", "search.comp-type", @@ -4951,6 +5789,33 @@ class CheckSearch(Check): "search.unlimited-time-range", } ## TODO: we can do so much better than this + def _probe_text_uid(self): + """search.text.uid: does a calendar-query with a UID filter answer + the one object with that UID? + + iCloud answers any UID filter with 412 (caldav issue 783); a server + ignoring the filter returns the whole calendar. Uses the fixture + event csc_event_with_categories, which no other UID contains. + """ + uid = "csc_event_with_categories" + try: + events = self.checker.calendar.search(uid=uid, event=True, post_filter=False) + except (ReportError, AuthorizationError, DAVError): + self.set_feature("search.text.uid", "ungraceful") + return + if len(events) == 1 and str(events[0].icalendar_component.get("UID")) == uid: + self.set_feature("search.text.uid", True) + elif len(events) > 1: + self.set_feature( + "search.text.uid", + {"support": "unsupported", "behaviour": "objects not matching the UID come back"}, + ) + else: + self.set_feature( + "search.text.uid", + {"support": "broken", "behaviour": "the object is not found by its UID"}, + ) + def _check_time_range_with_recent_data(self, cal, tasklist): """Test time-range searches using temporary near-future objects. @@ -5288,6 +6153,7 @@ def _found(objs, uid): self.set_feature("search.text.category", len(events) == 1) except (ReportError, AuthorizationError, DAVError): self.set_feature("search.text.category", "ungraceful") + self._probe_text_uid() ## search.combined - category "hands" AND a time-range. Uses the ## in-window "hands" fixture (csc_event_with_categories, -01-07), so ## it works regardless of old-date support; only category search is needed. @@ -6559,12 +7425,102 @@ def _run_check(self) -> None: self._check_invalid_token(cal) +class _RawSync: + """Raw sync-collection REPORTs on the probe calendar, for probes that + need what the library's parser leaves out: statuses, the 507 marker, + and a member listed twice. Set ``cal_url``, ``collection_path`` and + ``time_based`` before use.""" + + ## _entries' stand-in for the collection listed as one of its own members + COLLECTION = "collection" + + def _setup_sync(self) -> bool: + """False where sync-collection is not supported well enough to probe.""" + sync = self.checker.features_checked.is_supported("sync-token", return_type=dict) + if sync.get("support") not in ("full", "fragile"): + return False + self.time_based = sync.get("behaviour") == "time-based" + self.cal_url = self.checker.calendar.url.canonical() + self.collection_path = unquote(self.cal_url.path).rstrip("/") + return True + + def _tick(self) -> None: + """Time-based tokens tell apart only changes a second apart.""" + if self.time_based: + time.sleep(1) + + def _name(self, url): + return unquote(url.path).rstrip("/").rsplit("/", 1)[-1] + + def _is_collection(self, url): + ## The request-URI may come back without its trailing slash, and on + ## OX by another path; members of a calendar are never collections + path = unquote(url.path) + return path.endswith("/") or path.rstrip("/") == self.collection_path + + def _report(self, token, limit): + """One raw sync-collection REPORT on the calendar. + + Returns the names of the members it names (changed or removed, and + COLLECTION for the collection itself), whether it carries the section + 3.6 507 marker for the collection, and its token. + """ + (entries, truncated, new_token) = self._entries(token, limit) + return ({name for (name, _) in entries}, truncated, new_token) + + def _entries(self, token, limit=None): + """Like _report, but every entry in the order given, with its status: + the number of a response-level DAV:status (404 for a removed member), + or None for an entry with properties (a changed member).""" + body = ( + '' + '' + f"{xml_escape(token)}" + "1" + + (f"{limit}" if limit else "") + + "" + ) + response = self.client.report(str(self.cal_url), body, depth=0) + if limit and response.status == 507 and "number-of-matches-within-limits" in str(response.raw): + raise LimitRefused() + if response.status not in (200, 207) or response.tree is None: + raise ReportError(f"sync-collection answered {response.status}") + (entries, truncated) = self._parse_entries(response) + new_token = response.tree.find(f".//{dav.SyncToken.tag}") + return (entries, truncated, new_token.text if new_token is not None else None) + + def _parse_entries(self, response, propstat_status=False): + """The members a multistatus names, each with its response-level + status (None where it has properties), and whether it carries a 507 + for the collection. With ``propstat_status``, an entry whose + propstats all fail takes the first propstat's status instead.""" + entries = [] + truncated = False + for elem in response.tree.iter(dav.Response.tag): + href = elem.find(dav.Href.tag) + if href is None or not href.text: + continue + url = self.cal_url.join(href.text) + is_collection = self._is_collection(url) + status = elem.find(dav.Status.tag) + if status is not None and " 507" in (status.text or ""): + truncated |= is_collection + continue + if status is None and propstat_status: + failed = elem.findall(f"{dav.PropStat.tag}/{dav.Status.tag}") + if failed and not any(re.search(r" 2\d\d\b", f.text or "") for f in failed): + status = failed[0] + code = re.search(r" (\d{3})", status.text or "") if status is not None else None + entries.append((self.COLLECTION if is_collection else self._name(url), int(code[1]) if code else None)) + return (entries, truncated) + + class LimitRefused(Exception): """A sync-collection with DAV:limit failed as RFC 6578 section 3.7 says a server that cannot truncate must: DAV:number-of-matches-within-limits.""" -class CheckSyncTokenLimit(Check): +class CheckSyncTokenLimit(_RawSync, Check): """ Checks DAV:limit in a sync-collection report (RFC 6578 section 3.7), and following the checkpoint token of a truncated reply (section 3.6). @@ -6591,8 +7547,6 @@ class CheckSyncTokenLimit(Check): NRESULTS = 2 MAX_PAGES = 20 UIDS = ("csc_sync_limit_1", "csc_sync_limit_2", "csc_sync_limit_3") - ## _report's stand-in for the collection listed as one of its own members - COLLECTION = "collection" COLLECTION_NOTE = "lists the collection itself as a member" CUT = "the reply is cut at DAV:limit without a 507" STUCK = "a 507 without a new token" @@ -6600,13 +7554,9 @@ class CheckSyncTokenLimit(Check): REFUSED = "refused with 507 number-of-matches-within-limits" def _run_check(self) -> None: - sync = self.checker.features_checked.is_supported("sync-token", return_type=dict) - if sync.get("support") not in ("full", "fragile"): + if not self._setup_sync(): return - self.time_based = sync.get("behaviour") == "time-based" cal = self.checker.calendar - self.cal_url = cal.url.canonical() - self.collection_path = unquote(self.cal_url.path).rstrip("/") created = [] try: try: @@ -6639,11 +7589,6 @@ def _run_check(self) -> None: except Exception: pass - def _tick(self) -> None: - """Time-based tokens tell apart only changes a second apart.""" - if self.time_based: - time.sleep(1) - def _grade(self, token, expected, what): limit = max(self.NRESULTS, math.ceil(len(expected) / (self.MAX_PAGES // 2))) try: @@ -6707,50 +7652,280 @@ def _follow(self, token, limit): token = new_token return (seen, truncated_any, notes) - def _name(self, url): - return unquote(url.path).rstrip("/").rsplit("/", 1)[-1] - def _is_collection(self, url): - ## The request-URI may come back without its trailing slash, and on - ## OX by another path; members of a calendar are never collections - path = unquote(url.path) - return path.endswith("/") or path.rstrip("/") == self.collection_path +class CheckSyncMembers(_RawPut, _RawSync, Check): + """ + Checks which members a sync-collection report lists. + + RFC 6578 section 3.4: an initial sync (empty token) lists the current + members and MUST NOT list removed ones; Radicale lists deleted members + with a 404, Stalwart also lists the collection itself. Section 3.5.2: a + member created and deleted between two reports MUST be reported as + removed; Xandikos leaves it out, so a client never learns what became of + its own create, and so does Stalwart unless a member it had at the token + changed in the same interval. Sections 3.5.1 and 3.2: a member deleted + and re-created at one href is reported once, as changed; Stalwart reports + it as removed and then changed. + https://github.com/PyCalendar/calendaring-sync/blob/c40782bc7afe7b350fe63bf27ef13374c280ea8b/docs/reference/server-compatibility.rst + """ - def _report(self, token, limit): - """One raw sync-collection REPORT on the calendar. + depends_on = {CheckSyncToken} + LABEL = "sync-members" + INITIAL = "sync-token.initial-sync" + TRANSIENT = "sync-token.delete.created-and-deleted" + RECREATED = "sync-token.recreated" + features_to_be_checked = {INITIAL, TRANSIENT, RECREATED} + KEEP = "csc_sync_members_keep" + GONE = "csc_sync_members_gone" + SHORT_LIVED = "csc_sync_members_transient" + AGAIN = "csc_sync_members_recreated" - Returns the names of the members it names (changed or removed, and - COLLECTION for the collection itself), whether it carries the section - 3.6 507 marker for the collection, and its token. - """ + def _run_check(self) -> None: + self._graded_here = set() + if not self._setup_sync(): + return + names = (self.KEEP, self.GONE, self.SHORT_LIVED, self.AGAIN) + for name in names: + self._delete(self._url(name)) + try: + self._probe() + except (DAVError, ReportError) as e: + ## A report failing later leaves the verdicts the earlier ones gave + for feature in self.features_to_be_checked - self._graded_here: + self.set_feature(feature, {"support": "ungraceful", "behaviour": f"sync-collection failed: {e}"}) + finally: + for name in names: + self._delete(self._url(name)) + + def set_feature(self, feature, value=True): + self._graded_here.add(feature) + super().set_feature(feature, value) + + def _store(self, name, summary=None) -> bool: + return self._ok(self._put(self._url(name), self._ical(name, summary=summary))) + + def _probe(self) -> None: + if not all(self._store(name) for name in (self.KEEP, self.AGAIN, self.GONE)): + for feature in self.features_to_be_checked: + self.set_feature(feature, self._graded("unknown", "cannot test, the probe objects could not be stored")) + return + self._tick() + self._delete(self._url(self.GONE)) + self._tick() + (entries, _, token) = self._entries("") + self.set_feature(self.INITIAL, self._grade_initial(entries)) + self._tick() + short_lived = self._store(self.SHORT_LIVED) + self._tick() + self._delete(self._url(self.SHORT_LIVED)) + self._tick() + ## An interval of its own: Stalwart reports the short-lived member + ## only where a member it already had changed in the same interval + (entries, _, token) = self._entries(token) + if short_lived: + self.set_feature(self.TRANSIENT, self._grade_transient(self._statuses(entries, self.SHORT_LIVED))) + else: + self.set_feature(self.TRANSIENT, self._graded("unknown", "cannot test, the object could not be stored")) + self._tick() + self._delete(self._url(self.AGAIN)) + self._tick() + ## Nextcloud's trashbin keeps a deleted object's UID and refuses it + recreated = self._store(self.AGAIN, summary=f"{self.LABEL} probe again") + self._tick() + (entries, _, _) = self._entries(token) + if recreated: + self.set_feature(self.RECREATED, self._grade_recreated(self._statuses(entries, self.AGAIN))) + else: + self.set_feature( + self.RECREATED, self._graded("unknown", "cannot test, a deleted object could not be re-created") + ) + + @staticmethod + def _statuses(entries, name): + """The statuses a report lists the member ``name`` with, in order.""" + return [status for href, status in entries if href == f"{name}.ics"] + + def _grade_initial(self, entries): + notes = [] + names = {name for name, _ in entries} + if any(status == 404 for name, status in entries if name != self.COLLECTION): + notes.append("lists deleted members with a 404") + elif f"{self.GONE}.ics" in names: + notes.append("lists a deleted member") + if self.COLLECTION in names: + notes.append("lists the collection itself") + missing = [name for name in (self.KEEP, self.AGAIN) if f"{name}.ics" not in names] + if missing: + return self._graded("broken", f"{len(missing)} current member(s) not listed", *notes) + return self._graded("quirk", *notes) if notes else "full" + + def _grade_transient(self, statuses): + if not statuses: + return self._graded("unsupported", "a member created and deleted between two reports is not reported") + if statuses[-1] != 404: + return self._graded("broken", "a member created and deleted between two reports is reported as changed") + return "full" + + def _grade_recreated(self, statuses): + if not statuses: + return self._graded("unsupported", "a member deleted and re-created is not reported") + changed = [status for status in statuses if status != 404] + if not changed: + return self._graded("broken", "a member deleted and re-created is reported as removed only") + if statuses == [404, *changed] and len(changed) == 1: + return self._graded("quirk", "reported twice: as removed, then as changed") + if len(statuses) > 1: + return self._graded("quirk", f"reported {len(statuses)} times") + return "full" + + +class CheckMultiget(_RawPut, _RawSync, Check): + """ + Checks calendar-multiget for a missing href, and limits on how many + members a multiget, a Depth: 1 PROPFIND or a calendar-query answers. + + RFC 4791 section 7.9 gives every requested href an entry, a 404 for a + missing one; Baikal leaves it out. Stalwart answers at most its + WebDav.maxResults (2000 by default) members with a 507 for the collection, + even to a multiget that asked for fewer than it holds, and to a + calendar-query matching more. None of the requests has a + client-side limit to provoke that with, so the probe has to store more + objects than the server's limit: CALDAV_SERVER_TESTER_LIMIT_OBJECTS says + how many, and without it the limits are not probed. + """ + + depends_on = {PrepareCalendar} + LABEL = "multiget" + MISSING_HREF = "multiget.missing-href" + LIMIT = "multiget.limit" + PROPFIND_LIMIT = "propfind.limit" + SEARCH_LIMIT = "search.limit" + features_to_be_checked = {MISSING_HREF, LIMIT, PROPFIND_LIMIT, SEARCH_LIMIT} + ENV = "CALDAV_SERVER_TESTER_LIMIT_OBJECTS" + PRESENT = "csc_multiget_present" + ABSENT = "csc_multiget_missing" + PROPFIND = ( + '' + ) + + def _run_check(self) -> None: + self.cal_url = self.checker.calendar.url.canonical() + self.collection_path = unquote(self.cal_url.path).rstrip("/") + present = self._url(self.PRESENT) + self._delete(self._url(self.ABSENT)) + try: + if self._ok(self._put(present, self._ical(self.PRESENT))): + self.set_feature(self.MISSING_HREF, self._grade_missing()) + else: + self.set_feature(self.MISSING_HREF, None) + finally: + self._delete(present) + count = self._object_count() + if not count: + for feature in (self.LIMIT, self.PROPFIND_LIMIT, self.SEARCH_LIMIT): + self.set_feature( + feature, {"support": "unknown", "behaviour": f"not probed: set {self.ENV} to a number of objects"} + ) + return + self._probe_limits(count) + + def _object_count(self) -> int: + try: + return max(0, int(os.environ.get(self.ENV, "0"))) + except ValueError: + return 0 + + def _multiget(self, names): + hrefs = "".join(f"{xml_escape(urlsplit(self._url(name)).path)}" for name in names) body = ( '' - '' - f"{xml_escape(token)}" - "1" - + (f"{limit}" if limit else "") - + "" + '' + f"{hrefs}" ) - response = self.client.report(str(self.cal_url), body, depth=0) - if limit and response.status == 507 and "number-of-matches-within-limits" in str(response.raw): - raise LimitRefused() + response = self.client.report(str(self.cal_url), body, depth=1) if response.status not in (200, 207) or response.tree is None: - raise ReportError(f"sync-collection answered {response.status}") - members = set() - truncated = False - for elem in response.tree.iter(dav.Response.tag): - href = elem.find(dav.Href.tag) - if href is None or not href.text: - continue - url = self.cal_url.join(href.text) - is_collection = self._is_collection(url) - status = elem.find(dav.Status.tag) - if status is not None and " 507" in (status.text or ""): - truncated |= is_collection - continue - members.add(self.COLLECTION if is_collection else self._name(url)) - new_token = response.tree.find(f".//{dav.SyncToken.tag}") - return (members, truncated, new_token.text if new_token is not None else None) + raise ReportError(f"calendar-multiget answered {response.status}") + return self._parse_entries(response, propstat_status=True) + + def _grade_missing(self): + try: + (entries, _) = self._multiget((self.PRESENT, self.ABSENT)) + except (DAVError, ReportError) as e: + return self._graded("ungraceful", f"a multiget naming a missing object failed: {e}") + statuses = [status for name, status in entries if name == f"{self.ABSENT}.ics"] + if not statuses: + return self._graded("unsupported", "a missing href is left out of the multistatus") + if statuses[0] is None or 200 <= statuses[0] < 300: + answer = "properties" if statuses[0] is None else statuses[0] + return self._graded("broken", f"a missing href is answered with {answer}, as if it existed") + if statuses[0] != 404: + return self._graded("full", f"a missing href is answered with {statuses[0]}") + return "full" + + def _probe_limits(self, count) -> None: + names = [f"csc_limit_{i:05d}" for i in range(count)] + try: + stored = sum(self._ok(self._put(self._url(name), self._ical(name))) for name in names) + if stored < count: + for feature in (self.LIMIT, self.PROPFIND_LIMIT, self.SEARCH_LIMIT): + self.set_feature( + feature, self._graded("unknown", f"cannot test, only {stored} of {count} objects were stored") + ) + return + mine = {f"{name}.ics" for name in names} + requests = ( + (self.LIMIT, lambda: self._multiget(names)), + (self.PROPFIND_LIMIT, self._propfind), + (self.SEARCH_LIMIT, self._calendar_query), + ) + for feature, request in requests: + try: + (entries, truncated) = request() + except (DAVError, ReportError) as e: + ## A server refusing the whole listing; Stalwart lists up + ## to its limit and adds a 507 for the collection instead + if re.search(r"answered 507\b", str(e)) or dav_error_status(e) == 507: + verdict = self._graded("unsupported", f"the whole answer was a 507, with {count} objects") + else: + verdict = self._graded("ungraceful", f"failed with {count} objects: {e}") + self.set_feature(feature, verdict) + continue + seen = len({name for name, status in entries if name in mine and status is None}) + ## A cap counts the calendar's other members too + listed = len({name for name, status in entries if name != self.COLLECTION and status is None}) + self.set_feature(feature, self._grade_limit(seen, count, truncated, listed)) + finally: + for name in names: + self._delete(self._url(name)) + + def _calendar_query(self): + """Every event in the calendar, raw for the same reason as the multiget""" + body = ( + '' + '' + "" + '' + "" + ) + response = self.client.report(str(self.cal_url), body, depth=1) + if response.status not in (200, 207) or response.tree is None: + raise ReportError(f"calendar-query answered {response.status}") + return self._parse_entries(response, propstat_status=True) + + def _propfind(self): + ## Raw, as for the multiget: caldav's propfind() parses at once and + ## raises on the collection's 507, hiding the members listed before it + response = self.client.request(str(self.cal_url), "PROPFIND", self.PROPFIND, {"Depth": "1"}) + if response.status not in (200, 207) or response.tree is None: + raise ReportError(f"PROPFIND answered {response.status}") + return self._parse_entries(response) + + def _grade_limit(self, seen, count, truncated, listed): + if seen >= count: + return self._graded("full", f"no limit up to {count} objects") + if truncated: + return self._graded("unsupported", f"stops at {listed} members, with a 507") + return self._graded("broken", f"lists {seen} of {count} objects, without a 507") class CheckFreeBusyQuery(Check): diff --git a/tests/test_attendee_params.py b/tests/test_attendee_params.py new file mode 100644 index 0000000..d6280c1 --- /dev/null +++ b/tests/test_attendee_params.py @@ -0,0 +1,120 @@ +"""Whether the parameters of an ATTENDEE are stored as sent. + +SOGo adds PARTSTAT=NEEDS-ACTION, which is RFC 5545's default and changes +nothing, and RSVP=TRUE, which is not: the default is FALSE. +""" + +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet + +from caldav_server_tester.checks import CheckAttendeeParams + +BASE = "http://example.com/cal/" +FEATURE = "save-load.icalendar.attendee-params" + + +class RewritingServer: + def __init__(self, rewrite=lambda line: line, put_status=201): + self.rewrite = rewrite + self.put_status = put_status + self.objects = {} + + def put(self, url, body, headers=None): + if self.put_status >= 300: + return Mock(status=self.put_status, headers={}) + lines = [self.rewrite(line) if line.startswith("ATTENDEE") else line for line in body.split("\r\n")] + self.objects[url] = "\r\n".join(line for line in lines if line is not None) + return Mock(status=201, headers={}) + + def request(self, url, method="GET"): + if url not in self.objects: + return Mock(status=404, headers={}, raw="") + return Mock(status=200, headers={}, raw=self.objects[url]) + + def delete(self, url, headers=None): + self.objects.pop(url, None) + return Mock(status=204) + + +def _sogo(line): + params, address = line.split(":", 1) + if "PARTSTAT" not in params: + params += ";PARTSTAT=NEEDS-ACTION" + return f"{params};RSVP=TRUE:{address}" + + +def _run(server): + checker = Mock() + checker._features_checked = FeatureSet() + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url.join.side_effect = lambda name: BASE + name + CheckAttendeeParams(checker)._run_check() + return checker._features_checked.is_supported(FEATURE, return_type=dict) + + +def test_stored_as_sent_is_full(): + server = RewritingServer() + assert _run(server) == {"support": "full"} + assert not server.objects + + +def test_the_probe_sends_no_organizer(): + """Attendees without an ORGANIZER: not a scheduling object resource.""" + sent = [] + server = RewritingServer() + original = server.put + server.put = lambda url, body, headers=None: sent.append(body) or original(url, body, headers) + _run(server) + assert "ATTENDEE" in sent[0] + assert "ORGANIZER" not in sent[0] + + +def test_sogo_is_unsupported(): + verdict = _run(RewritingServer(rewrite=_sogo)) + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "RSVP=TRUE is added; PARTSTAT=NEEDS-ACTION is added" + + +@pytest.mark.parametrize( + "added", ["PARTSTAT=NEEDS-ACTION", "ROLE=REQ-PARTICIPANT", "CUTYPE=INDIVIDUAL", "RSVP=FALSE", "SCHEDULE-STATUS=1.2"] +) +def test_an_added_default_is_full(added): + def add(line): + params, address = line.split(":", 1) + return line if added.split("=")[0] in params else f"{params};{added}:{address}" + + verdict = _run(RewritingServer(rewrite=add)) + assert verdict["support"] == "full" + assert added in verdict["behaviour"] + + +def test_a_dropped_parameter_is_unsupported(): + verdict = _run(RewritingServer(rewrite=lambda line: line.replace(";PARTSTAT=ACCEPTED", ""))) + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "PARTSTAT=ACCEPTED is dropped" + + +def test_a_changed_parameter_is_unsupported(): + verdict = _run(RewritingServer(rewrite=lambda line: line.replace("PARTSTAT=ACCEPTED", "PARTSTAT=NEEDS-ACTION"))) + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "PARTSTAT=ACCEPTED is changed to NEEDS-ACTION" + + +def test_a_mangled_name_is_unsupported(): + verdict = _run(RewritingServer(rewrite=lambda line: line.replace("Åse", "?se"))) + assert verdict["support"] == "unsupported" + assert "CN=Åse is changed" in verdict["behaviour"] + + +def test_a_dropped_attendee_is_unsupported(): + verdict = _run(RewritingServer(rewrite=lambda line: None if "PARTSTAT" in line else line)) + assert verdict["support"] == "unsupported" + assert "is dropped" in verdict["behaviour"] + + +def test_refused_is_ungraceful(): + assert _run(RewritingServer(put_status=400))["support"] == "ungraceful" diff --git a/tests/test_delete_if_match.py b/tests/test_delete_if_match.py new file mode 100644 index 0000000..30bc83d --- /dev/null +++ b/tests/test_delete_if_match.py @@ -0,0 +1,210 @@ +"""Whether a DELETE honours If-Match. + +RFC 9110 section 13.1.1 has the server refuse a request whose If-Match etag +the object no longer has. SOGo does so on PUT (412), but a DELETE with a +stale etag goes through and the object is gone. +""" + +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet +from caldav.lib.error import AuthorizationError + +from caldav_server_tester.checks import CheckDeleteIfMatch + +BASE = "http://example.com/cal/" +FEATURE = "delete.if-match" + + +class VersionedServer: + """A collection giving each stored version of an object a new etag. + + ``delete_if_match`` is how a conditional DELETE is evaluated: "rfc" + (holds for the current etag only), "ignored" (always holds) or "never". + ``stale_answer`` overrides the answer to a stale DELETE with a status, or + raises it where it is an exception. ``etags`` False leaves the ETag + header out; ``bump`` False keeps the etag across versions. + """ + + def __init__( + self, + delete_if_match="rfc", + stale_answer=None, + etags=True, + bump=True, + create_status=201, + missing_answer=404, + missing_plain_answer=404, + ): + self.missing_answer = missing_answer + self.missing_plain_answer = missing_plain_answer + self.delete_if_match = delete_if_match + self.stale_answer = stale_answer + self.etags = etags + self.bump = bump + self.create_status = create_status + self.versions = {} + self.deletes = [] + + def _etag(self, url): + return f'"v{self.versions[url]}"' + + def put(self, url, body, headers=None): + if self.create_status >= 300: + return self._response(self.create_status) + if url in self.versions: + self.versions[url] += 1 if self.bump else 0 + else: + self.versions[url] = 0 + return self._response(201, url) + + def request(self, url, method="GET"): + if url not in self.versions: + return self._response(404) + return self._response(200, url) + + def delete(self, url, headers=None): + etag = (headers or {}).get("If-Match") + self.deletes.append(etag) + if url not in self.versions: + if etag is not None and isinstance(self.missing_answer, Exception): + raise self.missing_answer + return self._response(self.missing_answer if etag is not None else self.missing_plain_answer) + if etag is not None: + current = etag == self._etag(url) + if not current and self.stale_answer is not None: + if isinstance(self.stale_answer, Exception): + raise self.stale_answer + return self._response(self.stale_answer) + holds = {"rfc": current, "ignored": True, "never": False}[self.delete_if_match] + if not holds: + return self._response(412) + del self.versions[url] + return self._response(204) + + def _response(self, status, url=None): + response = Mock() + response.status = status + response.headers = {"Etag": self._etag(url)} if url and self.etags else {} + return response + + +def _run(server, feature=FEATURE): + checker = Mock() + checker._features_checked = FeatureSet() + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url.join.side_effect = lambda name: BASE + name + CheckDeleteIfMatch(checker)._run_check() + return checker._features_checked.is_supported(feature, return_type=dict) + + +def test_stale_refused_and_current_taken_is_full(): + server = VersionedServer() + assert _run(server)["support"] == "full" + ## The stale etag first, then the current one + stale = server.deletes.index('"v0"') + assert server.deletes[stale : stale + 2] == ['"v0"', '"v1"'] + assert not server.versions + + +def test_stale_if_match_deleting_is_broken(): + """SOGo""" + server = VersionedServer(delete_if_match="ignored") + verdict = _run(server) + assert verdict["support"] == "broken" + assert "stale If-Match deleted the object (204)" in verdict["behaviour"] + + +def test_current_if_match_refused_too_is_unsupported(): + server = VersionedServer(delete_if_match="never") + verdict = _run(server) + assert verdict["support"] == "unsupported" + + +@pytest.mark.parametrize("status", [400, 409, 500]) +def test_stale_if_match_refused_without_412_is_ungraceful(status): + verdict = _run(VersionedServer(stale_answer=status)) + assert verdict["support"] == "ungraceful" + assert str(status) in verdict["behaviour"] + + +def test_stale_if_match_answered_403_is_ungraceful(): + verdict = _run(VersionedServer(stale_answer=AuthorizationError("403 Forbidden"))) + assert verdict["support"] == "ungraceful" + + +def test_no_etag_is_unknown(): + assert _run(VersionedServer(etags=False))["support"] == "unknown" + + +def test_an_etag_unchanged_by_an_update_is_unknown(): + assert _run(VersionedServer(bump=False))["support"] == "unknown" + + +def test_an_object_that_cannot_be_stored_is_unknown(): + assert _run(VersionedServer(create_status=403))["support"] == "unknown" + + +def test_the_object_is_deleted_whatever_the_verdict(): + server = VersionedServer(delete_if_match="never") + _run(server) + assert not server.versions + + +MISSING = "delete.missing.if-match" +PLAIN = "delete.missing" + + +def test_a_conditional_delete_of_a_missing_object_answered_404_is_full(): + assert _run(VersionedServer(), MISSING) == {"support": "full"} + + +def test_a_conditional_delete_of_a_missing_object_answered_412_is_unsupported(): + """Baikal, Robur""" + verdict = _run(VersionedServer(missing_answer=412), MISSING) + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "a DELETE with If-Match on a missing object answered 412" + + +def test_a_conditional_delete_of_a_missing_object_answered_otherwise_is_ungraceful(): + assert _run(VersionedServer(missing_answer=500), MISSING)["support"] == "ungraceful" + + +def test_the_missing_object_is_probed_whatever_the_stale_verdict(): + """It needs no etag, so a server without one still gets a verdict.""" + assert _run(VersionedServer(etags=False, missing_answer=412), MISSING)["support"] == "unsupported" + + +def test_a_conditional_delete_answered_as_the_plain_one_is_full(): + """RFC 9110 section 13.2.1: the precondition is ignored where the request + would fail without it, so a 403 for both is right""" + verdict = _run(VersionedServer(missing_plain_answer=403, missing_answer=403), MISSING) + assert verdict == {"support": "full", "behaviour": "answered 403, as a plain DELETE of a missing object is"} + + +def test_a_conditional_delete_answered_412_where_the_plain_one_is_refused_is_unsupported(): + verdict = _run(VersionedServer(missing_plain_answer=403, missing_answer=412), MISSING) + assert verdict["support"] == "unsupported" + + +def test_a_conditional_delete_is_not_graded_where_the_plain_one_succeeds(): + """With nothing to fail, the condition decides, and a 412 is right""" + verdict = _run(VersionedServer(missing_plain_answer=204, missing_answer=412), MISSING) + assert verdict["support"] == "unknown" + + +def test_a_plain_delete_of_a_missing_object_answered_404_is_full(): + assert _run(VersionedServer(missing_answer=412), PLAIN) == {"support": "full"} + + +@pytest.mark.parametrize("status", [204, 200]) +def test_a_plain_delete_of_a_missing_object_succeeding_is_unsupported(status): + verdict = _run(VersionedServer(missing_plain_answer=status), PLAIN) + assert verdict == {"support": "unsupported", "behaviour": f"a DELETE of a missing object answered {status}"} + + +def test_a_plain_delete_of_a_missing_object_answered_otherwise_is_ungraceful(): + assert _run(VersionedServer(missing_plain_answer=403), PLAIN)["support"] == "ungraceful" diff --git a/tests/test_floating_event.py b/tests/test_floating_event.py new file mode 100644 index 0000000..7f504d4 --- /dev/null +++ b/tests/test_floating_event.py @@ -0,0 +1,114 @@ +"""Whether an event with a floating time is stored floating. + +SOGo adds a VTIMEZONE for Etc/UTC and gives DTSTART and DTEND TZID=Etc/UTC. +The probe reads the server's own text: the icalendar library writes such a +time back with a 'Z' after the TZID, which RFC 5545 section 3.3.5 forbids. +""" + +import re +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet +from caldav.lib.error import AuthorizationError + +from caldav_server_tester.checks import CheckFloatingEvent + +BASE = "http://example.com/cal/" +FEATURE = "save-load.event.floating" +VTIMEZONE = ( + "BEGIN:VTIMEZONE\r\nTZID:Etc/UTC\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0000\r\n" + "TZOFFSETTO:+0000\r\nDTSTART:19700101T000000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n" +) + + +class RewritingServer: + """Stores what ``rewrite`` makes of the body; ``put_answer`` overrides the + answer to the PUT (raised where it is an exception).""" + + def __init__(self, rewrite=lambda body: body, put_answer=None, get_status=200): + self.rewrite = rewrite + self.put_answer = put_answer + self.get_status = get_status + self.objects = {} + + def put(self, url, body, headers=None): + if isinstance(self.put_answer, Exception): + raise self.put_answer + if self.put_answer is not None: + return Mock(status=self.put_answer, headers={}) + self.objects[url] = self.rewrite(body) + return Mock(status=201, headers={}) + + def request(self, url, method="GET"): + if url not in self.objects: + return Mock(status=404, headers={}, raw="") + return Mock(status=self.get_status, headers={}, raw=self.objects[url]) + + def delete(self, url, headers=None): + self.objects.pop(url, None) + return Mock(status=204) + + +def _pin_to_utc(body, z=""): + body = re.sub(r"(DTSTART|DTEND):(\d{8}T\d{6})\r\n", rf"\1;TZID=Etc/UTC:\2{z}\r\n", body) + return body.replace("BEGIN:VEVENT", VTIMEZONE + "BEGIN:VEVENT") + + +def _run(server): + checker = Mock() + checker._features_checked = FeatureSet() + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url.join.side_effect = lambda name: BASE + name + CheckFloatingEvent(checker)._run_check() + return checker._features_checked.is_supported(FEATURE, return_type=dict) + + +def test_stored_floating_is_full(): + server = RewritingServer() + assert _run(server)["support"] == "full" + assert not server.objects + + +def test_the_probe_sends_floating_times(): + sent = [] + _run(RewritingServer(rewrite=lambda body: sent.append(body) or body)) + assert re.search(r"\r\nDTSTART:\d{8}T\d{6}\r\n", sent[0]) + assert re.search(r"\r\nDTEND:\d{8}T\d{6}\r\n", sent[0]) + + +def test_pinned_to_a_time_zone_is_unsupported(): + """SOGo; the VTIMEZONE's own DTSTART is not mistaken for the event's.""" + verdict = _run(RewritingServer(rewrite=_pin_to_utc)) + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "a floating time is stored with TZID=Etc/UTC" + + +def test_tzid_and_z_both_is_broken(): + verdict = _run(RewritingServer(rewrite=lambda body: _pin_to_utc(body, "Z"))) + assert verdict["support"] == "broken" + assert "5545" in verdict["behaviour"] + + +def test_converted_to_utc_is_unsupported(): + verdict = _run(RewritingServer(rewrite=lambda body: re.sub(r"(DT(?:START|END):\d{8}T\d{6})\r\n", r"\1Z\r\n", body))) + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "a floating time is stored as UTC" + + +def test_a_folded_line_is_read_whole(): + def fold(body): + return re.sub(r"DTSTART:(\d{8})", r"DTSTART;TZID=Etc/UTC:\r\n \1", body) + + assert _run(RewritingServer(rewrite=fold))["support"] == "unsupported" + + +@pytest.mark.parametrize("answer", [400, 403, AuthorizationError("403 Forbidden")]) +def test_refused_is_ungraceful(answer): + assert _run(RewritingServer(put_answer=answer))["support"] == "ungraceful" + + +def test_unreadable_is_unknown(): + assert _run(RewritingServer(get_status=500))["support"] == "unknown" diff --git a/tests/test_icalendar_roundtrip.py b/tests/test_icalendar_roundtrip.py new file mode 100644 index 0000000..106697c --- /dev/null +++ b/tests/test_icalendar_roundtrip.py @@ -0,0 +1,150 @@ +"""Whether a PARTICIPANT component and a VTIMEZONE survive a round trip. + +CCS, Bedework 3, robur, GMX and Ox (and Zimbra, reportedly) refuse an event +carrying an RFC 9073 PARTICIPANT; CCS was seen to return an object without the VTIMEZONE it was +sent with. +""" + +import re +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet +from caldav.lib.error import AuthorizationError + +from caldav_server_tester.checks import CheckParticipant, CheckVtimezone, CheckXProperty + +from .test_floating_event import RewritingServer + +BASE = "http://example.com/cal/" +TASKS = "http://example.com/tasks/" + + +def _run(check, server, todo="full"): + checker = Mock() + checker._features_checked = FeatureSet({"save-load.todo": {"support": todo}}) + checker.features_checked = checker._features_checked + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url.join.side_effect = lambda name: BASE + name + checker.tasklist.url.join.side_effect = lambda name: TASKS + name + check(checker)._run_check() + return checker._features_checked.is_supported(check.FEATURE, return_type=dict) + + +def _drop(component): + return lambda body: re.sub(rf"BEGIN:{component}\r\n.*?END:{component}\r\n", "", body, flags=re.DOTALL) + + +@pytest.mark.parametrize("check", [CheckParticipant, CheckVtimezone]) +def test_kept_is_full(check): + server = RewritingServer() + assert _run(check, server)["support"] == "full" + assert not server.objects + + +def test_the_participant_probe_sends_a_participant_inside_the_event(): + sent = [] + _run(CheckParticipant, RewritingServer(rewrite=lambda body: sent.append(body) or body)) + event = sent[0][sent[0].index("BEGIN:VEVENT") : sent[0].index("END:VEVENT")] + assert "BEGIN:PARTICIPANT\r\n" in event + assert "\r\nPARTICIPANT-TYPE:" in event + + +def test_the_vtimezone_probe_sends_a_tzid_and_its_vtimezone(): + sent = [] + _run(CheckVtimezone, RewritingServer(rewrite=lambda body: sent.append(body) or body)) + tzid = re.search(r"\r\nDTSTART;TZID=([^:]+):", sent[0])[1] + assert f"BEGIN:VTIMEZONE\r\nTZID:{tzid}\r\n" in sent[0] + + +def test_participant_dropped_is_unsupported(): + assert _run(CheckParticipant, RewritingServer(rewrite=_drop("PARTICIPANT")))["support"] == "unsupported" + + +def test_vtimezone_dropped_is_unsupported(): + """CCS: DTSTART keeps its TZID, the VTIMEZONE defining it is gone.""" + verdict = _run(CheckVtimezone, RewritingServer(rewrite=_drop("VTIMEZONE"))) + assert verdict["support"] == "unsupported" + assert "Europe/Berlin" in verdict["behaviour"] + + +def test_vtimezone_for_another_tzid_is_unsupported(): + def rename(body): + return body.replace("BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin", "BEGIN:VTIMEZONE\r\nTZID:Etc/UTC") + + assert _run(CheckVtimezone, RewritingServer(rewrite=rename))["support"] == "unsupported" + + +def test_vtimezone_tzid_need_not_be_its_first_property(): + def push_down(body): + return body.replace("BEGIN:VTIMEZONE\r\n", "BEGIN:VTIMEZONE\r\nLAST-MODIFIED:20200101T000000Z\r\n") + + assert _run(CheckVtimezone, RewritingServer(rewrite=push_down))["support"] == "full" + + +def test_times_converted_to_utc_say_nothing_about_the_vtimezone(): + def to_utc(body): + body = _drop("VTIMEZONE")(body) + return re.sub(r"(DT(?:START|END));TZID=[^:]*:(\d{8}T\d{6})", r"\1:\2Z", body) + + assert _run(CheckVtimezone, RewritingServer(rewrite=to_utc))["support"] == "unknown" + + +@pytest.mark.parametrize("check", [CheckParticipant, CheckVtimezone]) +@pytest.mark.parametrize("answer", [400, 403, 500, AuthorizationError("403 Forbidden")]) +def test_refused_is_ungraceful(check, answer): + assert _run(check, RewritingServer(put_answer=answer))["support"] == "ungraceful" + + +@pytest.mark.parametrize("check", [CheckParticipant, CheckVtimezone]) +def test_unanswered_or_unreadable_is_unknown(check): + assert _run(check, RewritingServer(put_answer=503))["support"] == "unknown" + assert _run(check, RewritingServer(get_status=500))["support"] == "unknown" + + +def test_x_property_kept_is_full_in_an_event_and_a_task(): + sent = [] + assert _run(CheckXProperty, RewritingServer(rewrite=lambda body: sent.append(body) or body))["support"] == "full" + assert [re.search(r"BEGIN:(VEVENT|VTODO)", body)[1] for body in sent] == ["VEVENT", "VTODO"] + assert all(re.search(r"\r\nX-[A-Z-]+:", body) for body in sent) + + +def test_x_property_dropped_is_unsupported(): + verdict = _run(CheckXProperty, RewritingServer(rewrite=lambda body: re.sub(r"X-[^\r]*\r\n", "", body))) + assert verdict == {"support": "unsupported", "behaviour": "X-CALDAV-SERVER-TESTER is dropped, in an event"} + + +def test_x_property_dropped_from_a_task_only_is_unsupported(): + """Ox""" + + def from_tasks(body): + return re.sub(r"X-[^\r]*\r\n", "", body) if "BEGIN:VTODO" in body else body + + verdict = _run(CheckXProperty, RewritingServer(rewrite=from_tasks)) + assert verdict == {"support": "unsupported", "behaviour": "X-CALDAV-SERVER-TESTER is dropped, in a task"} + + +def test_x_property_in_events_only_where_tasks_cannot_be_stored(): + sent = [] + verdict = _run(CheckXProperty, RewritingServer(rewrite=lambda body: sent.append(body) or body), todo="unsupported") + assert verdict == {"support": "full", "behaviour": "probed in an event only, tasks cannot be stored"} + assert len(sent) == 1 and "BEGIN:VEVENT" in sent[0] + + +def test_x_property_event_and_task_go_to_their_own_calendars_and_urls(): + server = RewritingServer() + urls = [] + put = server.put + server.put = lambda url, body, headers=None: urls.append(url) or put(url, body, headers) + _run(CheckXProperty, server) + assert urls[0].startswith(BASE) and urls[1].startswith(TASKS) + assert urls[0].rsplit("/", 1)[1] != urls[1].rsplit("/", 1)[1] + + +@pytest.mark.parametrize("check", [CheckParticipant, CheckVtimezone]) +def test_unread_without_a_status_says_so_plainly(check): + verdict = _run(check, RewritingServer(get_status=503)) + assert verdict["support"] == "unknown" + assert "None" not in verdict["behaviour"] diff --git a/tests/test_multiget.py b/tests/test_multiget.py new file mode 100644 index 0000000..c022f48 --- /dev/null +++ b/tests/test_multiget.py @@ -0,0 +1,257 @@ +"""calendar-multiget for a missing href, and limits on big listings. + +RFC 4791 section 7.9 gives every requested href an entry in the multistatus, +a 404 for a missing one; Baikal leaves it out. Stalwart answers a multiget +or a Depth: 1 PROPFIND with at most WebDav.maxResults members and a 507 for +the collection, and a calendar-query matching more the same way. The limits +are only probed with +CALDAV_SERVER_TESTER_LIMIT_OBJECTS set, since it takes that many objects. +""" + +import re +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet +from caldav.lib.error import ResponseError +from caldav.lib.url import URL +from lxml import etree + +from caldav_server_tester.checks import CheckMultiget + +BASE = "http://example.com/cal/" +MISSING = "multiget.missing-href" +LIMIT = "multiget.limit" +PROPFIND = "propfind.limit" +SEARCH = "search.limit" +ENV = "CALDAV_SERVER_TESTER_LIMIT_OBJECTS" + + +class MultigetServer: + """``omit_missing`` leaves a missing href out (Baikal); ``cap`` answers at + most that many members, with a 507 for the collection unless + ``cap_marker`` is False. ``missing_as`` answers a missing href with that + entry instead of a response-level 404: "propstat 404", "propstat 200" or a + response-level status code. ``query_status`` is the status of every + calendar-query answer.""" + + def __init__( + self, + omit_missing=False, + cap=None, + cap_marker=True, + report_status=207, + propfind_raises=None, + missing_as=None, + propfind_status=None, + query_status=207, + ): + self.query_status = query_status + self.propfind_status = propfind_status + self.missing_as = missing_as + self.propfind_raises = propfind_raises + self.omit_missing = omit_missing + self.cap = cap + self.cap_marker = cap_marker + self.report_status = report_status + self.objects = set() + self.puts = 0 + + @staticmethod + def _name(url): + return url.rstrip("/").rsplit("/", 1)[-1] + + def put(self, url, body, headers=None): + self.puts += 1 + self.objects.add(self._name(url)) + return Mock(status=201, headers={}) + + def delete(self, url, headers=None): + self.objects.discard(self._name(url)) + return Mock(status=204) + + def report(self, url, body, depth=0): + if "calendar-query" in body: + if self.query_status != 207: + return Mock(status=self.query_status, tree=None, raw="") + return self._answer([(n, None) for n in sorted(self.objects)]) + if self.report_status != 207: + return Mock(status=self.report_status, tree=None, raw="") + names = [self._name(h) for h in re.findall(r"(.*?)", body)] + entries = [(n, None if n in self.objects else (self.missing_as or 404)) for n in names] + if self.omit_missing: + entries = [e for e in entries if e[1] is None] + return self._answer(entries) + + def propfind(self, url, props=None, depth=0): + """caldav parses the answer at once, and raises on the collection's 507""" + answer = self.request(url, "PROPFIND") + if b"507" in etree.tostring(answer.tree): + raise ResponseError("HTTP/1.1 507 Insufficient Storage") + return answer + + def request(self, url, method="GET", body="", headers=None): + """The raw answer, left unparsed""" + assert method == "PROPFIND" + if self.propfind_raises: + raise self.propfind_raises + if self.propfind_status: + return Mock(status=self.propfind_status, tree=None, raw="") + ## Last, so that a cap drops it, as on Stalwart: its members, then a 507 + return self._answer([(n, None) for n in sorted(self.objects)] + [("", None)]) + + def _answer(self, entries): + truncated = self.cap is not None and len(entries) > self.cap + if truncated: + entries = entries[: self.cap] + parts = [] + for name, status in entries: + href = f"/cal/{name}" + if isinstance(status, str): + code = status.split()[1] + parts.append( + f"{href}" + f"HTTP/1.1 {code} X" + ) + elif status: + parts.append(f"{href}HTTP/1.1 {status} Not Found") + else: + parts.append( + f'{href}"x"' + "HTTP/1.1 200 OK" + ) + if truncated and self.cap_marker: + parts.append( + "/cal/HTTP/1.1 507 Insufficient Storage" + ) + xml = '' + "".join(parts) + "" + return Mock(status=207, tree=etree.fromstring(xml.encode()), raw=xml) + + +def _run(server, monkeypatch, objects=None): + if objects is None: + monkeypatch.delenv(ENV, raising=False) + else: + monkeypatch.setenv(ENV, str(objects)) + checker = Mock() + checker._features_checked = FeatureSet() + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url = URL.objectify(BASE) + CheckMultiget(checker)._run_check() + fs = checker._features_checked + return {f: fs.is_supported(f, return_type=dict) for f in (MISSING, LIMIT, PROPFIND, SEARCH)} + + +def test_a_404_entry_for_the_missing_href_is_full(monkeypatch): + server = MultigetServer() + assert _run(server, monkeypatch)[MISSING]["support"] == "full" + assert not server.objects + + +def test_the_missing_href_left_out_is_unsupported(monkeypatch): + """Baikal""" + verdict = _run(MultigetServer(omit_missing=True), monkeypatch)[MISSING] + assert verdict["support"] == "unsupported" + assert verdict["behaviour"] == "a missing href is left out of the multistatus" + + +def test_a_404_inside_the_propstat_is_full(monkeypatch): + assert _run(MultigetServer(missing_as="propstat 404"), monkeypatch)[MISSING] == {"support": "full"} + + +@pytest.mark.parametrize("answer", ["propstat 200", 200]) +def test_a_missing_href_answered_as_if_it_existed_is_broken(monkeypatch, answer): + verdict = _run(MultigetServer(missing_as=answer), monkeypatch)[MISSING] + assert verdict["support"] == "broken" + assert "as if it existed" in verdict["behaviour"] + + +def test_a_missing_href_answered_otherwise_names_the_status(monkeypatch): + assert _run(MultigetServer(missing_as=410), monkeypatch)[MISSING] == { + "support": "full", + "behaviour": "a missing href is answered with 410", + } + + +def test_a_failing_multiget_is_ungraceful(monkeypatch): + assert _run(MultigetServer(report_status=400), monkeypatch)[MISSING]["support"] == "ungraceful" + + +def test_the_limits_are_not_probed_by_default(monkeypatch): + server = MultigetServer() + verdicts = _run(server, monkeypatch) + assert server.puts == 1 + for feature in (LIMIT, PROPFIND, SEARCH): + assert verdicts[feature]["support"] == "unknown" + assert ENV in verdicts[feature]["behaviour"] + + +def test_no_limit_up_to_the_objects_stored(monkeypatch): + server = MultigetServer() + verdicts = _run(server, monkeypatch, objects=12) + for feature in (LIMIT, PROPFIND, SEARCH): + assert verdicts[feature] == {"support": "full", "behaviour": "no limit up to 12 objects"} + assert not server.objects + + +def test_a_cap_with_a_507_is_unsupported(monkeypatch): + """Stalwart""" + verdicts = _run(MultigetServer(cap=5), monkeypatch, objects=12) + assert verdicts[LIMIT] == {"support": "unsupported", "behaviour": "stops at 5 members, with a 507"} + assert verdicts[PROPFIND] == {"support": "unsupported", "behaviour": "stops at 5 members, with a 507"} + assert verdicts[SEARCH] == {"support": "unsupported", "behaviour": "stops at 5 members, with a 507"} + + +def test_a_propfind_cap_counts_every_member_listed(monkeypatch): + """A server's cap counts the calendar's other members too""" + server = MultigetServer(cap=5) + server.objects.update({"aa_other_1.ics", "aa_other_2.ics"}) # listed first + verdicts = _run(server, monkeypatch, objects=12) + assert verdicts[PROPFIND] == {"support": "unsupported", "behaviour": "stops at 5 members, with a 507"} + + +def test_a_cap_without_a_507_is_broken(monkeypatch): + verdicts = _run(MultigetServer(cap=5, cap_marker=False), monkeypatch, objects=12) + assert verdicts[LIMIT]["support"] == "broken" + assert verdicts[LIMIT]["behaviour"] == "lists 5 of 12 objects, without a 507" + + +@pytest.mark.parametrize("value", ["0", "", "many"]) +def test_an_unusable_object_count_does_not_probe(monkeypatch, value): + monkeypatch.setenv(ENV, value) + server = MultigetServer() + checker = Mock() + checker._features_checked = FeatureSet() + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url = URL.objectify(BASE) + CheckMultiget(checker)._run_check() + assert server.puts == 1 + + +def test_a_propfind_answered_507_as_a_whole_is_unsupported(monkeypatch): + """No member listed at all, the HTTP status itself a 507""" + verdict = _run(MultigetServer(propfind_status=507), monkeypatch, objects=12)[PROPFIND] + assert verdict == {"support": "unsupported", "behaviour": "the whole answer was a 507, with 12 objects"} + + +def test_a_multiget_answered_507_as_a_whole_is_unsupported(monkeypatch): + verdict = _run(MultigetServer(report_status=507), monkeypatch, objects=12)[LIMIT] + assert verdict == {"support": "unsupported", "behaviour": "the whole answer was a 507, with 12 objects"} + + +def test_another_failure_is_ungraceful(monkeypatch): + server = MultigetServer(propfind_raises=ResponseError("HTTP/1.1 500 Internal Server Error")) + assert _run(server, monkeypatch, objects=12)[PROPFIND]["support"] == "ungraceful" + + +def test_a_calendar_query_answered_507_as_a_whole_is_unsupported(monkeypatch): + verdict = _run(MultigetServer(query_status=507), monkeypatch, objects=12)[SEARCH] + assert verdict == {"support": "unsupported", "behaviour": "the whole answer was a 507, with 12 objects"} + + +def test_a_failing_calendar_query_is_ungraceful(monkeypatch): + assert _run(MultigetServer(query_status=500), monkeypatch, objects=12)[SEARCH]["support"] == "ungraceful" diff --git a/tests/test_no_uid_conflict.py b/tests/test_no_uid_conflict.py index 31ce645..95a4504 100644 --- a/tests/test_no_uid_conflict.py +++ b/tests/test_no_uid_conflict.py @@ -2,7 +2,8 @@ RFC 4791 section 5.3.2.1 has the server refuse it with the CALDAV:no-uid-conflict precondition. Radicale answers 409, Xandikos and Stalwart 412, Cyrus 403 (which -the client raises rather than returns). +the client raises rather than returns). Bedework answers a plain PUT with 403 +and the precondition, one carrying If-None-Match: * with a bare 412. """ from unittest.mock import Mock @@ -30,27 +31,55 @@ class UidServer: ``create_status`` is the answer to the first object. """ - def __init__(self, conflict=409, body=CONFLICT, create_status=201): + def __init__( + self, + conflict=409, + body=CONFLICT, + create_status=201, + inm_conflict=None, + inm_body=b"", + stored_anyway=False, + get_status=None, + ): self.conflict = conflict self.body = body self.create_status = create_status + ## The answer to a PUT carrying If-None-Match: *, where it differs + self.inm_conflict = inm_conflict + self.inm_body = inm_body + ## Refuses the duplicate, yet stores it + self.stored_anyway = stored_anyway + ## Answers every GET with this, where given + self.get_status = get_status self.objects = {} self.deleted = [] self.headers = [] def put(self, url, body, headers=None): - self.headers.append(headers or {}) + headers = headers or {} + self.headers.append(headers) uid = next(line[4:] for line in body.split("\r\n") if line.startswith("UID:")) if any(u == uid and other != url for other, u in self.objects.items()): - if isinstance(self.conflict, Exception): - raise self.conflict - if self.conflict is not None: - return self._response(self.conflict, self.body) + conflict, body = self.conflict, self.body + if self.inm_conflict is not None and "If-None-Match" in headers: + conflict, body = self.inm_conflict, self.inm_body + if isinstance(conflict, Exception): + raise conflict + if conflict is not None: + if self.stored_anyway: + self.objects[url] = uid + return self._response(conflict, body) elif self.create_status >= 300: return self._response(self.create_status, b"") self.objects[url] = uid return self._response(201, b"") + def request(self, url, method="GET", *args, **kwargs): + assert method == "GET" + if self.get_status is not None: + return self._response(self.get_status, b"") + return self._response(200 if url in self.objects else 404, b"") + @staticmethod def _response(status, raw): response = Mock() @@ -65,7 +94,7 @@ def delete(self, url): return Mock(status=204) -def _run(server, features=None): +def _run(server, features=None, feature="save.no-uid-conflict"): checker = Mock() checker._features_checked = FeatureSet() if features: @@ -75,16 +104,22 @@ def _run(server, features=None): checker.fixture_base_year = 2027 checker.calendar.url.join.side_effect = lambda name: BASE + name CheckNoUidConflict(checker)._run_check() - return checker._features_checked.is_supported("save.no-uid-conflict", return_type=dict) + return checker._features_checked.is_supported(feature, return_type=dict) -@pytest.mark.parametrize("status", [403, 409, 412]) +@pytest.mark.parametrize("status", [403, 409]) def test_refused_with_the_precondition_is_full(status): server = UidServer(conflict=status) assert _run(server) == {"support": "full"} assert server.objects == {} +def test_412_with_the_precondition_is_a_quirk(): + """Xandikos, Stalwart: recognisable by the body, but the RFC status is 403 or 409.""" + verdict = _run(UidServer(conflict=412)) + assert (verdict["support"], verdict["status"], verdict["precondition"]) == ("quirk", 412, True) + + def test_forbidden_raised_by_the_client_is_full(): """Cyrus: the client raises 403 before a response exists.""" exc = AuthorizationError( @@ -103,12 +138,42 @@ def test_cyrus_scheduling_precondition_is_full(): assert _run(UidServer(conflict=exc)) == {"support": "full"} -def test_refused_without_the_precondition_is_ungraceful(): - """SOGo: the library cannot tell it from any other failed PUT.""" - verdict = _run(UidServer(conflict=409, body=b"")) +def test_bare_refusal_is_a_quirk(): + """SOGo: nothing stored at the URL, and the same PUT with another UID goes through.""" + server = UidServer(conflict=409, body=b"") + verdict = _run(server) + assert (verdict["support"], verdict["status"], verdict["precondition"]) == ("quirk", 409, False) + assert server.objects == {} + + +def test_bare_refusal_storing_the_object_anyway_is_ungraceful(): + verdict = _run(UidServer(conflict=409, body=b"", stored_anyway=True)) assert verdict["support"] == "ungraceful" - assert "no-uid-conflict" in verdict["behaviour"] - assert "409" in verdict["behaviour"] + assert "GET" in verdict["behaviour"] + + +def test_bare_refusal_of_any_uid_is_ungraceful(): + """The same PUT with another UID is refused too: not about the UID.""" + server = UidServer(conflict=409, body=b"", create_status=201) + put = server.put + + def put_refusing_other_uids(url, body, headers=None): + if "-other" in body: + server.headers.append(headers or {}) + return server._response(409, b"") + return put(url, body, headers) + + server.put = put_refusing_other_uids + verdict = _run(server) + assert verdict["support"] == "ungraceful" + assert "another UID" in verdict["behaviour"] + + +def test_bare_403_is_ungraceful(): + """A read-only calendar answers 403 as well: it may be anything.""" + exc = AuthorizationError(url="403 Forbidden", reason="Forbidden") + server = UidServer(conflict=exc) + assert _run(server)["support"] == "ungraceful" def test_precondition_in_the_wrong_namespace_is_ungraceful(): @@ -119,19 +184,54 @@ def test_precondition_in_the_wrong_namespace_is_ungraceful(): assert _run(UidServer(conflict=exc))["support"] == "ungraceful" -def test_second_put_carries_if_none_match(): - """As save(no_overwrite=True) does: Bedework drops the precondition then.""" +def test_plain_put_grades_the_parent(): server = UidServer() _run(server) - assert server.headers[1].get("If-None-Match") == "*" + assert "If-None-Match" not in server.headers[1] + assert any(h.get("If-None-Match") == "*" for h in server.headers[2:]) + + +def test_if_none_match_graded_apart(): + """Bedework: 403 with the precondition, but a bare 412 with If-None-Match: *.""" + exc = AuthorizationError( + url="403 Forbidden", + reason="Forbidden", + preconditions=["{urn:ietf:params:xml:ns:caldav}no-uid-conflict"], + ) + server = UidServer(conflict=exc, inm_conflict=412) + assert _run(server) == {"support": "full"} + verdict = _run(server, feature="save.no-uid-conflict.if-none-match") + assert (verdict["support"], verdict["status"], verdict["precondition"]) == ("quirk", 412, False) + + +def test_if_none_match_same_answer(): + verdict = _run(UidServer(), feature="save.no-uid-conflict.if-none-match") + assert verdict == {"support": "full"} @pytest.mark.parametrize("support", ["broken", "unsupported", "unknown"]) def test_no_if_none_match_where_the_server_mishandles_it(support): """A 412 there could be about the header, not the UID.""" server = UidServer() - _run(server, {"save.if-none-match": {"support": support}}) - assert "If-None-Match" not in server.headers[1] + verdict = _run(server, {"save.if-none-match": {"support": support}}, feature="save.no-uid-conflict.if-none-match") + assert all("If-None-Match" not in h for h in server.headers) + ## Left unset, so it is what the parent says + assert verdict == {"support": "full"} + + +def test_href_given(): + assert _run(UidServer(), feature="save.no-uid-conflict.href-given") == {"support": "full"} + + +def test_href_not_given(): + body = CONFLICT.replace(b"/cal/csc_no_uid_conflict_1.ics", b"") + verdict = _run(UidServer(body=body), feature="save.no-uid-conflict.href-given") + assert verdict["support"] == "unsupported" + + +def test_href_not_graded_without_the_precondition(): + verdict = _run(UidServer(body=b""), feature="save.no-uid-conflict.href-given") + assert verdict["support"] == "unknown" def test_leftover_second_object_deleted_before_the_first_put(): @@ -183,3 +283,45 @@ def test_no_answer_is_not_graded(answer): def test_first_object_that_cannot_be_created_grades_nothing(): server = UidServer(create_status=500) assert _run(server)["support"] == "unknown" + + +def test_no_answer_to_the_get_is_not_graded(): + server = UidServer(conflict=409, body=b"", get_status=503) + assert _run(server)["support"] == "unknown" + + +def test_no_answer_to_the_other_uid_is_not_graded(): + server = UidServer(conflict=409, body=b"") + put = server.put + + def put_overloaded_for_other_uids(url, body, headers=None): + if "-other" in body: + server.headers.append(headers or {}) + return server._response(503, b"") + return put(url, body, headers) + + server.put = put_overloaded_for_other_uids + assert _run(server)["support"] == "unknown" + + +def test_href_in_a_raised_403(): + """Cyrus: the 403 is raised by the client, the href is in its body.""" + exc = AuthorizationError( + url="403 Forbidden", + reason="Forbidden", + preconditions=["{urn:ietf:params:xml:ns:caldav}no-uid-conflict"], + body=CONFLICT, + ) + assert _run(UidServer(conflict=exc), feature="save.no-uid-conflict.href-given") == {"support": "full"} + + +def test_if_none_match_probe_cleans_up(): + """Bedework-like: the INM probe's PUT with another UID is deleted too.""" + exc = AuthorizationError( + url="403 Forbidden", + reason="Forbidden", + preconditions=["{urn:ietf:params:xml:ns:caldav}no-uid-conflict"], + ) + server = UidServer(conflict=exc, inm_conflict=412) + _run(server) + assert server.objects == {} diff --git a/tests/test_put_etag.py b/tests/test_put_etag.py index 6074404..2817f68 100644 --- a/tests/test_put_etag.py +++ b/tests/test_put_etag.py @@ -57,7 +57,14 @@ def __init__( create_status=201, create_raises=None, answers=None, + rewrite=None, + rewrite_updates=None, + raw_body=True, ): + self.rewrite = rewrite + self.rewrite_updates = rewrite_updates + self.raw_body = raw_body + self.bodies = {} self.put_etag = put_etag self.get_etag = put_etag if get_etag is None else get_etag self.accepts = {put_etag} if accepts is None else set(accepts) @@ -95,6 +102,8 @@ def put(self, url, body, headers=None): if not holds: return self._response(412) self.objects.add(url) + rewrite = self.rewrite_updates if exists and self.rewrite_updates else self.rewrite + self.bodies[url] = rewrite(body) if rewrite else body return self._response(204 if exists else 201) def _response(self, status): @@ -107,6 +116,11 @@ def request(self, url, method="GET"): response = Mock() response.status = 200 if url in self.objects else 404 response.headers = {"Etag": self.get_etag} if response.status == 200 and self.get_etag else {} + ## DAVResponse keeps the bytes as received, and turns CRLF into LF in .raw + body = self.bodies.get(url, "") + if self.raw_body: + response._raw_body = body.encode() + response.raw = body.replace("\r\n", "\n") return response def delete(self, url): @@ -403,3 +417,87 @@ def test_object_that_cannot_be_created_grades_nothing(server): for feature in ("save.etag", "save-load.mutable.if-match-wildcard", "save.if-none-match"): assert fs.is_supported(feature, return_type=dict)["support"] == "unknown" assert server.requests == [None] + + +STORED = "save.etag.matches-stored" + + +def _add_dtstamp_last(body): + """Radicale and Baikal add or move DTSTAMP; here it goes to the end.""" + lines = body.split("\r\n") + dtstamp = next(line for line in lines if line.startswith("DTSTAMP")) + lines.remove(dtstamp) + lines.insert(lines.index("END:VEVENT"), dtstamp) + return "\r\n".join(lines) + + +def test_stored_as_sent_is_full(): + assert _run_all(EtagServer()).is_supported(STORED, return_type=dict) == {"support": "full"} + + +def test_stored_differently_with_a_strong_etag_is_broken(): + """Radicale, Xandikos""" + verdict = _run_all(EtagServer(rewrite=_add_dtstamp_last)).is_supported(STORED, return_type=dict) + assert verdict == { + "support": "broken", + "behaviour": "stored with its properties reordered, yet the PUT answered with a strong ETag", + } + + +def test_line_endings_rewritten_with_a_strong_etag_is_broken(): + verdict = _run_all(EtagServer(rewrite=lambda body: body.replace("\r\n", "\n"))).is_supported( + STORED, return_type=dict + ) + assert verdict["support"] == "broken" + assert verdict["behaviour"].startswith("stored with other line endings") + + +def test_stored_with_lines_added_names_them(): + verdict = _run_all( + EtagServer(rewrite=lambda body: body.replace("END:VEVENT", "CLASS:PUBLIC\r\nEND:VEVENT")) + ).is_supported(STORED, return_type=dict) + assert verdict["behaviour"].startswith("stored with CLASS added") + + +def test_stored_differently_without_an_etag_is_full(): + """Baikal leaves the ETag out when it rewrites.""" + verdict = _run_all(EtagServer(put_etag=None, get_etag='"g"', rewrite=_add_dtstamp_last)).is_supported( + STORED, return_type=dict + ) + assert verdict["support"] == "full" + + +def test_stored_differently_with_a_weak_etag_is_full(): + verdict = _run_all(EtagServer(put_etag='W/"w"', rewrite=_add_dtstamp_last)).is_supported(STORED, return_type=dict) + assert verdict["support"] == "full" + + +def test_line_endings_are_compared_on_the_bytes_received(): + """caldav's .raw has LF where the server sent CRLF; that is not a rewrite.""" + server = EtagServer(rewrite=lambda body: body.replace("\r\n", "\n")) + assert _run_all(server).is_supported(STORED, return_type=dict)["support"] == "broken" + + +def test_without_the_bytes_received_line_endings_are_not_compared(): + """An older caldav keeps only .raw, with CRLF turned into LF""" + server = EtagServer(raw_body=False, rewrite=lambda body: body.replace("\r\n", "\n")) + assert _run_all(server).is_supported(STORED, return_type=dict) == {"support": "full"} + + +def test_without_the_bytes_received_a_rewrite_is_still_seen(): + verdict = _run_all(EtagServer(raw_body=False, rewrite=_add_dtstamp_last)).is_supported(STORED, return_type=dict) + assert verdict["support"] == "broken" + + +def test_a_rewrite_on_update_only_is_not_blamed_on_the_create(): + """The probe PUTs the object again for the If-Match checks; what an update + adds (LAST-MODIFIED, SEQUENCE) says nothing about the create's ETag""" + server = EtagServer(rewrite_updates=_add_dtstamp_last) + assert _run_all(server).is_supported(STORED, return_type=dict) == {"support": "full"} + + +def test_an_unusable_etag_is_not_asked_whether_it_matches(): + """Robur: the ETag is malformed, so whether it matches the stored object is moot.""" + fs = _run_all(EtagServer(put_etag="a22ccfb2985aed13f50b4991a110d32253fd99aa")) + assert fs.is_supported(STORED, return_type=dict) == fs.is_supported("save.etag", return_type=dict) + assert fs.is_supported(STORED, return_type=dict)["support"] == "broken" diff --git a/tests/test_search_text_uid.py b/tests/test_search_text_uid.py new file mode 100644 index 0000000..90b1ddd --- /dev/null +++ b/tests/test_search_text_uid.py @@ -0,0 +1,75 @@ +"""Unit tests for CheckSearch._probe_text_uid (search.text.uid). + +A calendar-query with a UID filter works if it answers the one object +with that UID. iCloud answers it with 412. +https://github.com/python-caldav/caldav/issues/783 +""" + +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet +from caldav.lib.error import AuthorizationError, DAVError, ReportError + +from caldav_server_tester.checker import ServerQuirkChecker +from caldav_server_tester.checks import CheckSearch + +pytestmark = pytest.mark.skipif( + "search.text.uid" not in FeatureSet.FEATURES, + reason="needs a caldav with the search.text.uid feature", +) + + +UID = "csc_event_with_categories" + + +def _event(uid: str = UID) -> Mock: + event = Mock() + event.icalendar_component = {"UID": uid} + return event + + +def _probe(**search_result) -> tuple[CheckSearch, Mock]: + cal = Mock() + cal.search.configure_mock(**search_result) + client = Mock() + client.features = FeatureSet() + checker = ServerQuirkChecker(client, debug_mode=None) + checker.calendar = cal + check = CheckSearch(checker) + check._probe_text_uid() + return check, cal + + +def _support(check: CheckSearch) -> str: + return check.checker.features_checked.is_supported("search.text.uid", str) + + +def test_full_when_one_object_is_returned() -> None: + check, cal = _probe(return_value=[_event()]) + assert _support(check) == "full" + assert cal.search.call_args.kwargs["uid"] == "csc_event_with_categories" + assert cal.search.call_args.kwargs["post_filter"] is False + + +def test_unsupported_when_the_filter_is_ignored() -> None: + check, _ = _probe(return_value=[_event(), _event("other-1"), _event("other-2")]) + assert _support(check) == "unsupported" + + +def test_broken_when_another_object_is_returned() -> None: + check, _ = _probe(return_value=[_event("some-other-uid")]) + assert _support(check) == "broken" + + +def test_broken_when_the_object_is_not_found() -> None: + check, _ = _probe(return_value=[]) + assert _support(check) == "broken" + + +@pytest.mark.parametrize( + "exc", [ReportError("412"), AuthorizationError("403"), DAVError("500")], ids=["412", "403", "500"] +) +def test_ungraceful_when_the_search_fails(exc: Exception) -> None: + check, _ = _probe(side_effect=exc) + assert _support(check) == "ungraceful" diff --git a/tests/test_supported_component_set.py b/tests/test_supported_component_set.py index 7c7e3a7..37adfee 100644 --- a/tests/test_supported_component_set.py +++ b/tests/test_supported_component_set.py @@ -1,73 +1,147 @@ -"""A calendar created VTODO-only, and whether it really is one. - -RFC 4791 section 5.2.3 lets a client hand MKCALENDAR a -CALDAV:supported-calendar-component-set, and section 5.3.1 has the server -answer 403 (CALDAV:supported-calendar-component-set) if it cannot do what was -asked. Bedework 5 does neither: it answers the property "200 ok" and then -creates a VEVENT-only collection anyway, because a Bedework collection's -component set is a function of its internal calType, which CalDAV cannot set. -So the restriction has to be measured after the fact, from what the collection -advertises and from what it accepts. +"""The supported-calendar-component-set property, asked four questions. + +RFC 4791 section 5.2.3 makes the property optional; where a calendar has it, +storing a component type it does not list "MUST result in an error", and +where it has none, every type must be accepted. Section 5.3.1.1 has a +MKCALENDAR whose property cannot be set answer 207 with 403/409 for it and +create nothing. So: + +- ``supported-components.property-exists``: does the test calendar report it? +- ``supported-components.honoured``: does a calendar accept exactly what it + lists - everything where it lists nothing? +- ``create-calendar.with-supported-component-types``: does a MKCALENDAR asking + for {VTODO} give a calendar with that set? And ``.multiple``: one asking for + {VEVENT, VTODO}? + +Bedework 5 answers the property "200 ok" and creates a VEVENT-only collection +anyway; Bedework 3.10, DAViCal and Stalwart report the VTODO-only set and take +a VEVENT; CCS refuses a two-type MKCALENDAR the 5.3.1.1 way. """ from unittest.mock import Mock import pytest from caldav.compatibility_hints import FeatureSet -from caldav.lib.error import PutError +from caldav.lib.error import MkcalendarError, PropfindError, PutError import caldav_server_tester.checks as checks_mod from caldav_server_tester.checker import ServerQuirkChecker -from caldav_server_tester.checks import CheckMakeDeleteCalendar, CheckSupportedComponentSet +from caldav_server_tester.checks import CheckMakeDeleteCalendar, CheckSupportedComponentSet, PrepareCalendar -FEATURE = "create-calendar.with-supported-component-types" +PARENT = "create-calendar.with-supported-component-types" +MULTIPLE = "create-calendar.with-supported-component-types.multiple" +EXISTS = "supported-components.property-exists" +HONOURED = "supported-components.honoured" + +ALL = ["VEVENT", "VTODO", "VJOURNAL"] +MULTI_ERROR_BODY = ( + '' + "/cal/x/" + "HTTP/1.1 403 Forbidden" +) + + +def listing_as_asked(asked): + return list(asked) + + +def accepting_listed(asked, component): + return component in asked + + +def accepting_all(asked, component): + return True class ComponentSetServer: - """A server that answers a component-restricted MKCALENDAR in one of the - ways the four support levels describe. - - ``advertises`` is what the created collection reports for - supported-calendar-component-set (``[]`` for a server that does not report - the property at all), ``accepts_wrong_type`` whether a VEVENT may be saved - to the collection the probe asked to be VTODO-only, and ``refuses`` whether - the MKCALENDAR carrying the component set is refused outright. + """A server answering the probe's MKCALENDARs and saves. + + ``reports(asked)`` is what a calendar created for ``asked`` lists (``None``: + the property is absent, ``"fail"``: the PROPFIND fails), ``accepts(asked, + component)`` whether such a calendar stores ``component``. ``refuses`` is + ``"always"``, ``"with-component-set"``, or ``"multiple-207"`` / + ``"multiple-500"`` / ``"multiple-403"`` for a MKCALENDAR naming several + types. ``test_calendar_reports`` is what the test calendar lists. """ - def __init__(self, advertises=("VTODO",), accepts_wrong_type=False, refuses=None): - self.advertises = list(advertises) - self.accepts_wrong_type = accepts_wrong_type + def __init__( + self, + reports=listing_as_asked, + accepts=accepting_listed, + refuses=None, + test_calendar_reports=ALL, + raises_on=None, + property_raises=False, + multi_body=None, + ): + self.reports = reports + self.accepts = accepts self.refuses = refuses + self.test_calendar_reports = test_calendar_reports + self.raises_on = raises_on + self.property_raises = property_raises + self.multi_body = multi_body self.created = [] self.deleted = [] self.saved = [] + self.objects_deleted = [] def make_calendar(self, cal_id=None, supported_calendar_component_set=None, **kwargs): - if self.refuses == "always" or (self.refuses == "with-component-set" and supported_calendar_component_set): - raise checks_mod.DAVError("403 Forbidden") - self.created.append((cal_id, supported_calendar_component_set)) - return self.calendar(cal_id=cal_id) - - def calendar(self, cal_id=None, name=None): + asked = supported_calendar_component_set + if self.refuses == "always" or (self.refuses == "with-component-set" and asked): + raise MkcalendarError("403 Forbidden", status=403) + if asked and len(asked) > 1: + if self.refuses == "multiple-207": + raise MkcalendarError("207 Multi-Status", body=self.multi_body or MULTI_ERROR_BODY, status=207) + if self.refuses == "multiple-500": + raise MkcalendarError("500 Internal Server Error", status=500) + if self.refuses == "multiple-403": + raise MkcalendarError("403 Forbidden", status=403) + self.created.append((cal_id, asked)) + return self._calendar(cal_id, asked or ALL, self.reports(asked) if asked else ALL) + + def _calendar(self, cal_id, asked, reported): cal = Mock() cal.cal_id = cal_id cal.url = f"/dav/user/{cal_id}/" cal.events.return_value = [] - cal.get_supported_components.return_value = list(self.advertises) - cal.save_event.side_effect = self._save_event + + def get_supported_components(with_fallback=True): + if self.property_raises and cal_id == CheckSupportedComponentSet.CAL_ID: + raise ValueError("the fake blew up in a place the probe does not catch") + if reported == "fail": + raise PropfindError("500 Internal Server Error", status=500) + if reported is None: + raise PropfindError("supported-calendar-component-set not supported") + return list(reported) + + cal.get_supported_components.side_effect = get_supported_components + cal.save_event.side_effect = lambda **kw: self._save(asked, "VEVENT", kw) + cal.save_todo.side_effect = lambda **kw: self._save(asked, "VTODO", kw) + cal.save_journal.side_effect = lambda **kw: self._save(asked, "VJOURNAL", kw) return cal - def _save_event(self, *args, **kwargs): - if not self.accepts_wrong_type: + def _save(self, asked, component, kwargs): + if self.raises_on == component: + raise RuntimeError("the probe blew up") + if not self.accepts(asked, component): raise PutError("403 Forbidden") - self.saved.append(kwargs.get("uid")) - return Mock() + self.saved.append((asked, component)) + obj = Mock() + obj.delete.side_effect = lambda: self.objects_deleted.append(kwargs.get("uid")) + return obj + + def calendar(self, cal_id=None, name=None): + return self._calendar(cal_id, ALL, ALL) + + def test_calendar(self): + return self._calendar("caldav-server-checker-calendar", ALL, self.test_calendar_reports) def delete(self, cal): self.deleted.append(getattr(cal, "cal_id", None)) -def make_check(monkeypatch, server, create_calendar="full"): +def make_check(monkeypatch, server, create_calendar="full", mixed=None): monkeypatch.setattr(checks_mod.time, "sleep", lambda _s: None) client = Mock() client.features = FeatureSet() @@ -77,124 +151,394 @@ def make_check(monkeypatch, server, create_calendar="full"): checker.principal.calendar.side_effect = server.calendar checker.principal.make_calendar.side_effect = server.make_calendar checker.principal.calendars.return_value = [] + checker.calendar = server.test_calendar() checker.features_checked.set_feature("create-calendar", {"support": create_calendar}) - checker._checks_run.add(CheckMakeDeleteCalendar) + for feature, value in (mixed or {}).items(): + checker.features_checked.set_feature(feature, value) + checker._checks_run.update({CheckMakeDeleteCalendar, PrepareCalendar}) probe = CheckSupportedComponentSet(checker) probe.expected_features = client.features return probe -def observed(check, as_type=dict): - return check.checker.features_checked.is_supported(FEATURE, as_type) +def observed(check, feature, as_type=str): + return check.checker.features_checked.is_supported(feature, as_type) -def test_a_restriction_that_sticks_is_full(monkeypatch) -> None: - check = make_check(monkeypatch, ComponentSetServer(advertises=["VTODO"], accepts_wrong_type=False)) +def explicit(check, feature): + return feature in check.checker.features_checked._server_features - check._run_check() - assert observed(check, str) == "full" +def run(monkeypatch, server, **kwargs): + check = make_check(monkeypatch, server, **kwargs) + check.run_check() + return check -def test_a_restriction_that_is_ignored_is_unsupported(monkeypatch) -> None: - """The Bedework 5 shape: the collection comes back VEVENT-only and takes a - VEVENT quite happily.""" - server = ComponentSetServer(advertises=["VEVENT"], accepts_wrong_type=True) - check = make_check(monkeypatch, server) +## One fake server per column of the design's grade table - check._run_check() - assert observed(check, str) == "unsupported" - assert "VTODO" in observed(check)["behaviour"] - assert "VEVENT" in observed(check)["behaviour"] +def test_sabredav_shape_is_full_throughout(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer()) + assert observed(check, EXISTS) == "full" + assert observed(check, HONOURED) == "full" + assert observed(check, PARENT) == "full" + assert observed(check, MULTIPLE) == "full" -def test_a_restriction_that_is_advertised_but_not_enforced_is_full(monkeypatch) -> None: - """Advertising it is half the job and the feature says so ("advertises (or - enforces)"), but a wrong-type object getting in is worth writing down.""" - check = make_check(monkeypatch, ComponentSetServer(advertises=["VTODO"], accepts_wrong_type=True)) - check._run_check() +def test_ccs_shape_refuses_a_set_of_several(monkeypatch) -> None: + """CCS answers 207 with 403 for the property and creates nothing, as RFC + 4791 section 5.3.1.1 has a server do that cannot set it.""" + check = run(monkeypatch, ComponentSetServer(refuses="multiple-207")) - assert observed(check, str) == "full" - assert "not enforced" in observed(check)["behaviour"] + assert observed(check, PARENT) == "full" + assert observed(check, HONOURED) == "full" + assert observed(check, MULTIPLE) == "unsupported" + assert "403" in observed(check, MULTIPLE, dict)["behaviour"] -def test_a_restriction_that_is_enforced_but_not_advertised_is_full(monkeypatch) -> None: - """No property to read back - RFC 4791 section 5.2.3 makes it optional - - but the collection refuses the wrong type, so the restriction is real.""" - check = make_check(monkeypatch, ComponentSetServer(advertises=[], accepts_wrong_type=False)) +def test_advertised_but_not_enforced_is_disobedience_not_a_creation_failure(monkeypatch) -> None: + """Bedework 3.10, DAViCal, Stalwart: the calendar lists ['VTODO'], as asked, + and takes a VEVENT anyway.""" + check = run(monkeypatch, ComponentSetServer(accepts=accepting_all)) - check._run_check() + assert observed(check, PARENT) == "full" + assert observed(check, HONOURED) == "broken" + assert "VEVENT" in observed(check, HONOURED, dict)["behaviour"] + assert observed(check, EXISTS) == "full" + assert observed(check, MULTIPLE) == "full" - assert observed(check, str) == "full" - assert "not advertised" in observed(check)["behaviour"] +def test_a_set_of_several_reported_as_one_name_is_broken(monkeypatch) -> None: + """Bedework 3.10 is said to report a VEVENT+VTODO calendar as 'VEVENTVTODO'.""" + server = ComponentSetServer( + reports=lambda asked: ["".join(asked)] if len(asked) > 1 else list(asked), + accepts=accepting_all, + ) + check = run(monkeypatch, server) -def test_a_creation_refusing_the_component_set_is_ungraceful(monkeypatch) -> None: - check = make_check(monkeypatch, ComponentSetServer(refuses="with-component-set")) + assert observed(check, MULTIPLE) == "broken" + assert "VEVENTVTODO" in observed(check, MULTIPLE, dict)["behaviour"] - check._run_check() - assert observed(check, str) == "ungraceful" +def test_bedework_5_shape(monkeypatch) -> None: + """Every calendar is VEVENT-only, whatever was asked for, and says so.""" + server = ComponentSetServer( + reports=lambda asked: ["VEVENT"], accepts=lambda asked, component: component == "VEVENT" + ) + check = run(monkeypatch, server) + assert observed(check, PARENT) == "unsupported" + assert observed(check, HONOURED) == "full" + ## measured, not inherited from the negative parent + assert explicit(check, MULTIPLE) + assert observed(check, MULTIPLE) == "broken" -def test_a_creation_failing_either_way_says_nothing(monkeypatch) -> None: - """Not evidence about the component set: this server is refusing to create - calendars at all, which is create-calendar's verdict to give.""" - check = make_check(monkeypatch, ComponentSetServer(refuses="always")) - check._run_check() +def test_sogo_shape(monkeypatch) -> None: + """The set is ignored: VEVENT, VFREEBUSY and VTODO are listed and taken.""" + server = ComponentSetServer( + reports=lambda asked: ["VEVENT", "VFREEBUSY", "VTODO"], + accepts=lambda asked, component: component in ("VEVENT", "VTODO"), + ) + check = run(monkeypatch, server) - assert observed(check, str) == "unknown" + assert observed(check, PARENT) == "unsupported" + assert observed(check, HONOURED) == "full" + assert explicit(check, MULTIPLE) + assert observed(check, MULTIPLE) == "full" -def test_it_collapses_under_an_unsupported_create_calendar(monkeypatch) -> None: - server = ComponentSetServer() - check = make_check(monkeypatch, server, create_calendar="unsupported") +def test_a_set_lacking_the_requested_type_is_not_honoured_at_creation(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer(reports=lambda asked: ["VFREEBUSY"])) + + assert observed(check, PARENT) == "unsupported" + + +def test_zimbra_shape(monkeypatch) -> None: + """Zimbra adds VFREEBUSY to the set it reports - which nothing is ever stored + as - so a VTODO calendar is a VTODO calendar; a mixed one is not, since + tasks live on task lists of their own.""" + server = ComponentSetServer( + reports=lambda asked: ["VFREEBUSY", "VTODO"] if asked == ["VTODO"] else ["VEVENT", "VFREEBUSY"], + accepts=lambda asked, component: component in asked or (component == "VEVENT" and len(asked) > 1), + ) + check = run(monkeypatch, server) + + assert observed(check, PARENT) == "full" + assert observed(check, HONOURED) == "full" + assert observed(check, MULTIPLE) == "broken" + assert "VFREEBUSY" in observed(check, MULTIPLE, dict)["behaviour"] + + +def test_robur_shape(monkeypatch) -> None: + """No property, so every type must be accepted - yet a VJOURNAL is refused, + on the probe calendar itself and not just on a dedicated one.""" + server = ComponentSetServer( + reports=lambda asked: None, + accepts=lambda asked, component: component in ("VEVENT", "VTODO"), + test_calendar_reports=None, + ) + check = run(monkeypatch, server) + + assert observed(check, EXISTS) == "unsupported" + assert observed(check, HONOURED) == "broken" + assert "VJOURNAL" in observed(check, HONOURED, dict)["behaviour"] + + +def test_a_listed_set_without_the_journal_refuses_it(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer()) + + assert observed(check, HONOURED) == "full" + + +def test_an_ignored_and_unreported_set_is_unsupported(monkeypatch) -> None: + """Nothing reported, and the VEVENT goes in: an absent property means every + type is welcome, so obedience holds, but the calendar is not VTODO-only.""" + server = ComponentSetServer(reports=lambda asked: None, accepts=accepting_all) + check = run(monkeypatch, server) + + assert observed(check, PARENT) == "unsupported" + assert observed(check, HONOURED) == "full" + + +def test_enforced_but_not_advertised(monkeypatch) -> None: + """The VEVENT is refused, though the calendar lists no set: the creation did + what was asked, but a calendar without the property must take every type.""" + check = run(monkeypatch, ComponentSetServer(reports=lambda asked: None)) + + assert observed(check, PARENT) == "full" + assert observed(check, HONOURED) == "broken" + assert "no component set" in observed(check, HONOURED, dict)["behaviour"] + + +def test_another_set_reported_and_the_wrong_type_refused(monkeypatch) -> None: + """Undecided case in the design: another set is a creation failure, and + refusing a type the property lists is disobedience.""" + server = ComponentSetServer(reports=lambda asked: ["VEVENT", "VTODO"]) + check = run(monkeypatch, server) + + assert observed(check, PARENT) == "unsupported" + assert observed(check, HONOURED) == "broken" + + +## The test calendar + + +def test_property_exists_follows_the_test_calendar(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer(test_calendar_reports=None)) + + assert observed(check, EXISTS) == "unsupported" + + +def test_a_failing_propfind_on_the_test_calendar_is_unknown(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer(test_calendar_reports="fail")) + + assert observed(check, EXISTS) == "unknown" - check._run_check() - assert FEATURE not in check.checker.features_checked._server_features +@pytest.mark.parametrize("mixed", ["save-load.todo.mixed-calendar", "save-load.journal.mixed-calendar"]) +def test_a_refused_type_on_a_test_calendar_without_the_property_is_disobedience(monkeypatch, mixed) -> None: + """No property, yet no VJOURNAL either. Where no calendar can be made to + look at, what PrepareCalendar found out is all there is, and the verdict + says it is inferred.""" + check = run( + monkeypatch, ComponentSetServer(test_calendar_reports=None), create_calendar="unsupported", mixed={mixed: False} + ) + + assert observed(check, EXISTS) == "unsupported" + assert observed(check, HONOURED) == "broken" + assert "save-load" in observed(check, HONOURED, dict)["behaviour"] + + +def test_a_flag_on_the_test_calendar_does_not_override_a_probe_calendar(monkeypatch) -> None: + """PrepareCalendar sets mixed-calendar False after any failure of its first + save, a gateway error included. With a probe calendar that took every type + there is better evidence.""" + server = ComponentSetServer(reports=lambda asked: None, accepts=accepting_all, test_calendar_reports=None) + check = run(monkeypatch, server, mixed={"save-load.journal.mixed-calendar": False}) + + assert observed(check, HONOURED) == "full" + + +def test_a_refused_type_the_test_calendar_does_not_list_is_obedience(monkeypatch) -> None: + server = ComponentSetServer(test_calendar_reports=["VEVENT", "VTODO"]) + check = run(monkeypatch, server, mixed={"save-load.journal.mixed-calendar": False}) + + assert observed(check, HONOURED) == "full" + + +def test_journals_refused_everywhere_count_as_refused(monkeypatch) -> None: + """save-load.journal negative: the mixed-calendar child inherits it.""" + server = ComponentSetServer(test_calendar_reports=None) + check = run(monkeypatch, server, create_calendar="unsupported", mixed={"save-load.journal": "ungraceful"}) + + assert observed(check, HONOURED) == "broken" + + +def test_an_unreadable_probe_calendar_property_is_not_reported_as_not_created(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer(reports=lambda asked: "fail")) + + assert observed(check, HONOURED) == "unknown" + assert "could not be read" in observed(check, HONOURED, dict)["behaviour"] + + +## Without MKCALENDAR + + +def test_without_mkcalendar_obedience_is_unknown(monkeypatch) -> None: + server = ComponentSetServer() + check = run(monkeypatch, server, create_calendar="unsupported") + + assert observed(check, EXISTS) == "full" + assert observed(check, HONOURED) == "unknown" + assert not explicit(check, PARENT) + assert not explicit(check, MULTIPLE) + assert observed(check, MULTIPLE) == "unsupported" assert server.created == [] -@pytest.mark.parametrize("advertises,accepts", [(["VTODO"], False), (["VEVENT"], True), ([], False)]) -def test_the_probe_calendar_is_deleted_again(monkeypatch, advertises, accepts) -> None: - server = ComponentSetServer(advertises=advertises, accepts_wrong_type=accepts) - check = make_check(monkeypatch, server) +def test_without_mkcalendar_a_refusing_test_calendar_is_still_disobedience(monkeypatch) -> None: + server = ComponentSetServer(test_calendar_reports=None) + check = run(monkeypatch, server, create_calendar="unsupported", mixed={"save-load.journal.mixed-calendar": False}) + + assert observed(check, HONOURED) == "broken" + + +## Refused or failing creation - check._run_check() - assert CheckSupportedComponentSet.CAL_ID in server.deleted +def test_a_207_refusing_another_property_is_not_the_conformant_refusal(monkeypatch) -> None: + body = MULTI_ERROR_BODY.replace("", "") + check = run(monkeypatch, ComponentSetServer(refuses="multiple-207", multi_body=body)) + + assert observed(check, MULTIPLE) == "ungraceful" + + +def test_a_creation_refusing_the_component_set_is_ungraceful(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer(refuses="with-component-set")) + + assert observed(check, PARENT) == "ungraceful" + ## not probed: inherits the parent + assert not explicit(check, MULTIPLE) + assert observed(check, MULTIPLE) == "ungraceful" + assert observed(check, HONOURED) == "unknown" + + +def test_a_creation_failing_either_way_says_nothing(monkeypatch) -> None: + check = run(monkeypatch, ComponentSetServer(refuses="always")) + + assert observed(check, PARENT) == "unknown" + assert not explicit(check, MULTIPLE) + + +@pytest.mark.parametrize("refuses", ["multiple-500", "multiple-403"]) +def test_a_set_of_several_refused_otherwise_is_ungraceful(monkeypatch, refuses) -> None: + check = run(monkeypatch, ComponentSetServer(refuses=refuses)) + + assert observed(check, PARENT) == "full" + assert observed(check, MULTIPLE) == "ungraceful" class FailingComponentSetServer(ComponentSetServer): - """Fails the wrong-type save with ``status`` rather than a refusal.""" + """Fails the VEVENT save into the VTODO-only calendar with ``status``.""" def __init__(self, status, **kwargs): super().__init__(**kwargs) self.status = status - def _save_event(self, *args, **kwargs): - raise PutError(f"{self.status} Server Error") + def _save(self, asked, component, kwargs): + if component == "VEVENT" and asked == ["VTODO"]: + raise PutError(f"{self.status} Server Error") + return super()._save(asked, component, kwargs) + + +def test_a_500_on_the_wrong_type_is_ungraceful_obedience(monkeypatch) -> None: + """A 500 is the server failing on the wrong type: enforcement, ungracefully. + The listed set still answers the creation question.""" + check = run(monkeypatch, FailingComponentSetServer(500)) + + assert observed(check, PARENT) == "full" + assert observed(check, HONOURED) == "ungraceful" + assert "500" in observed(check, HONOURED, dict)["behaviour"] + +def test_a_500_on_the_wrong_type_with_nothing_reported_is_ungraceful(monkeypatch) -> None: + check = run(monkeypatch, FailingComponentSetServer(500, reports=lambda asked: None)) -@pytest.mark.parametrize("advertises", [[], ["VTODO"]], ids=["unadvertised", "advertised"]) -def test_a_500_on_the_wrong_type_is_ungraceful(monkeypatch, advertises) -> None: - """A 500 is the server failing on the wrong type: enforcement, ungracefully.""" - check = make_check(monkeypatch, FailingComponentSetServer(500, advertises=advertises)) + assert observed(check, PARENT) == "ungraceful" + assert "500" in observed(check, PARENT, dict)["behaviour"] - check._run_check() - assert observed(check, str) == "ungraceful" - assert "500" in observed(check, dict)["behaviour"] +def test_a_gateway_status_on_the_wrong_type_is_not_enforcement(monkeypatch) -> None: + check = run(monkeypatch, FailingComponentSetServer(503, reports=lambda asked: None, accepts=accepting_all)) + assert observed(check, PARENT) == "unknown" + assert observed(check, HONOURED) == "unknown" -def test_a_gateway_or_overload_status_on_the_wrong_type_is_not_enforcement(monkeypatch) -> None: - """A 503 says the server had a bad moment, not that it enforced anything.""" - check = make_check(monkeypatch, FailingComponentSetServer(503, advertises=[])) - check._run_check() +## Clean-up - assert observed(check, str) == "unknown" + +@pytest.mark.parametrize( + "server", + [ + ComponentSetServer(), + ComponentSetServer(accepts=accepting_all), + ComponentSetServer(refuses="multiple-207"), + ComponentSetServer(raises_on="VTODO"), + ComponentSetServer(property_raises=True), + ], + ids=["sabredav", "bedework3", "ccs", "save-raises", "probe-raises"], +) +def test_both_probe_calendars_are_deleted_before_and_after(monkeypatch, server) -> None: + check = run(monkeypatch, server) + + for cal_id in (CheckSupportedComponentSet.CAL_ID, CheckSupportedComponentSet.CAL_ID_MULTIPLE): + assert server.deleted.count(cal_id) >= 2, cal_id + assert cal_id.startswith("caldav-server-checker") + assert explicit(check, EXISTS) + assert explicit(check, HONOURED) + + +def test_the_first_probe_calendar_is_gone_before_the_second_is_made(monkeypatch) -> None: + """A provider with a calendar quota would refuse the second otherwise.""" + server = ComponentSetServer() + order = [] + original_make, original_delete = server.make_calendar, server.delete + server.make_calendar = lambda cal_id=None, **kw: (order.append(("make", cal_id)), original_make(cal_id, **kw))[1] + server.delete = lambda cal: (order.append(("delete", getattr(cal, "cal_id", None))), original_delete(cal))[1] + run(monkeypatch, server) + + first, second = CheckSupportedComponentSet.CAL_ID, CheckSupportedComponentSet.CAL_ID_MULTIPLE + assert order.index(("delete", first), order.index(("make", first))) < order.index(("make", second)) + + +def test_saved_objects_are_deleted_again(monkeypatch) -> None: + server = ComponentSetServer(accepts=accepting_all) + run(monkeypatch, server) + + assert len(server.objects_deleted) == len(server.saved) == 5 + + +@pytest.mark.parametrize( + ("create_calendar", "server"), + [ + ("full", ComponentSetServer()), + ("unsupported", ComponentSetServer()), + ("full", ComponentSetServer(refuses="always")), + ("full", ComponentSetServer(refuses="with-component-set")), + ("full", ComponentSetServer(raises_on="VEVENT")), + ("full", ComponentSetServer(property_raises=True)), + ("full", ComponentSetServer(test_calendar_reports="fail")), + ], + ids=["full", "no-mkcalendar", "no-creation", "ungraceful", "raises", "probe-raises", "propfind-fails"], +) +def test_run_check_finds_every_declared_feature(monkeypatch, create_calendar, server) -> None: + """run_check asserts every declared feature was set or has a set ancestor, + and the grouping node supported-components never is.""" + check = run(monkeypatch, server, create_calendar=create_calendar) + + assert explicit(check, EXISTS) + assert explicit(check, HONOURED) diff --git a/tests/test_sync_members.py b/tests/test_sync_members.py new file mode 100644 index 0000000..d07079a --- /dev/null +++ b/tests/test_sync_members.py @@ -0,0 +1,232 @@ +"""What a sync-collection report lists, member by member. + +RFC 6578 section 3.4: an initial sync (empty token) lists the current members +and no removed ones. Radicale lists deleted members with a 404, Stalwart also +lists the collection itself. Section 3.5.2: a member created and deleted +between two reports is reported as removed; Xandikos leaves it out, and so +does Stalwart where nothing else it had changed. Sections 3.5.1 and 3.2: a member deleted and re-created at one href is +reported once, as changed; Stalwart reports it as removed and then changed. +""" + +import re +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet +from caldav.lib.error import DAVError +from caldav.lib.url import URL +from lxml import etree + +from caldav_server_tester.checks import CheckSyncMembers + +BASE = "http://example.com/cal/" +INITIAL = "sync-token.initial-sync" +TRANSIENT = "sync-token.delete.created-and-deleted" +RECREATED = "sync-token.recreated" + + +class SyncServer: + """A calendar keeping a change log; a token is the log's length. + + ``removed_in_initial`` lists deleted members with a 404 in an initial + sync (Radicale), ``collection_in_initial`` lists the collection itself + (Stalwart), ``skip_transient`` leaves out a member created and deleted + since the token (Xandikos), ``skip_lone_transient`` does so only where no + member that existed at the token changed too (Stalwart), and + ``recreate_twice`` reports a re-created member as removed and then + changed (Stalwart). + """ + + def __init__( + self, + removed_in_initial=False, + collection_in_initial=False, + skip_transient=False, + skip_lone_transient=False, + recreate_twice=False, + recreate_as_removed=False, + report_raises=None, + reports_before_raising=0, + refuse_recreate=False, + ): + self.refuse_recreate = refuse_recreate + self.removed_in_initial = removed_in_initial + self.collection_in_initial = collection_in_initial + self.skip_transient = skip_transient + self.skip_lone_transient = skip_lone_transient + self.recreate_twice = recreate_twice + self.recreate_as_removed = recreate_as_removed + self.report_raises = report_raises + self.reports_before_raising = reports_before_raising + self.log = [] + self.objects = set() + self.deleted = set() + + @staticmethod + def _name(url): + return url.rstrip("/").rsplit("/", 1)[-1] + + def put(self, url, body, headers=None): + name = self._name(url) + if self.refuse_recreate and name in self.deleted: + return Mock(status=409, headers={}) + self.log.append((name, "put")) + self.objects.add(name) + self.deleted.discard(name) + return Mock(status=201, headers={}) + + def delete(self, url, headers=None): + name = self._name(url) + if name in self.objects: + self.log.append((name, "delete")) + self.objects.discard(name) + self.deleted.add(name) + return Mock(status=204) + return Mock(status=404) + + def _state_at(self, seq): + state = set() + for name, action in self.log[:seq]: + (state.add if action == "put" else state.discard)(name) + return state + + def report(self, url, body, depth=0): + if self.report_raises: + if not self.reports_before_raising: + raise self.report_raises + self.reports_before_raising -= 1 + token = re.search(r"(.*?)", body)[1] + entries = [] + if not token: + entries = [(name, None) for name in sorted(self.objects)] + if self.removed_in_initial: + entries += [(name, 404) for name in sorted(self.deleted)] + if self.collection_in_initial: + entries.append(("", None)) + else: + since = int(token) + before = self._state_at(since) + window = self.log[since:] + ## Stalwart reports a transient member only beside a change to one it had + skip_transient = self.skip_transient or ( + self.skip_lone_transient and not any(n in before for n, _ in window) + ) + for name in dict.fromkeys(n for n, _ in window): + deleted_in_window = any(n == name and a == "delete" for n, a in window) + if name in self.objects: + if deleted_in_window and name in before and self.recreate_as_removed: + entries.append((name, 404)) + elif deleted_in_window and name in before and self.recreate_twice: + entries += [(name, 404), (name, None)] + else: + entries.append((name, None)) + elif name in before or not skip_transient: + entries.append((name, 404)) + return self._multistatus(entries, str(len(self.log))) + + @staticmethod + def _multistatus(entries, token): + parts = [] + for name, status in entries: + href = f"/cal/{name}" + if status: + parts.append(f"{href}HTTP/1.1 {status} Not Found") + else: + parts.append( + f'{href}"x"' + "HTTP/1.1 200 OK" + ) + xml = ( + '' + + "".join(parts) + + f"{token}" + ) + return Mock(status=207, tree=etree.fromstring(xml.encode()), raw=xml) + + +def _run(server, sync=None): + checker = Mock() + checker._features_checked = FeatureSet({"sync-token": sync or {"support": "full"}}) + checker.features_checked = checker._features_checked + checker.debug_mode = None + checker._client_obj = server + checker.fixture_base_year = 2027 + checker.calendar.url = URL.objectify(BASE) + CheckSyncMembers(checker)._run_check() + fs = checker._features_checked + return {f: fs.is_supported(f, return_type=dict) for f in (INITIAL, TRANSIENT, RECREATED)} + + +def test_an_rfc_server_is_full_on_all_three(): + server = SyncServer() + verdicts = _run(server) + assert {v["support"] for v in verdicts.values()} == {"full"} + assert not server.objects + + +def test_deleted_members_in_an_initial_sync(): + """Radicale""" + verdict = _run(SyncServer(removed_in_initial=True))[INITIAL] + assert verdict["support"] == "quirk" + assert verdict["behaviour"] == "lists deleted members with a 404" + + +def test_the_collection_in_an_initial_sync(): + """Stalwart""" + verdict = _run(SyncServer(collection_in_initial=True))[INITIAL] + assert verdict["support"] == "quirk" + assert verdict["behaviour"] == "lists the collection itself" + + +def test_a_created_and_deleted_member_left_out(): + """Xandikos, Stalwart""" + verdict = _run(SyncServer(skip_transient=True))[TRANSIENT] + assert verdict["support"] == "unsupported" + assert "not reported" in verdict["behaviour"] + + +def test_a_created_and_deleted_member_alone_is_not_reported(): + """Stalwart 0.16: reported only where a member it had changed in the same interval""" + verdict = _run(SyncServer(skip_lone_transient=True))[TRANSIENT] + assert verdict["support"] == "unsupported" + + +def test_a_recreated_member_reported_twice(): + """Stalwart""" + verdict = _run(SyncServer(recreate_twice=True))[RECREATED] + assert verdict["support"] == "quirk" + assert verdict["behaviour"] == "reported twice: as removed, then as changed" + + +def test_a_recreated_member_reported_as_removed_only(): + verdict = _run(SyncServer(recreate_as_removed=True))[RECREATED] + assert verdict["support"] == "broken" + + +def test_a_later_failing_report_keeps_the_verdicts_already_given(): + verdicts = _run(SyncServer(skip_transient=True, report_raises=DAVError("500"), reports_before_raising=2)) + assert verdicts[INITIAL] == {"support": "full"} + assert verdicts[TRANSIENT]["support"] == "unsupported" + assert verdicts[RECREATED]["support"] == "ungraceful" + + +def test_a_failing_report_is_ungraceful(): + verdicts = _run(SyncServer(report_raises=DAVError("500"))) + assert {v["support"] for v in verdicts.values()} == {"ungraceful"} + + +@pytest.mark.parametrize("support", ["unsupported", "broken"]) +def test_nothing_is_probed_without_sync_support(support): + server = SyncServer() + verdicts = _run(server, sync={"support": support}) + assert not server.log + ## Not probed, so derived from sync-token + assert {v["support"] for v in verdicts.values()} == {support} + + +def test_a_member_that_cannot_be_recreated_is_not_graded(): + """Nextcloud keeps a deleted object's UID in its trashbin and refuses it.""" + verdicts = _run(SyncServer(refuse_recreate=True)) + assert verdicts[RECREATED]["support"] == "unknown" + assert "re-created" in verdicts[RECREATED]["behaviour"] + assert verdicts[TRANSIENT]["support"] == "full" diff --git a/tests/test_todo_roundtrip.py b/tests/test_todo_roundtrip.py new file mode 100644 index 0000000..0ca7392 --- /dev/null +++ b/tests/test_todo_roundtrip.py @@ -0,0 +1,154 @@ +"""Whether a task keeps what it was stored with. + +Ox answers a task carrying an ATTENDEE with 201 and then 404 on GET, drops +ESTIMATED-DURATION, and drops COMPLETED where the task has no STATUS. +""" + +import re +from unittest.mock import Mock + +import pytest +from caldav.compatibility_hints import FeatureSet + +from caldav_server_tester.checks import ( + CheckCompletedWithoutStatus, + CheckTodoAttendee, + CheckUnknownProperty, + PrepareCalendar, +) + +from .test_floating_event import RewritingServer + +BASE = "http://example.com/cal/" +TASKS = "http://example.com/tasks/" +CHECKS = [CheckTodoAttendee, CheckUnknownProperty, CheckCompletedWithoutStatus] + + +class VanishingServer(RewritingServer): + """Accepts the PUT, but stores nothing: with ``everything``, no object at + all is readable at the URL it was put to; otherwise only a task with an + ATTENDEE vanishes (Ox).""" + + def __init__(self, everything=False, **kwargs): + super().__init__(**kwargs) + self.everything = everything + + def put(self, url, body, headers=None): + if self.everything or "\r\nATTENDEE" in body: + return Mock(status=201, headers={}) + return super().put(url, body, headers) + + +def _run(check, server, todo="full", tasklist=True, whole=False): + """``whole``: through run_check(), with its check that every declared + feature got a value""" + checker = Mock() + checker._checks_run = {PrepareCalendar} + checker._features_checked = FeatureSet({"save-load.todo": {"support": todo}}) + checker.features_checked = checker._features_checked + checker.debug_mode = None + checker._client_obj = server + server.features = FeatureSet() + checker.fixture_base_year = 2027 + checker.calendar.url.join.side_effect = lambda name: BASE + name + if tasklist: + checker.tasklist.url.join.side_effect = lambda name: TASKS + name + else: + checker.tasklist = None + if whole: + check(checker).run_check() + else: + check(checker)._run_check() + return checker._features_checked.is_supported(check.FEATURE, return_type=dict, return_defaults=False) + + +def _drop(name): + return lambda body: re.sub(rf"\r\n{name}[;:][^\r]*", "", body) + + +@pytest.mark.parametrize("check", CHECKS) +def test_kept_is_full_and_stored_as_a_task_on_the_task_list(check): + sent = [] + server = RewritingServer(rewrite=lambda body: sent.append(body) or body) + assert _run(check, server)["support"] == "full" + assert list(server.objects) == [] + assert "BEGIN:VTODO\r\n" in sent[0] and "VEVENT" not in sent[0] + + +@pytest.mark.parametrize("check", CHECKS) +def test_a_task_lands_in_the_task_calendar(check): + server = RewritingServer() + urls = [] + put = server.put + server.put = lambda url, body, headers=None: urls.append(url) or put(url, body, headers) + _run(check, server) + assert urls[0].startswith(TASKS) + + +def test_a_task_with_an_attendee_that_vanishes_is_broken(): + """Ox: 201, then 404, while a task without the ATTENDEE reads back""" + server = VanishingServer() + verdict = _run(CheckTodoAttendee, server) + assert verdict["support"] == "broken" + assert "404" in verdict["behaviour"] + assert not server.objects + + +def test_a_404_for_every_object_says_nothing_about_the_attendee(): + """Objects not reachable at the URL they were put to (Zimbra's cal_id alias)""" + assert _run(CheckTodoAttendee, VanishingServer(everything=True))["support"] == "unknown" + + +def test_the_attendee_dropped_is_unsupported(): + assert _run(CheckTodoAttendee, RewritingServer(rewrite=_drop("ATTENDEE")))["support"] == "unsupported" + + +def test_estimated_duration_dropped_is_unsupported(): + verdict = _run(CheckUnknownProperty, RewritingServer(rewrite=_drop("ESTIMATED-DURATION"))) + assert verdict["support"] == "unsupported" + assert "ESTIMATED-DURATION" in verdict["behaviour"] + + +def test_completed_without_status_is_sent_without_status(): + sent = [] + _run(CheckCompletedWithoutStatus, RewritingServer(rewrite=lambda body: sent.append(body) or body)) + assert "\r\nCOMPLETED:" in sent[0] + assert "\r\nSTATUS:" not in sent[0] + + +def test_completed_dropped_is_unsupported(): + assert _run(CheckCompletedWithoutStatus, RewritingServer(rewrite=_drop("COMPLETED")))["support"] == "unsupported" + + +def test_a_status_added_with_completed_kept_is_full(): + def add_status(body): + return body.replace("\r\nCOMPLETED:", "\r\nSTATUS:COMPLETED\r\nCOMPLETED:") + + assert _run(CheckCompletedWithoutStatus, RewritingServer(rewrite=add_status))["support"] == "full" + + +@pytest.mark.parametrize("check", CHECKS) +def test_refused_is_ungraceful(check): + assert _run(check, RewritingServer(put_answer=403))["support"] == "ungraceful" + + +@pytest.mark.parametrize("check", CHECKS) +@pytest.mark.parametrize("todo", ["unsupported", "ungraceful"]) +def test_not_probed_where_tasks_cannot_be_stored(check, todo): + """A save-load.todo child is left unset and inherits its status; any + other feature is unknown""" + server = RewritingServer(put_answer=AssertionError("nothing is to be stored")) + verdict = _run(check, server, todo=todo, whole=True) + if check.FEATURE.startswith("save-load.todo."): + assert verdict["support"] == todo + else: + assert verdict == {"support": "unknown", "behaviour": "cannot test, tasks cannot be stored"} + + +@pytest.mark.parametrize("check", CHECKS) +def test_no_task_calendar_is_unknown(check): + assert _run(check, RewritingServer(), tasklist=False)["support"] == "unknown" + + +def test_an_unreadable_task_other_than_404_is_unknown(): + assert _run(CheckTodoAttendee, RewritingServer(get_status=500))["support"] == "unknown"