diff --git a/.lycheeignore b/.lycheeignore index 2a9c7c03..eb8d6353 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -2,6 +2,7 @@ https?://your\.server\.example\.com/.* https?://.*\.example\.com(:\d+)?(/.*)?$ https?://domain/.* +https?://evil.attacker.com/caldav/ # Localhost URLs for test servers (not accessible in CI) http://localhost:\d+/.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 486aa3ed..8c9512cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,71 @@ Changelogs prior to v3.0 is pruned, but was available in the v3.1 release This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence. +## [Unreleased] + +### Fixed + +* `async_davclient.py` `_async_request()`: the issue-#158 connection-abort workaround sent a probe GET to detect the auth challenge; if the probe returned anything other than 401+WWW-Authenticate (e.g. a 200 HTML login page), the code fell through to `response = DAVResponse(probe_r, self)` — returning the probe response as if it were the original request's response, and silently swallowing the real connection error. Now the original exception is re-raised when the probe does not yield a challenge. +* `collection.py` `freebusy_request()`: for async clients, `add_attendee()` was called on each attendee before the `is_async_client` check dispatched to `_async_freebusy_request()`. For a `Principal` attendee on an async client, `get_vcal_address()` returns a coroutine; `add_attendee` then tried to set `.params` on the coroutine → AttributeError. `_async_save_with_invites` already awaited `get_vcal_address()` correctly. Fixed by passing attendees to `_async_freebusy_request()` and performing the await there. +* `search.py`: the documented `operator='=='` exact-match guarantee was never enforced — `post_filter` was not set to `True` for `==` searches, so the server's substring semantics leaked through. `icalendar_searcher.check_component()` already handles `==` as exact-match; the fix adds `==` to the `post_filter=True` trigger conditions. +* `async_davclient.py` `aio.get_calendars(calendar_name=...)`: name-based lookup iterated `self.get_calendars()` synchronously through the non-async `Principal.calendar(name=...)` path, which returns a coroutine for async clients; the loop body was iterating over the coroutine object, never the calendars, so name-based lookup returned nothing. Fixed by calling `await principal.get_calendars()` and filtering by display-name in the async path. +* `async_davclient.py` `get_calendars()`: lacked the GMX principal-URL fallback present in the sync client — when `calendar-home-set` was missing the async path returned `[]` immediately instead of falling back to the principal URL as calendar home. Parity restored. +* `search.py`: the `undef` operator branch used `property.upper()` without the `category→CATEGORIES` alias mapping that the regular filter branch applies, so `add_property_filter('category', '', operator='undef')` queried the nonexistent `CATEGORY` property. `is-not-defined` on a nonexistent property matches every object, so the filter silently returned all events regardless of whether they had categories. +* `davclient.py` and `async_davclient.py`: rate-limit retry raised `TypeError: unsupported operand type(s) for +=: 'NoneType' and 'float'` on the second 429 response when the server provides no usable `Retry-After` value (`compute_sleep_seconds` returns `None`). The `sleep_seconds += rate_limit_time_slept / 2` line executed before the `sleep_seconds is None` guard. Now re-raise `RateLimitError` first, then update the sleep estimate. +* `davclient.py` `DAVClient.__init__()`: (a) `DAVClient(url='https://user@host/', password='secret')` crashed with `TypeError` — `unquote(self.url.password)` was called unconditionally when the URL had a username, but `self.url.password` is `None` when the URL contains no password. (b) URL-embedded credentials silently overrode explicit `username`/`password` kwargs; the async client already gave explicit kwargs higher precedence. Now: explicit kwargs win; URL credentials are only used as fallback when kwargs are absent. +* `config.py` `resolve_features()`: returning a named profile (`features='xandikos'`) returned the module-level dict object directly without copying it. Any code that then mutated the returned dict (e.g. `testing.py` patching `auto-connect.url.domain`) permanently corrupted the module-level dict for the whole process lifetime, so a second `DAVClient(features='xandikos')` would see the mutated domain. Similarly `testing.py` `XandikosServer`/`RadicaleServer` used a shallow `.copy()` — nested dict mutation still reached the module level. All three now use `copy.deepcopy()`. +* `config.py` `get_connection_params()`: explicit keyword arguments (e.g. `get_davclient(password='secret')`) were only respected when `url` or `features` was also present. When an env-var or config-file source was found instead, explicit params were silently dropped. Now the explicit params are merged (overlaid) on top of whatever lower-priority source wins. +* `calendarobjectresource.py` `_complete_recurring_safe()`: completing a recurring task passed the caller-supplied `completion_timestamp` to `_next()` correctly but then called `completed.complete()` without it, so the completed copy always recorded the current wall-clock time as `COMPLETED` regardless of the timestamp the caller specified. The async twin already passed `completion_timestamp` through; sync is now consistent. +* `calendarobjectresource.py` `_get_duration()`: `isinstance(i["DTSTART"], datetime)` tested the `vDDDTypes` wrapper object (which is never a `datetime`), so the date-vs-datetime branch always took the "is a date" path. A VTODO with a timed DTSTART and no DUE/DURATION got `duration = timedelta(days=1)` instead of `timedelta(0)`, shifting the next due date by one day when completing a recurring task. Fixed: test `isinstance(i["DTSTART"].dt, datetime)`. +* `jmap/client.py` and `jmap/async_client.py` `create_task()`: a JMAP server response that returned an empty `created` dict (with neither a `created` entry nor a `notCreated` entry for `"new-0"`) raised a bare `KeyError` instead of the documented `JMAPMethodError`. The `create_event()` method already had the required guard; `create_task()` was missing it in both sync and async clients. +* `lib/vcal.py` `fix()`: truncated iCalendar data (no `END:` line) triggered a bare `assert` which gave no useful message and was silently skipped under `python -O`. Now logs a warning and returns the data unchanged instead. +* `lib/vcal.py` `create_ical()`: when both `alarm_*` props and `ical_fragment` were supplied, the fragment was injected before the first `END:V` line — which is `END:VALARM`, placing e.g. an `RRULE` *inside* the alarm component. The regex now targets `END:V(EVENT|TODO|JOURNAL)` specifically. +* `lib/vcal.py` `fix()`: the backslash-unescape step used `('\"')` as a regex group, which matches only the literal two-character sequence `'"`. A backslash before a lone `'` or lone `"` was silently left in place. Fixed by using the character class `['\"]`. +* `lib/vcal.py` `fix()`: the trailing-whitespace fixup (`re.sub(" *$", "", fixed)`) lacked `re.MULTILINE`, so it only stripped trailing spaces at the very end of the document and never per-line. iCloud X-APPLE-STRUCTURED-LOCATION fold lines with trailing spaces were left intact, which can distort base64-encoded property values. Fixed by adding `re.MULTILINE`. +* `lib/error.py` `PYTHON_CALDAV_COMMDUMP`: when this debug env-var is set, a `logging.warning()` is now emitted at import time to remind the operator that request/response bodies and headers (including credentials and calendar PII) are being written to uniquely-named files under `/tmp` that accumulate indefinitely. +* `jmap/objects/calendar.py` `JMAPCalendar.search()`: `datetime` arguments for `start`/`end` were formatted with `datetime.isoformat()`, which produces `+HH:MM` offsets for aware non-UTC datetimes and no timezone indicator for naive datetimes. JMAP requires UTCDate format (`YYYY-MM-DDTHH:MM:SSZ`). Fixed by converting to UTC and using `strftime`. +* `jmap/client.py` and `jmap/async_client.py` `get_objects_by_sync_token()`: the `newState` from `CalendarEvent/changes` was discarded into `_`, so callers could not chain sync calls without a separate `get_sync_token()` round-trip — a race window where intervening changes would be silently missed. The method now returns a 4-tuple `(added, modified, deleted, new_sync_token)` instead of a 3-tuple. +* `lib/vcal.py` `fix()`: the trailing-whitespace fixup (`re.sub(" *$", "", fixed)`) lacked `re.MULTILINE`, so it only stripped trailing spaces at the very end of the document and never per-line. iCloud X-APPLE-STRUCTURED-LOCATION fold lines with trailing spaces were left intact, which can distort base64-encoded property values. Fixed by adding `re.MULTILINE`. +* `compatibility_hints.py` `FeatureSet.copyFeatureSet()`: merging a plain-string feature value over an existing string-valued entry in the feature set raised a bare `AssertionError` — the `'support' not in server_node` guard blocked the update branch and fell through to `else: raise AssertionError`. Plain strings are the dominant style in the hint dicts, so any two-layer server config expressing the same feature crashed. Fixed by removing the `not in server_node` condition. +* `compatibility_hints.py` `FeatureSet.copyFeatureSet()`: an unknown feature name in a config file produced only a `UserWarning` but still stored the bad key in `_server_features`; a later `collapse()`/`is_supported()` call then raised a message-less `AssertionError` far from the original config. Fixed by `continue`-ing after the warning so unknown keys are never stored. +* `async_davclient.py`: HTML-on-401 diagnostic hint checked `self.headers` (the client's own request headers) for `Content-Type: text/html` instead of `r.headers`, so the intended "server returned an HTML login page, consider setting auth_type" message could never fire. +* `base_client.py` `get_calendars(calendar_urls=...)`: a calendar explicitly requested by URL was silently omitted from the result when its `displayname` property is the empty string `""`, because the check `if _try(calendar.get_display_name, ...)` was a truthiness test. The async counterpart already used `is not None`; sync is now consistent. +* `config.py` `expand_config_section()`: requesting a section name that is absent from the config raised `KeyError` instead of returning `[]`, causing plain `caldav.get_calendars()` to crash with `KeyError: 'default'` on configs with no `default` section. +* `config.py` `expand_config_section()`: `disable: true` was silently ignored for sections fetched by explicit name or via a `contains` list — the check used the string literal `"section"` as the config key instead of the `section` variable. Only the glob `"*"` path honoured `disable`. +* `lib/auth.py` `extract_auth_types()`: a `WWW-Authenticate` header ending with a trailing comma (seen in the wild) raised `IndexError` in the set comprehension because `h.split()` on an empty segment fails. Added an `if h.strip()` guard. +* `calendarobjectresource.py` `change_attendee_status()`: calling the method on an event with no `ATTENDEE` properties at all raised a bare `KeyError('ATTENDEE')` instead of the expected `NotFoundError`; the `try/except NotFoundError` wrapper in the `Principal` dispatch path could not catch it, so the "Principal is not invited" message was unreachable. Also, the genuine not-found error message contained a literal `%s` that was never substituted with the attendee address. +* `calendarobjectresource.py` `add_attendee()`: passing an attendee address with an uppercase or mixed-case URI scheme (`"MAILTO:user@example.com"`) raised `UnboundLocalError` — the scheme check used `str.startswith("mailto:")` which is case-sensitive, so the address fell through all branches without assigning `attendee_obj`. RFC 3986 §3.1 specifies URI schemes are case-insensitive. +* `response.py`: XML parser lacked `resolve_entities=False` and `no_network=True`, leaving entity expansion and DTD network fetches enabled by default. A malicious or MITM server could inject arbitrary text into parsed property values via inline DOCTYPE entity definitions. Fixed by adding `resolve_entities=False, no_network=True` to `etree.XMLParser`. +* `discovery.py` `discover_service()`: `require_tls=True` was not enforced on the well-known URI redirect target — a same-domain `Location: http://...` passed the domain-validation check and was returned as `ServiceInfo(tls=False)`, allowing a misconfigured or MITM server to silently downgrade the connection to plaintext. Fixed by checking `well_known_info.tls` against `require_tls` before returning the result. +* `datastate.py` `RawDataState.get_component_type()`: tested for the string `"BEGIN:FREEBUSY"` but real iCalendar data uses `"BEGIN:VFREEBUSY"`, so any `FreeBusy` object holding raw data returned `component_type=None` — making `is_loaded()` and `has_component()` return `False`, `save()` silently no-op at its early return, and `load(only_if_unloaded=True)` reload spuriously on every call. The same typo appeared in the base-class `get_uid()` and `get_component_type()` fallback parsers which looked for `comp.name == "FREEBUSY"` instead of `"VFREEBUSY"`. +* `calendarobjectresource.py` `_set_data()`: the raw-string branch cleared the legacy `_data`/`_vobject_instance`/`_icalendar_instance` attributes but never reset `self._state`. Once `_state` was populated by an earlier call to `_ensure_state()` (triggered by e.g. `.id` or `is_loaded()`), all subsequent reads via `get_data()`, `get_icalendar_instance()`, and `.id` served the pre-reload content even after `load()` fetched new data from the server. +* `search.py`: the `search.combined-is-logical-and: unsupported` workaround (triggered on e.g. Nextcloud) stripped all property filters from the server query to send only the time range, but passed `post_filter=None` (the ambient value) to `filter()` instead of `True`. `_filter_search_results` short-circuits when `post_filter` is falsy, so a search with both a time range and a property filter (e.g. `SUMMARY contains "foo"`) returned every object in the time range — the property filter was silently dropped. The sibling workarounds in the same function already used `post_filter=True`; this one now does too. +* `URL.canonical()`: two related bugs — (a) the canonical form was built from `self.url_parsed` (which still contains `user:pass@` in the netloc) rather than the auth-stripped URL, so `canonical()` leaked credentials into the returned URL and `__eq__`/`__hash__` comparisons between an authenticated client URL and a server-returned href (no credentials) were False; (b) when a URL had no auth part, `unauth()` returned `self` and `canonical()` then overwrote `url_raw`/`url_parsed` in place — a bare `==` or `hash()` call silently mutated the URL object, potentially re-encoding special characters (e.g. `+` → `%2B`) and causing subsequent requests to target the wrong resource. Fixed by using the auth-stripped URL's parsed form for `arr` and always returning a fresh `URL` object. +* `_post_put`: a 302 response to `PUT` always raised `IndexError` instead of following the redirect — iterating the headers dict yields key strings, not tuples, so `x[0]` was the first character of each header name, never `"location"`. Fixed by using `r.headers.get("location")`. +* `vcal.fix()`: the `COMPLETED` date-to-datetime regex consumed the trailing newline, merging the following iCal property into the `COMPLETED` value on every inbound object from a server that stores `COMPLETED` as a plain date (e.g. SOGo). Fixed by using a lookahead `(?=\s)` instead of consuming `\s`. + +* Time-range searches without a component type (`search(start=..., end=...)` with no `event`/`todo`/`journal`/`comp_class`) crashed against SabreDAV-based servers (Baikal, Nextcloud, ...) with `ReportError`: *"You cannot add time-range filters on the VCALENDAR component"*. A `CALDAV:time-range` is only valid inside a `VEVENT`/`VTODO`/`VJOURNAL`/`VFREEBUSY`/`VALARM` comp-filter (RFC4791 section 9.7), never directly under `VCALENDAR`. The library now splits such a search into one query per component type, and additionally recovers from the server rejection at runtime if it occurs anyway. See https://github.com/python-caldav/caldav/issues/681 +* Property-filter searches without a component type (e.g. `search(category=...)` or other attribute filters with no `event`/`todo`/`journal`/`comp_class`) silently returned nothing on most servers (Xandikos, SabreDAV, ...): the prop-filter landed under the `VCALENDAR` comp-filter, which has no component properties like `CATEGORIES` to match. The library now splits such a search into one query per component type as well (`search.text.comp-type-optional`). See https://github.com/python-caldav/caldav/issues/681 +* `search()`'s generator driver now feeds exceptions raised while executing a request back into the search logic, so the server-compatibility fallbacks and per-object load error handling actually take effect (previously dead code). Applies to both the sync and async code paths. +* `compatibility_hints`: OX was pinned to `create-calendar.set-displayname: unsupported` (a value masked by a checker bug that verified the feature by display-name lookup, which a leftover/colliding calendar would shadow); OX stores the display name as a property separate from the calendar URL and honours it at creation time, so the expectation is corrected to `full`. +* `compatibility_hints`: Stalwart's `search.recurrences.expanded.exception` was inheriting the default `full`, but Stalwart's server-side `CALDAV:expand` only suppresses the exception-overridden occurrence when `SEQUENCE` is absent. With `SEQUENCE` present (as real-world clients always emit) it returns both the original occurrence and the override, so the expectation is corrected to `fragile`. +* Config file sections with `features` but no `caldav_url` were rejected, even though the URL can be derived from the `auto-connect.url` compatibility hints. Explicitly passed parameters already worked this way; now `get_davclient(config_section=...)` and friends behave consistently. +* `jmap/convert/jscal_to_ical.py`: a `recurrenceOverrides` entry that does not include a `"start"` key (the common case — title-only change, description update, etc.) produced a child `VEVENT` with `DTSTART` copied from the master event's start time rather than from the override key. This effectively relocated every non-rescheduled override to the master's first occurrence, breaking all override display. Default is now the override key itself. +* `jmap/convert/jscal_to_ical.py`: `EXDATE` and `RECURRENCE-ID` values were always emitted as floating (timezone-less) `DATE-TIME` regardless of the event's `timeZone` or `showWithoutTime` flag. Per RFC 5545 §3.8.5.1 the value type must match `DTSTART`; a floating `EXDATE` on a `TZID`-anchored event does not match any instance, so excluded occurrences reappear. Override keys are now parsed with the event timezone applied (`TZID`-anchored events) or as `date` objects (all-day events). +* `jmap/convert/_utils.py` `_format_local_dt()`: UTC datetimes produced a `Z`-suffixed string. RFC 8984 §1.4 defines `LocalDateTime` (the type required for `recurrenceOverrides` keys and `recurrenceRules.until`) as a bare `YYYY-MM-DDThh:mm:ss` without any suffix; `Z`-suffixed override keys cannot match `LocalDateTime` occurrence keys, causing mismatches on strict servers. The function now always returns a timezone-stripped local representation. +* `jmap/convert/ical_to_jscal.py` and `jmap/convert/jscal_to_ical.py`: the `STATUS` property was silently dropped in both conversion directions. `STATUS:CANCELLED` round-tripped as `status: confirmed` (JSCalendar default), so cancelled meetings appeared active. Mappings `CONFIRMED ↔ confirmed`, `TENTATIVE ↔ tentative`, `CANCELLED ↔ cancelled` are now implemented. +* `jmap/client.py` and `jmap/async_client.py` `update_event()`: RFC 8620 §3.3 specifies that absent keys in a PatchObject preserve the server value. `update_event` sent the full converted JSCalendar dict as the patch; properties the caller removed (e.g. LOCATION, VALARM) were absent from the patch and therefore silently persisted on the server. `update_event` now explicitly sets all optional top-level JSCalendar properties to `null` when they are absent from the conversion result, ensuring the server removes them. + +### Added + +* `caldav.config.extract_conn_params_from_section` is now public API (renamed from `_extract_conn_params_from_section`), so that downstream tools like plann can map plann-style config sections (`caldav_url`, `caldav_user`, `features`, etc.) to `DAVClient` parameters without duplicating the logic. +* New compatibility feature `create-calendar.set-displayname.stable-url` (default `full`): whether setting a calendar's display name leaves its URL unchanged. Both Zimbra and OX couple the two — a calendar created with a display name is exposed under a display-name-derived URL (Zimbra) or an internal `cal://0/NNN` id (OX) rather than the requested `cal_id`; an alias may linger at the `cal_id` URL but is unreliable (Zimbra) or non-canonical (OX). Both are marked `create-calendar.set-displayname: full` + `create-calendar.set-displayname.stable-url: unsupported`; the library omits the display name from the `MKCALENDAR` request for such servers so the calendar stays addressable at its requested `cal_id` URL. +* New `compatibility_workarounds` parameter on `Calendar.search()` / `CalDAVSearcher.search()` / `async_search()`. When `False`, all server-compatibility workarounds are disabled and the query is sent verbatim (a single REPORT, no comp-type splitting, no filter rewriting, no fallback retries). Mainly for the server-compatibility checker, to observe raw server behaviour. + +### Changed + +* `compatibility_hints`: eight directly-probed feature *nodes* that also have refinement sub-features now carry their own explicit `default` (`get-current-user-principal`, `create-calendar.set-displayname`, `delete-calendar`, `save-load.todo.recurrences`, `search.text.category`, `search.recurrences.includes-implicit.todo`, `scheduling`, `sync-token`). This marks them as *independent* features: `is_supported()` and `collapse()` no longer derive/fold them away from their children, so e.g. `sync-token` stays `full` even when `sync-token.delete` is `unsupported`. Each such node has a corresponding check in the server-tester (`search.comp-type` gained one); `principal-search` deliberately keeps no default since it is a genuine OR-grouping of its sub-searches. + ## [3.2.1] - 2026-05-28 The changeset in 3.2.1 is predominently added async integration tests. Those tests should now be replicating all the logic in the good old sync integration tests under `test_caldav.py`. Some few more bugs were found while adding those tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6110246c..3e33f20a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ The types used should (as for now) be one of: The `compatibility_hints.py` has been moved from the test directory to the codebase not so very long ago. Some special rules here: * Adjusting the feature set for some calendar server? Check if there exists some workarounds etc in the code for said feature, if so, then it should be considered a fix or a feature. Perhaps even a breaking change. Otherwise, use `test: ...`. (because it is relevant for the compatibility test, if nothing else). -* Adding a new feature hint? Ensure it's covered by the caldav-server-tester. Since we have a compatibility test, it will be relevant for the test - so use `test: (...)`. It should be covered by the caldav-serveer-tester, so refer to some issue or pull request for the caldav-server-tester in the commit message. +* Adding a new feature hint? Ensure it's covered by the caldav-server-tester. Since we have a compatibility test, it will be relevant for the test - so use `test: (...)`. It should be covered by the caldav-server-tester. * Changing some descriptions? That goes as `docs: ...` even if it's actually changing a variable in the code. This is not set in stone. If you feel strongly for using something else, use something else in the commit message and update this file in the same commit. diff --git a/SECURITY.md b/SECURITY.md index 0a1a63aa..03ca1a28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,48 +1,82 @@ # Security policy -Issues should be fixed ASAP, and information on any security issue should be published as soon as it's fixed. Use the GitHub issue tracker or check up [CONTACT](CONTACT.md) or even the [CODE OF CONDUCT](CODE_OF_CONDUCT) file to get in touch with the maintainer. +Issues should be fixed ASAP, and information on any security issue should be published as soon as it's fixed. Serious issues should be reported privately and kept under wraps until a fix is released — use [GitHub's private vulnerability reporting](https://github.com/python-caldav/caldav/security/advisories/new) (the "Report a vulnerability" button on the Security tab), or get in touch with the maintainer via [CONTACT](CONTACT.md) or the [CODE OF CONDUCT](CODE_OF_CONDUCT) file. Use the public GitHub issue tracker only for non-sensitive issues. There are no "LTS"-releases of the CalDAV package, but the maintainer will always consider backporting security fixes if it's deemed relevant. The maintainer is doing most of the maintenance on hobby-basis and may have other things in life preventing him from dealing with issues on the go, so no guarantees are given. -All contributions are carefully reviewed by the maintainer, and all releases are carefully tested and tagged with a PGP-signed commit. +All contributions are carefully reviewed by Tobias Brox, and AI-tools are used for code reviews prior to each release. All releases are carefully tested and tagged with a PGP-signed commit. # Known security issues and risks ## RFC6764 -I do see a major security flaw with the RFC6764 discovery. If the DNS is not to be trusted, someone can highjack the connection by spoofing the service records, and also spoofing the TLS setting, encouraging the client to connect over plain-text HTTP without certificate validation. Utilizing this it may be possible to steal the credentials. This flaw can be mitigated by using DNSSEC, but DNSSEC is not widely used, and fixing support for DNSSEC validation in the CalDAV library was found to be non-trivial (perhaps I'll look into it again some time after 3.0 has been released). This has been mitigated by adding a require_tls` connection parameter that is True by default, plus by ensuring one isn't routed to a different domain. +**Summary**: If you're using auto-discovery of the CalDAV-URL, anyone controlling your local resolver may try to fish out username and password. -## DDoS/OOM risk +**Mitigation**: Use a URL rather than a domain when configuring the library. Leave `require_tls`, `ssl_verify_cert` to the default. + +I do see a major security flaw with the RFC6764 discovery. If the DNS is not to be trusted, someone can hijack the connection by spoofing the service records, and also spoofing the TLS setting, encouraging the client to connect over plain-text HTTP without certificate validation. Utilizing this it may be possible to steal the credentials. This flaw can be mitigated by using DNSSEC, but DNSSEC is not widely used, and fixing support for DNSSEC validation in the CalDAV library was found to be non-trivial - the work done is now on a stalled pull request. This has been partly mitigated by adding a `require_tls` connection parameter that is `True` by default, plus by ensuring one isn't routed to a different domain. + +Auto-discovery and HTTP redirects also mean the host you end up talking to may not be the one you configured. If you run the library in a context where it can reach internal/private network resources (server-side request forgery, SSRF), be aware that a malicious DNS resolver or a malicious/compromised server can steer requests towards other hosts. Configuring an explicit URL rather than relying on domain-based discovery limits this. + +## DDoS/OOM risk - recurring events/tasks search + +**Summary:** If you allow untrusted parties to specify search-terms towards a calendar containing recurring events/tasks, bad things may happen. The package offers both client-side and server-side expansion of recurring events and tasks. It currently does not offer expansion for open-ended date searches - but with a large enough timespan and a frequent enough RRULE, there may be millions of recurrences returned. Those recurrences are returned as a generator, so things will not break down immediately. However, there is no guaranteed sort order of the recurrences ... and once you add sorting parameters to the search, bad things may happen. +## XML parsing + +**Summary:** XML responses are parsed defensively by default; only relax this against servers you trust. + +The library parses XML responses from the server using lxml. By default the parser is configured to resist common XML attacks: external entity resolution is disabled (`resolve_entities=False`) and network access during parsing is blocked (`no_network=True`), guarding against XXE (XML External Entity) attacks, and lxml's built-in limits protect against oversized "billion laughs"-style entity-expansion payloads. + +The `huge_tree` connection option (default off) disables lxml's built-in parser limits so that very large calendar objects can be handled. With `huge_tree` enabled, a malicious or compromised server can exhaust available memory with a crafted XML payload — only enable it against servers you trust. See the [lxml XMLParser documentation](https://lxml.de/api/lxml.etree.XMLParser-class.html). + ## Bugs causing weird things happening -Weird things may happen due to bugs both on in the CalDAV package, on your side and on the server side. Here are some weird experiences with Zimbra: +**Summary:** Always expect the unexpected + +Weird things may happen due to bugs both on in the CalDAV package, on your side and on the server side. Some anecdotes from using Zimbra: -* I have experiences that cancelling participation in an event caused the event to be cancelled for all participants (even if the person deciding to not go to the event was not an organizer and should have no permissions to edit the event). Clearly a server-side issue. -* I once tried to restore from backup and push ten years of ical code to the calendar server. The calendar server responded by re-inviting people to the meetings we had ten years ago. I'm inclined to call that also a server side bug. -* Many other things may happen. +* I once tried to restore from backup and push ten years of ical code to the calendar server. The calendar server responded by re-inviting people to the meetings we had ten years ago. I'm inclined to call that a server side bug - but it also highlights the risk of using the CalDAV library for doing operations that ordinary calendaring clients aren't doing. +* It's been observed that cancelling participation in an event caused the event to be cancelled for all participants (even if the person deciding to not go to the event was not an organizer and should have no permissions to edit the event). Clearly a server-side issue. -## Malicious usage +## Other things to consider -Beware of risks and exposure when creating applications: +**Summary:** Beware of risks and exposure when creating applications: -* Your code may handle username and password, be careful not to expose such credentials. Even the URL to the calendar server and/or calendar may be something people want to keep private. +* Your code may handle username and password, be careful not to expose such credentials. Even the URL to the calendar server and/or calendar may be something people want to keep private. The library includes code for reading this data from a standard config file - please use it rather than reinventing the wheel or hard-coding credentials directly into your code. * Consider that calendar events and such is personal data, which deserves protection. In the EU with the GDPR, such protection is even mandated by law. * If you allow arbitrary people to create calendar content to be saved to a server, there may be some risks involved: * Depending on the server implementation, it may be possible to use the caldav library for sending spam emails. * Be aware of DoS-attacks: By storing too much / too big / specially crafted icalendar data, the server and/or client may crash or consume all available resources. - * If allowing anonymous parties to save and retrieve data from your server, you may end up with responsibility for spreading illicit information. This may include things like child porn. Political or religious propaganda may be legitimate and legal in some countries, but may involve death penalty in other countries. Your calendar server may also be used for coordinating criminal activity. -* If you allow arbitrary people to fetch calendar content from the server, there may also be some risks involved - in particular, a DoS-attack by requesting a large time span of expanded events. + * If allowing anonymous parties to save and retrieve data from your server, you may end up with responsibility for spreading illicit information. This may include things like child sexual abuse material. Political or religious propaganda may be legitimate and legal in some countries, but may involve death penalty in other countries. Your calendar server may also be used for coordinating criminal activity. +* If you allow arbitrary people to fetch calendar content from the server, there may also be some risks involved - see the separate section on DoS-attack by requesting a large time span of expanded events. -## Malicious code +## Supply attack risk -All code contributions are carefully reviewed by Tobias Brox. Version tags are signed with PGP. Of course there is always a risk that someone takes over my PGP key and github access (It's hard to be immune against a [5$ wrench attack](https://xkcd.com/538/)). The original owner of the repository is still alive and may take over the project again should something happen to me. I would anyway encourage using AI to do risk assessments. +**Summary:** Stick to released versions and check the PGP signature in the release-tag + +All code contributions are carefully reviewed by Tobias Brox. Version tags are signed with PGP. Of course there is always a risk that someone takes over my PGP key and GitHub access (It's hard to be immune against a [$5 wrench attack](https://xkcd.com/538/)). The original owner of the repository is still alive and may take over the project again should something happen to me. I would encourage using AI to do risk assessments. The library comes with a number of dependencies, one may need to evaluate the security of those too. The pyproject contains the current list. Some notes: * niquests is an optional dependency - you may replace it with requests if you don't trust niquests -* recurring-ical-events and icalendar both has the same maintainer (Nicco Kunzmann). He is considered trustworthy. -* Tobias now has a policy of moving code not related to CalDAV into separate packages. Packages under the `python-caldav` ownership on GitHub should be considered to be of the same quality and security level as the CalDAV library. -* No security review have been done of the other dependencies. +* recurring-ical-events and icalendar both have the same maintainer (Nicco Kunzmann). He is considered trustworthy. +* Tobias now has a policy of moving code not related to CalDAV into separate packages. Those packages are most of the time either published under the `python-caldav` or `pycalendar` ownership on GitHub, and should be considered to be of the same quality and security level as the CalDAV library. +* No independent security review has been done of the other dependencies - those are all considered to be mature and robust projects. + +## Communication dumper debug hook + +**Summary:** If someone has the ability to both alter the environment and full read access to /tmp (basically, someone has root access to the computer where the code is run), it will be possible to get access to all communication. Also, anyone using this debug hook must take responsibility of deleting the dumped files. + +**Mitigation:** If this worries you, set `caldav.lib.error.debug_dump_communication=False` after importing caldav. + +The following was written when `PYTHON_CALDAV_COMMDUMP` was introduced in v1.4.0: + +* An attacker that has access to alter the environment the application is running under may cause a DoS-attack, filling up available disk space with debug logging. +* An attacker that has access to alter the environment the application is running under, and access to read files under /tmp (files being 0600 and owned by the uid the application is running under), will be able to read the communication between the server and the client, communication that may be private and confidential. + +Thinking it through three times, I'm not too concerned — if someone has access to alter the environment the process is running under and access to read files run by the uid of the application, then this someone should already be trusted and will probably have the possibility to DoS the system or gather this communication through other means. + +As of v3.3 (to be released towards the end of 2026-06), a warning is logged at import time when this variable is set, reminding the operator that request/response bodies and headers (including credentials and calendar PII) are written to uniquely-named files under `/tmp` that accumulate indefinitely. diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index 2c4bd006..bc9a2616 100644 --- a/caldav/async_davclient.py +++ b/caldav/async_davclient.py @@ -372,13 +372,13 @@ async def request( self.rate_limit_default_sleep, self.rate_limit_max_sleep, ) - if rate_limit_time_slept: - sleep_seconds += rate_limit_time_slept / 2 if sleep_seconds is None or ( self.rate_limit_max_sleep is not None and rate_limit_time_slept > self.rate_limit_max_sleep ): raise + if rate_limit_time_slept: + sleep_seconds += rate_limit_time_slept / 2 await asyncio.sleep(sleep_seconds) return await self.request( url, method, body, headers, rate_limit_time_slept + sleep_seconds @@ -432,7 +432,7 @@ async def _async_request( log.debug(f"server responded with {r.status_code} {reason}") if ( r.status_code == 401 - and "text/html" in self.headers.get("Content-Type", "") + and "text/html" in r.headers.get("Content-Type", "") and not self.auth ): msg = ( @@ -484,7 +484,11 @@ async def _async_request( # Retry original request with auth request_kwargs["auth"] = self.auth r = await self.session.request(**request_kwargs) - response = DAVResponse(r, self) + response = DAVResponse(r, self) + else: + # Probe GET did not give us a 401+WWW-Authenticate challenge — + # auth negotiation failed; re-raise the original connection error + raise # Handle 429/503 rate-limit responses error.raise_if_rate_limited(r.status_code, str(url_obj), r.headers.get("Retry-After")) @@ -955,7 +959,9 @@ async def get_calendars(self, principal: Optional["Principal"] = None) -> list[" ) calendar_home_url = extract_home_set(response.results) if not calendar_home_url: - return [] + # Fall back to the principal URL as calendar home + # (some servers like GMX don't support calendar-home-set) + calendar_home_url = str(principal.url) # Make URL absolute if relative calendar_home_url = self._make_absolute_url(calendar_home_url) @@ -1267,13 +1273,26 @@ def _try(coro_result, errmsg): raise # Fetch specific calendars by name - for cal_name in calendar_names: + if calendar_names: try: - calendar = await principal.calendar(name=cal_name) - if calendar: - calendars.append(calendar) + all_cals_for_name = await principal.get_calendars() + for cal_name in calendar_names: + for cal in all_cals_for_name: + try: + display_name = await cal.get_display_name() + if display_name == cal_name: + calendars.append(cal) + break + except Exception: + pass + else: + log.error(f"No calendar with name '{cal_name}' found") + if raise_errors: + raise error.NotFoundError(f"No calendar with name '{cal_name}' found") + except error.NotFoundError: + raise except Exception as e: - log.error(f"Problems fetching calendar by name '{cal_name}': {e}") + log.error(f"Problems fetching calendars by name: {e}") if raise_errors: raise diff --git a/caldav/base_client.py b/caldav/base_client.py index d1974bb7..4dbbc70c 100644 --- a/caldav/base_client.py +++ b/caldav/base_client.py @@ -686,7 +686,7 @@ def _try(meth, kwargs, errmsg): calendar = principal.calendar(cal_url=cal_url) else: calendar = principal.calendar(cal_id=cal_url) - if _try(calendar.get_display_name, {}, f"calendar {cal_url}"): + if _try(calendar.get_display_name, {}, f"calendar {cal_url}") is not None: calendars.append(calendar) for cal_name in calendar_names: diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index a0f33976..e8c3f7dd 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -722,7 +722,7 @@ def add_attendee(self, attendee, no_default_parameters: bool = False, **paramete raise NotImplementedError( "do we need to support this anyway? Should be trivial, but can't figure out how to do it with the icalendar.Event/vCalAddress objects right now" ) - elif attendee.startswith("mailto:"): + elif attendee.lower().startswith("mailto:"): attendee_obj = vCalAddress(attendee) elif "@" in attendee and ":" not in attendee and ";" not in attendee: attendee_obj = vCalAddress("mailto:" + attendee) @@ -1164,7 +1164,7 @@ def _post_put(self, r, retry_on_failure): else: raise error.PutError(errmsg(r)) elif r.status == 302: - self.url = URL.objectify([x[1] for x in r.headers if x[0] == "location"][0]) + self.url = URL.objectify(r.headers.get("location")) elif r.status not in (204, 201): if retry_on_failure: try: @@ -1184,8 +1184,7 @@ def _post_put(self, r, retry_on_failure): self.props[cdav.ScheduleTag.tag] = r.headers["schedule-tag"] if r.status == 302: - path = [x[1] for x in r.headers if x[0] == "location"][0] - self.url = URL.objectify(path) + self.url = URL.objectify(r.headers.get("location")) elif r.status not in (204, 201): if retry_on_failure: try: @@ -1269,7 +1268,12 @@ def change_attendee_status(self, attendee: Any | None = None, **kwargs) -> None: return ical_obj = self.icalendar_component - attendee_lines = ical_obj["attendee"] + try: + attendee_lines = ical_obj["attendee"] + except KeyError: + raise error.NotFoundError( + f"Participant {attendee!r} not found in attendee list (no ATTENDEE properties)" + ) from None if isinstance(attendee_lines, str): attendee_lines = [attendee_lines] @@ -1281,7 +1285,7 @@ def strip_mailto(x): attendee_line.params.update(kwargs) cnt += 1 if not cnt: - raise error.NotFoundError("Participant %s not found in attendee list") + raise error.NotFoundError(f"Participant {attendee!r} not found in attendee list") error.assert_(cnt == 1) def save( @@ -1570,6 +1574,7 @@ def _set_data(self, data): self._data = vcal.fix(data) self._vobject_instance = None self._icalendar_instance = None + self._state = RawDataState(self._data) return self def _get_data(self): @@ -1940,7 +1945,7 @@ def _get_duration(self, i): start = datetime(start.year, start.month, start.day) end = datetime(end.year, end.month, end.day) return end - start - elif "DTSTART" in i and not isinstance(i["DTSTART"], datetime): + elif "DTSTART" in i and not isinstance(i["DTSTART"].dt, datetime): return timedelta(days=1) else: return timedelta(0) @@ -2137,7 +2142,7 @@ def _complete_recurring_safe(self, completion_timestamp): completed.url = self.parent.url.join(completed.id + ".ics") completed.icalendar_component.pop("RRULE") completed.save() - completed.complete() + completed.complete(completion_timestamp=completion_timestamp) duration = self.get_duration() i = self.icalendar_component diff --git a/caldav/collection.py b/caldav/collection.py index 37b87b96..9cf9c860 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -598,22 +598,27 @@ def freebusy_request( freebusy_ical.add_component(freebusy_comp) outbox = self.schedule_outbox() caldavobj = FreeBusy(data=freebusy_ical, parent=self) - for attendee in attendees: - caldavobj.add_attendee(attendee, no_default_parameters=True) if self.is_async_client: - return self._async_freebusy_request(outbox, caldavobj) + return self._async_freebusy_request(outbox, caldavobj, attendees) + + for attendee in attendees: + caldavobj.add_attendee(attendee, no_default_parameters=True) caldavobj.add_organizer() response = self.client.post(outbox.url, caldavobj.data, headers=ICALH) return response._parse_scheduling_response_objects(parent=self) - async def _async_freebusy_request(self, outbox, fb_obj) -> dict: + async def _async_freebusy_request(self, outbox, fb_obj, attendees) -> dict: """Async implementation of freebusy_request() for async clients.""" ## TODO: could we have common headers as global variable? headers = ICALH outbox = await outbox + for attendee in attendees: + if isinstance(attendee, Principal): + attendee = await attendee.get_vcal_address() + fb_obj.add_attendee(attendee, no_default_parameters=True) ## TODO: it's really bad that arbitrary methods returns ## a coroutine in async mode. It's needed to make it much ## more clear what methods involves I/O and what methods @@ -747,14 +752,20 @@ def _create( prop = dav.Prop() display_name = None - # Some servers (e.g. Zimbra) use the DisplayName from the MKCALENDAR body - # as the calendar URL, ignoring the actual request path. When the server - # does not support setting a separate display name, omit it from the body so - # the request URL path is used as the calendar identifier. + # Some servers (e.g. Zimbra) couple the display name to the calendar URL: + # applying a display name renames/moves the collection, relocating its + # canonical URL to a display-name-derived path (an alias may linger at the + # request path, but unreliably). When the server cannot set a display name + # without moving the calendar (create-calendar.set-displayname.stable-url + # unsupported), or cannot set one at all, omit it from the body so the + # request URL path stays the calendar identifier. supports_displayname = not self.client or self.client.features.is_supported( "create-calendar.set-displayname" ) - if name and supports_displayname: + stable_url = not self.client or self.client.features.is_supported( + "create-calendar.set-displayname.stable-url" + ) + if name and supports_displayname and stable_url: display_name = dav.DisplayName(name) prop += [display_name] if supported_calendar_component_set: @@ -1417,6 +1428,7 @@ def search( filters=None, post_filter=None, _hacks=None, + compatibility_workarounds: bool | None = None, **searchargs, ) -> "list[_CC] | Coroutine[Any, Any, list[_CC]]": """Sends a search request towards the server, processes the @@ -1529,11 +1541,25 @@ def search( # For async clients, use async_search if self.is_async_client: return my_searcher.async_search( - self, server_expand, split_expanded, props, xml, post_filter, _hacks + self, + server_expand, + split_expanded, + props, + xml, + post_filter, + _hacks, + compatibility_workarounds, ) return my_searcher.search( - self, server_expand, split_expanded, props, xml, post_filter, _hacks + self, + server_expand, + split_expanded, + props, + xml, + post_filter, + _hacks, + compatibility_workarounds, ) def freebusy_request( diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 0ff4e690..9fb37e46 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -82,6 +82,10 @@ class FeatureSet: }, "get-current-user-principal": { "description": "Support for RFC5397, current principal extension. Most CalDAV servers have this, but it is an extension to the DAV standard. Possibly observed missing on mail.ru, DavMail gateway and it is possible to configure the support in some sabre-based servers", + ## Independent feature (directly probed): the default marks it so the + ## node uses its own probed value rather than being derived from + ## subfeatures such as .has-calendar. + "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc5397"], }, "get-current-user-principal.has-calendar": { @@ -91,9 +95,36 @@ class FeatureSet: "description": "Server returns the supported-calendar-component-set property (RFC 4791 section 5.2.3). The property is optional: when absent the RFC mandates that all component types are accepted, so 'unsupported' here is not a protocol violation, but the client cannot determine the actual supported set without trying.", "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-5.2.3"], }, + "propfind": { + "description": "Server supports the PROPFIND method (RFC4918 section 9.1): a PROPFIND for a named property returns a multistatus response. Independent feature (not just a grouping node) so that a server lacking a sub-feature like propfind.allprop.resourcetype is not mistaken for one that does not support PROPFIND at all.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4918#section-9.1"], + }, + "propfind.allprop": { + "description": "An PROPFIND returns a multistatus response. This is independent of whether resourcetype in particular is included (see propfind.allprop.resourcetype).", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4918#section-9.1"], + }, + "propfind.allprop.resourcetype": { + "description": "An PROPFIND returns the DAV:resourcetype live property. RFC4918 section 9.1 lists resourcetype among the live properties an allprop request should return, so 'full' (the default) is the conformant behaviour; a few servers (Bedework) omit it.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4918#section-9.1"], + }, "create-calendar.with-supported-component-types": { "description": "Server honours the supported-calendar-component-set restriction set at MKCALENDAR time. When 'full', the server both advertises (or enforces) the restriction; when 'unsupported', the restriction is silently ignored (wrong-type objects can be saved to the calendar). When 'ungraceful', the MKCALENDAR request itself fails when a component set is specified.", }, + "calendar-color": { + "description": "Server stores the nonstandard Apple/Mozilla {http://apple.com/ns/ical/}calendar-color property (set with a colour name like 'blue') on a calendar collection. 'full' covers servers that normalise the name to a hex value (the set value still tracks the input); 'broken' is a read-only property (the same value comes back regardless of what is set). Not described by RFC4791/RFC5545, so a server that rejects or ignores it ('unsupported') is not breaching any RFC. The default is 'fragile' because the behaviour varies a lot between servers and is rarely worth asserting on.", + "default": {"support": "fragile"}, + }, + "calendar-color.hex": { + "description": "Like calendar-color, but the property is set with a hex value (e.g. '#FF0000FF') rather than a colour name. Some servers accept one form but not the other.", + "default": {"support": "fragile"}, + }, + "calendar-order": { + "description": "Server stores the nonstandard Apple/Mozilla {http://apple.com/ns/ical/}calendar-order property on a calendar collection (a get/set round-trip). 'broken' is a read-only property (e.g. the server returns the calendar's own position regardless of what is set). Not described by RFC4791/RFC5545, so a server that rejects or ignores it ('unsupported') is not breaching any RFC. The default is 'fragile' because the behaviour varies a lot between servers.", + "default": {"support": "fragile"}, + }, "rate-limit": { "type": "client-feature", "description": "client (or test code) must sleep a bit between requests. Pro-active rate limiting is done through interval and count, server-flagged rate-limiting is controlled through default_sleep/max_sleep", @@ -127,10 +158,22 @@ class FeatureSet: "description": "Accessing a calendar which does not exist automatically creates it", }, "create-calendar.set-displayname": { - "description": "It's possible to set the displayname on a calendar upon creation" + "description": "It's possible to set the displayname on a calendar upon creation", + ## Independent feature (directly probed): the default marks it so the + ## node uses its own probed value rather than being derived from + ## .stable-url. + "default": {"support": "full"}, + }, + "create-calendar.set-displayname.stable-url": { + "description": "Setting a calendar's display name does not change the calendar's URL. When 'full' (the normal case) the display name and the calendar URL are independent: the calendar stays addressable at the URL derived from the requested cal_id. When 'unsupported', the server couples the two and relocates the calendar's canonical URL to a display-name-derived path when a display name is set (Zimbra applies the display name via a rename that moves the collection; an alias may linger at the original cal_id URL but is unreliable, cf. save-load.get-by-url). Clients that need a predictable calendar URL should therefore omit the display name from the MKCALENDAR request for such servers.", + "default": {"support": "full"}, }, "delete-calendar": { "description": "RFC4791 says nothing about deletion of calendars, so the server implementation is free to choose weather this should be supported or not. Section 3.2.3.2 in RFC 6638 says that if a calendar is deleted, all the calendarobjectresources on the calendar should also be deleted - but it's a bit unclear if this only applies to scheduling objects or not. Some calendar servers moves the object to a trashcan rather than deleting it", + ## Independent feature (directly probed): the default marks it so the + ## node uses its own probed value rather than being derived from + ## .free-namespace. + "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc6638#section-3.2.3.2"], }, "delete-calendar.free-namespace": { @@ -142,9 +185,12 @@ class FeatureSet: "default": { "support": "fragile" }, }, "save-load": { - "description": "it's possible to save and load objects to the calendar" + "description": "it's possible to save and load objects to the calendar", + }, + "save-load.event": { ## TODO: make this DRY + "description": "it's possible to save and load events to the calendar", + "default": { "support": "full" } }, - "save-load.event": {"description": "it's possible to save and load events to the calendar"}, "save-load.event.recurrences": {"description": "it's possible to save and load recurring events to the calendar - events with an RRULE property set, including recurrence sets", "default": {"support": "full"}}, "save-load.event.recurrences.count": {"description": "The server will receive and store a recurring event with a count set in the RRULE", "default": {"support": "full"}}, ## This was Claude's suggestion and it works as of today, the @@ -157,17 +203,31 @@ class FeatureSet: ## information was simply discarded, and the current search behaviour would in ## such a case be incorrect if the exception is simply discarded. "save-load.event.recurrences.exception": {"description": "When a VCALENDAR containing a master VEVENT (with RRULE) and exception VEVENT(s) (with RECURRENCE-ID) is stored, the server keeps them together as a single calendar object resource. When unsupported, the server splits exception VEVENTs into separate calendar objects, making client-side expansion unreliable (the master expands without knowing about its exceptions)."}, - "save-load.todo": {"description": "it's possible to save and load tasks to the calendar"}, - "save-load.todo.recurrences": {"description": "it's possible to save and load recurring tasks to the calendar"}, + "save-load.todo": { + "description": "it's possible to save and load tasks to the calendar", + "default": { "support": "full" } + }, + "save-load.todo.recurrences": {"description": "it's possible to save and load recurring tasks to the calendar", "default": {"support": "full"}}, "save-load.todo.recurrences.count": {"description": "The server will receive and store a recurring task with a count set in the RRULE", "default": {"support": "full"}}, "save-load.todo.recurrences.thisandfuture": {"description": "Completing a recurring task with rrule_mode='thisandfuture' works (modifies RRULE and saves back to server)", "default": {"support": "full"}}, "save-load.todo.mixed-calendar": {"description": "The same calendar may contain both events and tasks (Zimbra only allows tasks to be placed on special task lists)", "default": {"support": "full"}}, - "save-load.journal": {"description": "The server will even accept journals"}, + "save-load.journal": { + "description": "The server will even accept journals", + "default": { "support": "full" } + }, ## TODO: zimbra cannot mix events and tasks, but then davis surprised me by not allowing journals on the same calendar. But this may be a miss in the checking script - it may be that mixing is allowed, but that the calendar has to be set up from scratch with explicit support for both VJOURNAL and other things "save-load.journal.mixed-calendar": {"description": "The same calendar may contain events, tasks and journals (some servers require journals on a dedicated VJOURNAL calendar)", "default": {"support": "full"}}, "save-load.get-by-url": { "description": "GET requests to calendar object resource URLs work correctly. When unsupported, the server returns 404 on GET even for valid object URLs. The client works around this by falling back to UID-based lookup.", }, + "non-existing-raises-not-found": { + "description": "Looking up a non-existing calendar object resource raises NotFoundError (the server answers 404). 'full' (the default) is the expected behaviour; some servers answer 403 instead (raising AuthorizationError) - e.g. Robur, probably to avoid leaking whether a resource exists - which is a legitimate choice rather than an RFC breach, so it is recorded as 'unsupported' rather than 'broken'.", + "default": {"support": "full"}, + }, + "save-load.stable-url": { + "description": "The server reports a calendar object resource under the same URL the client used to store it. When 'unsupported', the server canonicalizes the URL: e.g. OX App Suite exposes a calendar both under its display name and under an internal 'cal://0/NNN' identifier, so an object looked up via a calendar-query REPORT (object_by_uid / search) is reported under a different calendar path than the PUT URL. A direct GET on the original URL still works (the server keeps an alias). Clients should therefore not assume that a searched object's URL equals the URL it was created at.", + "default": {"support": "full"}, + }, "save-load.reuse-deleted-uid": { "description": "After deleting an event, the server allows creating a new event with the same UID. When 'broken', the server keeps deleted events in a trashbin with a soft-delete flag, causing unique constraint violations on UID reuse. See https://github.com/nextcloud/server/issues/30096" }, @@ -184,6 +244,15 @@ class FeatureSet: "description": "A saved calendar object resource can be modified and PUT back to the server; the server accepts the update and returns the modified data on the next GET/REPORT. When 'unsupported', the server treats calendar objects as immutable after initial creation (e.g. Google Calendar's legacy CalDAV API). Replaces the old 'no_overwrite' compatibility flag.", "default": {"support": "full"}, }, + "save-load.mutable.attendee-partstat": { + "description": "A client can modify an attendee's PARTSTAT on an existing event and PUT it back directly to the calendar. When 'unsupported', the server forbids direct modification of attendee participation status via PUT (e.g. OX App Suite returns 403 Forbidden even with a matching If-Match etag) and expects the change to be made through iTIP scheduling instead. See https://github.com/python-caldav/caldav/issues/399", + "default": {"support": "full"}, + "links": ["https://github.com/python-caldav/caldav/issues/399"], + }, + "save-load.mutable.if-match-optional": { + "description": "The If-Match precondition is optional when overwriting an existing calendar object resource: the server accepts a PUT that carries no If-Match etag (i.e. add_event()/save() on an object that was not first fetched). When 'unsupported', the server requires an If-Match etag for updates and rejects a no-If-Match overwrite with 409 Conflict (e.g. OX App Suite enforces optimistic concurrency). Such servers still support save-load.mutable via a fetch-then-save (etag-conditional) update; only the blind-overwrite path is affected.", + "default": {"support": "full"}, + }, "search": { "description": "calendar MUST support searching for objects using the REPORT method, as specified in RFC4791, section 7", "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-7"], @@ -208,7 +277,17 @@ class FeatureSet: "description": "Time-range searches should only return events/todos that actually fall within the requested time range. Some servers incorrectly return recurring events whose recurrences fall outside (after) the search interval, or events with no recurrences in the requested time range at all. RFC4791 section 9.9 specifies that a VEVENT component overlaps a time range if the condition (start < search_end AND end > search_start) is true.", "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.9"], }, + "search.time-range.comp-type-optional": { + "description": "Whether the server accepts a calendar-query carrying a time-range filter but NOT specifying a component type. Per RFC4791 section 9.7 a CALDAV:time-range element is only valid inside a comp-filter for VEVENT/VTODO/VJOURNAL/VFREEBUSY/VALARM - never directly under the VCALENDAR comp-filter. A query without a component type therefore has nowhere RFC-legal to put the time-range. Consequently 'unsupported' (the default) is FULLY RFC-COMPLIANT and is NOT a server defect: SabreDAV-based servers (Baikal, Nextcloud, ...) correctly reject such queries with HTTP 400 'You cannot add time-range filters on the VCALENDAR component'. When unsupported, the library splits the search into one query per component type. See https://github.com/python-caldav/caldav/issues/681", + "default": {"support": "unsupported"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.7"], + }, "search.time-range.todo": {"description": "basic time range searches for tasks works", "default": {"support": "full"}}, + "search.time-range.todo.no-dtstart": { + "description": "A VTODO without DTSTART (but with DUE) is returned by a date-range search. RFC5545 and RFC4791 section 9.9 say such a task has a defined time span and should be found, so 'full' (the default) is the compliant behaviour; some servers (Davical, Stalwart, Synology) skip any task lacking DTSTART. Probed with a closed window; servers that skip such tasks only in closed ranges (returning them in open-ended ones) are instead tracked by the 'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range' flag.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.9"], + }, "search.time-range.todo.old-dates": {"description": "time range searches for tasks with old dates (e.g. year 2000) work - some servers enforce a min-date-time restriction"}, "search.time-range.todo.strict": { "description": "Bounded VTODO time-range searches do not return tasks whose time span falls entirely outside the searched range (no false positives).", @@ -264,6 +343,11 @@ class FeatureSet: "search.text": { "description": "Search for text attributes should work" }, + "search.text.comp-type-optional": { + "description": "Whether the server returns matching objects for a calendar-query that carries a prop-filter (CATEGORIES, SUMMARY, ...) but does NOT specify a component type. Such a prop-filter ends up directly under the VCALENDAR comp-filter, where it filters on VCALENDAR's own properties - which do not include component properties like CATEGORIES - so most servers (e.g. Xandikos, SabreDAV) match nothing. 'unsupported' (the default) is therefore the common, RFC-reasonable case; when unsupported the library splits the search into one query per component type. Analogous to search.time-range.comp-type-optional. See https://github.com/python-caldav/caldav/issues/681", + "default": {"support": "unsupported"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.7"], + }, "search.text.case-sensitive": { "description": "In RFC4791, section-9.7.5, a text-match may pass a collation, and i;ascii-casemap MUST be the default, this is not checked (yet - TODO) by the caldav-server-checker project. Section 7.5 describes that the servers also are REQUIRED to support i;octet. The definitions of those collations are given in RFC4790, i;octet is a case-sensitive byte-by-byte comparition (fastest). search.text.case-sensitive is supported if passing the i;octet collation to search causes the search to be case-sensitive.", "links": [ @@ -285,6 +369,10 @@ class FeatureSet: }, "search.text.category": { "description": "Search for category should work. This is not explicitly specified in RFC4791, but covered in section 9.7.5. No examples targets categories explicitly, but there are some text match examples in section 7.8.6 and following sections", + ## Independent feature (directly probed): the default marks it so the + ## node uses its own probed value rather than being derived from + ## .substring. + "default": {"support": "full"}, "links": [ "https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.5", "https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6", @@ -301,7 +389,11 @@ class FeatureSet: "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-7.4"], }, "search.recurrences.includes-implicit.todo": { - "description": "tasks can also be recurring" + "description": "tasks can also be recurring", + ## Independent feature (directly probed): the default marks it so the + ## node uses its own probed value rather than being derived from + ## .pending. + "default": {"support": "full"}, }, "search.recurrences.includes-implicit.todo.pending": { "description": "a future recurrence of a pending task should always be pending and appear in searches for pending tasks", @@ -332,6 +424,10 @@ class FeatureSet: }, "sync-token": { "description": "RFC6578 sync-collection reports are supported. Server provides sync tokens that can be used to efficiently retrieve only changed objects since last sync. Support can be 'full', 'fragile' (occasionally returns more content than expected), or 'unsupported'. Behaviour 'time-based' indicates second-precision tokens requiring sleep(1) between operations", + ## Independent feature (directly probed): the default marks it so the + ## node uses its own probed value rather than being derived from + ## .delete. + "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc6578"], }, "sync-token.delete": { @@ -339,6 +435,10 @@ class FeatureSet: }, "scheduling": { "description": "Server supports CalDAV Scheduling (RFC6638). Detected via the presence of 'calendar-auto-schedule' in the DAV response header.", + ## Independent feature (directly probed via the DAV header): the default + ## marks it so the node uses its own probed value rather than being + ## derived from subfeatures such as .calendar-user-address-set. + "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc6638"], }, "scheduling.mailbox": { @@ -390,6 +490,11 @@ class FeatureSet: }, "principal-search": { "description": "Server supports searching for principals (CalDAV users). Principal search may be restricted for privacy/security reasons on many servers. (not to be confused with get-current-user-principal)" + ## NB: genuine grouping node - 'supported' iff at least one search + ## method (.by-name / .list-all) works. The checker sets it directly + ## because that OR-semantics cannot be expressed by the library's + ## all-children-agree derivation; it deliberately has NO default so + ## that when all sub-searches fail the node is unsupported. }, "principal-search.by-name": { "description": "Server supports searching for principals by display name. Testing this properly requires setting up another user with a known name, so this check is not yet implemented" @@ -408,6 +513,10 @@ class FeatureSet: "save.duplicate-uid.cross-calendar": { "description": "Server allows events with the same UID to exist in different calendars and treats them as separate entities. Support can be 'full' (allowed), 'ungraceful' (rejected with error), or 'unsupported' (silently ignored or moved). Behaviour 'silently-ignored' means the duplicate is not saved but no error is thrown. Behaviour 'moved-instead-of-copied' means the event is moved from the original calendar to the new calendar (Zimbra behavior)" }, + "save.duplicate-event": { + "description": "Server allows two events with identical content but different UIDs to coexist in the same calendar. Some servers reject or de-duplicate such an event ('duplicates not allowed even with a different UID'), in which case this is 'unsupported' (silently dropped) or 'ungraceful' (rejected with an error). The default 'full' is the usual behaviour.", + "default": {"support": "full"}, + }, ## TODO: as for now, the tests will run towards the first calendar it will find, and most of the tests will assume the calendar is empty. This is bad. "test-calendar": { "type": "tests-behaviour", @@ -493,13 +602,14 @@ def copyFeatureSet(self, feature_set, collapse=True): UserWarning, stacklevel=3, ) + continue value = feature_set[feature] if feature not in self._server_features: self._server_features[feature] = {} server_node = self._server_features[feature] if isinstance(value, bool): server_node['support'] = "full" if value else "unsupported" - elif isinstance(value, str) and 'support' not in server_node: + elif isinstance(value, str): self._validate_support_level(value, feature) server_node['support'] = value elif isinstance(value, dict): @@ -541,44 +651,57 @@ def _collapse_key(self, feature_dict): def collapse(self): """ - If all subfeatures are the same, it should be collapsed into the parent - - Messy and complex logic :-( + Compact the stored feature set: a *grouping* parent (one without its own + explicit default) whose grouping children are all explicitly set to the + same status is replaced by a single entry on the parent, and the children + are dropped. + + The parent status comes from the single derivation path, + is_supported() -> _derive_from_subfeatures(). That path already: + * treats a node with an explicit default as an independent feature - + never derived/collapsed from its children (so e.g. save-load.mutable + stays "full" even when every child is "unsupported"), and + * ignores independent children (those with their own default) when + deriving a grouping parent. + collapse() adds only a losslessness check on top: it folds the children + in solely when every grouping child is explicitly set and matches the + derived value, so no per-child information is lost. """ - features = list(self._server_features.keys()) parents = set() - for feature in features: + for feature in self._server_features: if '.' in feature: parents.add(feature[:feature.rfind('.')]) - parents = list(parents) - ## Parents needs to be ordered by the number of dots. We proceed those with most dots first. - parents.sort(key = lambda x: (-x.count('.'), x)) - for parent in parents: + ## Deepest parents first, so a freshly collapsed child can feed its parent. + for parent in sorted(parents, key=lambda x: (-x.count('.'), x)): parent_info = self.find_feature(parent) - if len(parent_info['subfeatures']): - foo = self.is_supported(parent, return_type=dict, return_defaults=False) - if len(parent_info['subfeatures']) > 1 or foo is not None: - dont_collapse = False - foo_key = self._collapse_key(foo) if foo is not None else None - for sub in parent_info['subfeatures']: - bar = self._server_features.get(f"{parent}.{sub}") - if bar is None: - dont_collapse = True - break - bar_key = self._collapse_key(bar) - if foo is None: - foo = bar - foo_key = bar_key - elif bar_key != foo_key: - dont_collapse = True - break - if not dont_collapse: - if parent not in self._server_features: - self._server_features[parent] = {} - for sub in parent_info['subfeatures']: - self._server_features.pop(f"{parent}.{sub}") - self.copyFeatureSet({parent: foo}) + ## Independent node (its own explicit default) is never collapsed. + if 'default' in parent_info: + continue + + ## Independent children (their own default) are separate features: + ## neither folded in nor required to match. + grouping_children = [ + sub + for sub in parent_info['subfeatures'] + if 'default' not in self.find_feature(f"{parent}.{sub}") + ] + if not grouping_children: + continue + + derived = self.is_supported(parent, return_type=dict, return_defaults=False) + if derived is None: + continue + derived_key = self._collapse_key(derived) + + ## Lossless only if every grouping child is explicitly set and matches. + child_nodes = [self._server_features.get(f"{parent}.{sub}") for sub in grouping_children] + if any(node is None or self._collapse_key(node) != derived_key for node in child_nodes): + continue + + for sub in grouping_children: + self._server_features.pop(f"{parent}.{sub}", None) + self.copyFeatureSet({parent: derived}) def _default(self, feature_info): if isinstance(feature_info, str): @@ -689,8 +812,11 @@ def _derive_from_subfeatures(self, feature, feature_info, return_type, accept_fr if has_positive: if all_same: derived_status = subfeature_statuses[0] + elif not is_complete: + # Incomplete mixed set: unset siblings might be unsupported; inconclusive + return None else: - # Mixed positive/negative → unknown + # All relevant children seen, but mixed positive/negative → unknown derived_status = 'unknown' elif is_complete and all_same: # All relevant subfeatures set, all the same negative status @@ -825,28 +951,9 @@ def dotted_feature_set_list(self, compact=False): ## * Perhaps some more readable format should be considered (yaml?). ## * Consider how to get this into the documentation incompatibility_description = { - 'calendar_order': - """Server supports (nonstandard) calendar ordering property""", - - 'calendar_color': - """Server supports (nonstandard) calendar color property""", - - 'duplicates_not_allowed': - """Duplication of an event in the same calendar not allowed """ - """(even with different uid)""", - - 'event_by_url_is_broken': """A GET towards a valid calendar object resource URL will yield 404 (wtf?)""", - 'propfind_allprop_failure': - """The propfind test fails ... """ - """it asserts DAV:allprop response contains the text 'resourcetype', """ - """possibly this assert is wrong""", - - 'vtodo_datesearch_nodtstart_task_is_skipped': - """date searches for todo-items will not find tasks without a dtstart""", - 'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range': """only open-ended date searches for todo-items will find tasks without a dtstart""", @@ -866,24 +973,21 @@ def dotted_feature_set_list(self, compact=False): """Events should be deleted before the calendar is deleted, """ """and/or deleting a calendar may not have immediate effect""", - 'no_overwrite': - """events cannot be edited""", - 'dav_not_supported': """when asked, the server may claim it doesn't support the DAV protocol. Observed by one baikal server, should be investigated more (TODO) and robur""", 'fastmail_buggy_noexpand_date_search': """The 'blissful anniversary' recurrent example event is returned when asked for a no-expand date search for some timestamps covering a completely different date""", - 'non_existing_raises_other': - """Robur raises AuthorizationError when trying to access a non-existing resource (while 404 is expected). Probably so one shouldn't probe a public name space?""", - 'robur_rrule_freq_yearly_expands_monthly': """Robur expands a yearly event into a monthly event. I believe I've reported this one upstream at some point, but can't find back to it""", } xandikos = { + ## Genuinely returns matching objects for a comp-type-less query that carries + ## a time-range (verified: the event is returned, not just "no error"). + "search.time-range.comp-type-optional": {"support": "full"}, ## Principal property search returns 403 (not implemented) "principal-search": "ungraceful", @@ -900,6 +1004,9 @@ def dotted_feature_set_list(self, compact=False): ## There is much development going on at Radicale as of summar 2025, ## so I'm expecting this list to shrink a lot soon. radicale = { + ## Genuinely returns matching objects for a comp-type-less query that carries + ## a time-range (verified: the event is returned, not just "no error"). + "search.time-range.comp-type-optional": {"support": "full"}, "search.is-not-defined": {"support": "full"}, "search.text.case-sensitive": {"support": "unsupported"}, "search.recurrences.includes-implicit.todo.pending": {"support": "fragile", "behaviour": "inconsistent results between runs"}, @@ -909,11 +1016,9 @@ def dotted_feature_set_list(self, compact=False): ## this only applies for very simple installations "auto-connect.url": {"domain": "localhost", "scheme": "http", "basepath": "/"}, "scheduling": {"support": "unsupported"}, - 'old_flags': [ - ## extra features not specified in RFC4791 - "calendar_order", - "calendar_color" - ] + ## extra properties not specified in RFC4791/RFC5545 + "calendar-color": {"support": "full"}, + "calendar-order": {"support": "full"}, } ## Be aware that nextcloud by default have different rate limits, including how often a user is allowed to create a new calendar. This may break test runs badly. @@ -921,8 +1026,15 @@ def dotted_feature_set_list(self, compact=False): 'auto-connect.url': { 'basepath': '/remote.php/dav', }, - ## I'm surprised, I'm quite sure this was reported ungraceful earlier. Passed with caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 2026-02-15. The commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad was however development done on the wrong branch and has been force-pushed awway. It was again observed ungraceful at commits be26d42b1ca3ff3b4fd183761b4a9b024ce12b84 / 537a23b145487006bb987dee5ab9e00cdebb0492 - 'search.comp-type.optional': {'support': 'ungraceful'}, + ## Historically this flip-flopped between "ungraceful" and "full" - that + ## instability was a checker bug (https://github.com/python-caldav/caldav/issues/681): + ## the comp-type.optional probe used to send a comp-type-less query carrying a + ## time-range, which SabreDAV rejects (the time-range belongs in a VEVENT/... + ## comp-filter, not under VCALENDAR). Now that the probe omits the time-range, + ## Nextcloud correctly accepts the bare comp-type-less query. The time-range + ## variant is tracked separately as search.time-range.comp-type-optional + ## (unsupported on SabreDAV, the default). + 'search.comp-type.optional': {'support': 'full'}, 'search.recurrences.expanded.todo': {'support': 'unsupported'}, "search.recurrences.includes-implicit.infinite-scope": False, 'delete-calendar': { @@ -970,13 +1082,34 @@ def dotted_feature_set_list(self, compact=False): ## Zimbra is not very good at it's caldav support zimbra = { 'auto-connect.url': {'basepath': '/dav/'}, + ## Genuinely returns matching objects for a comp-type-less query that carries + ## a time-range (verified: the event is returned, not just "no error"). + 'search.time-range.comp-type-optional': {'support': 'full'}, 'delete-calendar': {'support': 'fragile', 'behaviour': 'may move to trashbin instead of deleting immediately'}, ## This is a zimbra bug when creating calendars with a display ## name. Now mitigated in the calendar creation code. #'save-load.get-by-url': {'support': 'fragile', 'behaviour': '404 most of the time - but sometimes 200. Weird, should be investigated more'}, ## Zimbra treats same-UID events across calendars as aliases of the same event 'save.duplicate-uid.cross-calendar': {'support': 'unsupported'}, - 'create-calendar.set-displayname': {'support': 'unsupported'}, + ## Zimbra DOES apply a display name set at creation - but it couples the + ## display name to the calendar URL. MKCALENDAR alone always lands the + ## calendar at the requested cal_id path (display name = cal_id); the display + ## name is then applied by a follow-up PROPPATCH, which Zimbra implements as a + ## rename that MOVES the collection: the canonical URL relocates to a + ## display-name-derived path (verified deterministic with a unique name). An + ## alias may linger at the original cal_id URL, but it is unreliable + ## (cf. save-load.get-by-url, "404 most of the time but sometimes 200"). The + ## earlier flip-flopping was just whether the rename's target name was free in + ## the namespace (including the trashbin), cf. delete-calendar=fragile. + ## + ## So set-displayname itself is 'full' (the name sticks), but the new child + ## set-displayname.stable-url is 'unsupported': is_supported() returns False + ## for the child, so Calendar._create() omits the DisplayName from MKCALENDAR + ## (no rename, no relocation) and the test fixture creates without a name - + ## keeping the calendar addressable at the cal_id path. Verified against + ## zcs-foss:latest 2026-06-07. + 'create-calendar.set-displayname': {'support': 'full'}, + 'create-calendar.set-displayname.stable-url': {'support': 'unsupported', 'behaviour': 'setting the display name renames/moves the collection, relocating the canonical calendar URL to a display-name-derived path'}, 'save-load.todo.mixed-calendar': {'support': 'unsupported'}, 'save-load.todo.recurrences.count': {'support': 'unsupported'}, ## This is a new problem? 'save-load.journal': {'support': 'ungraceful'}, @@ -987,7 +1120,10 @@ def dotted_feature_set_list(self, compact=False): # sometimes throws a 500 'search.text.category': {'support': 'ungraceful'}, 'search.recurrences.expanded.todo': { "support": "unsupported" }, - 'search.comp-type.optional': {'support': 'fragile'}, ## TODO: more research on this, looks like a bug in the checker, + ## was 'fragile' - that was the checker bug (it compared a comp-type-less + ## search against cnt, which counts objects stored in a separate + ## task/journal calendar). Confirmed full 2026-06-06. + 'search.comp-type.optional': {'support': 'full'}, 'search.time-range.alarm': {'support': 'unsupported'}, 'principal-search': "unsupported", ## Zimbra implements server-side automatic scheduling: invitations are @@ -1011,11 +1147,15 @@ def dotted_feature_set_list(self, compact=False): ## TODO: I just discovered that when searching for a date some ## years after a recurring daily event was made, the event does ## not appear. - - ## extra features not specified in RFC5545 - "calendar_order", - "calendar_color" - ] + ], + ## extra properties not specified in RFC4791/RFC5545. Zimbra stores + ## calendar-order, and stores calendar-color only when set as a hex value - + ## it rejects/ignores a colour name like "blue". (The old 'calendar_color' + ## flag was never actually exercised, because testSetCalendarProperties skips + ## on Zimbra: setting a display name relocates the calendar.) + "calendar-color": {"support": "unsupported"}, + "calendar-color.hex": {"support": "full"}, + "calendar-order": {"support": "full"}, } bedework = { @@ -1040,7 +1180,14 @@ def dotted_feature_set_list(self, compact=False): "search.recurrences": False, "sync-token": { "support": "fragile" }, 'search.comp-type': {'support': 'broken', 'behaviour': 'Server returns everything when searching for events and nothing when searching for todos'}, - 'search.comp-type.optional': {'support': 'ungraceful'}, + ## was 'ungraceful' - that was the checker bug (cnt counted the separately + ## stored journal); confirmed full 2026-06-06. + 'search.comp-type.optional': {'support': 'full'}, + ## Flaps between full and unsupported across runs - the comp-type-less + ## time-range query intermittently returns the in-range object vs nothing, + ## most likely the search-cache delay above. Marked fragile so the checker + ## skips it. Observed 2026-06-06. + 'search.time-range.comp-type-optional': {'support': 'fragile'}, 'search.is-not-defined.dtend': False, "principal-search": { "support": "ungraceful" }, ## Bedework hides past non-recurring events from REPORT without a time-range filter, @@ -1056,11 +1203,11 @@ def dotted_feature_set_list(self, compact=False): ## TODO: play with this and see if it's needed 'save-load.icalendar.related-to': {'support': 'broken', 'behaviour': 'first RELATED-TO line is preserved but subsequent RELATED-TO lines are stripped'}, - 'old_flags': [ - 'propfind_allprop_failure', - 'duplicates_not_allowed', - ], - + ## Bedework omits DAV:resourcetype from an allprop PROPFIND response. + "propfind.allprop.resourcetype": {"support": "unsupported"}, + ## (The old 'duplicates_not_allowed' flag was stale: Bedework does store a + ## second event with the same content under a different UID, so + ## save.duplicate-event is left at the default "full".) } synology = { @@ -1071,7 +1218,8 @@ def dotted_feature_set_list(self, compact=False): 'search.is-not-defined': {'support': 'fragile', 'behaviour': 'works for CLASS but not for CATEGORIES'}, 'search.text.case-sensitive': {'support': 'unsupported'}, 'search.time-range.alarm': {'support': 'unsupported'}, - 'old_flags': ['vtodo_datesearch_nodtstart_task_is_skipped'], + ## Synology skips VTODOs without DTSTART in date-range searches. + 'search.time-range.todo.no-dtstart': {'support': 'unsupported'}, 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, 'scheduling.schedule-tag': False, 'scheduling.mailbox.inbox-delivery': False, @@ -1082,7 +1230,9 @@ def dotted_feature_set_list(self, compact=False): # into their calendar. "scheduling.schedule-tag": False, "http.multiplexing": "fragile", ## ref https://github.com/python-caldav/caldav/issues/564 - 'search.comp-type.optional': {'support': 'ungraceful'}, + ## was 'ungraceful' - that was the checker bug (cnt counted the journal that + ## SabreDAV stores in a separate calendar); confirmed full 2026-06-06. + 'search.comp-type.optional': {'support': 'full'}, 'search.recurrences.expanded.todo': {'support': 'unsupported'}, 'search.recurrences.includes-implicit.todo': {'support': 'unsupported'}, "search.recurrences.includes-implicit.infinite-scope": False, @@ -1091,11 +1241,9 @@ def dotted_feature_set_list(self, compact=False): 'principal-search.by-name.self': {'support': 'unsupported'}, 'principal-search.list-all': {'support': 'ungraceful'}, #'sync-token.delete': {'support': 'unsupported'}, ## Perhaps on some older servers? - 'old_flags': [ - ## extra features not specified in RFC5545 - "calendar_order", - "calendar_color", - ], + ## extra properties not specified in RFC4791/RFC5545 + "calendar-color": {"support": "full"}, + "calendar-order": {"support": "full"}, ## I'm surprised, I'm quite sure this was passing earlier. Caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 'search.combined-is-logical-and': False } ## TODO: testPrincipals, testWrongAuthType, testTodoDatesearch fails @@ -1107,7 +1255,10 @@ def dotted_feature_set_list(self, compact=False): } cyrus = { - "search.comp-type.optional": {"support": "ungraceful"}, + ## A bare comp-type-less query is accepted; the previous "ungraceful" was a + ## checker bug where the probe carried a time-range + ## (https://github.com/python-caldav/caldav/issues/681). + "search.comp-type.optional": {"support": "full"}, "search.recurrences.includes-implicit.infinite-scope": False, "search.time-range.alarm": {"support": "ungraceful"}, 'principal-search': {'support': 'ungraceful'}, @@ -1146,29 +1297,41 @@ def dotted_feature_set_list(self, compact=False): # DAViCal delivers iTIP notifications to the attendee inbox AND auto-schedules # into their calendar. "scheduling.schedule-tag": False, - "search.comp-type.optional": { "support": "fragile" }, + ## was 'fragile' - that was the checker bug (cnt mismatch); confirmed full 2026-06-06. + "search.comp-type.optional": { "support": "full" }, + ## Genuinely returns matching objects for a comp-type-less query that carries + ## a time-range (verified: the event is returned, not just "no error"). + "search.time-range.comp-type-optional": { "support": "full" }, "search.time-range.alarm": { "support": "unsupported" }, 'sync-token': {'support': 'fragile'}, 'principal-search': {'support': 'unsupported'}, 'principal-search.list-all': {'support': 'unsupported'}, + ## DAViCal skips VTODOs without DTSTART in date-range searches. + 'search.time-range.todo.no-dtstart': {'support': 'unsupported'}, "old_flags": [ #'no_journal', ## it threw a 500 internal server error! ## for old versions #'nofreebusy', ## for old versions ## 'fragile_sync_tokens' removed - covered by 'sync-token': {'support': 'fragile'} - 'vtodo_datesearch_nodtstart_task_is_skipped', ## no issue raised yet - 'calendar_color', - 'calendar_order', 'vtodo_datesearch_notime_task_is_skipped', ], + ## extra properties not specified in RFC4791/RFC5545 + "calendar-color": {"support": "full"}, + "calendar-order": {"support": "full"}, } sogo = { "scheduling.schedule-tag": False, "scheduling.mailbox.inbox-delivery": False, + ## SOGo rejects the calendar-color property with an error (left at the + ## default "fragile" - rejecting a nonstandard extension is fine). It + ## accepts calendar-order but echoes back a server-computed position rather + ## than the value that was set, so that property is effectively read-only. + "calendar-order": {"support": "broken", "behaviour": "read-only; server returns its own calendar position rather than the value set"}, ## I'm surprised, I'm quite sure this was passing earlier. reported unsupported with caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 2026-02-15 "search.text.category": False, - "search.time-range.event.old-dates": False, - "search.time-range.todo.old-dates": False, + ## old-date time-range search works (probe found, definite-future object + ## correctly excluded); the earlier "False" was an artifact of the old + ## count==1 check, which a next-year open-start DUE-only task inflated. "save-load.journal": {"support": "ungraceful"}, "search.is-not-defined": {"support": "unsupported"}, "search.text.case-sensitive": { @@ -1180,9 +1343,13 @@ def dotted_feature_set_list(self, compact=False): "search.time-range.alarm": { "support": "unsupported" }, - ## was unsupported. reported ungraceful with caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 2026-02-15 + ## A comp-type-less query returns nothing - with or without a time-range - + ## so both search.comp-type.optional and search.time-range.comp-type-optional + ## are unsupported (the latter is the default). The previous "ungraceful" was + ## a checker bug where the comp-type.optional probe carried a time-range that + ## SabreDAV-likes reject (https://github.com/python-caldav/caldav/issues/681). "search.comp-type.optional": { - "support": "ungraceful" + "support": "unsupported" }, ## includes-implicit.todo has been observed as both supported and unsupported ## across different test runs. Other includes-implicit children are unsupported. @@ -1260,9 +1427,12 @@ def dotted_feature_set_list(self, compact=False): 'principal-search': {'support': 'ungraceful'}, 'freebusy-query': {'support': 'ungraceful'}, "scheduling": {"support": "unsupported"}, - 'old_flags': [ - 'non_existing_raises_other', ## AuthorizationError instead of NotFoundError - ], + ## Robur answers 403 (AuthorizationError) instead of 404 (NotFoundError) when + ## looking up a non-existing resource - probably to avoid leaking whether a + ## resource exists. (Not re-probed during this migration: the Robur test + ## server was down; value carried over from the old 'non_existing_raises_other' + ## flag.) + 'non-existing-raises-not-found': {'support': 'unsupported', 'behaviour': 'raises AuthorizationError (403) instead of NotFoundError (404)'}, 'save-load.icalendar.related-to': {'support': 'unsupported'}, 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, "sync-token": {"support": "ungraceful"}, @@ -1330,11 +1500,12 @@ def dotted_feature_set_list(self, compact=False): "principal-search.by-name.self": {"support": "unsupported"}, "principal-search": {"support": "ungraceful"}, "save-load.journal.mixed-calendar": {"support": "unsupported"}, - "search.comp-type.optional": {"support": "ungraceful"}, - "old_flags": [ - "calendar_order", - "calendar_color", - ], + ## was 'ungraceful' - that was the checker bug (cnt counted the journal that + ## SabreDAV stores in a separate calendar); confirmed full 2026-06-06. + "search.comp-type.optional": {"support": "full"}, + ## extra properties not specified in RFC4791/RFC5545 + "calendar-color": {"support": "full"}, + "calendar-order": {"support": "full"}, ## I'm surprised, I'm quite sure this was passing earlier. Caldav commit a98d50490b872e9b9d8e93e2e401c936ad193003, caldav server checker commit 3cae24cf99da1702b851b5a74a9b88c8e5317dad 'search.combined-is-logical-and': False } @@ -1356,25 +1527,35 @@ def dotted_feature_set_list(self, compact=False): "save.duplicate-uid.cross-calendar": {"support": "ungraceful"}, # CCS rejects multi-instance VTODOs (thisandfuture recurring completion) "save-load.todo.recurrences.thisandfuture": {"support": "unsupported"}, - "search.comp-type.optional": {"support": "ungraceful"}, - ## "full" observed, 70938dc1cbb6a839978eee4315699746d38ee5f0/3cae24cf99da1702b851b5a74a9b88c8e5317dad, 2026-02-17. - ## However, this may be due to mess with the caldav-server-checker branches. "unsupported" again at be26d42b1ca3ff3b4fd183761b4a9b024ce12b84 / 537a23b145487006bb987dee5ab9e00cdebb0492 + ## was 'ungraceful' - that was the checker bug (cnt mismatch: it counted a + ## journal object that CCS could not store, so the comp-type-less count never + ## matched). Confirmed full 2026-06-06. + ## ("full" had also been observed 2026-02-17, then "unsupported"/"ungraceful" + ## - all that flapping was the same checker bug, now fixed.) + "search.comp-type.optional": {"support": "full"}, "search.text.case-sensitive": {"support": "unsupported"}, "search.time-range.event": {"support": "full"}, "search.time-range.event.old-dates": {"support": "ungraceful"}, "search.time-range.todo": {"support": "full"}, "search.time-range.todo.old-dates": {"support": "ungraceful"}, - "search.time-range.open": {"support": "ungraceful"}, + ## open-ended time-range searches work with the near-future fixtures; CCS only + ## rejected them (ungraceful) for the old year-2000 range, so the leaves default + ## to "full" (a grouping "search.time-range.open: ungraceful" was removed here). "search.time-range.alarm": {"support": "unsupported"}, - "search.recurrences": {"support": "unsupported"}, + ## Recurrence expansion actually works within the (near-future) search window; + ## this was previously reported "unsupported" only because the test fixtures + ## lived in year 2000, which CCS's min-date-time restriction hid. Only infinite + ## scope (far-future) and server-side VTODO expansion remain unsupported. + "search.recurrences.includes-implicit.infinite-scope": {"support": "unsupported"}, + "search.recurrences.expanded.todo": {"support": "unsupported"}, "principal-search": {"support": "unsupported"}, # Ephemeral Docker container: wipe objects (avoids UID conflicts across calendars) "test-calendar": {"cleanup-regime": "wipe-calendar"}, - ## Did pass earlier, ungraceful at be26d42b1ca3ff3b4fd183761b4a9b024ce12b84 / 537a23b145487006bb987dee5ab9e00cdebb0492 - 'freebusy-query': {'support': 'ungraceful'}, - "old_flags": [ - "propfind_allprop_failure", - ], + ## freebusy-query works with the near-future fixtures; CCS rejected the + ## year-2000 range with an error, so this defaults to "full" now. + ## (The old 'propfind_allprop_failure' flag was stale: CCS does return + ## DAV:resourcetype in an allprop PROPFIND, so propfind.allprop.resourcetype + ## is left at the default "full".) } ## Stalwart - all-in-one mail & collaboration server (CalDAV added 2024/2025) @@ -1390,24 +1571,39 @@ def dotted_feature_set_list(self, compact=False): 'create-calendar.auto': True, 'principal-search': {'support': 'ungraceful'}, 'search.time-range.alarm': False, + ## Stalwart accepts comp-type-less queries fully, including the time-range + ## and prop-filter variants (both unsupported on most other servers). + ## Confirmed 2026-06-06. + 'search.time-range.comp-type-optional': {'support': 'full'}, + 'search.text.comp-type-optional': {'support': 'full'}, ## Stalwart supports implicit recurrence for datetime events but not for ## all-day (VALUE=DATE) recurring events in time-range searches. 'search.recurrences.includes-implicit.event': {'support': 'fragile', 'behaviour': 'broken for all-day (VALUE=DATE) events'}, ## Stalwart returns the recurring todo in search results but doesn't return the ## RRULE intact, so client-side expansion can't expand it to specific occurrences. 'search.recurrences.includes-implicit.todo': {'support': 'fragile'}, - ## Stalwart correctly handles exceptions in server-side CALDAV:expand (observed supported). - ## Stalwart stores master+exception VEVENTs as a single resource with 2 VEVENTs. + ## Stalwart stores master+exception VEVENTs as a single resource with 2 VEVENTs, + ## so client-side expand of the recurrence set works. 'save-load.event.recurrences.exception': {'support': 'full'}, + ## ...but server-side CALDAV:expand only suppresses the exception-overridden + ## occurrence when SEQUENCE is absent. With SEQUENCE present (as real clients + ## always emit) it returns both the original occurrence and the override. + ## Detected by the server-tester's csc_monthly_recurring_with_exception_seq fixture. + 'search.recurrences.expanded.exception': { + 'support': 'fragile', + 'behaviour': 'server-side expand fails to suppress the exception-overridden occurrence when SEQUENCE is present', + }, 'search.time-range.open': True, ## Stalwart delivers iTIP notifications to the attendee inbox AND auto-schedules ## into their calendar (verified by running CheckSchedulingInboxDelivery). "scheduling.mailbox.inbox-delivery": True, "scheduling.auto-schedule": True, - 'old_flags': [ - ## Stalwart does not return VTODO items without DTSTART in date searches - 'vtodo_datesearch_nodtstart_task_is_skipped', - ], + ## Stalwart's handling of DTSTART-less VTODOs in date searches is date + ## dependent: a near-future DUE-only task is returned (the server-tester + ## probe sees 'full'), but the old-date fixtures used by testTodoDatesearch + ## are skipped. Marked 'fragile' so the checker skips it and the integration + ## test (is_supported -> False) still treats the old-date task as skipped. + 'search.time-range.todo.no-dtstart': {'support': 'fragile'}, } ## Lots of transient problems with purelymail @@ -1498,8 +1694,19 @@ def dotted_feature_set_list(self, compact=False): ## OX App Suite CalDAV served at /caldav/ (Apache proxies to /servlet/dav/caldav on port 8009). ## The Docker image must be built locally before use (see tests/docker-test-servers/ox/build.sh). ox = { - ## Renaming a calendar after creation via PROPPATCH is not supported - 'create-calendar.set-displayname': {'support': 'unsupported'}, + ## Renaming a calendar after creation via PROPPATCH is not supported, but + ## setting the display name AT creation time is - and that's what the probe + ## tests. Was 'unsupported' (conflated the two operations, and masked by the + ## checker's display-name-lookup bug). Confirmed full 2026-06-07. + 'create-calendar.set-displayname': {'support': 'full'}, + ## ... but like Zimbra, OX couples the display name to the calendar URL: a + ## calendar created with a display name is exposed under an internal + ## 'cal://0/NNN' identifier rather than the requested cal_id (cf. the + ## save-load.stable-url note below). A direct GET on the cal_id URL works as + ## an alias, but the canonical URL differs - so the library omits the display + ## name from MKCALENDAR to keep the calendar addressable at the cal_id path + ## (creating without a name does stay at cal_id; verified 2026-06-08). + 'create-calendar.set-displayname.stable-url': {'support': 'unsupported', 'behaviour': 'a calendar created with a display name is exposed under an internal cal://0/NNN URL, not the requested cal_id'}, ## VTODOs must be in a dedicated VTODO-only calendar; mixed calendars not supported 'save-load.todo.mixed-calendar': {'support': 'unsupported'}, ## Basic VTODO support works fine; only recurrences are broken @@ -1508,20 +1715,58 @@ def dotted_feature_set_list(self, compact=False): 'save-load.todo.recurrences': {'support': 'ungraceful'}, ## VJOURNAL is not supported 'save-load.journal': {'support': 'unsupported'}, + ## OX exposes the calendar both under its display name and under an internal + ## "cal://0/NNN" id, so objects looked up via REPORT come back under a + ## different calendar URL than the one used to PUT them (GET on the original + ## URL still works via an alias). + 'save-load.stable-url': {'support': 'unsupported'}, + ## OX enforces optimistic concurrency: a no-If-Match overwrite PUT is rejected + ## with 409 Conflict (etag-conditional save() still works). + 'save-load.mutable.if-match-optional': {'support': 'unsupported'}, + ## OX forbids changing an attendee's PARTSTAT via a direct PUT (403 Forbidden + ## even with a matching etag); it must go through iTIP scheduling. + 'save-load.mutable.attendee-partstat': {'support': 'unsupported'}, ## Search limitations 'search.time-range.event.old-dates': {'support': 'unsupported'}, 'search.time-range.todo.old-dates': {'support': 'unsupported'}, 'search.time-range.alarm': {'support': 'unsupported'}, 'search.unlimited-time-range': {'support': 'broken'}, - 'search.comp-type.optional': {'support': 'ungraceful'}, + ## was 'ungraceful' - that was the checker bug (cnt mismatch across the + ## separate VTODO calendar); confirmed full 2026-06-06. + 'search.comp-type.optional': {'support': 'full'}, + ## OX silently ignores the CALDAV comp-filter: a calendar-query that + ## specifies a component type returns the calendar's whole contents + ## regardless of the requested type (a VEVENT-calendar answers a VTODO query + ## with its VEVENT, and vice versa). No right-typed objects are dropped, so + ## the library recovers the correct result by post-filtering - hence + ## "unsupported" (silently ignored), not "broken". Confirmed by direct probe + ## 2026-06-09. Contrast bedework, which drops the todos (data loss = broken). + 'search.comp-type': {'support': 'unsupported'}, 'search.text': {'support': 'unsupported'}, 'search.text.category': {'support': 'unsupported'}, 'search.text.case-sensitive': {'support': 'unsupported'}, 'search.text.case-insensitive': {'support': 'unsupported'}, - ## Recurrence searching broken (sliding window + old-dates limitation) - 'search.recurrences.includes-implicit': {'support': 'unsupported'}, + ## Recurrence searching: the sliding window hides far-past/far-future + ## occurrences, but implicit expansion of *datetime* events and server-side + ## expansion of exceptions work within the window (detectable now that the + ## fixtures are in the near future rather than year 2000). VTODO recurrence, + ## datetime-event server-side expansion, and infinite scope remain unsupported. + ## (event and exception expansion are left at the default "full".) + 'search.recurrences.includes-implicit.todo': {'support': 'unsupported'}, 'search.recurrences.includes-implicit.todo.pending': {'support': 'unsupported'}, - 'search.recurrences.expanded': {'support': 'unsupported'}, + 'search.recurrences.includes-implicit.infinite-scope': {'support': 'unsupported'}, + 'search.recurrences.expanded.event': {'support': 'unsupported'}, + 'search.recurrences.expanded.todo': {'support': 'unsupported'}, + ## OX ignores the time-range on VTODO queries and returns every task + 'search.time-range.todo.strict': {'support': 'broken'}, + ## OX silently ignores the is-not-defined prop-filter and returns the whole + ## calendar regardless (confirmed by direct probe 2026-06-09: a no_category + ## search still returns the categorised event; a no_class search still + ## returns the CONFIDENTIAL event). Same "filter ignored" behaviour as + ## search.comp-type above - silently ignored, hence unsupported. + 'search.is-not-defined': {'support': 'unsupported'}, + 'search.is-not-defined.category': {'support': 'unsupported'}, + 'search.is-not-defined.class': {'support': 'unsupported'}, ## is-not-defined for DTEND is not supported 'search.is-not-defined.dtend': {'support': 'unsupported'}, ## Freebusy queries are not supported (returns 400) @@ -1538,9 +1783,15 @@ def dotted_feature_set_list(self, compact=False): "scheduling.freebusy-query": "ungraceful", 'search.time-range.open.start': "broken", 'search.time-range.open.end': True, - ## time-range.open is "broken", while time-range.open.start.duration is "unsupported"? - ## this may possibly be some problems with the checker rather than with Ox - 'search.time-range.open.start.duration': "unsupported" + ## DTSTART+DURATION components ARE found by an overlapping time-range search: + ## confirmed by direct probe 2026-06-09 for VEVENT, and the VTODO duration + ## fixture is returned too. The VTODO time-range is not honoured strictly + ## (out-of-range tasks leak in - tracked separately as + ## search.time-range.todo.strict=broken), so the checker now treats the VTODO + ## duration probe as inconclusive rather than a failure and judges this + ## feature from the conclusive VEVENT result. (Previously mis-reported as a + ## VTODO/VEVENT asymmetry; see the old "checker problem" note here.) + 'search.time-range.open.start.duration': {'support': 'full'}, } # fmt: on diff --git a/caldav/config.py b/caldav/config.py index d0008506..a0ec94a4 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -33,6 +33,8 @@ def expand_config_section(config, section="default", blacklist=None): ## If it's not a glob-pattern ... if set(section).isdisjoint(set("[*?")): + if section not in config: + return [] ## If it's referring to a "meta section" with the "contains" keyword if "contains" in config[section]: results = [] @@ -47,7 +49,7 @@ def expand_config_section(config, section="default", blacklist=None): return results else: ## Disabled sections should be ignored - if config.get("section", {}).get("disable", False): + if config.get(section, {}).get("disable", False): return [] ## NORMAL CASE - return [ section ] @@ -177,7 +179,7 @@ def resolve_features(features): feature_name = features if feature_name.startswith("compatibility_hints."): feature_name = feature_name[len("compatibility_hints.") :] - return getattr(caldav.compatibility_hints, feature_name) + return copy.deepcopy(getattr(caldav.compatibility_hints, feature_name)) if isinstance(features, dict) and "base" in features: base_name = features["base"] if isinstance(base_name, str): @@ -259,15 +261,15 @@ def get_connection_params( or None if no configuration found. """ # 1. Explicit parameters take highest priority - if explicit_params: - # Filter to valid connection keys - conn_params = {k: v for k, v in explicit_params.items() if k in CONNKEYS} - if conn_params.get("url") or conn_params.get("features"): - # Return when URL is given, or when features are given (the - # client constructor resolves URL from auto-connect.url hints - # via _auto_url()). Don't fall through to env vars/config - # files when the caller explicitly provided connection info. - return conn_params + explicit_conn = ( + {k: v for k, v in explicit_params.items() if k in CONNKEYS} if explicit_params else {} + ) + if explicit_conn.get("url") or explicit_conn.get("features"): + # Return when URL is given, or when features are given (the + # client constructor resolves URL from auto-connect.url hints + # via _auto_url()). Don't fall through to env vars/config + # files when the caller explicitly provided connection info. + return explicit_conn # Check for config file path from environment early (needed for test server config too) if environment: @@ -293,15 +295,17 @@ def get_connection_params( if environment: conn_params = _get_env_config() if conn_params: + conn_params.update(explicit_conn) return conn_params # 4. Config file if check_config_file: conn_params = _get_file_config(config_file, config_section) if conn_params: + conn_params.update(explicit_conn) return conn_params - return None + return explicit_conn or None def _get_env_config() -> dict[str, Any] | None: @@ -330,7 +334,7 @@ def _get_file_config(file_path: str | None, section_name: str | None) -> dict[st return None section_data = config_section(cfg, section_name) - return _extract_conn_params_from_section(section_data) + return extract_conn_params_from_section(section_data) def _get_test_server_config( @@ -492,14 +496,22 @@ def _test_server_to_params(server: Any, was_already_started: bool) -> dict[str, return params -def _extract_conn_params_from_section(section_data: dict[str, Any]) -> dict[str, Any] | None: +def extract_conn_params_from_section(section_data: dict[str, Any]) -> dict[str, Any] | None: """Extract connection parameters from a config section dict. - Returns a dict containing only CONNKEYS entries. Returns ``None`` if no - server URL is present. Calendar filter keys (``calendar_name``, - ``calendar_url``) are intentionally excluded — callers that need them - (e.g. :func:`get_all_file_connection_params`) read ``section_data`` - directly. + Keys prefixed with ``caldav_`` are mapped to client constructor parameters + (with ``caldav_user``/``caldav_pass`` accepted as aliases for + username/password), environment variable references are expanded, and a + ``features`` key is resolved through :func:`resolve_features`. Public so + that downstream tools (e.g. plann) can reuse it on plann-style config + sections. + + Returns a dict containing only CONNKEYS entries. Returns ``None`` if + neither a server URL nor features are present (with features, the client + constructor can resolve the URL from auto-connect.url hints). Calendar + filter keys (``calendar_name``, ``calendar_url``) are intentionally + excluded — callers that need them (e.g. + :func:`get_all_file_connection_params`) read ``section_data`` directly. """ conn_params: dict[str, Any] = {} for k in section_data: @@ -518,7 +530,7 @@ def _extract_conn_params_from_section(section_data: dict[str, Any]) -> dict[str, elif k == "features" and section_data[k]: conn_params["features"] = resolve_features(section_data[k]) - return conn_params if conn_params.get("url") else None + return conn_params if (conn_params.get("url") or conn_params.get("features")) else None def get_all_file_connection_params( @@ -536,7 +548,7 @@ def get_all_file_connection_params( ``calendar_url`` calendar-filter keys read from the config section. Returns an empty list when the config file is absent or the section has - no usable URL. + neither a usable URL nor features to derive one from. """ if not section_name: section_name = "default" @@ -549,7 +561,7 @@ def get_all_file_connection_params( result: list[dict[str, Any]] = [] for s in sections: section_data = config_section(cfg, s) - params = _extract_conn_params_from_section(section_data) + params = extract_conn_params_from_section(section_data) if params: # Add calendar filter keys — these must NOT flow into DAVClient() for k in ("calendar_name", "calendar_url"): @@ -584,7 +596,7 @@ def get_all_test_servers( for section_name in cfg: section_data = config_section(cfg, section_name) if section_data.get("testing_allowed"): - conn_params = _extract_conn_params_from_section(section_data) + conn_params = extract_conn_params_from_section(section_data) if conn_params: # Also copy the raw section data for keys not in CONNKEYS # (e.g., testing_allowed itself, or custom keys) diff --git a/caldav/datastate.py b/caldav/datastate.py index 72c89dc2..85836cb4 100644 --- a/caldav/datastate.py +++ b/caldav/datastate.py @@ -64,18 +64,18 @@ def get_uid(self) -> str | None: """ cal = self.get_icalendar_copy() for comp in cal.subcomponents: - if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "FREEBUSY") and "UID" in comp: + if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY") and "UID" in comp: return str(comp["UID"]) return None def get_component_type(self) -> str | None: - """Get the component type (VEVENT, VTODO, VJOURNAL, FREEBUSY) without full parsing. + """Get the component type (VEVENT, VTODO, VJOURNAL, VFREEBUSY) without full parsing. Default implementation parses the data, but subclasses can optimize. """ cal = self.get_icalendar_copy() for comp in cal.subcomponents: - if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "FREEBUSY"): + if comp.name in ("VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"): return comp.name return None @@ -149,7 +149,7 @@ def get_component_type(self) -> str | None: return "VTODO" elif "BEGIN:VJOURNAL" in self._data: return "VJOURNAL" - elif "BEGIN:FREEBUSY" in self._data: + elif "BEGIN:VFREEBUSY" in self._data: return "VFREEBUSY" return None diff --git a/caldav/davclient.py b/caldav/davclient.py index 74d37803..89beaeff 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -298,8 +298,10 @@ def __init__( ) self.headers.update(headers or CaseInsensitiveDict()) if self.url.username is not None: - username = unquote(self.url.username) - password = unquote(self.url.password) + if username is None: + username = unquote(self.url.username) + if password is None and self.url.password is not None: + password = unquote(self.url.password) # Use discovered username if no explicit username was provided if username is None and discovered_username is not None: @@ -832,13 +834,13 @@ def request( self.rate_limit_default_sleep, self.rate_limit_max_sleep, ) - if rate_limit_time_slept: - sleep_seconds += rate_limit_time_slept / 2 if sleep_seconds is None or ( self.rate_limit_max_sleep is not None and rate_limit_time_slept > self.rate_limit_max_sleep ): raise + if rate_limit_time_slept: + sleep_seconds += rate_limit_time_slept / 2 time.sleep(sleep_seconds) return self.request(url, method, body, headers, rate_limit_time_slept + sleep_seconds) diff --git a/caldav/discovery.py b/caldav/discovery.py index c240858b..2591b148 100644 --- a/caldav/discovery.py +++ b/caldav/discovery.py @@ -481,10 +481,16 @@ def discover_service( well_known_info = _well_known_lookup(domain, service_type, timeout, ssl_verify_cert) if well_known_info: - # Preserve username from email address - well_known_info.username = username - log.info(f"Discovered {service_type} service via well-known URI: {well_known_info.url}") - return well_known_info + if require_tls and not well_known_info.tls: + log.warning( + f"require_tls=True: Rejecting well-known redirect to non-TLS URL " + f"{well_known_info.url!r} — possible misconfiguration or downgrade attack" + ) + else: + # Preserve username from email address + well_known_info.username = username + log.info(f"Discovered {service_type} service via well-known URI: {well_known_info.url}") + return well_known_info # All discovery methods failed log.warning(f"Failed to discover {service_type} service for {domain}") diff --git a/caldav/jmap/async_client.py b/caldav/jmap/async_client.py index d87fa682..fee51573 100644 --- a/caldav/jmap/async_client.py +++ b/caldav/jmap/async_client.py @@ -34,6 +34,7 @@ ) from caldav.jmap.client import _DEFAULT_USING, _TASK_USING, _JMAPClientBase from caldav.jmap.convert import ical_to_jscal +from caldav.jmap.convert._patch import _NULL_FOR_UPDATE from caldav.jmap.error import JMAPAuthError, JMAPMethodError from caldav.jmap.objects.calendar import JMAPCalendar from caldav.jmap.objects.calendar_object import JMAPCalendarObject @@ -232,6 +233,9 @@ async def update_event(self, event_id: str, ical_str: str) -> None: session = await self._get_session() patch = ical_to_jscal(ical_str) patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it + for key in _NULL_FOR_UPDATE: + if key not in patch: + patch[key] = None call = build_event_set_update(session.account_id, {event_id: patch}) responses = await self._request([call]) @@ -311,7 +315,7 @@ async def get_sync_token(self) -> str: async def get_objects_by_sync_token( self, sync_token: str - ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str]]: + ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], str]: """Fetch events changed since a previous sync token. Calls ``CalendarEvent/changes`` to discover which events were created, @@ -325,11 +329,12 @@ async def get_objects_by_sync_token( or by a prior call to this method. Returns: - A 3-tuple ``(added, modified, deleted)``: + A 4-tuple ``(added, modified, deleted, new_sync_token)``: - ``added``: objects for newly created events (``parent`` is ``None``). - ``modified``: objects for updated events (``parent`` is ``None``). - ``deleted``: Event IDs that were destroyed. + - ``new_sync_token``: Pass to the next call to this method as ``sync_token``. Raises: JMAPMethodError: If the server reports ``hasMoreChanges: true``. @@ -341,10 +346,13 @@ async def get_objects_by_sync_token( created_ids: list[str] = [] updated_ids: list[str] = [] destroyed: list[str] = [] + new_sync_token: str = "" for method_name, resp_args, _ in responses: if method_name == "CalendarEvent/changes": - _, _, has_more, created_ids, updated_ids, destroyed = parse_event_changes(resp_args) + _, new_sync_token, has_more, created_ids, updated_ids, destroyed = ( + parse_event_changes(resp_args) + ) if has_more: raise JMAPMethodError( url=session.api_url, @@ -358,7 +366,7 @@ async def get_objects_by_sync_token( fetch_ids = created_ids + updated_ids if not fetch_ids: - return [], [], destroyed + return [], [], destroyed, new_sync_token get_call = build_event_get(session.account_id, ids=fetch_ids) get_responses = await self._request([get_call]) @@ -371,7 +379,7 @@ async def get_objects_by_sync_token( added = [events_by_id[i] for i in created_ids if i in events_by_id] modified = [events_by_id[i] for i in updated_ids if i in events_by_id] - return added, modified, destroyed + return added, modified, destroyed, new_sync_token async def delete_event(self, event_id: str) -> None: """Delete a calendar event. @@ -458,6 +466,11 @@ async def create_task(self, task_list_id: str, title: str, **kwargs) -> str: created, _, _, not_created, _, _ = parse_task_set(resp_args) if "new-0" in not_created: self._raise_set_error(session, not_created["new-0"]) + if "new-0" not in created: + raise JMAPMethodError( + url=session.api_url, + reason="Task/set response missing created entry for new-0", + ) return created["new-0"]["id"] raise JMAPMethodError(url=session.api_url, reason="No Task/set response") diff --git a/caldav/jmap/client.py b/caldav/jmap/client.py index 59cd333b..69a0639c 100644 --- a/caldav/jmap/client.py +++ b/caldav/jmap/client.py @@ -43,6 +43,7 @@ ) from caldav.jmap.constants import CALENDAR_CAPABILITY, CORE_CAPABILITY, TASK_CAPABILITY from caldav.jmap.convert import ical_to_jscal +from caldav.jmap.convert._patch import _NULL_FOR_UPDATE from caldav.jmap.error import JMAPAuthError, JMAPMethodError from caldav.jmap.objects.calendar import JMAPCalendar from caldav.jmap.objects.calendar_object import JMAPCalendarObject @@ -345,6 +346,9 @@ def update_event(self, event_id: str, ical_str: str) -> None: session = self._get_session() patch = ical_to_jscal(ical_str) patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it + for key in _NULL_FOR_UPDATE: + if key not in patch: + patch[key] = None call = build_event_set_update(session.account_id, {event_id: patch}) responses = self._request([call]) @@ -426,7 +430,7 @@ def get_sync_token(self) -> str: def get_objects_by_sync_token( self, sync_token: str - ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str]]: + ) -> tuple[list[JMAPCalendarObject], list[JMAPCalendarObject], list[str], str]: """Fetch events changed since a previous sync token. Calls ``CalendarEvent/changes`` to discover which events were created, @@ -440,11 +444,12 @@ def get_objects_by_sync_token( or by a prior call to this method. Returns: - A 3-tuple ``(added, modified, deleted)``: + A 4-tuple ``(added, modified, deleted, new_sync_token)``: - ``added``: objects for newly created events (``parent`` is ``None``). - ``modified``: objects for updated events (``parent`` is ``None``). - ``deleted``: Event IDs that were destroyed. + - ``new_sync_token``: Pass to the next call to this method as ``sync_token``. Raises: JMAPMethodError: If the server reports ``hasMoreChanges: true``. @@ -456,10 +461,13 @@ def get_objects_by_sync_token( created_ids: list[str] = [] updated_ids: list[str] = [] destroyed: list[str] = [] + new_sync_token: str = "" for method_name, resp_args, _ in responses: if method_name == "CalendarEvent/changes": - _, _, has_more, created_ids, updated_ids, destroyed = parse_event_changes(resp_args) + _, new_sync_token, has_more, created_ids, updated_ids, destroyed = ( + parse_event_changes(resp_args) + ) if has_more: raise JMAPMethodError( url=session.api_url, @@ -473,7 +481,7 @@ def get_objects_by_sync_token( fetch_ids = created_ids + updated_ids if not fetch_ids: - return [], [], destroyed + return [], [], destroyed, new_sync_token get_call = build_event_get(session.account_id, ids=fetch_ids) get_responses = self._request([get_call]) @@ -486,7 +494,7 @@ def get_objects_by_sync_token( added = [events_by_id[i] for i in created_ids if i in events_by_id] modified = [events_by_id[i] for i in updated_ids if i in events_by_id] - return added, modified, destroyed + return added, modified, destroyed, new_sync_token def delete_event(self, event_id: str) -> None: """Delete a calendar event. @@ -573,6 +581,11 @@ def create_task(self, task_list_id: str, title: str, **kwargs) -> str: created, _, _, not_created, _, _ = parse_task_set(resp_args) if "new-0" in not_created: self._raise_set_error(session, not_created["new-0"]) + if "new-0" not in created: + raise JMAPMethodError( + url=session.api_url, + reason="Task/set response missing created entry for new-0", + ) return created["new-0"]["id"] raise JMAPMethodError(url=session.api_url, reason="No Task/set response") diff --git a/caldav/jmap/convert/_patch.py b/caldav/jmap/convert/_patch.py new file mode 100644 index 00000000..db31a2ab --- /dev/null +++ b/caldav/jmap/convert/_patch.py @@ -0,0 +1,33 @@ +""" +RFC 8620 PatchObject helpers for CalendarEvent/set update calls. + +When updating an event, absent keys preserve the server's current value. +To delete an optional property the patch must set it to null explicitly. +""" + +from __future__ import annotations + +# Optional JSCalendar top-level properties that must be explicitly nulled in +# a CalendarEvent/set update when they are absent from the converted result. +# This ensures properties removed client-side (e.g. LOCATION deleted from +# the iCalendar) are actually removed on the server, not silently preserved. +_NULL_FOR_UPDATE: frozenset[str] = frozenset( + { + "description", + "color", + "locations", + "keywords", + "priority", + "privacy", + "freeBusyStatus", + "status", + "sequence", + "showWithoutTime", + "timeZone", + "recurrenceRules", + "excludedRecurrenceRules", + "recurrenceOverrides", + "participants", + "alerts", + } +) diff --git a/caldav/jmap/convert/_utils.py b/caldav/jmap/convert/_utils.py index 12b12263..475eca85 100644 --- a/caldav/jmap/convert/_utils.py +++ b/caldav/jmap/convert/_utils.py @@ -111,11 +111,12 @@ def _duration_to_timedelta(duration_str: str) -> timedelta: def _format_local_dt(dt: datetime | date) -> str: - """Format a datetime or date as a JSCalendar LocalDateTime or UTCDateTime string. + """Format a datetime or date as a JSCalendar LocalDateTime string. - JSCalendar uses: - - LocalDateTime: "2024-03-15T09:00:00" (no TZ suffix) - - UTCDateTime: "2024-03-15T09:00:00Z" (uppercase Z) + RFC 8984 requires LocalDateTime (no Z suffix) for override keys and RRULE + ``until`` values. Timezone information is stripped — callers must convert + UTC datetimes to the event's local timezone before calling if the event uses + TZID; for floating or all-day events the naive value is already correct. For date objects (all-day), uses T00:00:00 suffix. @@ -123,10 +124,8 @@ def _format_local_dt(dt: datetime | date) -> str: dt: A datetime (with or without tzinfo) or a date. Returns: - Formatted string suitable for use as a JSCalendar override key or datetime value. + Formatted string suitable for use as a JSCalendar override key or RRULE until. """ if isinstance(dt, datetime): - if dt.tzinfo is not None and dt.utcoffset() == timedelta(0): - return dt.strftime("%Y-%m-%dT%H:%M:%SZ") return dt.strftime("%Y-%m-%dT%H:%M:%S") return f"{dt.isoformat()}T00:00:00" diff --git a/caldav/jmap/convert/ical_to_jscal.py b/caldav/jmap/convert/ical_to_jscal.py index 00e9ce59..dbafd157 100644 --- a/caldav/jmap/convert/ical_to_jscal.py +++ b/caldav/jmap/convert/ical_to_jscal.py @@ -386,6 +386,17 @@ def ical_to_jscal(ical_str: str, calendar_id: str | None = None) -> dict: if location: jscal["locations"] = _location_str_to_jscal(str(location)) + status = master.get("STATUS") + if status: + _STATUS_ICAL_TO_JSCAL = { + "CONFIRMED": "confirmed", + "TENTATIVE": "tentative", + "CANCELLED": "cancelled", + } + jscal_status = _STATUS_ICAL_TO_JSCAL.get(str(status).upper()) + if jscal_status: + jscal["status"] = jscal_status + participants: dict = {} organizer = master.get("ORGANIZER") if organizer is not None: diff --git a/caldav/jmap/convert/jscal_to_ical.py b/caldav/jmap/convert/jscal_to_ical.py index 2a449d1b..4ed4c03d 100644 --- a/caldav/jmap/convert/jscal_to_ical.py +++ b/caldav/jmap/convert/jscal_to_ical.py @@ -353,6 +353,17 @@ def jscal_to_ical(jscal: dict) -> str: if loc_name: event.add("location", loc_name) + status = jscal.get("status") + if status: + _STATUS_JSCAL_TO_ICAL = { + "confirmed": "CONFIRMED", + "tentative": "TENTATIVE", + "cancelled": "CANCELLED", + } + ical_status = _STATUS_JSCAL_TO_ICAL.get(status) + if ical_status: + event.add("status", ical_status) + for rule in jscal.get("recurrenceRules") or []: ical_rule = _jscal_rrule_to_rrule(rule) if ical_rule: @@ -371,6 +382,15 @@ def jscal_to_ical(jscal: dict) -> str: rid_dt: datetime | date = datetime.strptime(override_key, "%Y-%m-%dT%H:%M:%SZ").replace( tzinfo=timezone.utc ) + elif show_without_time: + rid_dt = date.fromisoformat(override_key[:10]) + elif time_zone: + try: + rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S").replace( + tzinfo=ZoneInfo(time_zone) + ) + except ZoneInfoNotFoundError: + rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S") else: rid_dt = datetime.strptime(override_key[:19], "%Y-%m-%dT%H:%M:%S") @@ -381,7 +401,8 @@ def jscal_to_ical(jscal: dict) -> str: child.add("uid", uid) child.add("dtstamp", datetime.now(tz=timezone.utc)) child.add("recurrence-id", rid_dt) - child_start = patch.get("start", start_str) + # Default child start to the occurrence time (override key), not the master start. + child_start = patch.get("start", override_key) child_tz = patch.get("timeZone", time_zone) child_swt = patch.get("showWithoutTime", show_without_time) if child_start: diff --git a/caldav/jmap/objects/calendar.py b/caldav/jmap/objects/calendar.py index 28812ac9..6be47bd7 100644 --- a/caldav/jmap/objects/calendar.py +++ b/caldav/jmap/objects/calendar.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from caldav.jmap.objects.calendar_object import JMAPCalendarObject @@ -18,6 +18,17 @@ from caldav.jmap.client import JMAPClient +def _to_utcdate(dt: datetime) -> str: + """Convert a datetime to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ). + + Naive datetimes are assumed to be UTC. Aware datetimes are converted to + UTC before formatting. Microseconds are dropped as JMAP does not allow them. + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + @dataclass class JMAPCalendar: """A JMAP Calendar object. @@ -111,9 +122,9 @@ def search(self, **searchargs): start = searchargs.get("start") end = searchargs.get("end") if isinstance(start, datetime): - start = start.isoformat() + start = _to_utcdate(start) if isinstance(end, datetime): - end = end.isoformat() + end = _to_utcdate(end) return self._client._search( calendar_id=self.id, start=start, @@ -126,9 +137,9 @@ async def _async_search(self, **searchargs) -> list[JMAPCalendarObject]: start = searchargs.get("start") end = searchargs.get("end") if isinstance(start, datetime): - start = start.isoformat() + start = _to_utcdate(start) if isinstance(end, datetime): - end = end.isoformat() + end = _to_utcdate(end) return await self._client._search( calendar_id=self.id, start=start, diff --git a/caldav/lib/auth.py b/caldav/lib/auth.py index fa4d351e..c15b9ef1 100644 --- a/caldav/lib/auth.py +++ b/caldav/lib/auth.py @@ -28,7 +28,7 @@ def extract_auth_types(header: str) -> set[str]: Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate#syntax """ - return {h.split()[0] for h in header.lower().split(",")} + return {h.split()[0] for h in header.lower().split(",") if h.strip()} def select_auth_type( diff --git a/caldav/lib/error.py b/caldav/lib/error.py index 16d79883..2822c0b7 100644 --- a/caldav/lib/error.py +++ b/caldav/lib/error.py @@ -13,6 +13,12 @@ ## Environmental variables prepended with "PYTHON_CALDAV" are used for debug purposes, ## environmental variables prepended with "CALDAV_" are for connection parameters debug_dump_communication = os.environ.get("PYTHON_CALDAV_COMMDUMP", False) + if debug_dump_communication: + logging.getLogger("caldav").warning( + "PYTHON_CALDAV_COMMDUMP is set: request/response bodies and headers " + "(including credentials and calendar PII) will be written to uniquely-named " + "files under /tmp. These files accumulate indefinitely — remove them when done." + ) ## one of DEBUG_PDB, DEBUG, DEVELOPMENT, PRODUCTION debugmode = os.environ["PYTHON_CALDAV_DEBUGMODE"] except KeyError: diff --git a/caldav/lib/url.py b/caldav/lib/url.py index c2b426e0..3390371b 100644 --- a/caldav/lib/url.py +++ b/caldav/lib/url.py @@ -140,7 +140,13 @@ def canonical(self) -> "URL": """ url = self.unauth() - arr = list(cast(urllib.parse.ParseResult, self.url_parsed)) + # Use url's parsed form (credentials already stripped), not self's. + # Also always build a fresh URL so self is never mutated — unauth() + # returns self when there are no credentials, and the old code then + # overwrote url.url_raw/url_parsed which are the same object as self. + if url.url_parsed is None: + url.url_parsed = cast(urllib.parse.ParseResult, urlparse(str(url))) + arr = list(url.url_parsed) ## quoting path and removing double slashes arr[2] = quote(unquote(url.path.replace("//", "/"))) ## sensible defaults @@ -155,11 +161,7 @@ def canonical(self) -> "URL": portpart = "" arr[1] += portpart - # make sure to delete the string version - url.url_raw = urlunparse(arr) - url.url_parsed = None - - return url + return URL(urlunparse(arr)) def join(self, path: Any) -> "URL": """ diff --git a/caldav/lib/vcal.py b/caldav/lib/vcal.py index fb29f7cd..4748e723 100644 --- a/caldav/lib/vcal.py +++ b/caldav/lib/vcal.py @@ -77,20 +77,24 @@ def fix(event): ## TODO: add ^ before COMPLETED and CREATED? ## 1) Add an arbitrary time if completed is given as date - fixed = re.sub(r"COMPLETED(?:;VALUE=DATE)?:(\d+)\s", r"COMPLETED:\g<1>T120000Z", event) + fixed = re.sub(r"COMPLETED(?:;VALUE=DATE)?:(\d+)(?=\s)", r"COMPLETED:\g<1>T120000Z", event) ## 2) CREATED timestamps prior to epoch does not make sense, ## change from year 0001 to epoch. fixed = re.sub("CREATED:00001231T000000Z", "CREATED:19700101T000000Z", fixed) - fixed = re.sub(r"\\+('\")", r"\1", fixed) + fixed = re.sub(r"\\+(['\"])", r"\1", fixed) ## 4) trailing whitespace probably never makes sense - fixed = re.sub(" *$", "", fixed) + fixed = re.sub(" *$", "", fixed, flags=re.MULTILINE) ## 6) add DTSTAMP if not given ## (corner case that DTSTAMP is given in one but not all the recurrences is ignored) if "\nDTSTAMP:" not in fixed: - assert "\nEND" in fixed + if "\nEND" not in fixed: + logging.getLogger(__name__).warning( + "vcal.fix(): truncated iCalendar data (no END: line) — skipping DTSTAMP fixup" + ) + return fixed dtstamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") fixed = re.sub("(\nEND:(VTODO|VEVENT|VJOURNAL))", f"\nDTSTAMP:{dtstamp}\\1", fixed) @@ -240,8 +244,8 @@ def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): ret = to_normal_str(my_instance.to_ical()) if ical_fragment and ical_fragment.strip(): ret = re.sub( - "^END:V", - ical_fragment.strip() + "\nEND:V", + "^(END:V(?:EVENT|TODO|JOURNAL))", + ical_fragment.strip() + "\n\\1", ret, flags=re.MULTILINE, count=1, diff --git a/caldav/response.py b/caldav/response.py index 0b27d5b9..587efedb 100644 --- a/caldav/response.py +++ b/caldav/response.py @@ -274,7 +274,12 @@ def _init_from_response(self, response: "Response", davclient: Any = None) -> No # We'll try to parse the content as XML no matter the content type. self.tree = etree.XML( self._raw, - parser=etree.XMLParser(remove_blank_text=True, huge_tree=self.huge_tree), + parser=etree.XMLParser( + remove_blank_text=True, + huge_tree=self.huge_tree, + resolve_entities=False, + no_network=True, + ), ) except Exception: # Content wasn't XML. What does the content-type say? diff --git a/caldav/search.py b/caldav/search.py index 5b853382..114b7feb 100644 --- a/caldav/search.py +++ b/caldav/search.py @@ -190,7 +190,8 @@ def _build_search_xml_query( for property in searcher._property_operator: if searcher._property_operator[property] == "undef": match = cdav.NotDefined() - filters.append(cdav.PropFilter(property.upper()) + match) + prop_name = "CATEGORIES" if property.lower() == "category" else property.upper() + filters.append(cdav.PropFilter(prop_name) + match) else: value = searcher._property_filters[property] property_ = property.upper() @@ -316,6 +317,12 @@ class CalDAVSearcher(Searcher): comp_class: Optional["CalendarObjectResource"] = None _explicit_operators: set = field(default_factory=set) _calendar: Optional["Calendar"] = field(default=None, repr=False) + ## When False, all server-compatibility workarounds in _search_impl are + ## disabled and the query the searcher describes is sent verbatim (a single + ## REPORT, no comp-type splitting, no filter rewriting, no fallback retries). + ## Used by the server-compatibility checker to observe raw server behaviour. + ## Propagates to clones automatically via dataclasses.replace(). + _compatibility_workarounds: bool = True def add_property_filter( self, @@ -455,12 +462,18 @@ def _search_impl( "create the searcher via calendar.searcher()" ) + ## When disabled, every server-compatibility workaround below is skipped + ## and the query is sent verbatim (used by the compatibility checker to + ## observe raw server behaviour). + cw = self._compatibility_workarounds + ## Workaround for servers where REPORT without a time range only returns ## objects within a sliding window (search.unlimited-time-range: broken). ## Inject a wide time range covering 1970–2126 so that year-2000 test ## objects and other old data are returned. if ( - not self.start + cw + and not self.start and not self.end and not (self.expand or server_expand) and not calendar.client.features.is_supported("search.unlimited-time-range") @@ -480,7 +493,8 @@ def _search_impl( ## Handle servers with broken component-type filtering (e.g., Bedework) comp_type_support = calendar.client.features.is_supported("search.comp-type", str) no_comp_filter = ( - (self.comp_class or self.todo or self.event or self.journal) + cw + and (self.comp_class or self.todo or self.event or self.journal) and comp_type_support == "broken" and post_filter is not False ) @@ -490,13 +504,18 @@ def _search_impl( post_filter = True ## Setting default value for post_filter - if post_filter is None and ( - (self.todo and not self.include_completed) - or self.expand - or "categories" in self._property_filters - or "category" in self._property_filters - or not calendar.client.features.is_supported("search.text.case-sensitive") - or not calendar.client.features.is_supported("search.time-range.accurate") + if ( + cw + and post_filter is None + and ( + (self.todo and not self.include_completed) + or self.expand + or "categories" in self._property_filters + or "category" in self._property_filters + or any(op == "==" for op in self._property_operator.values()) + or not calendar.client.features.is_supported("search.text.case-sensitive") + or not calendar.client.features.is_supported("search.time-range.accurate") + ) ): post_filter = True @@ -508,7 +527,8 @@ def _search_impl( ## expansion is unreliable (the master expands without knowing its exceptions, yielding ## duplicate occurrences). Fall back to server-side expansion when it handles exceptions. if ( - self.expand + cw + and self.expand and not server_expand and not calendar.client.features.is_supported("save-load.event.recurrences.exception") and calendar.client.features.is_supported("search.recurrences.expanded.exception") @@ -523,7 +543,8 @@ def _search_impl( ## (e.g. purelymail where both i;octet and i;ascii-casemap collations are unsupported). ## Remove all text-value filters and rely on client-side post_filter instead. if ( - not calendar.client.features.is_supported("search.text") + cw + and not calendar.client.features.is_supported("search.text") and self._property_filters and post_filter is not False ): @@ -545,7 +566,8 @@ def _search_impl( ## special compatbility-case for servers that does not ## support category search properly if ( - not calendar.client.features.is_supported("search.text.category") + cw + and not calendar.client.features.is_supported("search.text.category") and ("categories" in self._property_filters or "category" in self._property_filters) and post_filter is not False ): @@ -562,7 +584,7 @@ def _search_impl( ## special compatibility-case for servers that do not support is-not-defined ## for specific properties (e.g. search.is-not-defined.category or .dtend) - if post_filter is not False: + if cw and post_filter is not False: undef_props_without_support = [ prop for prop, op in self._property_operator.items() @@ -587,7 +609,8 @@ def _search_impl( ## special compatibility-case for servers that do not support substring search if ( - not calendar.client.features.is_supported("search.text.substring") + cw + and not calendar.client.features.is_supported("search.text.substring") and post_filter is not False ): explicit_contains = [ @@ -614,7 +637,7 @@ def _search_impl( ## special compatibility-case for servers that does not ## support combined searches very well - if not calendar.client.features.is_supported("search.combined-is-logical-and"): + if cw and not calendar.client.features.is_supported("search.combined-is-logical-and"): if self.start or self.end: if self._property_filters: clone = self._clone_without_filters(clear_all_filters=True) @@ -624,7 +647,7 @@ def _search_impl( ) yield ( SearchAction.RETURN, - self.filter(objects, post_filter, split_expanded, server_expand), + self.filter(objects, True, split_expanded, server_expand), ) return @@ -657,7 +680,7 @@ def _search_impl( ## TODO: consider if not ignore_completed3 is sufficient, ## then the recursive part of the query here is moot, and ## we wouldn't waste so much time on repeated queries - if self.todo and self.include_completed is False: + if cw and self.todo and self.include_completed is False: clone = replace(self, include_completed=True) clone.include_completed = True ## Why? Isn't this redundant? clone.expand = False @@ -703,9 +726,43 @@ def _search_impl( server_expand, props=props, filters=xml, _hacks=_hacks ) - if not self.comp_class and not calendar.client.features.is_supported( - "search.comp-type.optional" - ): + ## A CALDAV:time-range (and VALARM) filter is a component-level filter: + ## RFC4791 section 9.7 only allows it inside a comp-filter for + ## VEVENT/VTODO/VJOURNAL/VFREEBUSY/VALARM, never directly under VCALENDAR. + ## So when no component type is given we cannot place such a filter in an + ## RFC-legal way - we must split the search into one query per component + ## type (search.time-range.comp-type-optional). This is independent of + ## search.comp-type.optional, which only governs comp-type-less queries + ## WITHOUT any filter. + ## The same applies to a prop-filter (CATEGORIES, SUMMARY, ...): under + ## VCALENDAR it would filter on VCALENDAR's own properties (which lack + ## component properties), so servers match nothing + ## (search.text.comp-type-optional). + ## See https://github.com/python-caldav/caldav/issues/681 + has_component_level_filter = bool( + self.start or self.end or self.alarm_start or self.alarm_end + ) + has_property_filter = bool(self._property_filters) + needs_comptype_split = ( + cw + and not self.comp_class + and ( + not calendar.client.features.is_supported("search.comp-type.optional") + or ( + has_component_level_filter + and not calendar.client.features.is_supported( + "search.time-range.comp-type-optional" + ) + ) + or ( + has_property_filter + and not calendar.client.features.is_supported( + "search.text.comp-type-optional" + ) + ) + ) + ) + if needs_comptype_split: if self.include_completed is None: self.include_completed = True @@ -722,8 +779,37 @@ def _search_impl( (calendar, xml, self.comp_class, props), ) except error.ReportError as err: + ## Reactive workaround for https://github.com/python-caldav/caldav/issues/681: + ## if the server was (optimistically) configured as supporting + ## search.time-range.comp-type-optional but actually rejects the + ## comp-type-less time-range query (e.g. SabreDAV's HTTP 400 "You cannot + ## add time-range filters on the VCALENDAR component"), retry by splitting + ## into one query per component type. Also covers prop-filters + ## (search.text.comp-type-optional). orig_xml must be empty - if the + ## caller passed a full calendar-query we cannot rebuild it per comp-type. if ( - calendar.client.features.backward_compatibility_mode + cw + and not self.comp_class + and not orig_xml + and (has_component_level_filter or has_property_filter) + ): + result = yield ( + SearchAction.SEARCH_WITH_COMPTYPES, + ( + calendar, + server_expand, + split_expanded, + props, + orig_xml, + _hacks, + post_filter, + ), + ) + yield (SearchAction.RETURN, result) + return + if ( + cw + and calendar.client.features.backward_compatibility_mode and not self.comp_class and "400" not in err.reason ): @@ -815,6 +901,7 @@ def search( xml: str = None, post_filter=None, _hacks: str = None, + compatibility_workarounds: bool | None = None, ) -> list[CalendarObjectResource]: """Do the search on a CalDAV calendar. @@ -831,6 +918,13 @@ def search( :param xml: XML query to be sent to the server (string or elements) :param post_filter: Do client-side filtering after querying the server :param _hacks: Please don't ask! + :param compatibility_workarounds: When ``False``, all server-compatibility + workarounds are disabled and the query is sent verbatim + (single REPORT, no comp-type splitting, no filter + rewriting, no fallback retries). Mainly for the + server-compatibility checker, to observe raw server + behaviour. ``None`` (the default) leaves the searcher's + current setting unchanged. Make sure not to confuse he CalDAV properties with iCalendar properties. @@ -851,6 +945,8 @@ def search( flag on. """ + if compatibility_workarounds is not None: + self._compatibility_workarounds = compatibility_workarounds gen = self._search_impl( calendar, server_expand, split_expanded, props, xml, post_filter, _hacks ) @@ -862,6 +958,11 @@ def search( return [] while True: + ## Phase 1: execute the action, capturing either a result or an exception. + ## The exception is fed back into the generator via gen.throw() (Phase 2) + ## so the search logic's own try/except blocks (e.g. the issue #681 + ## time-range fallback, or the per-object load error handling) can act on it. + exc = None try: if action == SearchAction.RECURSIVE_SEARCH: clone, cal, srv_exp, spl_exp, prp, xm, pf, hk = data @@ -877,8 +978,17 @@ def search( result = None elif action == SearchAction.RETURN: return data + except Exception as e: + exc = e - action, data = gen.send(result) + ## Phase 2: advance the generator. If the action raised, throw the + ## exception in at the yield point; if the generator does not handle it, + ## gen.throw() re-raises it out of here (correct propagation). + try: + if exc is not None: + action, data = gen.throw(exc) + else: + action, data = gen.send(result) except StopIteration: return [] @@ -927,6 +1037,7 @@ async def async_search( xml: str = None, post_filter=None, _hacks: str = None, + compatibility_workarounds: bool | None = None, ) -> list["AsyncCalendarObjectResource"]: """Async version of search() - does the search on an AsyncCalendar. @@ -935,6 +1046,8 @@ async def async_search( See the sync search() method for full documentation. """ + if compatibility_workarounds is not None: + self._compatibility_workarounds = compatibility_workarounds gen = self._search_impl( calendar, server_expand, split_expanded, props, xml, post_filter, _hacks ) @@ -946,6 +1059,11 @@ async def async_search( return [] while True: + ## Phase 1: execute the action, capturing either a result or an exception. + ## The exception is fed back into the generator via gen.throw() (Phase 2) + ## so the search logic's own try/except blocks (e.g. the issue #681 + ## time-range fallback, or the per-object load error handling) can act on it. + exc = None try: if action == SearchAction.RECURSIVE_SEARCH: clone, cal, srv_exp, spl_exp, prp, xm, pf, hk = data @@ -965,8 +1083,17 @@ async def async_search( result = None elif action == SearchAction.RETURN: return data + except Exception as e: + exc = e - action, data = gen.send(result) + ## Phase 2: advance the generator. If the action raised, throw the + ## exception in at the yield point; if the generator does not handle it, + ## gen.throw() re-raises it out of here (correct propagation). + try: + if exc is not None: + action, data = gen.throw(exc) + else: + action, data = gen.send(result) except StopIteration: return [] diff --git a/caldav/testing.py b/caldav/testing.py index 28a552d8..235e3214 100644 --- a/caldav/testing.py +++ b/caldav/testing.py @@ -9,6 +9,7 @@ Docker and external server support lives in tests/test_servers/ (source only). """ +import copy import socket import tempfile import threading @@ -123,7 +124,7 @@ def __init__(self, config: dict[str, Any] | None = None) -> None: if "features" not in config: from caldav import compatibility_hints - features = compatibility_hints.xandikos.copy() + features = copy.deepcopy(compatibility_hints.xandikos) features["auto-connect.url"]["domain"] = f"{config['host']}:{config['port']}" config["features"] = features super().__init__(config) @@ -259,7 +260,7 @@ def __init__(self, config: dict[str, Any] | None = None) -> None: if "features" not in config: from caldav import compatibility_hints - features = compatibility_hints.radicale.copy() + features = copy.deepcopy(compatibility_hints.radicale) features["auto-connect.url"]["domain"] = f"{config['host']}:{config['port']}" config["features"] = features super().__init__(config) diff --git a/docs/design/FULL_CODE_REVIEW_2026-06.md b/docs/design/FULL_CODE_REVIEW_2026-06.md new file mode 100644 index 00000000..a7996a77 --- /dev/null +++ b/docs/design/FULL_CODE_REVIEW_2026-06.md @@ -0,0 +1,440 @@ +# Full Codebase Review — June 2026 + +**Date:** 2026-06-11 +**Reviewer:** Claude Fable 5 via Claude Code (AI multi-agent review) +**Scope:** The entire `caldav/` package (~18,800 lines) at commit `672a8c06` +(branch `fix/issue-681-timerange-vcalendar`). Not a differential review — +every source file was read. +**Method:** Seven parallel "finder" agents (four correctness sweeps split by +layer, plus JMAP, cleanup/duplication, and security/altitude angles) produced +~54 candidates; every candidate not already proven by a live repro was then +passed to an independent verifier agent. **All 31 externally verified +candidates came back CONFIRMED; none were refuted.** Each finding below is +tagged `[repro]` (demonstrated by executing code) or `[code]` (confirmed by +code reading / cross-referencing). + +Tests were *not* run as part of this review (integration tests are slow); +line numbers refer to commit `672a8c06`. + +--- + +## Executive summary + +The codebase is in good shape architecturally — the sans-I/O search generator, +the compatibility-hints feature matrix, and the discovery module's TLS +handling were all noted positively. But the review found a substantial crop of +real bugs, clustering around four themes: + +1. **Sync/async drift is the dominant bug source.** The parallel + sync/async implementations (`davclient` vs `async_davclient`, + `jmap/client` vs `jmap/async_client`, sync/async twins in + `calendarobjectresource`) have diverged in at least seven places: + fixes/workarounds applied to one side only (§1.3, §2.9, §2.10, §2.11), + different credential precedence (§1.2), and outright broken async paths + (§1.4, §1.5). The ~600 lines of remaining duplication (§6) is the root + cause. +2. **String/regex-level iCalendar and URL fixups are brittle.** Three of the + four documented fixups in `vcal.fix()` are broken (one actively corrupts + data, two are dead code), and `URL.canonical()` both leaks credentials and + mutates the object during `==` (§2.1–§2.5). +3. **The search workaround layer can silently drop filters** — three confirmed + paths return wrong (over-broad) result sets (§2.6–§2.8). +4. **The JMAP backend has correctness gaps in the JSCalendar converters** + (wrong DTSTART on overrides, floating EXDATEs that don't exclude anything, + STATUS dropped both directions) and in update semantics (§5). + +**Counts:** 13 crash bugs, 21 silent-wrong-result bugs, 3 security findings, +8 JMAP findings (overlapping the previous categories), 8 cleanup items, +2 altitude/design notes. + +--- + +## 1. Crash bugs (realistic trigger → unhandled exception) + +### 1.1 `calendarobjectresource.py:1167` + `:1187` — 302 handling iterates headers as tuples `[repro]` ✅ FIXED (commit 22b9cc66+1) +`[x[1] for x in r.headers if x[0] == "location"][0]` — iterating a dict-like +`Headers` object (niquests `CaseInsensitiveDict` sync, `httpx.Headers` async) +yields key *strings*, so `x[0]` is the first character of each header name. +The list is always empty and **any 302 response to a PUT raises IndexError** +instead of following the redirect. The same broken pattern appears twice +because the whole block is pasted twice (see §6.2; the second copy is partly +dead code). Fix: `r.headers.get("location")`. + +### 1.2 `davclient.py:302` — URL with username but no password → TypeError `[repro]` ✅ FIXED +`DAVClient(url='https://user@example.com/dav/', password='secret')`: +`self.url.username` is set, so `unquote(self.url.password)` runs with +`password=None` → TypeError inside `urllib.parse.unquote`. The async client +(`async_davclient.py:229–237`) checks username/password independently — and +also gives **explicit kwargs precedence over URL credentials, while sync does +the opposite**. Pick one precedence (kwargs should win) and share the code. + +### 1.3 `davclient.py:836` / `async_davclient.py:376` — rate-limit retry: `None + float` `[code]` ✅ FIXED +`sleep_seconds += rate_limit_time_slept / 2` executes *before* the +`sleep_seconds is None` check. With `rate_limit_handle=True` and +`rate_limit_default_sleep=None`: first 429 has `Retry-After: 5` → retried; +second 429 has no usable Retry-After (`compute_sleep_seconds` returns None, +e.g. `Retry-After: 0`) → `None += 2.5` → TypeError instead of the documented +`RateLimitError`. Same bug copy-pasted in both clients. + +### 1.4 `async_davclient.py:1272` — `aio.get_calendars(calendar_name=...)` can never work `[code]` ✅ FIXED +The async module-level helper awaits the *synchronous* `Principal.calendar()`, +which has no async dispatch (`collection.py:448–475`): `calendar_home_set` → +`get_property` returns a coroutine for async clients, and +`CalendarSet.calendar()` iterates `self.get_calendars()` which is also a +coroutine → TypeError (swallowed into an empty result when +`raise_errors=False`). Name-based calendar lookup via `caldav.aio` is broken +end-to-end. + +### 1.5 `collection.py:601` — async `freebusy_request` with Principal attendees → AttributeError `[code]` ✅ FIXED +`add_attendee(attendee)` is called *before* the `is_async_client` branch at +line 604. For a `Principal` attendee on an async client, +`get_vcal_address()` returns a coroutine, and `add_attendee` then does +`attendee_obj.params[...]` → AttributeError (plus a never-awaited warning). +`_async_save_with_invites` (`collection.py:983–984`) already does the awaited +conversion correctly — the same dance is missing here. + +### 1.6 `calendarobjectresource.py:727` — `add_attendee("MAILTO:user@example.com")` → UnboundLocalError `[code]` ✅ FIXED +The string-branch chain is case-sensitive: uppercase `MAILTO:` (common in +real-world iCalendar; RFC 3986 schemes are case-insensitive) fails +`startswith("mailto:")` and fails the `":" not in attendee` branch, so +`attendee_obj` is never assigned and line 742 raises UnboundLocalError. + +### 1.7 `calendarobjectresource.py:1272` — `change_attendee_status` raises bare KeyError; `:1284` literal `%s` `[repro]` ✅ FIXED +When the component has no ATTENDEE property at all, `ical_obj['attendee']` +raises `KeyError('ATTENDEE')` — not `error.NotFoundError`, which is the only +thing the principal-address loops catch — so the "Principal is not invited" +fallback is unreachable. Additionally the genuine not-found raise is +`error.NotFoundError("Participant %s not found in attendee list")` with no +`% attendee`: the user literally sees `%s`. + +### 1.8 `lib/auth.py:31` — IndexError on malformed WWW-Authenticate `[repro]` ✅ FIXED +`extract_auth_types('Basic realm="x",')` (trailing comma — seen in the wild) +→ the empty segment makes `h.split()[0]` raise IndexError, aborting the auth +negotiation with an unrelated traceback. Guard with +`for h in header.split(",") if h.strip()`. + +### 1.9 `config.py:37` — missing section raises KeyError instead of returning empty `[repro]` ✅ FIXED +`expand_config_section` does `config[section]` for non-glob names. A config +file with only named sections (no `default`) makes plain +`caldav.get_calendars()` crash with `KeyError: 'default'` instead of falling +through to "no configuration found". + +### 1.10 `compatibility_hints.py:611` — `copyFeatureSet` crashes merging plain-string features `[repro]` ✅ FIXED +`FeatureSet({'scheduling': 'unsupported'}).copyFeatureSet({'scheduling': +'fragile'})` → bare AssertionError: the `'support' not in server_node` guard +makes string-valued updates of an existing feature fall through to the final +`else: raise AssertionError`. Plain strings are the dominant style in the +hint dicts, so any two-layer merge expressing the same feature crashes. + +### 1.11 `compatibility_hints.py:605` — unknown feature names: warn now, crash later `[repro]` ✅ FIXED +A typoed feature name in a user's config produces only a UserWarning at set +time, but the bad key is still stored — a later `collapse()` / +`is_supported()` hits a message-less AssertionError in `find_feature`, far +from the config that caused it. Reject (or drop) the key at intake instead. + +### 1.12 `lib/vcal.py:93` — bare `assert` on server-supplied data `[repro]` ✅ FIXED +Truncated/garbage iCalendar without DTSTAMP and without an `END:` line makes +`fix()` raise a bare AssertionError. Under `python -O` the assert (and thus +the DTSTAMP fixup logic it guards) is silently skipped. Should be +`error.assert_` or a proper parse error. + +### 1.13 `jmap/client.py:576` / `jmap/async_client.py:461` — `create_task` missing the guard `create_event` has `[code]` ✅ FIXED +`create_event` handles an empty `created` dict with a descriptive +`JMAPMethodError` (`client.py:294–298`); `create_task` does +`created["new-0"]["id"]` unguarded → bare KeyError, bypassing the JMAP error +hierarchy callers are told to catch. Copy-paste gap in both clients. + +--- + +## 2. Silent wrong results / data corruption + +### 2.1 `lib/vcal.py:80` — COMPLETED fixup merges the next line into the property ⚠ data corruption `[repro]` ✅ FIXED (commit 22b9cc66) +`fix()` normalizes CRLF→LF first, then the COMPLETED date-to-datetime regex +`(\d+)\s` *consumes the newline without restoring it*: +`COMPLETED:20240101\nSUMMARY:hello` becomes +`COMPLETED:20240101T120000ZSUMMARY:hello`. The following property is +destroyed and the object parses with corrupted data. This runs on every +inbound object. + +### 2.2 `lib/vcal.py:242` — `create_ical(ical_fragment=...)` injects the fragment inside VALARM `[repro]` ✅ FIXED +The fragment is re-inserted before the first `^END:V` line — which is +`END:VALARM` when any `alarm_*` props were given. `ical_fragment='RRULE:...'` +plus an alarm produces an event *without* recurrence and with an invalid +RRULE inside the alarm. Should target `END:VEVENT|VTODO|VJOURNAL`. + +### 2.3 `lib/vcal.py:88` — trailing-whitespace fixup is dead code `[repro]` ✅ FIXED +`re.sub(" *$", "", fixed)` without `re.MULTILINE` only touches the document +end, never the per-line trailing spaces (iCloud X-APPLE-STRUCTURED-EVENT) +that docstring fix #4 targets. The vobject traceback it was written to +prevent still occurs. + +### 2.4 `lib/vcal.py:85` — backslash-unescape regex is a no-op `[repro]` ✅ FIXED +`re.sub(r"\\+('\")", r"\1", fixed)` matches only the literal two-character +sequence `'"`; the group should be a character class `['\"]`. Harmless for +compliant data, but the fix does nothing. + +### 2.5 `lib/url.py:143` + `:159` — `canonical()` keeps credentials and mutates self `[repro]` ✅ FIXED +Two related bugs: (a) `canonical()` builds its result from `self.url_parsed` +instead of the `unauth()`'ed URL, so +`URL('https://user:pass@example.com/cal/').canonical()` **retains the +credentials** — and `__eq__`/`__hash__` between the client URL and the same +URL from a server href (no credentials) is False, breaking URL comparison. +(b) When there's no auth part, `unauth()` returns `self` and `canonical()` +then overwrites `url_raw`/`url_parsed` **in place** — a mere `==` comparison +silently rewrites the URL (port added, path re-quoted; a literal `+` becomes +`%2B`), so subsequent requests can go to a different resource. + +### 2.6 `search.py:648` — `combined-is-logical-and` workaround silently drops property filters `[code]` ✅ FIXED +The workaround strips property filters from the server query but passes the +*ambient* `post_filter` (still `None` on otherwise-capable servers — e.g. +Nextcloud, whose only relevant flag is `search.combined-is-logical-and: +False`) to `filter_search_results`, which short-circuits on falsy. A search +with a time range plus a SUMMARY filter returns **every** object in the time +range. The sibling workarounds at 597–604 and 625–632 correctly force +`post_filter=True`; this branch also uniquely lacks the +`post_filter is not False` guard. + +### 2.7 `search.py:193` — `undef` operator misses the category→CATEGORIES alias `[code]` ✅ FIXED +The `undef` branch emits `PropFilter(property.upper())` without the alias +mapping the non-undef branch applies, so +`add_property_filter('category', '', operator='undef')` queries the +nonexistent property `CATEGORY` — `is-not-defined` on it matches *every* +object, returning events that do have categories. + +### 2.8 `search.py:362`/`:506` — documented `'=='` exact-match is never enforced `[code]` ✅ FIXED +The docstring promises "`==` — exact match required, enforced client-side", +but no code path inspects the `==` operator (only `'contains'` is checked at +line 617) and the post-filter default block ignores it. On a fully-capable +server, RFC 4791 substring `text-match` semantics leak through: `'=='` +`'rain'` matches "Training". + +### 2.9 `calendarobjectresource.py:1570` — `_set_data` leaves a stale `DataState` cache `[repro]` ✅ FIXED +The raw-string branch clears the legacy instance attributes but never resets +`self._state`. Sequence: fetch event → touch `event.id` / `is_loaded()` +(caches state v1) → `event.load()` assigns `self.data = r.raw` → afterwards +`get_data()` / `get_icalendar_instance()` / `id` still serve the **pre-reload +content** while `.data` returns the new content. + +### 2.10 `datastate.py:152` (+ `:67`, `:78`) — `BEGIN:FREEBUSY` never matches `VFREEBUSY` `[repro]` ✅ FIXED +The component-type sniffing tests for `BEGIN:FREEBUSY`; real data says +`BEGIN:VFREEBUSY`. A `FreeBusy` object holding raw data gets +`get_component_type() → None`, so `is_loaded()`/`has_component()` are False, +`save()` **silently no-ops** at the early return, and +`load(only_if_unloaded=True)` reloads spuriously. + +### 2.11 `calendarobjectresource.py:1943` — `_get_duration` isinstance check on the wrapper, not `.dt` `[repro]` ✅ FIXED +`isinstance(i["DTSTART"], datetime)` tests the icalendar `vDDDTypes` wrapper +(never a datetime), so the date-vs-datetime branch always takes the date +path: a VTODO with a timed DTSTART and no DUE/DURATION gets duration **1 day +instead of 0**. Completing a recurring task then sets the next DUE a full day +late, and `Todo._next` shifts the recurrence. + +### 2.12 `calendarobjectresource.py:2140` — sync safe-mode completion ignores `completion_timestamp` `[code]` ✅ FIXED +`_complete_recurring_safe` calls `completed.complete()` (defaults to *now*) +while the async twin passes the caller's timestamp through. Sync/async +divergence with user-visible effect on the recorded COMPLETED time. + +### 2.13 `base_client.py:689` — calendar with displayname `""` dropped from results `[code]` ✅ FIXED +`if _try(calendar.get_display_name, ...)` is a truthiness check, so a +calendar explicitly requested by URL whose displayname is the empty string is +silently omitted. The async counterpart (`async_davclient.py:1262`) correctly +uses `is not None`. + +### 2.14 `async_davclient.py:957` — async `get_calendars()` lacks the GMX principal-URL fallback `[code]` ✅ FIXED +Sync `get_calendars()` (`davclient.py:486–489`) falls back to the principal +URL when `calendar-home-set` is missing; async returns `[]` for the same +server. Parity gap. + +### 2.15 `async_davclient.py:487` — issue-#158 workaround can return the probe response as the real one `[code]` ✅ FIXED +When the original request dies with a connection abort, the workaround sends +a probe GET; if that GET is *not* 401+WWW-Authenticate (e.g. 200 with a login +page), the code falls through and returns the **probe GET's response as the +original request's response** — the caller sees status 200 for a PUT that +never happened, and the real connection error is lost. Also: the sync client +has no #158 workaround at all (parity gap in the other direction). + +### 2.16 `async_davclient.py:435` — HTML-on-401 hint checks the wrong headers `[code]` ✅ FIXED +The diagnostic checks `self.headers` (the client's own request headers) for +`text/html` instead of `r.headers`, so the intended "server returned HTML, +maybe set auth_type" hint can never fire. + +### 2.17 `config.py:50` — `disable: true` ignored for named sections `[repro]` ✅ FIXED +`expand_config_section` checks `config.get("section", ...)` with the string +literal `"section"` instead of the variable. `disable` only works under +`section='*'`; sections pulled in via a meta-section's `contains` list (or by +name) connect to servers the user explicitly disabled. + +### 2.18 `config.py:265` — explicit params without url/features silently discarded `[code]` ✅ FIXED +`get_connection_params` honors `explicit_params` only when `url` or +`features` is present, and never merges them with the env/file source that +wins: `get_davclient(password='secret')` with `CALDAV_URL`/`CALDAV_USERNAME` +in env returns a config **without the password**, contradicting the +docstring's "explicit parameters take highest priority". + +### 2.19 `config.py:180` + `testing.py:127`/`:263` — shared module-level hint dicts get mutated `[repro]` ✅ FIXED +`resolve_features` with a string name returns the module-level +`compatibility_hints` dict itself (the `base` branch deepcopies; this branch +doesn't). `XandikosServer`/`RadicaleServer` then do a *shallow* `.copy()` and +mutate the nested `auto-connect.url` dict — after instantiating +`XandikosServer({'port': 9999})`, `compatibility_hints.xandikos` is +permanently polluted for the whole process, redirecting any later +`features='xandikos'` client. Fix at the source: deepcopy in +`resolve_features` for all branches. + +--- + +## 3. Security + +### 3.1 `discovery.py:329` — `require_tls=True` not enforced on well-known redirect target `[code]` ✅ FIXED +`_well_known_lookup` never receives `require_tls`; a same-domain `Location: +http://...` passes the `_is_subdomain_or_same` check and is returned as +`ServiceInfo(tls=False)`, which `discover_service` returns unchecked. A +misconfigured or MITM'd server can downgrade the documented "ONLY accept TLS" +guarantee to plaintext, and credentials follow. (Otherwise the discovery +module's security posture is good: require_tls defaults True, same-domain +redirect validation, single manual redirect hop.) + +### 3.2 `response.py:277` — XML parser for untrusted server data lacks entity hardening `[code]` ✅ FIXED +`etree.XMLParser(remove_blank_text=True, huge_tree=self.huge_tree)` relies on +libxml2 defaults for entity resolution. Current libxml2 blocks the classic +XXE paths, but the library makes no guarantee across the unpinned dependency +range, and `huge_tree=True` lifts expansion limits. This is the *only* parser +of server data in the package — one line fixes it: add +`resolve_entities=False` (and consider `no_network=True`, `dtd_validation=False` +explicitly). + +**Fixed**: added `resolve_entities=False, no_network=True` to the `etree.XMLParser` call in `response.py:277`. `dtd_validation=False` is lxml's default so was not added explicitly. + +### 3.3 `lib/error.py:51` — `PYTHON_CALDAV_COMMDUMP` persists bodies/headers in /tmp (low) `[code]` ✅ FIXED +`NamedTemporaryFile(delete=False)` dumps full request/response headers and +bodies (calendar PII, custom auth headers) to files that accumulate +indefinitely. Files are 0600, and the niquests-applied Authorization header +is added after the dump point, so exposure is limited — but a cleanup policy +or a documented warning would be appropriate. **Human notes:** This is in /tmp, so we should expect some kind of cleanup on the OS level. There does exist some security notes in the CHANGELOG for the revision adding the feature, but the CHANGELOG has been pruned, so it's needed to consult git history to find it - it should definitively be lifted up to a more visible place. + +**Ruled out** (checked, found safe): SSRF via server-returned hrefs +(`_normalize_href` reduces absolute URLs to path-only); credential leak on +cross-host redirects (auth applied via auth callable, stripped by +`rebuild_auth`); eval/shell/format-string injection from iCalendar content. + +--- + +## 4. JMAP backend + +### 4.1 `jmap/convert/jscal_to_ical.py:384` — override child VEVENT gets the master's DTSTART `[repro]` ✅ FIXED +`child_start = patch.get("start", start_str)` defaults to the master start. +An override that doesn't move the occurrence (e.g. title-only change — the +common case) renders a child VEVENT with RECURRENCE-ID at the occurrence but +DTSTART at the *master's* start, relocating the occurrence. Default must be +the override key (`rid_dt`). + +### 4.2 `jmap/convert/jscal_to_ical.py:375` — EXDATE/RECURRENCE-ID value-type mismatch `[repro]` ✅ FIXED +Override keys are rendered as naive floating DATE-TIMEs regardless of the +event's `timeZone`/`showWithoutTime`: a TZID-anchored event gets +`EXDATE:20260620T100000` (floating — per RFC 5545 it does not match the +instance, so the **excluded occurrence reappears**), and an all-day event +gets a DATETIME EXDATE against a `VALUE=DATE` DTSTART. + +### 4.3 `jmap/convert/ical_to_jscal.py:100` (via `_utils.py:129`) — `Z`-suffix in LocalDateTime slots `[repro]` ✅ FIXED +UTC inputs produce `...Z` strings for RRULE `until` and recurrenceOverrides +keys; RFC 8984 requires LocalDateTime there. Strict servers reject with +`invalidArguments`; lenient ones mis-set the boundary, and a `Z`-suffixed +override key can never match a LocalDateTime occurrence key. + +### 4.4 `jmap/convert/*` — STATUS dropped in both directions `[code]` ✅ FIXED +Neither converter maps `STATUS` ↔ `status` (only +participationStatus/freeBusyStatus exist). `STATUS:CANCELLED` round-trips to +the JSCalendar default `confirmed`; cancelled meetings come back as active. + +### 4.5 `jmap/client.py:346` / `async_client.py:233` — `update_event` patch never clears removed properties `[code]` ✅ FIXED +The full converted object is sent as the RFC 8620 PatchObject; the converter +only includes keys conditionally, so a property deleted client-side (e.g. +LOCATION, VALARM) is simply *absent* from the patch and **persists on the +server**. Clearing requires explicit `null` entries. + +### 4.6 `jmap/objects/calendar.py:113` — search `after`/`before` are not UTCDate `[code]` ✅ FIXED +`datetime.isoformat()` is passed straight through (naive → no `Z`, aware → +`+02:00` offset, plus microseconds); JMAP requires `...Z` UTCDate. Strict +servers reject the query; lenient ones interpret the window inconsistently. + +### 4.7 `jmap/client.py:462` / `async_client.py:347` — `newState` from `/changes` discarded `[code]` ✅ FIXED +`get_objects_by_sync_token` unpacks `new_state` into `_`. Callers' only +option for a new baseline is a separate `get_sync_token()` call — changes +landing in between are silently skipped on the next sync. + +(See also §1.13 — `create_task` KeyError.) + +--- + +## 5. Cleanup: duplication, simplification, efficiency + +These don't break anything today, but §1–§4 show the duplication is already +producing drift bugs. + +1. **`jmap/async_client.py` duplicates ~400 lines of `client.py` verbatim** + modulo `await`. The build-side is already shared via `_JMAPClientBase` / + `jmap/_methods`; moving the response-parsing glue into shared pure + methods would shrink each sync/async method to ~3 lines. (The §1.13 and + §4.5 bugs are duplicated exactly because of this.) +2. **`calendarobjectresource.py:1166–1205` — `_post_put` block pasted twice + in sequence**; the second `elif r.status not in (204, 201)` is + unreachable. Also factor the Etag/Schedule-Tag header→props snippet + repeated in `load`/`_async_load` (the code itself carries a "consider + refactoring - this is repeated many places now" comment). +3. **`async_davclient.py` re-implements ~200 lines of `DAVClient`** + (init tail, get_calendars, rate-limit retry loop — byte-identical except + `time.sleep` vs `asyncio.sleep`). The §2.14 GMX gap and §1.3 retry bug + are direct drift products. Move into `BaseDAVClient` / `lib/error.py`. +4. **`search.py:869` — post-processing loads unloaded results one GET at a + time**; `Calendar._multiget` can fetch them in a single REPORT. On the + issue-#201 workaround path a 200-event search costs ~200 extra + round-trips. +5. **JMAP clients open a fresh HTTP connection per request** (async: + `async with AsyncSession()` per `_request`; sync: module-level + `requests.post`). `__exit__`/`__aexit__` already exist but do nothing — + hold one session in `_JMAPClientBase` and close it there. +6. **`Todo._async_complete_recurring_thisandfuture` copies ~60 lines of its + sync twin** (the file says "TERRIBLY much code duplication here"), and + the async safe-variant has drifted: it PUTs the completed copy twice. + Extract a pure icalendar-mutation helper; keep 5-line sync/async + wrappers. +7. **`response.py` carries two parallel multistatus-parsing stacks** — + legacy `_find_objects_and_props`/`expand_simple_props` (still load-bearing + for `_multiget`, report-result building, `search_principals`) vs the + newer dataclass parsers. Every parsing quirk (Confluence %2540, + purelymail 404) must be maintained twice; the TODO at line 577 already + acknowledges this. +8. **`search.py` sync/async driver loops duplicated** (~80 lines including + the hase-1/Phase-2 exception-rethrow protocol and + `_search_with_comptypes`). A small executor object with sync/async + implementations would leave one driver. + +--- + +## 6. Altitude / design notes + +1. **`response.py:464` — server fingerprints hardcoded in the generic + parser.** purelymail's `{https://purelymail.com}does-not-exist` tag, + Stalwart's "No resources found" string, and SOGo status notes live in the + core multistatus path instead of going through the compatibility-hints + feature matrix. Adding the next server's 404 shape means editing generic + parsing — the exact inversion the hints mechanism exists to avoid. +2. **`vcal.fix()` is a regex-rewriting layer applied to every inbound + object.** §2.1–§2.4 show the current fixups are individually broken in + four different ways; the module's own TODOs flag the approach. Worth + considering parser-level normalization (icalendar) or at least + regression-testing each fixup against the exact server output it was + written for. + + **Human comment:** we've been discussing a bit moving this logic into the icalendar library - but it's hard to make good solutions, so we'll need to keep the stop-gap implementation as for now. + +--- + +## 7. Recommended priorities + +| Priority | Items | +|----------|-------| +| **Fix before next release** | §2.1 (data corruption on every fetch), §2.5 (URL credential leak + mutation during `==`), §2.6 (silently unfiltered search results), §1.1 (302 IndexError), §2.9/§2.10 (stale data / silent save no-op), §3.1 (require_tls downgrade), §3.2 (one-line XML hardening) | +| **Fix soon** | Remaining §1 crashes (1.2–1.13), §2.11–§2.19, §4.1–§4.5 (JMAP correctness) | +| **Schedule** | §5 dedup work (it is the bug factory behind at least 7 findings), §6 altitude items, §4.6–§4.7 | +| **Test gaps suggested by this review** | sync/async parity tests that diff behavior of twin methods; round-trip property tests for `jmap/convert`; unit tests for `vcal.fix()` fixups #1–#4; URL `canonical()`/`__eq__` immutability test | diff --git a/docs/source/http-libraries.rst b/docs/source/http-libraries.rst index dcc97b5f..08a5f483 100644 --- a/docs/source/http-libraries.rst +++ b/docs/source/http-libraries.rst @@ -30,12 +30,15 @@ According to https://github.com/python-caldav/caldav/issues/611#issuecomment-4278875543 the httpx development seems stagnant, and httpx is even flagged as a supply-chain risk in some Reddit-discussions. It seems like the http -user space is filled with drama and intrigues. +user space is filled with drama and intrigues. httpxyz is a +maintained fork of httpx. For async communication, the fallback chain +now is niquests, httpxyz and finally httpx if import of the former two +fails. Fallbacks --------- -To enable the fallbacks, just ensure the requests and/or httpx library is available and that niquests isn't available. In virtual environments, fix the dependencies in `pyproject.toml`. +To enable the fallbacks, just ensure the requests and/or httpxyz/httpx library is available and that niquests isn't available. In virtual environments, fix the dependencies in `pyproject.toml`. Recommendations --------------- diff --git a/pyproject.toml b/pyproject.toml index 6fcfe317..2f2211dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ classifiers = [ dependencies = [ "lxml", - "niquests", + "niquests", ## see docs/source/http-libraries.rst "recurring-ical-events>=2.0.0", "typing_extensions;python_version<'3.11'", "icalendar>6.0.0", diff --git a/tests/caldav_test_servers.yaml.example b/tests/caldav_test_servers.yaml.example index cb68b379..25f8241d 100644 --- a/tests/caldav_test_servers.yaml.example +++ b/tests/caldav_test_servers.yaml.example @@ -30,14 +30,14 @@ test-servers: # Docker servers (require docker-compose, see docker-test-servers/) # ========================================================================= # - # Set enabled to: - # - true: always enable - # - false: always disable - # - "auto": enable if docker is available (default for docker servers) + # Set enabled to true or false. Docker servers are skipped automatically + # when Docker is not available, and a running container is auto-detected + # even without a config entry, so listing them here is mainly to opt in or + # out explicitly and to override credentials/ports. baikal: type: docker - enabled: ${TEST_BAIKAL:-auto} + enabled: true host: ${BAIKAL_HOST:-localhost} port: ${BAIKAL_PORT:-8800} username: ${BAIKAL_USERNAME:-testuser} @@ -47,7 +47,7 @@ test-servers: nextcloud: type: docker - enabled: ${TEST_NEXTCLOUD:-false} + enabled: false host: ${NEXTCLOUD_HOST:-localhost} port: ${NEXTCLOUD_PORT:-8801} username: ${NEXTCLOUD_USERNAME:-testuser} @@ -56,7 +56,7 @@ test-servers: cyrus: type: docker - enabled: ${TEST_CYRUS:-false} + enabled: false host: ${CYRUS_HOST:-localhost} port: ${CYRUS_PORT:-8802} username: ${CYRUS_USERNAME:-user1} @@ -76,7 +76,7 @@ test-servers: sogo: type: docker - enabled: ${TEST_SOGO:-false} + enabled: false host: ${SOGO_HOST:-localhost} port: ${SOGO_PORT:-8803} username: ${SOGO_USERNAME:-testuser} @@ -84,7 +84,7 @@ test-servers: bedework: type: docker - enabled: ${TEST_BEDEWORK:-false} + enabled: false host: ${BEDEWORK_HOST:-localhost} port: ${BEDEWORK_PORT:-8804} username: ${BEDEWORK_USERNAME:-admin} @@ -92,7 +92,7 @@ test-servers: davical: type: docker - enabled: ${TEST_DAVICAL:-false} + enabled: false host: ${DAVICAL_HOST:-localhost} port: ${DAVICAL_PORT:-8805} username: ${DAVICAL_USERNAME:-testuser} @@ -101,7 +101,7 @@ test-servers: davis: type: docker - enabled: ${TEST_DAVIS:-false} + enabled: false host: ${DAVIS_HOST:-localhost} port: ${DAVIS_PORT:-8806} username: ${DAVIS_USERNAME:-testuser} @@ -109,7 +109,7 @@ test-servers: ccs: type: docker - enabled: ${TEST_CCS:-false} + enabled: false host: ${CCS_HOST:-localhost} port: ${CCS_PORT:-8807} username: ${CCS_USERNAME:-user01} @@ -117,7 +117,7 @@ test-servers: zimbra: type: docker - enabled: ${TEST_ZIMBRA:-false} + enabled: false host: ${ZIMBRA_HOST:-zimbra-docker.zimbra.io} port: ${ZIMBRA_PORT:-8808} username: ${ZIMBRA_USERNAME:-testuser@zimbra.io} @@ -126,7 +126,7 @@ test-servers: stalwart: type: docker - enabled: ${TEST_STALWART:-false} + enabled: false host: ${STALWART_HOST:-localhost} port: ${STALWART_PORT:-8809} # v0.16+: username is a full email address; password must not be in zxcvbn common-word list. @@ -136,7 +136,7 @@ test-servers: # OX App Suite requires a locally built Docker image — run build.sh first. ox: type: docker - enabled: ${TEST_OX:-false} + enabled: false host: ${OX_HOST:-localhost} port: ${OX_PORT:-8810} username: ${OX_USERNAME:-oxadmin} diff --git a/tests/docker-test-servers/baikal/README.md b/tests/docker-test-servers/baikal/README.md index 104cbcd3..3ecb778d 100644 --- a/tests/docker-test-servers/baikal/README.md +++ b/tests/docker-test-servers/baikal/README.md @@ -62,8 +62,6 @@ baikal: enabled: false ``` -Or use the environment variable: `TEST_BAIKAL=false`. - Or simply don't install Docker - the tests will automatically skip Baikal if Docker is not available. ## GitHub Actions (CI/CD) @@ -99,7 +97,7 @@ You can add more secrets in GitHub Actions settings for credentials. The test suite will automatically detect and use Baikal if configured. Configuration is in `tests/caldav_test_servers.yaml` (copy from `tests/caldav_test_servers.yaml.example` and customize). -To enable Baikal testing, set `enabled: true` (or `enabled: auto` to auto-detect Docker availability) in the YAML config: +To enable Baikal testing, set `enabled: true` in the YAML config: ```yaml baikal: @@ -107,7 +105,9 @@ baikal: enabled: true ``` -Or use the environment variable: `TEST_BAIKAL=true`. +Docker servers are also auto-detected: a running container is picked up by the +test suite even without an explicit config entry, and is skipped automatically +when Docker is not available. ## Troubleshooting diff --git a/tests/docker-test-servers/ccs/start.sh b/tests/docker-test-servers/ccs/start.sh index 02ea1447..658c9028 100755 --- a/tests/docker-test-servers/ccs/start.sh +++ b/tests/docker-test-servers/ccs/start.sh @@ -42,7 +42,7 @@ echo " Users: user01/user01, user02/user02, admin/admin" echo "" echo "Run tests from project root:" echo " cd ../../.." -echo " TEST_CCS=true pytest" +echo " pytest" echo "" echo "To stop CCS: ./stop.sh" echo "To view logs: docker-compose logs -f ccs" diff --git a/tests/docker-test-servers/cyrus/README.md b/tests/docker-test-servers/cyrus/README.md index 9c4dfdd2..02a15df1 100644 --- a/tests/docker-test-servers/cyrus/README.md +++ b/tests/docker-test-servers/cyrus/README.md @@ -67,8 +67,6 @@ cyrus: enabled: false ``` -Or use the environment variable: `TEST_CYRUS=false`. - Or simply don't install Docker - the tests will automatically skip Cyrus if Docker is not available. ## Troubleshooting diff --git a/tests/docker-test-servers/davical/start.sh b/tests/docker-test-servers/davical/start.sh index 5b9edb54..32add709 100755 --- a/tests/docker-test-servers/davical/start.sh +++ b/tests/docker-test-servers/davical/start.sh @@ -26,7 +26,7 @@ bash "$SCRIPT_DIR/setup_davical.sh" echo "" echo "Run tests from project root:" echo " cd ../../.." -echo " TEST_DAVICAL=true pytest" +echo " pytest" echo "" echo "To stop DAViCal: ./stop.sh" echo "To view logs: docker-compose logs -f" diff --git a/tests/docker-test-servers/davis/start.sh b/tests/docker-test-servers/davis/start.sh index 3e0235b0..34d52b22 100755 --- a/tests/docker-test-servers/davis/start.sh +++ b/tests/docker-test-servers/davis/start.sh @@ -23,7 +23,7 @@ bash "$SCRIPT_DIR/setup_davis.sh" echo "" echo "Run tests from project root:" echo " cd ../../.." -echo " TEST_DAVIS=true pytest" +echo " pytest" echo "" echo "To stop Davis: ./stop.sh" echo "To view logs: docker-compose logs -f davis" diff --git a/tests/docker-test-servers/nextcloud/README.md b/tests/docker-test-servers/nextcloud/README.md index 44e696b8..b6d53f11 100644 --- a/tests/docker-test-servers/nextcloud/README.md +++ b/tests/docker-test-servers/nextcloud/README.md @@ -44,7 +44,8 @@ This will: This Nextcloud instance comes **pre-configured** with: - Admin user: `admin` / `admin` -- Test user: `testuser` / `TestPassword123!` +- Test user: `testuser` / `testpass` +- Scheduling test users: `user1` / `testpass1`, `user2` / `testpass2`, `user3` / `testpass3` - Calendar and Contacts apps enabled - CalDAV URL: `http://localhost:8801/remote.php/dav` @@ -56,7 +57,7 @@ This Nextcloud instance comes **pre-configured** with: - `NEXTCLOUD_URL`: URL of the Nextcloud server (default: `http://localhost:8801`) - `NEXTCLOUD_USERNAME`: Test user username (default: `testuser`) -- `NEXTCLOUD_PASSWORD`: Test user password (default: `TestPassword123!`) +- `NEXTCLOUD_PASSWORD`: Test user password (default: `testpass`) ## Disabling Nextcloud Tests @@ -68,8 +69,6 @@ nextcloud: enabled: false ``` -Or use the environment variable: `TEST_NEXTCLOUD=false`. - Or simply don't install Docker - the tests will automatically skip Nextcloud if Docker is not available. ## Troubleshooting diff --git a/tests/docker-test-servers/ox/README.md b/tests/docker-test-servers/ox/README.md index eedf2695..0dfcb458 100644 --- a/tests/docker-test-servers/ox/README.md +++ b/tests/docker-test-servers/ox/README.md @@ -42,7 +42,7 @@ are used so the container always starts clean). ```bash cd ../../.. -TEST_OX=true pytest tests/test_caldav.py -k OX -v +pytest tests/test_caldav.py -k OX -v ``` ## Notes diff --git a/tests/docker-test-servers/ox/start.sh b/tests/docker-test-servers/ox/start.sh index 38de5c98..6c923f84 100755 --- a/tests/docker-test-servers/ox/start.sh +++ b/tests/docker-test-servers/ox/start.sh @@ -60,7 +60,7 @@ echo " User: oxadmin / oxadmin" echo "" echo "Run tests from project root:" echo " cd ../../.." -echo " TEST_OX=true pytest tests/test_caldav.py -k OX -v" +echo " pytest tests/test_caldav.py -k OX -v" echo "" echo "To stop: ./stop.sh" echo "To view logs: docker-compose logs -f ox" diff --git a/tests/docker-test-servers/sogo/README.md b/tests/docker-test-servers/sogo/README.md index a9fce51e..230157fc 100644 --- a/tests/docker-test-servers/sogo/README.md +++ b/tests/docker-test-servers/sogo/README.md @@ -66,8 +66,6 @@ sogo: enabled: false ``` -Or use the environment variable: `TEST_SOGO=false`. - Or simply don't install Docker - the tests will automatically skip SOGo if Docker is not available. ## Troubleshooting diff --git a/tests/docker-test-servers/stalwart/start.sh b/tests/docker-test-servers/stalwart/start.sh index b9e7d0fc..39bc91a8 100755 --- a/tests/docker-test-servers/stalwart/start.sh +++ b/tests/docker-test-servers/stalwart/start.sh @@ -17,7 +17,7 @@ bash "$SCRIPT_DIR/setup_stalwart.sh" echo "" echo "Run tests from project root:" echo " cd ../../.." -echo " TEST_STALWART=true pytest" +echo " pytest" echo "" echo "To stop Stalwart: ./stop.sh" echo "To view logs: docker-compose logs -f stalwart" diff --git a/tests/docker-test-servers/zimbra/README.md b/tests/docker-test-servers/zimbra/README.md index 05e34cfc..213b3d3a 100644 --- a/tests/docker-test-servers/zimbra/README.md +++ b/tests/docker-test-servers/zimbra/README.md @@ -45,7 +45,7 @@ The start script will: ```bash cd ../../.. -TEST_ZIMBRA=true pytest tests/test_caldav.py -k Zimbra -v +pytest tests/test_caldav.py -k Zimbra -v ``` ## Notes diff --git a/tests/docker-test-servers/zimbra/start.sh b/tests/docker-test-servers/zimbra/start.sh index 2e0f267c..81142b8e 100755 --- a/tests/docker-test-servers/zimbra/start.sh +++ b/tests/docker-test-servers/zimbra/start.sh @@ -74,7 +74,7 @@ echo " testuser3@$ZIMBRA_DOMAIN / testpass" echo "" echo "Run tests from project root:" echo " cd ../../.." -echo " TEST_ZIMBRA=true pytest tests/test_caldav.py -k Zimbra -v" +echo " pytest tests/test_caldav.py -k Zimbra -v" echo "" echo "To stop Zimbra: ./stop.sh" echo "To view logs: docker-compose logs -f zimbra" diff --git a/tests/test_async_integration.py b/tests/test_async_integration.py index 97565781..7aec289a 100644 --- a/tests/test_async_integration.py +++ b/tests/test_async_integration.py @@ -28,6 +28,10 @@ from .test_caldav import evr as evr_static # recurring annual event (1997) from .test_caldav import evr2 as evr2_static # bi-weekly with exception (2024) from .test_caldav import journal as journal_static +from .test_caldav import ( + near_now_ics, # shift an ical event's DTSTART/DTEND to ~now (sliding-window servers) + next_anniversary_windows, # near-future search windows for a FREQ=YEARLY event +) from .test_caldav import todo as todo_static # avoids clash with local var in add_todo() from .test_caldav import todo2 as todo2_static # avoids clash with todo2() generator from .test_caldav import todo3 as todo3_static @@ -535,6 +539,112 @@ async def test_search_events_by_date_range(self, async_calendar: Any) -> None: assert len(events) >= 1 assert "Async Test Event" in events[0].data + @pytest.mark.asyncio + async def test_search_without_comptype_with_date_range(self, async_calendar: Any) -> None: + """Async mirror of testSearchWithoutCompTypeWithDateRange. + + Test for https://github.com/python-caldav/caldav/issues/681 + + A time-range search that does NOT specify a component type must work + even on SabreDAV-based servers (Baikal, Nextcloud, ...) which - correctly + per RFC4791 section 9.7 - reject a CALDAV:time-range placed directly under + VCALENDAR with HTTP 400. The library works around this by splitting the + search into one query per component type. + + The search is run twice: once with the server's real feature + configuration, and once with search.time-range.comp-type-optional forced + to "supported", exercising the reactive HTTP-400 fallback. + """ + self.skip_unless_support("search.time-range.event") + base = _get_base_date() + uid = f"issue681-async-{uuid.uuid4()}@example.com" + await add_event( + async_calendar, + make_event( + uid, + "issue 681 async comp-type-less time-range", + base, + base + timedelta(hours=1), + ), + ) + + start = base - timedelta(hours=1) + end = base + timedelta(days=1) + + async def _assert_event_found(): + ## must not raise (the crux of issue #681) and must find the event + objects = await async_calendar.search(start=start, end=end) + assert [o for o in objects if uid in o.data], ( + "comp-type-less time-range search did not return the event" + ) + + ## Run 1: the server's real feature configuration (proactive comp-type split) + await _assert_event_found() + + ## Determine how this server reacts to the raw comp-type-less time-range + ## query. Only SabreDAV-style servers reject it with a ReportError (HTTP + ## 400) - the case the reactive fallback (issue #681 item 4) recovers from. + ## Others return nothing or a different error (e.g. Cyrus may answer 403), + ## where forcing the feature on is an unrecoverable misconfiguration. + from caldav.lib import error + + try: + await async_calendar.search(start=start, end=end, compatibility_workarounds=False) + raw_report_error = False + except error.ReportError: + raw_report_error = True + except error.DAVError: + raw_report_error = False + + ## Run 2 (only meaningful where the raw query raises a ReportError): force + ## the feature ON and verify the reactive fallback recovers and finds the event. + if raw_report_error: + features = async_calendar.client.features + key = "search.time-range.comp-type-optional" + had_key = key in features._server_features + saved = features._server_features.get(key) + features.set_feature(key, {"support": "full"}) + try: + objects = await async_calendar.search(start=start, end=end) + assert [o for o in objects if uid in o.data], ( + "reactive fallback did not recover the comp-type-less time-range search" + ) + finally: + if had_key: + features._server_features[key] = saved + else: + features._server_features.pop(key, None) + + @pytest.mark.asyncio + async def test_search_without_comptype_with_category(self, async_calendar: Any) -> None: + """Async mirror of testSearchWithoutCompTypeWithCategory. + + Test for https://github.com/python-caldav/caldav/issues/681 + + A property filter (CATEGORIES) without a component type must work. Under + the VCALENDAR comp-filter it targets VCALENDAR's own properties (no + CATEGORIES), so servers match nothing; the library splits the search into + one query per component type (search.text.comp-type-optional unsupported). + """ + self.skip_unless_support("search.text.category") + base = _get_base_date() + category = "issue681cat" + uuid.uuid4().hex[:8] + uid = f"issue681cat-async-{uuid.uuid4()}@example.com" + data = make_event( + uid, + "issue 681 async comp-type-less category search", + base, + base + timedelta(hours=1), + ).replace("END:VEVENT", f"CATEGORIES:{category}\nEND:VEVENT") + await add_event(async_calendar, data) + + ## Only the proactive split is testable here: servers silently return + ## nothing for a prop-filter under VCALENDAR (no error to recover from). + objects = await async_calendar.search(category=category) + assert [o for o in objects if uid in o.data], ( + "comp-type-less category search did not return the event" + ) + @pytest.mark.asyncio async def test_search_todos_pending(self, async_task_list: Any) -> None: """Test searching for pending todos.""" @@ -643,8 +753,9 @@ async def test_lookup_event(self, async_calendar: Any) -> None: self.skip_unless_support("save-load.event") c = async_calendar - # create the event - e1 = await c.add_event(ev1_static) + # create the event (near-now date so it stays visible to REPORT-based + # lookups on sliding-window servers; see near_now_ics) + e1 = await c.add_event(near_now_ics(ev1_static)) assert e1.url is not None # Verify that we can look it up from calendar by url @@ -655,7 +766,10 @@ async def test_lookup_event(self, async_calendar: Any) -> None: # look up by UID e3 = await c.get_event_by_uid("20010712T182145Z-123401@example.com") assert str(e3.icalendar_component["uid"]) == "20010712T182145Z-123401@example.com" - assert e3.url == e1.url + ## get_event_by_uid may return a different (canonical) URL than the PUT + ## URL on servers that don't preserve it (e.g. OX); see save-load.stable-url + if self.is_supported("save-load.stable-url"): + assert e3.url == e1.url # load directly from URL without going through the calendar object e4 = Event(client=c.client, url=e1.url) @@ -673,23 +787,31 @@ async def test_create_overwrite_delete_event(self, async_calendar: Any) -> None: self.skip_unless_support("save-load.event") c = async_calendar + ## near-now date so the event stays visible to REPORT-based lookups on + ## sliding-window servers (e.g. OX); see near_now_ics + ev1_now = near_now_ics(ev1_static) + # attempting to update a non-existing event must raise ConsistencyError with pytest.raises(error.ConsistencyError): - await c.add_event(ev1_static, no_create=True) + await c.add_event(ev1_now, no_create=True) # no_create + no_overwrite is always an error with pytest.raises(error.ConsistencyError): - await c.add_event(ev1_static, no_create=True, no_overwrite=True) + await c.add_event(ev1_now, no_create=True, no_overwrite=True) - e1 = await c.add_event(ev1_static) + e1 = await c.add_event(ev1_now) assert e1.url is not None - # same UID again → overwrite (unless server forbids it) - if not self.is_supported("save-load.mutable"): - e2 = await c.add_event(ev1_static) + # same UID again → overwrite (unless server forbids it). Overwriting via + # a fresh PUT without an If-Match etag is gated on save-load.mutable.if-match-optional: + # OX enforces optimistic concurrency and rejects such a PUT with 409. + if self.is_supported("save-load.mutable") and self.is_supported( + "save-load.mutable.if-match-optional" + ): + e2 = await c.add_event(ev1_now) # no_create on an existing event must succeed - e2 = await c.add_event(ev1_static, no_create=True) + e2 = await c.add_event(ev1_now, no_create=True) # modify and save with no_create e2.icalendar_component["summary"] = "Bastille Day Party!" @@ -700,7 +822,7 @@ async def test_create_overwrite_delete_event(self, async_calendar: Any) -> None: # no_overwrite on an existing event must raise ConsistencyError with pytest.raises(error.ConsistencyError): - await c.add_event(ev1_static, no_overwrite=True) + await c.add_event(ev1_now, no_overwrite=True) await e1.delete() @@ -760,14 +882,18 @@ async def test_load_event(self, async_calendar: Any, async_calendar2: Any) -> No c1 = async_calendar - e1_ = await c1.add_event(ev1_static) + e1_ = await c1.add_event(near_now_ics(ev1_static)) await e1_.load() # load the object returned by add_event events = await c1.get_events() assert len(events) >= 1 e1 = events[0] await e1.load() # load a freshly fetched handle - assert e1.url == e1_.url + ## e1 came from a search and may carry a different (canonical) URL than + ## the PUT URL on servers that don't preserve it (e.g. OX); see + ## save-load.stable-url + if self.is_supported("save-load.stable-url"): + assert e1.url == e1_.url @pytest.mark.asyncio async def test_copy_event(self, async_calendar: Any, async_calendar2: Any) -> None: @@ -778,14 +904,15 @@ async def test_copy_event(self, async_calendar: Any, async_calendar2: Any) -> No c1 = async_calendar c2 = async_calendar2 - e1_ = await c1.add_event(ev1_static) + e1_ = await c1.add_event(near_now_ics(ev1_static)) events = await c1.get_events() e1 = events[0] # duplicate in same calendar with a new UID - e1_dup = e1.copy() - await e1_dup.save() - assert len(await c1.get_events()) == 2 + if self.is_supported("save.duplicate-event"): + e1_dup = e1.copy() + await e1_dup.save() + assert len(await c1.get_events()) == 2 # copy cross-calendar keeping the same UID if self.is_supported("save.duplicate-uid.cross-calendar"): @@ -802,8 +929,12 @@ async def test_copy_event(self, async_calendar: Any, async_calendar2: Any) -> No # copy in same calendar keeping UID — same-UID PUT is a no-op / overwrite e1_dup2 = e1.copy(keep_uid=True) await e1_dup2.save() - # count should still be 2 (not 3) because same UID overwrites - assert len(await c1.get_events()) == 2 + # same UID overwrites, so the count is unchanged: 2 where a new-UID + # duplicate was created above, 1 where duplicates are not allowed + if self.is_supported("save.duplicate-event"): + assert len(await c1.get_events()) == 2 + else: + assert len(await c1.get_events()) == 1 @pytest.mark.asyncio async def test_multi_get(self, async_calendar: Any) -> None: @@ -855,7 +986,7 @@ async def test_object_by_sync_token(self, async_calendar: Any) -> None: objcnt += len(await c.get_todos()) objcnt += len(await c.get_events()) - obj = await c.add_event(ev1_static) + obj = await c.add_event(near_now_ics(ev1_static)) objcnt += 1 if self.is_supported("save-load.event.recurrences"): await c.add_event(evr_static) @@ -915,7 +1046,7 @@ async def test_object_by_sync_token(self, async_calendar: Any) -> None: if is_time_based: await asyncio.sleep(1) - obj3 = await c.add_event(ev3_static) + obj3 = await c.add_event(near_now_ics(ev3_static)) if is_time_based: await asyncio.sleep(1) my_changed_objects = await c.get_objects_by_sync_token( @@ -977,7 +1108,7 @@ async def test_sync(self, async_calendar: Any) -> None: objcnt += len(await c.get_todos()) objcnt += len(await c.get_events()) - obj = await c.add_event(ev1_static) + obj = await c.add_event(near_now_ics(ev1_static)) objcnt += 1 if self.is_supported("save-load.event.recurrences"): await c.add_event(evr_static) @@ -995,6 +1126,25 @@ async def test_sync(self, async_calendar: Any) -> None: assert my_objects.sync_token != "" assert len(list(my_objects)) == objcnt + stable_url = self.is_supported("save-load.stable-url") + + def synced_match(o): + """Return the synced object corresponding to o, or None. + + objects_by_url() is keyed by the server-reported URL, which on + servers that don't preserve the PUT URL (e.g. OX; see + save-load.stable-url) differs from o.url - so fall back to matching + by UID there. + """ + synced = my_objects.objects_by_url() + if stable_url: + return synced.get(o.url) + uid = o.icalendar_component["uid"] + return next( + (cand for cand in synced.values() if cand.icalendar_component["uid"] == uid), + None, + ) + if is_time_based: await asyncio.sleep(1) @@ -1018,12 +1168,12 @@ async def test_sync(self, async_calendar: Any) -> None: if not is_fragile: assert len(list(updated)) == 1 assert len(list(deleted)) == 0 - assert "foobar" in my_objects.objects_by_url()[obj.url].data + assert "foobar" in synced_match(obj).data if is_time_based: await asyncio.sleep(1) - obj3 = await c.add_event(ev3_static) + obj3 = await c.add_event(near_now_ics(ev3_static)) if is_time_based: await asyncio.sleep(1) @@ -1032,7 +1182,7 @@ async def test_sync(self, async_calendar: Any) -> None: if not is_fragile: assert len(list(updated)) == 1 assert len(list(deleted)) == 0 - assert obj3.url in my_objects.objects_by_url() + assert synced_match(obj3) is not None self.skip_unless_support("sync-token.delete") @@ -1046,7 +1196,7 @@ async def test_sync(self, async_calendar: Any) -> None: if not is_fragile: assert len(list(updated)) == 0 assert len(list(deleted)) == 1 - assert obj.url not in my_objects.objects_by_url() + assert synced_match(obj) is None if is_time_based: await asyncio.sleep(1) @@ -1298,30 +1448,35 @@ async def test_recurring_date_search(self, async_calendar: Any) -> None: self.skip_unless_support("search.recurrences.includes-implicit.event") c = async_calendar + # evr is a yearly event starting at 1997-11-02. Search the next future + # Nov-2 anniversary rather than a fixed historic year, so sliding-window + # servers (e.g. OX) can serve the time range. + year, narrow_start, narrow_end, wide_end = next_anniversary_windows() + await c.add_event(evr_static) r = await c.search( event=True, - start=datetime(2008, 11, 1, 17, 0, 0), - end=datetime(2008, 11, 3, 17, 0, 0), + start=narrow_start, + end=narrow_end, expand=False, ) assert len(r) == 1 r = await c.search( event=True, - start=datetime(2008, 11, 1, 17, 0, 0), - end=datetime(2008, 11, 3, 17, 0, 0), + start=narrow_start, + end=narrow_end, expand=True, ) assert len(r) == 1 assert r[0].data.count("END:VEVENT") == 1 - assert r[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + assert r[0].data.count(f"DTSTART;VALUE=DATE:{year}") == 1 r2 = await c.search( event=True, - start=datetime(2008, 11, 1, 17, 0, 0), - end=datetime(2009, 11, 3, 17, 0, 0), + start=narrow_start, + end=wide_end, expand=True, ) assert len(r2) == 2 @@ -1585,8 +1740,8 @@ async def test_todo_datesearch(self, async_task_list: Any) -> None: foo = 5 if not self.is_supported("search.recurrences.includes-implicit.todo"): foo -= 1 - if self.check_compatibility_flag( - "vtodo_datesearch_nodtstart_task_is_skipped" + if not self.is_supported( + "search.time-range.todo.no-dtstart" ) or self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range" ): @@ -1658,7 +1813,9 @@ async def test_propfind(self, async_client: Any) -> None: """Raw XML propfind returns a multistatus response.""" from caldav.lib.python_utilities import to_local - self._skip_on_compatibility_flag("propfind_allprop_failure") + ## This only asserts a multistatus is returned, so (unlike the sync + ## testPropfind, which checks for DAV:resourcetype) it needs no + ## propfind.allprop.resourcetype gate. principal = await async_client.principal() foo = await async_client.propfind( principal.url, @@ -1790,6 +1947,9 @@ async def test_set_calendar_properties(self, async_client: Any) -> None: from .fixture_helpers import cleanup_calendar_objects self.skip_unless_support("create-calendar.set-displayname") + ## This test expects the display name to round-trip at a stable URL; + ## servers that relocate the calendar when a name is set (Zimbra) can't. + self.skip_unless_support("create-calendar.set-displayname.stable-url") self.skip_unless_support("delete-calendar") self.skip_unless_support("create-calendar") @@ -1910,6 +2070,9 @@ async def test_change_attendee_status_with_email_given( ) -> None: """change_attendee_status(attendee=email) updates PARTSTAT correctly.""" self.skip_unless_support("save-load.event") + ## Some servers (e.g. OX) forbid changing an attendee's PARTSTAT via a + ## direct PUT (403 Forbidden) and require iTIP scheduling instead. + self.skip_unless_support("save-load.mutable.attendee-partstat") c = async_calendar event = await c.add_event( uid="test1", @@ -1957,55 +2120,69 @@ async def test_edit_single_recurrence(self, async_calendar: Any) -> None: self.skip_unless_support("search.text") cal = async_calendar + ## Anchor the daily recurring event a few days in the future so servers + ## with a sliding REPORT window / no old-date support (e.g. CCS, ref + ## search.time-range.event.old-dates) can still serve the time ranges. + ## The integer passed to search()/summary_on() is a day offset from this + ## anchor day; the values just need to be distinct future days. + base = (datetime.now() + timedelta(days=2)).replace( + hour=8, minute=7, second=6, microsecond=0 + ) + await cal.add_event( uid="test1", summary="daily test", - dtstart=datetime(2015, 1, 1, 8, 7, 6), - dtend=datetime(2015, 1, 1, 9, 7, 6), + dtstart=base, + dtend=base + timedelta(hours=1), rrule={"FREQ": "DAILY"}, ) - async def search(month): + def day_start(offset): + return (base + timedelta(days=offset)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + async def search(offset): recurrence = await cal.search( event=True, - start=datetime(2015, month, 1), - end=datetime(2015, month, 2), + start=day_start(offset), + end=day_start(offset) + timedelta(days=1), expand=True, ) assert len(recurrence) == 1 return recurrence[0] - async def summary_by_month(month): - return (await search(month)).icalendar_component["summary"] + async def summary_on(offset): + return (await search(offset)).icalendar_component["summary"] recurrence = await search(7) recurrence.icalendar_component["summary"] = "half a year of daily testing" await recurrence.save() - assert await summary_by_month(6) == "daily test" - assert await summary_by_month(7) == "half a year of daily testing" - assert await summary_by_month(8) == "daily test" + assert await summary_on(6) == "daily test" + assert await summary_on(7) == "half a year of daily testing" + assert await summary_on(8) == "daily test" recurrence = await search(2) recurrence.icalendar_component["summary"] = "one month of daily testing" await recurrence.save() - assert await summary_by_month(1) == "daily test" - assert await summary_by_month(2) == "one month of daily testing" - assert await summary_by_month(7) == "half a year of daily testing" + assert await summary_on(1) == "daily test" + assert await summary_on(2) == "one month of daily testing" + assert await summary_on(7) == "half a year of daily testing" recurrence = await search(7) recurrence.icalendar_component["summary"] = "six months of daily testing" await recurrence.save() - assert await summary_by_month(7) == "six months of daily testing" + assert await summary_on(7) == "six months of daily testing" recurrence = await search(9) recurrence.icalendar_component["summary"] = "daily testing" await recurrence.save(all_recurrences=True) - assert await summary_by_month(1) == "daily testing" - assert await summary_by_month(2) == "one month of daily testing" - assert await summary_by_month(3) == "daily testing" - assert await summary_by_month(7) == "six months of daily testing" + assert await summary_on(1) == "daily testing" + assert await summary_on(2) == "one month of daily testing" + assert await summary_on(3) == "daily testing" + assert await summary_on(7) == "six months of daily testing" # ==================== Group G – Auth errors & misc ==================== @@ -2135,7 +2312,9 @@ async def test_utf8_event(self, async_client: Any) -> None: c = await principal.make_calendar(name="Yølp", cal_id=cal_id) try: - await c.add_event(ev1_static.replace("Bastille Day Party", "Bringebærsyltetøyfestival")) + await c.add_event( + near_now_ics(ev1_static).replace("Bastille Day Party", "Bringebærsyltetøyfestival") + ) events = await c.get_events() if "zimbra" not in str(c.url): assert len(events) == 1 @@ -2152,7 +2331,7 @@ async def test_create_calendar_and_event_from_vobject(self, async_calendar: Any) self.skip_unless_support("save-load.event") c = async_calendar cnt = len(await c.get_events()) - ve1 = vobject.readOne(ev1_static) + ve1 = vobject.readOne(near_now_ics(ev1_static)) await c.add_event(ve1) cnt += 1 events = await c.get_events() @@ -2360,8 +2539,13 @@ async def test_invite_and_respond(self, scheduling_setup: Any) -> None: new_attendee_inbox_items: list[Any] = [] auto_scheduled = False for _ in range(30): + ## Correlate by UID: a late METHOD:CANCEL from another scheduling + ## test's teardown can otherwise land here as a stray "new" item + ## (see testAcceptInviteUsernameEmailFallback). new_attendee_inbox_items = [ - item for item in await inbox1.get_items() if item.url not in inbox_urls_before + item + for item in await inbox1.get_items() + if item.url not in inbox_urls_before and item.id == event_uid ] ## Check whether the server auto-scheduled the event directly into ## the attendee's calendar. The event may land in any calendar, diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 2df72fbf..fafb5814 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -12,6 +12,7 @@ import logging import os import random +import re import sys import tempfile import time @@ -138,6 +139,44 @@ def _make_client( END:VEVENT """ + +def near_now_ics(ics, days=30, hours=11): + """Return a copy of an ical event string with DTSTART/DTEND shifted to ~now. + + Some servers (e.g. OX App Suite) only return objects within a sliding ~±1 + year window from REPORT-based lookups (ref search.unlimited-time-range), so + an event with a static historic date (the year-2006 dates in ev1/broken_ev1) + is invisible to get_events()/get_event_by_uid() even though it was stored + correctly. Using a near-now date keeps these save-then-search tests + meaningful on such servers. Only plain "DTSTART:"/"DTEND:" properties are + shifted; recurring all-day templates (DTSTART;VALUE=DATE:) are left alone. + """ + start = datetime.now() + timedelta(days=days) + end = start + timedelta(hours=hours) + ics = re.sub(r"DTSTART:[0-9T]+Z?", start.strftime("DTSTART:%Y%m%dT%H%M%SZ"), ics) + ics = re.sub(r"DTEND:[0-9T]+Z?", end.strftime("DTEND:%Y%m%dT%H%M%SZ"), ics) + return ics + + +def next_anniversary_windows(month=11, day=2, hour=17): + """Search windows around the next future anniversary of (month, day). + + A FREQ=YEARLY event (e.g. evr, anchored at 1997-11-02) recurs forever, so + historic search windows like 2008-11 are arbitrary. Servers with a sliding + REPORT window (e.g. OX App Suite, ref search.time-range.event.old-dates) can + only serve time ranges near now, so we search the next future occurrence + instead. Returns (year, narrow_start, narrow_end, wide_end): a ±1-day window + catching one occurrence in `year`, plus a wide_end one year later so that + [narrow_start, wide_end] catches two consecutive occurrences. + """ + now = datetime.now() + year = now.year if (now.month, now.day) <= (month, day) else now.year + 1 + narrow_start = datetime(year, month, day - 1, hour, 0, 0) + narrow_end = datetime(year, month, day + 1, hour, 0, 0) + wide_end = datetime(year + 1, month, day + 1, hour, 0, 0) + return year, narrow_start, narrow_end, wide_end + + ev2 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN @@ -796,10 +835,13 @@ def testInviteAndRespond(self): new_attendee_inbox_items = [] auto_scheduled = False for _ in range(30): + ## Correlate by UID: a late METHOD:CANCEL from another scheduling + ## test's teardown can otherwise land here as a stray "new" item + ## (see testAcceptInviteUsernameEmailFallback). new_attendee_inbox_items = [ item for item in self.principals[1].schedule_inbox().get_items() - if item.url not in inbox_items + if item.url not in inbox_items and item.id == event_uid ] ## Check whether the server auto-scheduled the event directly into ## the attendee's calendar (server-side automatic scheduling). @@ -904,12 +946,19 @@ def testAcceptInviteUsernameEmailFallback(self): ) self._auto_scheduled_event_uids.append(saved_event.id) + ## Correlate the inbox item to THIS invite by UID. Picking the first + ## arbitrary "new" item is flaky: deleting an organizer event (e.g. a + ## previous scheduling test's teardown) makes Zimbra deliver a late + ## METHOD:CANCEL for the old UID, which can land in this test's poll + ## window before our own REQUEST arrives. We match on UID (not method) + ## so that a wrongly-delivered non-REQUEST for our own UID still fails + ## the is_invite_request() assertion below rather than being hidden. new_attendee_inbox_items = [] for _ in range(30): new_attendee_inbox_items = [ item for item in self.principals[1].schedule_inbox().get_items() - if item.url not in inbox_items + if item.url not in inbox_items and item.id == saved_event.id ] if new_attendee_inbox_items: break @@ -1458,10 +1507,27 @@ def _fixCalendar_(self, **kwargs): return self._default_calendar # Pre-processing: set up defaults for name and cal_id + comp_set = kwargs.get("supported_calendar_component_set", []) + # A component-restricted fixture (VTODO-only / VJOURNAL-only) is always + # looked up by cal_id, never by display name. + restricted = bool(comp_set) and "VEVENT" not in comp_set if "name" not in kwargs: if self.cleanup_regime in ("light", "pre"): self._teardownCalendar(cal_id=self.testcal_id) - if not self.is_supported("create-calendar.set-displayname"): + # Only give a display name when the server both accepts one and keeps + # the calendar URL stable when it's set. On servers that relocate the + # calendar URL when a display name is applied (Zimbra), giving a name + # would move the calendar away from its cal_id URL and break later + # URL-based lookups. Component-restricted fixtures stay nameless: they + # are only ever found by cal_id, and giving them the same "Yep" name as + # the primary fixture would make principal.calendar(name="Yep") ambiguous + # and, on servers enforcing per-principal unique calendar names (SOGo), + # block the primary calendar from being (re)named "Yep". + if ( + restricted + or not self.is_supported("create-calendar.set-displayname") + or not self.is_supported("create-calendar.set-displayname.stable-url") + ): kwargs["name"] = None else: kwargs["name"] = "Yep" @@ -1470,10 +1536,9 @@ def _fixCalendar_(self, **kwargs): # that a VTODO-only calendar and a VJOURNAL-only calendar don't share the # same slot and cause MKCALENDAR failures (and wrong-type PUT errors) when # the calendar persists across tests under wipe-calendar cleanup regime. - comp_set = kwargs.get("supported_calendar_component_set", []) if comp_set and "VJOURNAL" in comp_set and "VEVENT" not in comp_set: kwargs["cal_id"] = self.testcal_id + "-journals" - elif comp_set and "VEVENT" not in comp_set: + elif restricted: kwargs["cal_id"] = self.testcal_id + "-tasks" else: kwargs["cal_id"] = self.testcal_id @@ -1777,9 +1842,9 @@ def testPropfind(self): this is implicitly run by the setup) """ # ResourceType MUST be defined, and SHOULD be returned on a propfind - # for "allprop" if I have the permission to see it. - # So, no ResourceType returned seems like a bug in bedework - self.skip_on_compatibility_flag("propfind_allprop_failure") + # for "allprop" if I have the permission to see it (RFC4918 section 9.1). + # A few servers (bedework, CCS) omit it. + self.skip_unless_support("propfind.allprop.resourcetype") # first a raw xml propfind to the root URL foo = self.caldav.propfind( @@ -1792,12 +1857,15 @@ def testPropfind(self): assert "resourcetype" in to_local(foo.raw) # next, the internal _query_properties, returning an xml tree ... + # (DAV:status is a response-only element, not a queryable property - + # asking for it makes some servers, e.g. CCS, answer 400 - so we query a + # real live property instead and assert on this response, not the first.) foo2 = self.principal._query_properties( [ - dav.Status(), + dav.ResourceType(), ] ) - assert "resourcetype" in to_local(foo.raw) + assert "resourcetype" in to_local(foo2.raw) # TODO: more advanced asserts def testGetCalendarHomeSet(self): @@ -1848,10 +1916,12 @@ def testGetCalendar(self): assert str(c.url) in repr(c) def _notFound(self): - if self.check_compatibility_flag("non_existing_raises_other"): - return error.DAVError - else: + if self.is_supported("non-existing-raises-not-found"): return error.NotFoundError + else: + ## Some servers answer 403 instead of 404 (e.g. Robur); accept any + ## DAVError in that case. + return error.DAVError def testPrincipal(self): collections = self.principal.get_calendars() @@ -1897,6 +1967,9 @@ def testCreateDeleteCalendar(self): def testChangeAttendeeStatusWithEmailGiven(self): self.skip_unless_support("save-load.event") + ## Some servers (e.g. OX) forbid changing an attendee's PARTSTAT via a + ## direct PUT (403 Forbidden) and require iTIP scheduling instead. + self.skip_unless_support("save-load.mutable.attendee-partstat") c = self._fixCalendar() event = c.add_event( @@ -1950,8 +2023,9 @@ def cleanse(events): ## we're supposed to be working towards a brand new calendar assert len(existing_events) == 0 - # add event - c.add_event(broken_ev1) + # add event (near-now date so it stays visible to REPORT-based lookups + # on sliding-window servers; see near_now_ics) + c.add_event(near_now_ics(broken_ev1)) # c.get_events() should give a full list of events events = cleanse(c.get_events()) @@ -1963,8 +2037,13 @@ def cleanse(events): assert len(events2) == 1 assert events2[0].url == events[0].url - if self.is_supported("create-calendar") and self.is_supported( - "create-calendar.set-displayname" + if ( + self.is_supported("create-calendar") + and self.is_supported("create-calendar.set-displayname") + ## _fixCalendar only gives the calendar a display name ("Yep") when + ## the server also keeps the URL stable; on servers that relocate the + ## calendar when a name is set (Zimbra) the fixture is created nameless. + and self.is_supported("create-calendar.set-displayname.stable-url") ): ## We should be able to access the calender through the name c2 = self.principal.calendar(name="Yep") @@ -1973,16 +2052,22 @@ def cleanse(events): self.is_supported("delete-calendar") or self.is_supported("delete-calendar", str) == "fragile" ): - assert c2.url == c.url + ## A name lookup may return a different (canonical) calendar URL + ## than the one we created it at on servers that don't preserve + ## the URL (e.g. OX exposes the calendar under an internal + ## cal://0/NNN id); see save-load.stable-url. + if self.is_supported("save-load.stable-url"): + assert c2.url == c.url events2 = cleanse(c2.get_events()) assert len(events2) == 1 assert events2[0].url == events[0].url # add another event, it should be doable without having premade ICS + _dt = datetime.now() + timedelta(days=31) ev2 = c.add_event( - dtstart=datetime(2015, 10, 10, 8, 7, 6), + dtstart=_dt, summary="This is a test event", - dtend=datetime(2016, 10, 10, 9, 8, 7), + dtend=_dt + timedelta(hours=1), uid="ctuid1", ) events = c.get_events() @@ -2096,7 +2181,7 @@ def testObjectBySyncToken(self): if self.is_supported("save-load.todo.mixed-calendar"): objcnt += len(c.get_todos()) objcnt += len(c.get_events()) - obj = c.add_event(ev1) + obj = c.add_event(near_now_ics(ev1)) objcnt += 1 if self.is_supported("save-load.event.recurrences"): c.add_event(evr) @@ -2178,7 +2263,7 @@ def testObjectBySyncToken(self): ## ADDING yet another object ... and it should also be reported if is_time_based: time.sleep(1) - obj3 = c.add_event(ev3) + obj3 = c.add_event(near_now_ics(ev3)) if is_time_based: time.sleep(1) my_changed_objects = c.get_objects_by_sync_token(sync_token=my_changed_objects.sync_token) @@ -2242,7 +2327,7 @@ def testSync(self): if self.is_supported("save-load.todo.mixed-calendar"): objcnt += len(c.get_todos()) objcnt += len(c.get_events()) - obj = c.add_event(ev1) + obj = c.add_event(near_now_ics(ev1)) objcnt += 1 if self.is_supported("save-load.event.recurrences"): c.add_event(evr) @@ -2261,6 +2346,25 @@ def testSync(self): assert my_objects.sync_token != "" assert len(list(my_objects)) == objcnt + stable_url = self.is_supported("save-load.stable-url") + + def synced_match(o): + """Return the synced object corresponding to o, or None. + + objects_by_url() is keyed by the server-reported URL, which on + servers that don't preserve the PUT URL (e.g. OX; see + save-load.stable-url) differs from o.url - so fall back to matching + by UID there. + """ + synced = my_objects.objects_by_url() + if stable_url: + return synced.get(o.url) + uid = o.icalendar_component["uid"] + return next( + (cand for cand in synced.values() if cand.icalendar_component["uid"] == uid), + None, + ) + if is_time_based: time.sleep(1) @@ -2287,13 +2391,13 @@ def testSync(self): if not is_fragile: assert len(list(updated)) == 1 assert len(list(deleted)) == 0 - assert "foobar" in my_objects.objects_by_url()[obj.url].data + assert "foobar" in synced_match(obj).data if is_time_based: time.sleep(1) ## ADDING yet another object ... and it should also be reported - obj3 = c.add_event(ev3) + obj3 = c.add_event(near_now_ics(ev3)) if is_time_based: time.sleep(1) @@ -2302,7 +2406,7 @@ def testSync(self): if not is_fragile: assert len(list(updated)) == 1 assert len(list(deleted)) == 0 - assert obj3.url in my_objects.objects_by_url() + assert synced_match(obj3) is not None self.skip_unless_support("sync-token.delete") @@ -2317,7 +2421,7 @@ def testSync(self): if not is_fragile: assert len(list(updated)) == 0 assert len(list(deleted)) == 1 - assert obj.url not in my_objects.objects_by_url() + assert synced_match(obj) is None if is_time_based: time.sleep(1) @@ -2337,12 +2441,16 @@ def testLoadEvent(self): c1 = self._fixCalendar(name="Yep", cal_id=self.testcal_id) c2 = self._fixCalendar(name="Yapp", cal_id=self.testcal_id2) - e1_ = c1.add_event(ev1) + e1_ = c1.add_event(near_now_ics(ev1)) if not self.check_compatibility_flag("event_by_url_is_broken"): e1_.load() e1 = c1.get_events()[0] if not self.check_compatibility_flag("event_by_url_is_broken"): - assert e1.url == e1_.url + ## e1 came from a search and may carry a different (canonical) URL + ## than the PUT URL on servers that don't preserve it (e.g. OX); see + ## save-load.stable-url. + if self.is_supported("save-load.stable-url"): + assert e1.url == e1_.url e1.load() if self.cleanup_regime == "post": self._teardownCalendar(cal_id=self.testcal_id) @@ -2361,10 +2469,10 @@ def testCopyEvent(self): assert not len(c1.get_events()) assert not len(c2.get_events()) - e1_ = c1.add_event(ev1) + e1_ = c1.add_event(near_now_ics(ev1)) e1 = c1.get_events()[0] - if not self.check_compatibility_flag("duplicates_not_allowed"): + if self.is_supported("save.duplicate-event"): ## Duplicate the event in the same calendar, with new uid e1_dup = e1.copy() e1_dup.save() @@ -2392,10 +2500,10 @@ def testCopyEvent(self): ## this makes no sense, there won't be any duplication e1_dup2 = e1.copy(keep_uid=True) e1_dup2.save() - if self.check_compatibility_flag("duplicates_not_allowed"): - assert len(c1.get_events()) == 1 - else: + if self.is_supported("save.duplicate-event"): assert len(c1.get_events()) == 2 + else: + assert len(c1.get_events()) == 1 if self.cleanup_regime == "post": self._teardownCalendar(cal_id=self.testcal_id) @@ -2408,8 +2516,9 @@ def testCreateCalendarAndEventFromVobject(self): ## in case the calendar is reused cnt = len(c.get_events()) - # add event from vobject data - ve1 = vobject.readOne(ev1) + # add event from vobject data (near-now date so it stays visible to + # REPORT-based lookups on sliding-window servers; see near_now_ics) + ve1 = vobject.readOne(near_now_ics(ev1)) c.add_event(ve1) cnt += 1 @@ -3335,8 +3444,8 @@ def testTodoDatesearch(self): ) if not self.is_supported("search.recurrences.includes-implicit.todo"): foo -= 1 ## t6 will not be returned - if self.check_compatibility_flag( - "vtodo_datesearch_nodtstart_task_is_skipped" + if not self.is_supported( + "search.time-range.todo.no-dtstart" ) or self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range" ): @@ -3369,10 +3478,20 @@ def testTodoDatesearch(self): todos2 = c.search(start=datetime(2025, 4, 14), todo=True, include_completed=True) todos3 = c.search(start=datetime(2025, 4, 14), todo=True) + ## On a compliant server t1/t4/t6 are returned by an open-ended future + ## search, so we get Todo objects back. Some servers legitimately return + ## nothing here: they skip no-dtstart todos (t1/t4) and don't carry the + ## recurring todo (t6) into the future - e.g. Stalwart, which skips + ## no-dtstart todos and only marks implicit-recurrence todos "fragile". + ## The presence/absence of each todo is verified by the urls_found logic + ## below; here we only type-check whatever did come back. if self.is_supported("search.time-range.open.end"): - assert isinstance(todos1[0], Todo) - assert isinstance(todos2[0], Todo) - assert isinstance(todos3[0], Todo) + if todos1: + assert isinstance(todos1[0], Todo) + if todos2: + assert isinstance(todos2[0], Todo) + if todos3: + assert isinstance(todos3[0], Todo) ## * t6 should be returned, as it's a yearly task spanning over 2025 ## * t1 should probably be returned, as it has no due date set and hence @@ -3385,8 +3504,8 @@ def testTodoDatesearch(self): urls_found = set(urls_found) if self.is_supported("search.recurrences.includes-implicit.todo", accept_fragile=True): urls_found.discard(t6.url) - if not self.check_compatibility_flag( - "vtodo_datesearch_nodtstart_task_is_skipped" + if self.is_supported( + "search.time-range.todo.no-dtstart" ) and not self.check_compatibility_flag("vtodo_datesearch_notime_task_is_skipped"): urls_found.discard(t4.url) if self.check_compatibility_flag("vtodo_no_due_infinite_duration"): @@ -3412,6 +3531,135 @@ def testSearchWithoutCompType(self): assert len(objects) == 2 assert set([type(x).__name__ for x in objects]) == {"Todo", "Event"} + def testSearchWithoutCompTypeWithDateRange(self): + """Test for https://github.com/python-caldav/caldav/issues/681 + + A time-range search that does NOT specify a component type must work + even on SabreDAV-based servers (Baikal, Nextcloud, ...) which - correctly + per RFC4791 section 9.7 - reject a CALDAV:time-range placed directly under + the VCALENDAR comp-filter with HTTP 400. The library works around this by + splitting the search into one query per component type + (search.time-range.comp-type-optional being unsupported). + + The search is run twice: once with the server's real feature + configuration, and once with search.time-range.comp-type-optional forced + to "supported". The forced run makes the library optimistically send the + comp-type-less time-range query that SabreDAV rejects, exercising the + reactive 400-fallback. Without that fallback the forced run fails on + Baikal. + """ + self.skip_unless_support("search.time-range.event") + cal = self._fixCalendar() + + ## Near-future dates, to steer clear of servers that restrict old-date + ## time-range searches. + now = datetime.now(timezone.utc) + dtstart = now + timedelta(days=1) + dtend = dtstart + timedelta(hours=1) + uid = "issue681-" + uuid.uuid4().hex + ical = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//python-caldav//issue681 test//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"DTSTAMP:{now.strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n" + "SUMMARY:issue 681 comp-type-less time-range search\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + cal.save_event(ical) + + start = now + end = now + timedelta(days=2) + + def _assert_event_found(): + ## must not raise (this is the crux of issue #681) and must find the event + objects = cal.search(start=start, end=end) + assert [o for o in objects if uid in o.data], ( + "comp-type-less time-range search did not return the event" + ) + + ## Run 1: the server's real feature configuration (proactive comp-type split) + _assert_event_found() + + ## Determine how this server reacts to the raw comp-type-less time-range + ## query. Only SabreDAV-style servers (Baikal, Nextcloud) reject it with a + ## ReportError (HTTP 400) - that is the case the reactive fallback (issue + ## #681 item 4) is designed to recover from. Others return nothing, or a + ## different error (e.g. Cyrus may answer 403), where forcing the feature on + ## is an unrecoverable misconfiguration not worth asserting on. + try: + cal.search(start=start, end=end, compatibility_workarounds=False) + raw_report_error = False + except error.ReportError: + raw_report_error = True + except error.DAVError: + raw_report_error = False + + ## Run 2 (only meaningful where the raw query raises a ReportError): force + ## search.time-range.comp-type-optional ON and verify the reactive fallback + ## recovers and still finds the event. + if raw_report_error: + features = self.caldav.features + key = "search.time-range.comp-type-optional" + had_key = key in features._server_features + saved = features._server_features.get(key) + features.set_feature(key, {"support": "full"}) + try: + objects = cal.search(start=start, end=end) + assert [o for o in objects if uid in o.data], ( + "reactive fallback did not recover the comp-type-less time-range search" + ) + finally: + if had_key: + features._server_features[key] = saved + else: + features._server_features.pop(key, None) + + def testSearchWithoutCompTypeWithCategory(self): + """Test for https://github.com/python-caldav/caldav/issues/681 + + A property filter (here CATEGORIES) without a component type must work. + Placed directly under the VCALENDAR comp-filter the prop-filter targets + VCALENDAR's own properties, which lack component properties like + CATEGORIES, so servers (Xandikos, SabreDAV, ...) match nothing. The + library works around this by splitting the search into one query per + component type (search.text.comp-type-optional being unsupported). + """ + self.skip_unless_support("search.text.category") + cal = self._fixCalendar() + + category = "issue681cat" + uuid.uuid4().hex[:8] + uid = "issue681cat-" + uuid.uuid4().hex + ical = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//python-caldav//issue681 test//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"DTSTAMP:{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTSTART:{(datetime.now(timezone.utc) + timedelta(days=1)).strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTEND:{(datetime.now(timezone.utc) + timedelta(days=1, hours=1)).strftime('%Y%m%dT%H%M%SZ')}\r\n" + "SUMMARY:issue 681 comp-type-less category search\r\n" + f"CATEGORIES:{category}\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + cal.save_event(ical) + + ## The proactive per-component-type split is the only safe fix here: unlike + ## the time-range case (where SabreDAV returns HTTP 400, which the reactive + ## fallback can catch), servers silently return nothing for a prop-filter + ## under VCALENDAR, so there is no error to recover from. Hence we only + ## verify the default (proactive) behaviour. + objects = cal.search(category=category) + assert [o for o in objects if uid in o.data], ( + "comp-type-less category search did not return the event" + ) + def testTodoCompletion(self): """ Will check that todo-items can be completed and deleted @@ -3530,7 +3778,9 @@ def testUtf8Event(self): c = self._fixCalendar(name="Yølp", cal_id=self.testcal_id) # add event - e1 = c.add_event(ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival")) + e1 = c.add_event( + near_now_ics(ev1).replace("Bastille Day Party", "Bringebærsyltetøyfestival") + ) # fetch it back events = c.get_events() @@ -3555,7 +3805,9 @@ def testUnicodeEvent(self): c = self._fixCalendar(name="Yølp", cal_id=self.testcal_id) # add event - e1 = c.add_event(to_str(ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival"))) + e1 = c.add_event( + to_str(near_now_ics(ev1).replace("Bastille Day Party", "Bringebærsyltetøyfestival")) + ) # c.get_events() should give a full list of events events = c.get_events() @@ -3566,6 +3818,9 @@ def testUnicodeEvent(self): def testSetCalendarProperties(self): self.skip_unless_support("create-calendar.set-displayname") + ## This test expects the fixture's display name ("Yep") and renames the + ## calendar in place; both require the URL to stay put when a name is set. + self.skip_unless_support("create-calendar.set-displayname.stable-url") self.skip_unless_support("delete-calendar") c = self._fixCalendar() @@ -3595,56 +3850,44 @@ def testSetCalendarProperties(self): if not self.is_supported("delete-calendar"): raise - c.set_properties( - [ - dav.DisplayName("hooray"), - ] - ) - props = c.get_properties( - [ - dav.DisplayName(), - ] - ) - assert props[dav.DisplayName.tag] == "hooray" - - ## calendar color and calendar order are extra properties not - ## described by RFC5545, but anyway supported by quite some - ## server implementations - if self.check_compatibility_flag("calendar_color"): - props = c.get_properties( - [ - ical.CalendarColor(), - ] - ) - assert props[ical.CalendarColor.tag] != "sort of blueish" - c.set_properties( - [ - ical.CalendarColor("blue"), - ] - ) - props = c.get_properties( - [ - ical.CalendarColor(), - ] - ) - assert props[ical.CalendarColor.tag] == "blue" - if self.check_compatibility_flag("calendar_order"): - props = c.get_properties( - [ - ical.CalendarOrder(), - ] - ) - assert props[ical.CalendarOrder.tag] != "-434" + try: c.set_properties( [ - ical.CalendarOrder("12"), + dav.DisplayName("hooray"), ] ) props = c.get_properties( [ - ical.CalendarOrder(), + dav.DisplayName(), ] ) + assert props[dav.DisplayName.tag] == "hooray" + finally: + ## Restore the fixture's canonical display name. Under the + ## wipe-calendar cleanup regime the calendar is reused (never + ## deleted) between tests, so a lingering "hooray" would make this + ## test non-idempotent (the next run reads "hooray", not "Yep") and, + ## on servers enforcing per-principal unique calendar names (SOGo), + ## block other calendars from taking the "hooray" name. + try: + c.set_properties([dav.DisplayName("Yep")]) + except error.PropsetError: + pass + + ## calendar color and calendar order are extra properties not + ## described by RFC5545, but anyway supported by quite some server + ## implementations. How they behave is probed in detail by the + ## server-tester (calendar-color / calendar-order); here we just + ## smoke-test that a supported property can be set. Some servers + ## normalise the colour name (e.g. "blue" -> a hex value), so for the + ## colour we only assert that *something* was stored, not the exact value. + if self.is_supported("calendar-color"): + c.set_properties([ical.CalendarColor("blue")]) + props = c.get_properties([ical.CalendarColor()]) + assert props[ical.CalendarColor.tag] + if self.is_supported("calendar-order"): + c.set_properties([ical.CalendarOrder("12")]) + props = c.get_properties([ical.CalendarOrder()]) assert props[ical.CalendarOrder.tag] == "12" def testLookupEvent(self): @@ -3656,8 +3899,9 @@ def testLookupEvent(self): c = self._fixCalendar() assert c.url is not None - # add event - e1 = c.add_event(ev1) + # add event, with a near-now date so it stays visible to REPORT-based + # lookups on sliding-window servers (see near_now_ics()). + e1 = c.add_event(near_now_ics(ev1)) assert e1.url is not None # Verify that we can look it up, both by URL and by ID @@ -3668,7 +3912,15 @@ def testLookupEvent(self): # look up by UID e3 = c.get_event_by_uid("20010712T182145Z-123401@example.com") assert e3.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid - assert e3.url == e1.url + if self.is_supported("save-load.stable-url"): + assert e3.url == e1.url + else: + ## The server reports the object under a different (canonical) URL + ## than the one we stored it at (e.g. OX App Suite). We can't compare + ## URLs, but we can confirm the looked-up URL is a real, fetchable + ## resource holding the same event. + e3.load() + assert e3.icalendar_component["uid"] == e1.icalendar_component["uid"] e4 = Event(client=self.caldav, url=e1.url) e4.load() @@ -3686,17 +3938,21 @@ def testCreateOverwriteDeleteEvent(self): c = self._fixCalendar() assert c.url is not None + ## near-now date so the event stays visible to REPORT-based lookups on + ## sliding-window servers (e.g. OX); see near_now_ics + ev1_now = near_now_ics(ev1) + # attempts on updating/overwriting a non-existing event should fail: with pytest.raises(error.ConsistencyError): - c.add_event(ev1, no_create=True) + c.add_event(ev1_now, no_create=True) # no_create and no_overwrite is mutually exclusive, this will always # raise an error (unless the ical given is blank) with pytest.raises(error.ConsistencyError): - c.add_event(ev1, no_create=True, no_overwrite=True) + c.add_event(ev1_now, no_create=True, no_overwrite=True) # add event - e1 = c.add_event(ev1) + e1 = c.add_event(ev1_now) todo_ok = self.is_supported("save-load.todo.mixed-calendar") if todo_ok: @@ -3706,19 +3962,30 @@ def testCreateOverwriteDeleteEvent(self): assert t1.url is not None if not self.check_compatibility_flag("event_by_url_is_broken"): assert c.event_by_url(e1.url).url == e1.url - assert c.get_event_by_uid(e1.id).url == e1.url + ## get_event_by_uid may return a different (canonical) URL than the PUT + ## URL on servers that don't preserve it (e.g. OX); see save-load.stable-url + e_by_uid = c.get_event_by_uid(e1.id) + if self.is_supported("save-load.stable-url"): + assert e_by_uid.url == e1.url + else: + assert e_by_uid.icalendar_component["uid"] == e1.icalendar_component["uid"] no_create = True ## add same event again. As it has same uid, it should be overwritten - ## (but some calendars may throw a "409 Conflict") - if self.is_supported("save-load.mutable"): - e2 = c.add_event(ev1) + ## (but some calendars may throw a "409 Conflict"). Overwriting via a + ## fresh PUT without an If-Match etag is gated on save-load.mutable.if-match-optional: + ## OX enforces optimistic concurrency and rejects such a PUT with 409 + ## (etag-conditional save() still works, so save-load.mutable stays full). + if self.is_supported("save-load.mutable") and self.is_supported( + "save-load.mutable.if-match-optional" + ): + e2 = c.add_event(ev1_now) if todo_ok: t2 = c.add_todo(todo) ## add same event with "no_create". Should work like a charm. - e2 = c.add_event(ev1, no_create=no_create) + e2 = c.add_event(ev1_now, no_create=no_create) if todo_ok: t2 = c.add_todo(todo, no_create=no_create) @@ -3740,7 +4007,7 @@ def testCreateOverwriteDeleteEvent(self): ## "no_overwrite" should throw a ConsistencyError. with pytest.raises(error.ConsistencyError): - c.add_event(ev1, no_overwrite=True) + c.add_event(ev1_now, no_overwrite=True) if todo_ok: with pytest.raises(error.ConsistencyError): c.add_todo(todo, no_overwrite=True) @@ -3750,15 +4017,13 @@ def testCreateOverwriteDeleteEvent(self): if todo_ok: t1.delete() - if self.check_compatibility_flag("non_existing_raises_other"): - expected_error = error.DAVError - else: - expected_error = error.NotFoundError - # Verify that we can't look it up, both by URL and by ID with pytest.raises(self._notFound()): c.event_by_url(e1.url) - if self.is_supported("save-load.mutable"): + ## e2 only exists if the put-overwrite block above ran + if self.is_supported("save-load.mutable") and self.is_supported( + "save-load.mutable.if-match-optional" + ): with pytest.raises(self._notFound()): c.event_by_url(e2.url) if not self.check_compatibility_flag("event_by_url_is_broken"): @@ -3876,20 +4141,23 @@ def testRecurringDateSearch(self): self.skip_unless_support("search.recurrences.includes-implicit.event") c = self._fixCalendar() - # evr is a yearly event starting at 1997-11-02 + # evr is a yearly event starting at 1997-11-02. We search the next + # future Nov-2 anniversary rather than a fixed historic year, so that + # sliding-window servers (e.g. OX) can serve the time range. + year, narrow_start, narrow_end, wide_end = next_anniversary_windows() e = c.add_event(evr) - ## Without "expand", we should still find it when searching over 2008 ... + ## Without "expand", we should still find it when searching the anniversary with pytest.deprecated_call(): r = c.date_search( - datetime(2008, 11, 1, 17, 00, 00), - datetime(2008, 11, 3, 17, 00, 00), + narrow_start, + narrow_end, expand=False, ) r2 = c.search( event=True, - start=datetime(2008, 11, 1, 17, 00, 00), - end=datetime(2008, 11, 3, 17, 00, 00), + start=narrow_start, + end=narrow_end, expand=False, ) assert len(r) == 1 @@ -3899,46 +4167,46 @@ def testRecurringDateSearch(self): ## legacy method name with pytest.deprecated_call(): r1 = c.date_search( - datetime(2008, 11, 1, 17, 00, 00), - datetime(2008, 11, 3, 17, 00, 00), + narrow_start, + narrow_end, expand=True, ) ## server expansion, with client side fallback r2 = c.search( event=True, - start=datetime(2008, 11, 1, 17, 00, 00), - end=datetime(2008, 11, 3, 17, 00, 00), + start=narrow_start, + end=narrow_end, expand=True, ) ## r3 was client-side expansion, but this is the default now ## server side expansion r4 = c.search( event=True, - start=datetime(2008, 11, 1, 17, 00, 00), - end=datetime(2008, 11, 3, 17, 00, 00), + start=narrow_start, + end=narrow_end, server_expand=True, ) assert len(r1) == 1 assert len(r2) == 1 assert r1[0].data.count("END:VEVENT") == 1 assert r2[0].data.count("END:VEVENT") == 1 - ## due to expandation, the DTSTART should be in 2008 - assert r1[0].data.count("DTSTART;VALUE=DATE:2008") == 1 - assert r2[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + ## due to expandation, the DTSTART should be in the anniversary year + assert r1[0].data.count(f"DTSTART;VALUE=DATE:{year}") == 1 + assert r2[0].data.count(f"DTSTART;VALUE=DATE:{year}") == 1 if self.is_supported("search.recurrences.expanded.event"): - assert r4[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + assert r4[0].data.count(f"DTSTART;VALUE=DATE:{year}") == 1 ## With expand=True and searching over two recurrences ... with pytest.deprecated_call(): r1 = c.date_search( - datetime(2008, 11, 1, 17, 00, 00), - datetime(2009, 11, 3, 17, 00, 00), + narrow_start, + wide_end, expand=True, ) r2 = c.search( event=True, - start=datetime(2008, 11, 1, 17, 00, 00), - end=datetime(2009, 11, 3, 17, 00, 00), + start=narrow_start, + end=wide_end, expand=True, ) @@ -4052,30 +4320,45 @@ def testEditSingleRecurrence(self): cal = self._fixCalendar() + ## Anchor the daily recurring event a few days in the future so servers + ## with a sliding REPORT window / no old-date support (e.g. CCS, ref + ## search.time-range.event.old-dates) can still serve the time ranges. + ## The integer passed to search()/summary_on() is a day offset from this + ## anchor day; the values just need to be distinct future days. + base = (datetime.now() + timedelta(days=2)).replace( + hour=8, minute=7, second=6, microsecond=0 + ) + ## Create a daily recurring event cal.add_event( uid="test1", summary="daily test", - dtstart=datetime(2015, 1, 1, 8, 7, 6), - dtend=datetime(2015, 1, 1, 9, 7, 6), + dtstart=base, + dtend=base + timedelta(hours=1), rrule={"FREQ": "DAILY"}, ) - def search(month): + def day_start(offset): + return (base + timedelta(days=offset)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + def search(offset): """ - Internal function to find one recurrence object + Internal function to find one recurrence object - the occurrence on + the day `offset` days after the event's anchor day. """ recurrence = cal.search( event=True, - start=datetime(2015, month, 1), - end=datetime(2015, month, 2), + start=day_start(offset), + end=day_start(offset) + timedelta(days=1), expand=True, ) assert len(recurrence) == 1 return recurrence[0] - def summary_by_month(month): - return search(month).icalendar_component["summary"] + def summary_on(offset): + return search(offset).icalendar_component["summary"] ## Search for a recurrence recurrence = search(7) @@ -4085,51 +4368,51 @@ def summary_by_month(month): recurrence.save() ## Only one day should be affected - assert summary_by_month(6) == "daily test" - assert summary_by_month(7) == "half a year of daily testing" - assert summary_by_month(8) == "daily test" + assert summary_on(6) == "daily test" + assert summary_on(7) == "half a year of daily testing" + assert summary_on(8) == "daily test" ## let's try to set several recurrence exceptions recurrence = search(2) recurrence.icalendar_component["summary"] = "one month of daily testing" recurrence.save() - assert summary_by_month(1) == "daily test" - assert summary_by_month(2) == "one month of daily testing" - assert summary_by_month(7) == "half a year of daily testing" + assert summary_on(1) == "daily test" + assert summary_on(2) == "one month of daily testing" + assert summary_on(7) == "half a year of daily testing" ## Changing any of the exceptions should also work recurrence = search(7) recurrence.icalendar_component["summary"] = "six months of daily testing" recurrence.save() - assert summary_by_month(7) == "six months of daily testing" + assert summary_on(7) == "six months of daily testing" ## parameter all_recurrences should change all recurrences - - ## except February and July + ## except the two edited exceptions (offsets 2 and 7) recurrence = search(9) recurrence.icalendar_component["summary"] = "daily testing" recurrence.save(all_recurrences=True) - assert summary_by_month(1) == "daily testing" - assert summary_by_month(2) == "one month of daily testing" - assert summary_by_month(3) == "daily testing" - assert summary_by_month(7) == "six months of daily testing" + assert summary_on(1) == "daily testing" + assert summary_on(2) == "one month of daily testing" + assert summary_on(3) == "daily testing" + assert summary_on(7) == "six months of daily testing" ## Last ... let's change the dtend and dtstart of the recurrence recurrence = search(9) recurrence.icalendar_component.pop("dtstart") - recurrence.icalendar_component.add("dtstart", datetime(2015, 9, 1, 8, 0, 0)) + recurrence.icalendar_component.add("dtstart", day_start(9).replace(hour=8)) recurrence.icalendar_component.pop("dtend") - recurrence.icalendar_component.add("dtend", datetime(2015, 9, 1, 10, 0, 0)) + recurrence.icalendar_component.add("dtend", day_start(9).replace(hour=10)) recurrence.save(all_recurrences=True) recurrence = search(8) assert ( recurrence.icalendar_component.start.astimezone() - == datetime(2015, 8, 1, 8, 0, 0).astimezone() + == day_start(8).replace(hour=8).astimezone() ) assert ( recurrence.icalendar_component.end.astimezone() - == datetime(2015, 8, 1, 10, 0, 0).astimezone() + == day_start(8).replace(hour=10).astimezone() ) def testOffsetURL(self): diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 8ae1c68b..1b119cf8 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -1503,6 +1503,85 @@ def testDataAPINoDataState(self): assert event._get_component_type_cheap() is None assert event._has_data() is False + def test_set_data_updates_state_cache(self) -> None: + """§2.9: _set_data (raw string branch) must reset _state so that + get_data()/get_icalendar_instance()/id return the new content. + + Bug: _set_data cleared _data/_vobject_instance/_icalendar_instance + but never updated self._state. Once _state was cached by an earlier + call to _ensure_state() (e.g. via event.id or is_loaded()), all + subsequent reads through the new API served stale content. + """ + from caldav.datastate import RawDataState + + client = DAVClient(url="http://cal.example.com/") + ev2 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:updated-uid@example.com +DTSTAMP:20260101T000000Z +DTSTART:20260601T100000Z +DTEND:20260601T110000Z +SUMMARY:Updated Event +END:VEVENT +END:VCALENDAR +""" + event = Event(client, data=ev1) + + # Prime the state cache — simulates the common scenario where the + # object is accessed before a reload (e.g. event.id or is_loaded()) + assert event.id == "20010712T182145Z-123401@example.com" + assert isinstance(event._state, RawDataState) + + # Simulate what load() does: assign new raw data + event.data = ev2 + + # _state must now reflect the new data + assert isinstance(event._state, RawDataState) + assert event.get_data() == ev2, "get_data() returned stale pre-reload content" + assert event.id == "updated-uid@example.com", "id returned stale UID after reload" + assert "Updated Event" in event.get_data() + + def test_vfreebusy_component_type_detection(self) -> None: + """§2.10: RawDataState.get_component_type() tested for 'BEGIN:FREEBUSY' + but real iCalendar data uses 'BEGIN:VFREEBUSY', so FreeBusy objects + got component_type=None → is_loaded()/has_component() False → save() + silent no-op and load(only_if_unloaded=True) spuriously reloads. + Also fixes get_uid()/get_component_type() in DataState base class + which listed 'FREEBUSY' instead of 'VFREEBUSY' as comp.name. + """ + from caldav.datastate import RawDataState + + freebusy_data = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VFREEBUSY +UID:freebusy@example.com +DTSTAMP:20240101T120000Z +DTSTART:20240601T090000Z +DTEND:20240601T110000Z +FREEBUSY:20240601T090000Z/20240601T100000Z +END:VFREEBUSY +END:VCALENDAR +""" + state = RawDataState(freebusy_data) + assert state.get_component_type() == "VFREEBUSY", ( + "RawDataState.get_component_type() returned None for VFREEBUSY data " + "(was checking for 'BEGIN:FREEBUSY' instead of 'BEGIN:VFREEBUSY')" + ) + assert state.get_uid() == "freebusy@example.com" + + # Also verify via the base class parsers (IcalendarState path) + import icalendar + + from caldav.datastate import IcalendarState + + ical = icalendar.Calendar.from_ical(freebusy_data) + istate = IcalendarState(ical) + assert istate.get_component_type() == "VFREEBUSY" + assert istate.get_uid() == "freebusy@example.com" + def testDataAPIEdgeCases(self): """Test edge cases in the data API (issue #613).""" cal_url = "http://me:hunter2@calendar.example:80/" @@ -1625,6 +1704,31 @@ def testTodoDuration(self): assert "DUE" not in my_todo4.component assert my_todo4.component["duration"].dt == timedelta(2) + def testTodoDurationTimedDtstart(self): + """§2.11: _get_duration must return timedelta(0) for a VTODO with a timed DTSTART + and no DUE/DURATION — not timedelta(days=1). + + isinstance(i["DTSTART"], datetime) tested the vDDDTypes wrapper (always False), + so the date-vs-datetime branch always took the 'is a date' path, returning 1 day. + Fix: test isinstance(i["DTSTART"].dt, datetime) instead. + """ + cal_url = "http://me:hunter2@calendar.example:80/" + client = DAVClient(url=cal_url) + todo_timed_dtstart = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VTODO +UID:timed-dtstart@example.com +DTSTAMP:20240101T000000Z +DTSTART:20240601T100000Z +SUMMARY:Todo with timed DTSTART only +END:VTODO +END:VCALENDAR""" + todo_item = Todo(client, data=todo_timed_dtstart) + assert todo_item.get_duration() == timedelta(0), ( + f"Expected timedelta(0) for timed DTSTART with no DUE, got {todo_item.get_duration()}" + ) + def testURL(self): """Exercising the URL class""" long_url = "http://foo:bar@www.example.com:8080/caldav.php/?foo=bar" @@ -1722,6 +1826,28 @@ def testURL(self): == URL("//www.example.com/bar/").canonical() ) + # 9b) canonical() must strip credentials (§2.5a) + cred_url = URL("https://user:pass@example.com/cal/") + canon = cred_url.canonical() + assert "user" not in str(canon), "canonical() leaked username" + assert "pass" not in str(canon), "canonical() leaked password" + + # 9c) canonical() must not mutate self (§2.5b): + # a URL with no auth — unauth() returns self, canonical() must + # still return a fresh object and leave self unchanged. + plain_url = URL("http://example.com/cal/path/") + before = str(plain_url) + _ = plain_url.canonical() + assert str(plain_url) == before, "canonical() mutated self" + + # 9d) __eq__ calls canonical() — must not mutate self (§2.5b) + # A literal '+' in the path would become '%2B' after quote(unquote()), + # silently changing what resource subsequent requests target. + plus_url = URL("http://example.com/cal/foo+bar/") + before_plus = str(plus_url) + _ = plus_url == URL("http://example.com/cal/foo+bar/") + assert str(plus_url) == before_plus, "__eq__ mutated URL containing '+'" + # 10) pickle assert pickle.loads(pickle.dumps(url1)) == url1 @@ -1781,6 +1907,9 @@ def testExtractAuth(self): "basic", "digest", } + # §1.8: trailing comma (seen in the wild) must not raise IndexError + assert client.extract_auth_types("Basic,") == {"basic"} + assert client.extract_auth_types('Basic realm="x",') == {"basic"} def testAutoUrlEcloudWithEmailUsername(self) -> None: """ @@ -2756,6 +2885,77 @@ def test_rate_limit_max_sleep_stops_adaptive_retries(self, mocked): client.request("/") +class TestAsyncProbeResponseNotReturnedAsReal: + """§2.15: async _async_request: when the probe GET for issue-#158 workaround does + not receive a 401+WWW-Authenticate response, the original exception must be re-raised. + + Before the fix, a probe response with status != 401 (e.g. 200 HTML login page) fell + through to response = DAVResponse(r, self), returning the probe GET response as if + it were the real request's response — status 200 for a PUT that never happened. + """ + + @pytest.mark.asyncio + async def test_probe_200_reraises_original_exception(self): + """If the probe GET returns 200 (not a 401 challenge), the original error must propagate.""" + from unittest.mock import AsyncMock, patch + + from caldav.async_davclient import AsyncDAVClient + + client = AsyncDAVClient(url="http://cal.example.com/", password="secret") + + probe_resp = mock.MagicMock() + probe_resp.status_code = 200 + probe_resp.reason = "OK" + probe_resp.headers = {"Content-Type": "text/html"} + probe_resp.reason_phrase = "OK" + + original_error = ConnectionError("server aborted connection") + + async def mock_request(*args, **kwargs): + if kwargs.get("method") == "GET" and not kwargs.get("auth"): + return probe_resp + raise original_error + + with patch.object(client.session, "request", side_effect=mock_request): + with pytest.raises((ConnectionError, Exception)): + await client._async_request("/some/resource", "PUT", "data", {}) + + +class TestRateLimitNoPlusNone: + """§1.3: rate-limit retry must not raise TypeError when second 429 has no usable Retry-After. + + sleep_seconds += rate_limit_time_slept / 2 executed before the is-None check, + so None += 2.5 raised TypeError instead of the documented RateLimitError. + """ + + def _make_response(self, status_code, headers=None): + r = mock.MagicMock() + r.status_code = status_code + r.headers = headers or {} + r.reason = "Too Many Requests" + return r + + @mock.patch("caldav.davclient.requests.Session.request") + def test_second_429_without_retry_after_raises_rate_limit_error(self, mocked): + """Second 429 with Retry-After: 0 (compute_sleep_seconds → None) must raise + RateLimitError, not TypeError.""" + ok = mock.MagicMock() + ok.status_code = 200 + ok.headers = {} + mocked.side_effect = [ + self._make_response(429, {"Retry-After": "5"}), + self._make_response(429, {"Retry-After": "0"}), # compute_sleep_seconds → None + ] + client = DAVClient( + url="http://cal.example.com/", + rate_limit_handle=True, + rate_limit_default_sleep=None, + ) + with mock.patch("caldav.davclient.time.sleep"): + with pytest.raises(error.RateLimitError): + client.request("/") + + class TestDateToUtcConversion: """ RFC 4791 §9.9: time-range start/end MUST be UTC datetime values. @@ -2897,6 +3097,26 @@ def test_recursive_meta_section(self): } assert set(expand_config_section(config, "all")) == {"a", "b", "c"} + def test_missing_section_returns_empty(self): + """§1.9: expand_config_section(config, "default") when "default" is absent must + return [] rather than raising KeyError.""" + from caldav.config import expand_config_section + + config = {"work": {"caldav_url": "https://work.example.com/"}} + # Requesting a section that doesn't exist should return [] (no match), not crash + assert expand_config_section(config, "default") == [] + + def test_disable_respected_for_named_sections(self): + """§2.17: disable:true must suppress named sections, not just glob '*' results. + + The old code used the literal string 'section' instead of the variable, + so disable was only effective under the '*' glob path. + """ + from caldav.config import expand_config_section + + config = {"work": {"caldav_url": "https://work.example.com/", "disable": True}} + assert expand_config_section(config, "work") == [] + class TestConfigSectionInheritance: """Unit tests for caldav.config.config_section (inherits key).""" @@ -3047,6 +3267,50 @@ def test_calendar_url_extracted_from_section(self, tmp_path): assert len(results) == 1 assert results[0]["calendar_url"] == "/dav/user/mycalendar/" + def test_section_with_features_but_no_url(self, tmp_path): + """A section without caldav_url is usable when it has features — + the client constructor resolves the URL from auto-connect.url hints.""" + import json + + from caldav.config import get_all_file_connection_params + + config = { + "ecloud": { + "caldav_username": "user@e.email", + "caldav_password": "pass", + "features": "ecloud", + } + } + config_file = tmp_path / "calendar.conf" + config_file.write_text(json.dumps(config)) + results = get_all_file_connection_params(str(config_file), "ecloud") + assert len(results) == 1 + assert results[0]["username"] == "user@e.email" + assert results[0]["features"] + + def test_get_connection_params_features_but_no_url(self, tmp_path): + """Same as above, but through get_connection_params — the code path + used by get_davclient(config_section=...).""" + import json + + from caldav.config import get_connection_params + + config = { + "ecloud": { + "caldav_username": "user@e.email", + "caldav_password": "pass", + "features": "ecloud", + } + } + config_file = tmp_path / "calendar.conf" + config_file.write_text(json.dumps(config)) + params = get_connection_params( + config_file=str(config_file), config_section="ecloud", environment=False + ) + assert params is not None + assert params["username"] == "user@e.email" + assert params["features"] + def test_meta_section_returns_multiple_dicts(self, tmp_path): import json @@ -3074,6 +3338,48 @@ def test_meta_section_returns_multiple_dicts(self, tmp_path): } +class TestExplicitParamsMerge: + """§2.18: get_connection_params explicit kwargs must be merged with env/file config. + + The old code only returned explicit_params when 'url' or 'features' was present; + params like password-only were silently discarded when an env/file source was found. + """ + + def test_explicit_password_merged_with_env_url(self, monkeypatch): + """get_connection_params(password='secret') with CALDAV_URL in env must include the password.""" + from caldav.config import get_connection_params + + monkeypatch.setenv("CALDAV_URL", "https://env.example.com/") + monkeypatch.setenv("CALDAV_USERNAME", "envuser") + # Unset file config to avoid config-file interference + monkeypatch.delenv("CALDAV_CONFIG_FILE", raising=False) + result = get_connection_params(password="secret", check_config_file=False) + assert result is not None + assert result.get("password") == "secret" + assert result.get("url") == "https://env.example.com/" + + +class TestResolveFeaturesMutation: + """§2.19: resolve_features and testing.py server classes must deepcopy hint dicts. + + Returning or shallow-copying a module-level dict then mutating a nested key + permanently corrupts the module-level dict for all subsequent users. + """ + + def test_resolve_features_string_returns_independent_copy(self): + """resolve_features('xandikos') must return a deep copy, not the module object.""" + import caldav.compatibility_hints as hints + from caldav.config import resolve_features + + original_domain = hints.xandikos.get("auto-connect.url", {}).get("domain", "") + result = resolve_features("xandikos") + # Mutate the returned copy + if "auto-connect.url" in result and isinstance(result["auto-connect.url"], dict): + result["auto-connect.url"]["domain"] = "MUTATED:9999" + # Original must be unchanged + assert hints.xandikos.get("auto-connect.url", {}).get("domain") == original_domain + + class TestResolveProperties: """Tests for _resolve_properties unbound variable bug (issue #647 / calendar-cli #114).""" @@ -3245,3 +3551,219 @@ def test_change_attendee_status_raises_when_username_not_email(self): ev = self._make_event_with_mock_client("just_a_username") with pytest.raises(caldav_error.NotFoundError): ev.change_attendee_status(partstat="ACCEPTED") + + +class TestAddAttendee: + """§1.6: add_attendee() crashes with UnboundLocalError on uppercase MAILTO: scheme. + + RFC 3986 §3.1 specifies URI schemes are case-insensitive, so "MAILTO:user@example.com" + is valid and common in real-world iCalendar data. The old code only matched lowercase + "mailto:" — uppercase fell through all string branches, leaving attendee_obj unassigned. + """ + + _base_event = """\ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:test-add-attendee@example.com +DTSTAMP:20240101T000000Z +DTSTART:20240601T100000Z +DTEND:20240601T110000Z +SUMMARY:Test event +END:VEVENT +END:VCALENDAR +""" + + def test_add_attendee_uppercase_mailto(self): + """add_attendee('MAILTO:user@example.com') must not raise UnboundLocalError.""" + ev = Event(data=self._base_event) + ev.add_attendee("MAILTO:user@example.com") + attendee = ev.icalendar_component["attendee"] + assert "user@example.com" in str(attendee).lower() + + def test_add_attendee_mixed_case_mailto(self): + """Mixed-case scheme variants like 'Mailto:' must also work.""" + ev = Event(data=self._base_event) + ev.add_attendee("Mailto:user@example.com") + attendee = ev.icalendar_component["attendee"] + assert "user@example.com" in str(attendee).lower() + + +class TestChangeAttendeeStatusNoAttendees: + """§1.7: change_attendee_status() raises bare KeyError when event has no ATTENDEE property. + + ical_obj["attendee"] raises KeyError when the key is absent; the NotFoundError-catching + loop in the Principal branch never sees it, so the "Principal is not invited" message + is unreachable and callers get an unexpected KeyError instead. + + Also: the not-found message contained a literal '%s' placeholder that was never + substituted. + """ + + _event_no_attendees = """\ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:test-no-attendees@example.com +DTSTAMP:20240101T000000Z +DTSTART:20240601T100000Z +DTEND:20240601T110000Z +SUMMARY:Event with no attendees +END:VEVENT +END:VCALENDAR +""" + + def test_change_attendee_status_no_attendees_raises_not_found(self): + """Calling change_attendee_status on an event with no ATTENDEE must raise + NotFoundError, not KeyError.""" + ev = Event(data=self._event_no_attendees) + with pytest.raises(error.NotFoundError): + ev.change_attendee_status("mailto:nobody@example.com", partstat="ACCEPTED") + + def test_change_attendee_status_error_message_contains_attendee(self): + """The not-found error message must contain the attendee address, not a literal '%s'.""" + ev = Event(data=self._event_no_attendees) + with pytest.raises(error.NotFoundError) as exc_info: + ev.change_attendee_status("mailto:nobody@example.com", partstat="ACCEPTED") + assert "%s" not in str(exc_info.value) + assert "nobody@example.com" in str(exc_info.value) + + +class TestFeatureSetCopyFeatureSet: + """§1.10 + §1.11: FeatureSet.copyFeatureSet() correctness bugs. + + §1.10: Merging a plain-string feature over an existing string-valued feature raised + bare AssertionError because the 'support' not in server_node guard prevented the + update branch from running. + + §1.11: An unknown feature name produced a UserWarning but was still stored in + _server_features; a later collapse()/is_supported() then hit a message-less + AssertionError far from the originating config. Unknown features must be skipped + (continue after warning) so bad keys never contaminate the feature set. + """ + + def test_string_feature_can_be_overridden(self): + """copyFeatureSet must accept a string value that overrides an existing string.""" + from caldav.compatibility_hints import FeatureSet + + fs = FeatureSet({"scheduling": "unsupported"}) + fs.copyFeatureSet({"scheduling": "fragile"}) + assert fs.is_supported("scheduling") is False # fragile → False per is_supported semantics + + def test_string_feature_full_override(self): + """Overriding 'unsupported' with 'full' must make is_supported return True.""" + from caldav.compatibility_hints import FeatureSet + + fs = FeatureSet({"scheduling": "unsupported"}) + fs.copyFeatureSet({"scheduling": "full"}) + assert fs.is_supported("scheduling") is True + + def test_unknown_feature_warns_and_does_not_store(self): + """An unknown feature name must emit UserWarning and must NOT be stored.""" + import warnings + + from caldav.compatibility_hints import FeatureSet + + fs = FeatureSet({}) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + fs.copyFeatureSet({"totally_nonexistent_feature_xyz": "full"}) + + assert any("totally_nonexistent_feature_xyz" in str(warning.message) for warning in w) + # The bad key must NOT be in the internal feature dict + assert "totally_nonexistent_feature_xyz" not in fs._server_features + + +class TestXMLEntityHardening: + """§3.2: XML parser must not expand entity references from untrusted server data. + + etree.XMLParser without resolve_entities=False expands inline DOCTYPE + entities, allowing a malicious server to inject arbitrary text into + parsed values. With resolve_entities=False the entity reference is + left as-is (text becomes None for element content). + """ + + def test_xml_entity_not_expanded(self): + """Entity defined in DOCTYPE must NOT be expanded into element text.""" + xml = b""" +]> + + + / + + &xxe; + HTTP/1.1 200 OK + + +""" + resp = MockedDAVResponse(xml) + assert resp.tree is not None + displayname_el = resp.tree.find(".//{DAV:}displayname") + assert displayname_el is not None + assert displayname_el.text != "INJECTED", ( + "XML entity was expanded — resolve_entities=False is missing from the parser" + ) + + +class TestDAVClientCredentialPrecedence: + """§1.2: DAVClient credential handling bugs. + + - URL with username but no password (user@host) crashed with TypeError inside + urllib.parse.unquote(None). + - URL credentials had higher precedence than explicit kwargs; async client was + the opposite (explicit kwargs win). Now sync matches async: explicit kwargs win. + """ + + def test_url_with_user_but_no_password_does_not_crash(self): + """DAVClient(url='https://user@host/', password='p') must not raise TypeError.""" + client = DAVClient(url="https://user@cal.example.com/dav/", password="secret") + assert client.username == "user" + assert client.password == b"secret" + + def test_explicit_kwargs_take_precedence_over_url_credentials(self): + """Explicit username/password kwargs must override credentials embedded in the URL.""" + client = DAVClient( + url="https://urluser:urlpass@cal.example.com/dav/", + username="kwarguser", + password="kwargpass", + ) + assert client.username == "kwarguser" + assert client.password == b"kwargpass" + + +class TestPostPutRedirect: + """§1.1: 302 response to PUT must update event.url from Location header. + + Bug: `[x[1] for x in r.headers if x[0] == "location"]` iterates the + headers dict yielding key *strings*, so x[0] is the first character of + each header name — never "location". The list is always empty and any + 302 raises IndexError. + """ + + @mock.patch("caldav.davclient.requests.Session.request") + def test_302_put_updates_url_from_location_header(self, mocked): + try: + from niquests.structures import CaseInsensitiveDict + except ImportError: + from requests.structures import CaseInsensitiveDict + + new_url = "http://cal.example.com/cal/new-location.ics" + resp = mock.MagicMock() + resp.status_code = 302 + resp.headers = CaseInsensitiveDict({"Location": new_url}) + resp.reason = "Found" + resp.content = b"" + mocked.return_value = resp + + client = DAVClient(url="http://cal.example.com/") + cal = Calendar(client=client, url="http://cal.example.com/cal/") + event = Event( + client=client, + url="http://cal.example.com/cal/event.ics", + data=ev1, + parent=cal, + ) + event.save() + assert str(event.url) == new_url diff --git a/tests/test_compatibility_hints.py b/tests/test_compatibility_hints.py index 3277871f..627f0a4e 100644 --- a/tests/test_compatibility_hints.py +++ b/tests/test_compatibility_hints.py @@ -207,19 +207,27 @@ def test_collapse_parent_already_exists(self) -> None: assert fs._server_features["search.text"] == {"support": "fragile"} def test_collapse_parent_exists_same_value(self) -> None: - """When parent exists with same value as subfeatures, should still collapse""" + """When parent exists with same value as subfeatures, should still collapse. + + Uses a genuine *grouping* parent (principal-search.by-name has no + explicit default); independent parents such as sync-token are + intentionally never collapsed (see + test_collapse_independent_parent_not_collapsed). by-name's parent + principal-search has a second, unset child (list-all), so the collapse + does not cascade further up. + """ fs = FeatureSet() fs._server_features = { - "sync-token": {"support": "unsupported"}, - "sync-token.delete": {"support": "unsupported"}, + "principal-search.by-name": {"support": "unsupported"}, + "principal-search.by-name.self": {"support": "unsupported"}, } fs.collapse() # All have same value, so subfeature should be removed - assert "sync-token.delete" not in fs._server_features - assert fs._server_features["sync-token"] == {"support": "unsupported"} + assert "principal-search.by-name.self" not in fs._server_features + assert fs._server_features["principal-search.by-name"] == {"support": "unsupported"} def test_collapse_empty_featureset(self) -> None: """Collapse should handle empty featureset without errors""" @@ -243,19 +251,19 @@ def test_collapse_no_parent_features(self) -> None: assert fs._server_features == {"sync-token": {"support": "full"}} def test_collapse_single_subfeature(self) -> None: - """Single subfeature should collapse since parent derives from children""" + """Single subfeature should collapse since a grouping parent derives from children""" fs = FeatureSet() - # sync-token only has one subfeature: delete + # principal-search.by-name (a grouping node) only has one subfeature: self fs._server_features = { - "sync-token.delete": {"support": "unsupported"}, + "principal-search.by-name.self": {"support": "unsupported"}, } fs.collapse() # Parent status is derived from the single child, so collapse is valid - assert "sync-token" in fs._server_features - assert "sync-token.delete" not in fs._server_features + assert "principal-search.by-name" in fs._server_features + assert "principal-search.by-name.self" not in fs._server_features def test_collapse_with_complex_dict_values(self) -> None: """Collapse should handle complex dictionary values""" @@ -263,20 +271,20 @@ def test_collapse_with_complex_dict_values(self) -> None: complex_value = { "support": "fragile", - "behaviour": "time-based", + "behaviour": "inconsistent", "extra": "metadata", } fs._server_features = { - "sync-token": complex_value.copy(), - "sync-token.delete": complex_value.copy(), + "principal-search.by-name": complex_value.copy(), + "principal-search.by-name.self": complex_value.copy(), } fs.collapse() # Both have same value, should collapse - assert "sync-token.delete" not in fs._server_features - assert fs._server_features["sync-token"] == complex_value + assert "principal-search.by-name.self" not in fs._server_features + assert fs._server_features["principal-search.by-name"] == complex_value def test_collapse_principal_search_real_scenario(self) -> None: """Test user's real scenario: principal-search subfeatures with same value should collapse""" @@ -302,140 +310,40 @@ def test_collapse_principal_search_real_scenario(self) -> None: assert "principal-search.list-all" not in fs._server_features assert "principal-search" in fs._server_features - def test_independent_subfeature_not_derived(self) -> None: - """Test that independent subfeatures (with explicit defaults) don't affect parent derivation""" - fs = FeatureSet() - - # Scenario: create-calendar.auto is set to unsupported, but it's an independent - # feature (has explicit default) and should NOT cause create-calendar to be - # derived as unsupported - fs._server_features = { - "create-calendar.auto": {"support": "unsupported"}, - } - - # create-calendar should return its default (full), NOT derive from .auto - result = fs.is_supported("create-calendar", return_type=dict) - assert result == {"support": "full"}, ( - f"create-calendar should default to 'full' when only independent " - f"subfeature .auto is set, but got {result}" - ) - - # Verify that the independent subfeature itself is still accessible - auto_result = fs.is_supported("create-calendar.auto", return_type=dict) - assert auto_result == {"support": "unsupported"} + def test_collapse_independent_parent_not_collapsed(self) -> None: + """An independent parent (one with its own explicit default) is never + folded away by its children. - def test_parent_default_not_overridden_by_subfeature_derivation(self) -> None: - """Test that a parent with an explicit default is not overridden by subfeature derivation. - - Zimbra scenario: create-calendar.set-displayname is unsupported, but - create-calendar has an explicit default of 'full'. The parent feature - represents an independent capability (calendar creation works), so the - subfeature status should not override the default. + sync-token carries a default, so even when its only child + sync-token.delete is unsupported the parent keeps its own (separately + probed) status: the two represent distinct capabilities and must not be + conflated. """ fs = FeatureSet() fs._server_features = { - "create-calendar.set-displayname": {"support": "unsupported"}, + "sync-token": {"support": "full"}, + "sync-token.delete": {"support": "unsupported"}, } - # create-calendar should return its default (full), NOT derive unsupported - # from .set-displayname - result = fs.is_supported("create-calendar", return_type=dict) - assert result == {"support": "full"}, ( - f"create-calendar should default to 'full' even when " - f".set-displayname is unsupported, but got {result}" - ) - - def test_hierarchical_vs_independent_subfeatures(self) -> None: - """Test that hierarchical subfeatures derive parent, but independent ones don't""" - fs = FeatureSet() - - # Hierarchical subfeatures: principal-search.by-name and principal-search.list-all - # These should cause parent to derive to "unknown" when mixed - fs.set_feature("principal-search.by-name", {"support": "unknown"}) - fs.set_feature("principal-search.list-all", {"support": "unsupported"}) - - # Should derive to "unknown" due to mixed hierarchical subfeatures - result = fs.is_supported("principal-search", return_type=dict) - assert result == {"support": "unknown"}, ( - f"principal-search should derive to 'unknown' from mixed hierarchical " - f"subfeatures, but got {result}" - ) - - # Now test independent subfeature: create-calendar.auto - # This should NOT affect create-calendar parent - fs2 = FeatureSet() - fs2.set_feature("create-calendar.auto", {"support": "unsupported"}) - - # Should return default, NOT derive from independent subfeature - result2 = fs2.is_supported("create-calendar", return_type=dict) - assert result2 == {"support": "full"}, ( - f"create-calendar should default to 'full' ignoring independent " - f"subfeature .auto, but got {result2}" - ) - - def test_intermediate_feature_derives_from_children(self) -> None: - """Test that intermediate features (e.g. search.text) derive status from their children""" - # search.text has 4 direct children: case-sensitive, case-insensitive, - # substring, category (none have explicit defaults) - - # All children set with mixed statuses -> derive "unknown" - fs = FeatureSet( - { - "search.text.case-sensitive": {"support": "unsupported"}, - "search.text.case-insensitive": {"support": "unsupported"}, - "search.text.substring": {"support": "unsupported"}, - "search.text.category": {"support": "fragile"}, - } - ) - assert not fs.is_supported("search.text") - assert fs.is_supported("search.text", return_type=dict) == {"support": "unknown"} - - # Partial children set with mixed non-positive statuses -> inconclusive, - # falls back to default ("full") - fs1b = FeatureSet( - { - "search.text.case-sensitive": {"support": "unsupported"}, - "search.text.category.substring": {"support": "fragile"}, - } - ) - assert fs1b.is_supported("search.text") - - # All children unsupported -> parent derives as "unsupported" - fs2 = FeatureSet( - { - "search.text.case-sensitive": {"support": "unsupported"}, - "search.text.case-insensitive": {"support": "unsupported"}, - "search.text.substring": {"support": "unsupported"}, - "search.text.category": {"support": "unsupported"}, - } - ) - assert not fs2.is_supported("search.text") - assert fs2.is_supported("search.text", return_type=dict) == {"support": "unsupported"} - - # No children set -> falls back to default ("full") - fs3 = FeatureSet({}) - assert fs3.is_supported("search.text") - - # Explicit parent value takes precedence over children - fs4 = FeatureSet( - { - "search.text": {"support": "full"}, - "search.text.case-sensitive": {"support": "unsupported"}, - } - ) - assert fs4.is_supported("search.text") + fs.collapse() + assert fs._server_features["sync-token"] == {"support": "full"} + assert fs._server_features["sync-token.delete"] == {"support": "unsupported"} -class TestDeriveFromSubfeatures: - """Test _derive_from_subfeatures with partial and complete subfeature configs. - Uses search.recurrences which has two relevant children without defaults: - - search.recurrences.expanded - - search.recurrences.includes-implicit +class TestImplicitDerivation: + """Test is_supported() implicit derivation: parent→child, child→parent, explicit defaults. - The default for search.recurrences (a server-feature) is {"support": "full"}. + Covers: + - Children without explicit defaults derive the parent value. + - Parent set explicitly propagates down to unset children. + - Features with explicit defaults ignore subfeature derivation. + - Partial/incomplete child sets fall through to the feature's default. """ + ## TODO: the tests covering "all children" may need to be + ## protected against future additions in compatibility_hints.py + @pytest.mark.parametrize( "scenario, config, query, expected_support", [ @@ -448,6 +356,22 @@ class TestDeriveFromSubfeatures: "search.recurrences", "unsupported", ), + ( + "parent_unsupported", + { + "save-load": {"support": "unsupported"}, + }, + "save-load.event", + "unsupported", + ), + ( + "parent_with_explicit_default_unsupported", + { + "create-calendar": {"support": "unsupported"}, + }, + "create-calendar.auto", + "unsupported", + ), ( "all_children_supported", { @@ -482,6 +406,16 @@ class TestDeriveFromSubfeatures: "search.recurrences", "full", # any positive support → derive as supported ), + ( + ## Earlier logic had it that if a node has only one child, the parent should not be affected by the child, but if there are more children and all are unsupported, the parent is automatically flipped to unsupported. However, this special case logic should have been rendered obsolete by the new logic that every node having an explicit default is considered independent + "independent_feature_always_trumps", + { + "save-load.mutable.attendee-partstat": {"support": "unsupported"}, + "save-load.mutable.if-match-optional": {"support": "unsupported"}, + }, + "save-load.mutable", + "full", + ), ( "gmx_partial_unsupported_query_unset_sibling_child", { @@ -507,6 +441,33 @@ class TestDeriveFromSubfeatures: "search.recurrences.includes-implicit.todo", "full", ), + ( + "mixed_children_incomplete_unset_sibling_falls_to_default", + { + "save-load.todo": {"support": "full"}, + "save-load.journal": {"support": "unsupported"}, + }, + "save-load.event", + "full", # incomplete set: cannot derive anything about unset siblings + ), + ( + "explicit_default_overrides_children", + { + "create-calendar.auto": {"support": "unsupported"}, + "create-calendar.set-displayname": {"support": "unsupported"}, + }, + "create-calendar", + "full", # this feature does not depend on the sub-features + ), + ( + "partial_mixed_children_query_parent_falls_to_default", + { + "search.text.case-sensitive": {"support": "unsupported"}, + "search.text.case-insensitive": {"support": "full"}, + }, + "search.text", + "full", # partial+mixed: cannot conclude unsupported; default applies + ), ], ids=lambda x: x if isinstance(x, str) and "_" in x else "", ) diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 00000000..23bf4274 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +""" +Unit tests for caldav.discovery — RFC 6764 service discovery. + +No network communication; DNS and HTTP are mocked. +""" + +from unittest import mock + +import pytest + +from caldav.discovery import ServiceInfo, _well_known_lookup, discover_service + + +def _make_redirect_response(location: str, status_code: int = 302): + """Return a minimal mock HTTP response that redirects to *location*.""" + resp = mock.MagicMock() + resp.status_code = status_code + resp.headers = {"Location": location} + return resp + + +def _make_ok_response(): + resp = mock.MagicMock() + resp.status_code = 200 + resp.headers = {} + return resp + + +class TestRequireTLSDowngradeBlocked: + """§3.1: require_tls=True must be enforced on the well-known redirect target. + + _well_known_lookup always probes https:// but never received require_tls, + so a same-domain redirect to http:// passed the domain-validation check and + was returned as ServiceInfo(tls=False). discover_service returned it + unchecked — a misconfigured or MITM server could silently downgrade TLS. + """ + + @mock.patch("caldav.discovery.requests.get") + @mock.patch("caldav.discovery._srv_lookup", return_value=[]) + def test_http_redirect_rejected_when_require_tls(self, _srv, mock_get): + """discover_service(require_tls=True) must return None when the + well-known URI redirects to a plain-HTTP URL.""" + mock_get.return_value = _make_redirect_response( + "http://example.com/caldav/" # same domain, but HTTP + ) + + result = discover_service("example.com", require_tls=True) + + assert result is None, f"Expected None (TLS downgrade rejected), got {result}" + + @mock.patch("caldav.discovery.requests.get") + @mock.patch("caldav.discovery._srv_lookup", return_value=[]) + def test_http_redirect_accepted_when_require_tls_false(self, _srv, mock_get): + """discover_service(require_tls=False) must accept an HTTP redirect.""" + mock_get.return_value = _make_redirect_response("http://example.com/caldav/") + + result = discover_service("example.com", require_tls=False) + + assert result is not None + assert result.tls is False + assert result.url == "http://example.com/caldav/" + + @mock.patch("caldav.discovery.requests.get") + @mock.patch("caldav.discovery._srv_lookup", return_value=[]) + def test_https_redirect_accepted_when_require_tls(self, _srv, mock_get): + """HTTPS redirect is always accepted regardless of require_tls.""" + mock_get.return_value = _make_redirect_response("https://caldav.example.com/dav/") + + result = discover_service("example.com", require_tls=True) + + assert result is not None + assert result.tls is True + assert "caldav.example.com" in result.url + + @mock.patch("caldav.discovery.requests.get") + @mock.patch("caldav.discovery._srv_lookup", return_value=[]) + def test_cross_domain_http_redirect_also_rejected(self, _srv, mock_get): + """A cross-domain HTTP redirect must be rejected (domain check fires first, + but require_tls must also be a backstop).""" + mock_get.return_value = _make_redirect_response("http://evil.attacker.com/caldav/") + + result = discover_service("example.com", require_tls=True) + + assert result is None diff --git a/tests/test_jmap_integration.py b/tests/test_jmap_integration.py index 02f04fd3..255d249e 100644 --- a/tests/test_jmap_integration.py +++ b/tests/test_jmap_integration.py @@ -220,7 +220,7 @@ def test_event_sync(self, client, calendar_id): token_before = client.get_sync_token() event_id = client.create_event(calendar_id, _minimal_ical("Sync Test Event")) try: - added, _modified, _deleted = client.get_objects_by_sync_token(token_before) + added, _modified, _deleted, _new_token = client.get_objects_by_sync_token(token_before) assert any("Sync Test Event" in jscal_to_ical(a.get_data()) for a in added) finally: client.delete_event(event_id) @@ -280,7 +280,9 @@ async def test_event_sync(self, async_client, async_calendar_id): async_calendar_id, _minimal_ical("Async Sync Test Event") ) try: - added, _modified, _deleted = await async_client.get_objects_by_sync_token(token_before) + added, _modified, _deleted, _new_token = await async_client.get_objects_by_sync_token( + token_before + ) assert any("Async Sync Test Event" in jscal_to_ical(a.get_data()) for a in added) finally: await async_client.delete_event(event_id) diff --git a/tests/test_jmap_unit.py b/tests/test_jmap_unit.py index 4b7a6dc6..0efb9d28 100644 --- a/tests/test_jmap_unit.py +++ b/tests/test_jmap_unit.py @@ -207,6 +207,8 @@ def test_picks_first_calendar_capable_account(self): assert session.account_id == "user_calendar" +from datetime import datetime, timezone + from caldav.jmap.objects.calendar import JMAPCalendar from caldav.jmap.objects.calendar_object import JMAPCalendarObject @@ -372,6 +374,26 @@ def test_calendar_search_with_date_range(self, monkeypatch): assert query_args["filter"]["after"] == "2026-01-01T00:00:00" assert query_args["filter"]["before"] == "2026-12-31T23:59:59" + def test_calendar_search_datetime_converted_to_utcdate(self, monkeypatch): + """§4.6: datetime.isoformat() produced wrong format for JMAP UTCDate. + Naive datetimes produce no Z, aware non-UTC produce +HH:MM offset; + JMAP requires ...Z (UTC, no microseconds).""" + import datetime as _dt + + resp = self._query_get_response([self._RAW_EVENT]) + cal, captured = self._capturing_calendar(monkeypatch, resp) + tz_plus2 = _dt.timezone(_dt.timedelta(hours=2)) + start_aware = datetime(2026, 6, 1, 12, 0, 0, tzinfo=tz_plus2) # +02:00 noon → UTC 10:00 + end_utc = datetime(2026, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + cal.search(start=start_aware, end=end_utc) + query_args = captured["json"]["methodCalls"][0][1] + assert query_args["filter"]["after"] == "2026-06-01T10:00:00Z", ( + f"Expected UTC Z-format, got {query_args['filter']['after']!r}" + ) + assert query_args["filter"]["before"] == "2026-12-31T23:59:59Z", ( + f"Expected UTC Z-format, got {query_args['filter']['before']!r}" + ) + def test_calendar_search_ignores_unknown_params(self, monkeypatch): """Verify that unknown search parameters are silently ignored.""" resp = self._query_get_response([self._RAW_EVENT]) @@ -899,7 +921,7 @@ def test_parse_event_set_partial_failure(self): assert not_destroyed["ev-old"]["type"] == "notFound" -from datetime import date, datetime, timedelta, timezone +from datetime import date, timedelta import icalendar as _icalendar @@ -968,8 +990,9 @@ def test_duration_round_trip(self): assert _duration_to_timedelta(_timedelta_to_duration(td)) == td def test_format_local_dt_utc(self): + # RFC 8984: LocalDateTime slots (override keys, RRULE until) must not carry Z suffix. dt = datetime(2024, 6, 15, 9, 0, 0, tzinfo=timezone.utc) - assert _format_local_dt(dt) == "2024-06-15T09:00:00Z" + assert _format_local_dt(dt) == "2024-06-15T09:00:00" def test_format_local_dt_naive(self): dt = datetime(2024, 6, 15, 9, 0, 0) @@ -1604,6 +1627,37 @@ def test_update_event_drops_uid_from_patch(self, monkeypatch): patch = update_args["update"]["ev1"] assert "uid" not in patch + def test_update_event_nulls_removed_optional_properties(self, monkeypatch): + # RFC 8620 §3.3: absent keys in a PatchObject preserve the server value. + # To actually delete a property the patch must set it to null. + # An ical → jscal conversion that omits LOCATION/DESCRIPTION must send + # {"locations": null, "description": null, ...} so the server removes them. + _ICAL_WITH_LOCATION = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "UID:loc-uid@example.com\r\n" + "DTSTART:20240615T090000Z\r\n" + "SUMMARY:Event with Location\r\n" + "LOCATION:Old Conference Room\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + _ICAL_WITHOUT_LOCATION = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "UID:loc-uid@example.com\r\n" + "DTSTART:20240615T090000Z\r\n" + "SUMMARY:Event without Location\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + resp = self._set_response(updated={"ev1": None}) + client, captured = self._capturing_client(monkeypatch, resp) + # First, pretend the event had a location (we don't need to call create; just update) + client.update_event("ev1", _ICAL_WITHOUT_LOCATION) + patch = captured["json"]["methodCalls"][0][1]["update"]["ev1"] + # The patch must contain explicit null for 'locations' to remove it from the server + assert "locations" in patch + assert patch["locations"] is None + def test_delete_event_success(self, monkeypatch): resp = self._set_response(destroyed=["ev1"]) client = _make_client_with_mocked_session(monkeypatch, resp) @@ -1777,7 +1831,7 @@ def test_get_objects_no_changes(self, monkeypatch): monkeypatch.setattr( "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) ) - added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = self._make_client().get_objects_by_sync_token("state-1") assert added == [] and modified == [] and deleted == [] def test_get_objects_deleted_returns_ids(self, monkeypatch): @@ -1785,7 +1839,7 @@ def test_get_objects_deleted_returns_ids(self, monkeypatch): monkeypatch.setattr( "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) ) - added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = self._make_client().get_objects_by_sync_token("state-1") assert deleted == ["ev1"] and added == [] and modified == [] def test_get_objects_added_returns_ical(self, monkeypatch): @@ -1795,7 +1849,7 @@ def test_get_objects_added_returns_ical(self, monkeypatch): side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)] ) monkeypatch.setattr("caldav.jmap.client.requests.post", mock_post) - added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = self._make_client().get_objects_by_sync_token("state-1") assert len(added) == 1 assert isinstance(added[0], JMAPCalendarObject) assert added[0].id == "ev1" @@ -1808,7 +1862,7 @@ def test_get_objects_modified_returns_ical(self, monkeypatch): side_effect=[self._make_mock(changes_resp), self._make_mock(get_resp)] ) monkeypatch.setattr("caldav.jmap.client.requests.post", mock_post) - added, modified, deleted = self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = self._make_client().get_objects_by_sync_token("state-1") assert len(modified) == 1 assert isinstance(modified[0], JMAPCalendarObject) assert modified[0].id == "ev1" @@ -1823,6 +1877,20 @@ def test_get_objects_has_more_raises(self, monkeypatch): self._make_client().get_objects_by_sync_token("state-1") assert exc_info.value.error_type == "serverPartialFail" + def test_get_objects_returns_new_sync_token(self, monkeypatch): + """§4.7: newState from /changes was discarded into _. Callers had no + way to chain sync calls without a separate get_sync_token() round-trip, + creating a race window where intervening changes would be silently missed.""" + resp = self._changes_resp(new_state="state-99") + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + result = self._make_client().get_objects_by_sync_token("state-1") + assert len(result) == 4, "expected 4-tuple (added, modified, deleted, new_sync_token)" + added, modified, deleted, new_token = result + assert new_token == "state-99" + assert added == [] and modified == [] and deleted == [] + def test_parse_event_changes_all_fields(self): resp_args = { "oldState": "s1", @@ -2009,6 +2077,16 @@ def test_create_task_raises_on_failure(self, monkeypatch): self._make_client().create_task("tl1", "Test") assert exc_info.value.error_type == "invalidArguments" + def test_create_task_raises_jmap_error_when_created_is_empty(self, monkeypatch): + """§1.13: create_task must raise JMAPMethodError (not KeyError) when the server + returns a Task/set response with an empty 'created' dict and no 'notCreated' entry.""" + resp = self._set_response(created={}, notCreated={}) + monkeypatch.setattr( + "caldav.jmap.client.requests.post", lambda *a, **kw: self._make_mock(resp) + ) + with pytest.raises(JMAPMethodError): + self._make_client().create_task("tl1", "Test") + def test_get_task_returns_task_object(self, monkeypatch): resp = self._get_response([self._MINIMAL_TASK]) monkeypatch.setattr( @@ -2327,7 +2405,7 @@ async def test_get_objects_no_changes(self, monkeypatch): mock_http.__aexit__ = AsyncMock(return_value=None) mock_http.post = AsyncMock(return_value=self._make_mock_response(self._changes_resp())) monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") assert added == [] and modified == [] and deleted == [] @pytest.mark.asyncio @@ -2339,7 +2417,7 @@ async def test_get_objects_deleted_returns_ids(self, monkeypatch): return_value=self._make_mock_response(self._changes_resp(destroyed=["ev1"])) ) monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") assert deleted == ["ev1"] and added == [] and modified == [] @pytest.mark.asyncio @@ -2354,7 +2432,7 @@ async def test_get_objects_added_returns_ical(self, monkeypatch): ] ) monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") assert len(added) == 1 assert isinstance(added[0], JMAPCalendarObject) assert added[0].id == "ev-async-1" @@ -2534,7 +2612,7 @@ async def test_get_objects_modified_returns_ical(self, monkeypatch): ] ) monkeypatch.setattr("caldav.jmap.async_client.AsyncSession", lambda: mock_http) - added, modified, deleted = await self._make_client().get_objects_by_sync_token("state-1") + added, modified, deleted, _ = await self._make_client().get_objects_by_sync_token("state-1") assert len(modified) == 1 assert isinstance(modified[0], JMAPCalendarObject) assert modified[0].id == "ev-async-1" @@ -2548,3 +2626,126 @@ async def test_create_task_passes_task_list_id(self, monkeypatch): create_args = captured["json"]["methodCalls"][0][1] new_task = create_args["create"]["new-0"] assert new_task["taskListId"] == "tl-target" + + +class TestOverrideWithoutStartUsesOccurrenceTime: + """§4.1: override child VEVENT must use occurrence time as DTSTART, not master start.""" + + def test_title_only_override_dtstart_equals_occurrence(self): + # Master: 2024-06-17T09:00:00Z (UTC), weekly recurrence. + # Override for 2024-06-24T09:00:00Z changes only title — no "start" in patch. + # Child DTSTART must be 20240624T090000Z, not 20240617T090000Z. + jscal = { + "uid": "override-dtstart@example.com", + "title": "Master Title", + "start": "2024-06-17T09:00:00Z", + "duration": "PT1H", + "recurrenceRules": [{"@type": "RecurrenceRule", "frequency": "weekly"}], + "recurrenceOverrides": { + "2024-06-24T09:00:00Z": {"title": "Changed Title"}, + }, + } + result = jscal_to_ical(jscal) + import icalendar as _ic + + cal = _ic.Calendar.from_ical(result) + events = [c for c in cal.subcomponents if isinstance(c, _ic.Event)] + assert len(events) == 2 + child = next(e for e in events if e.get("RECURRENCE-ID") is not None) + # DTSTART of the child must match its own occurrence, not the master start + child_dtstart = child["DTSTART"].dt + if hasattr(child_dtstart, "utctimetuple"): + import datetime as _dt + + assert child_dtstart == _dt.datetime(2024, 6, 24, 9, 0, 0, tzinfo=_dt.timezone.utc) + else: + assert str(child_dtstart) == "2024-06-24" + + +class TestExdateValueType: + """§4.2: EXDATE value type must match DTSTART (TZID or DATE, not floating).""" + + def test_exdate_for_tzid_event_has_tzid_param(self): + # A TZID-anchored event's excluded override must produce EXDATE with TZID, + # not a floating EXDATE (which per RFC 5545 won't match the instance). + jscal = _minimal_jscal( + start="2024-06-17T14:00:00", + timeZone="Europe/Berlin", + recurrenceRules=[{"@type": "RecurrenceRule", "frequency": "weekly"}], + recurrenceOverrides={"2024-06-24T14:00:00": {"excluded": True}}, + ) + result = jscal_to_ical(jscal) + # Must have TZID on EXDATE; a plain EXDATE:... without TZID is a floating datetime + assert "EXDATE;TZID=Europe/Berlin:" in result + + def test_exdate_for_allday_event_is_date_value(self): + jscal = { + "uid": "allday-exdate@example.com", + "title": "All Day Recurring", + "start": "2024-06-17T00:00:00", + "showWithoutTime": True, + "duration": "P1D", + "recurrenceRules": [{"@type": "RecurrenceRule", "frequency": "weekly"}], + "recurrenceOverrides": {"2024-06-24T00:00:00": {"excluded": True}}, + } + result = jscal_to_ical(jscal) + # All-day EXDATE must be a DATE value (8-digit YYYYMMDD, not YYYYMMDDTHHMMSS datetime). + # The icalendar library may or may not emit explicit VALUE=DATE — either form is acceptable. + assert "EXDATE" in result + assert "20240624" in result + assert "20240624T" not in result # must not be a datetime + + +class TestStatusMapping: + """§4.4: STATUS must be mapped in both ical→jscal and jscal→ical directions.""" + + def test_ical_status_cancelled_to_jscal(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Cancelled Meeting\r\nSTATUS:CANCELLED\r\n" + ) + result = ical_to_jscal(ical) + assert result.get("status") == "cancelled" + + def test_ical_status_tentative_to_jscal(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Tentative Meeting\r\nSTATUS:TENTATIVE\r\n" + ) + result = ical_to_jscal(ical) + assert result.get("status") == "tentative" + + def test_ical_status_confirmed_to_jscal(self): + ical = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Confirmed Meeting\r\nSTATUS:CONFIRMED\r\n" + ) + result = ical_to_jscal(ical) + assert result.get("status") == "confirmed" + + def test_ical_no_status_omits_jscal_status(self): + ical = _make_ical("DTSTART:20240615T100000Z\r\nSUMMARY:No Status\r\n") + result = ical_to_jscal(ical) + assert "status" not in result + + def test_jscal_status_cancelled_to_ical(self): + result = jscal_to_ical(_minimal_jscal(status="cancelled")) + assert "STATUS:CANCELLED" in result + + def test_jscal_status_tentative_to_ical(self): + result = jscal_to_ical(_minimal_jscal(status="tentative")) + assert "STATUS:TENTATIVE" in result + + def test_jscal_status_confirmed_to_ical(self): + result = jscal_to_ical(_minimal_jscal(status="confirmed")) + assert "STATUS:CONFIRMED" in result + + def test_jscal_no_status_omits_ical_status(self): + result = jscal_to_ical(_minimal_jscal()) + assert "STATUS:" not in result + + def test_status_cancelled_round_trips(self): + original = _make_ical( + "DTSTART:20240615T100000Z\r\nSUMMARY:Cancelled\r\nSTATUS:CANCELLED\r\n" + ) + jscal = ical_to_jscal(original) + assert jscal.get("status") == "cancelled" + round_tripped = jscal_to_ical(jscal) + assert "STATUS:CANCELLED" in round_tripped diff --git a/tests/test_search.py b/tests/test_search.py index 1dc7a3f2..a9af1d30 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -16,6 +16,7 @@ from caldav import Event, Journal, Todo from caldav.davclient import DAVClient +from caldav.lib import error from caldav.lib.url import URL from caldav.search import CalDAVSearcher @@ -140,6 +141,31 @@ END:VEVENT END:VCALENDAR""" +# Two events for §2.6 combined-is-logical-and tests +SPECIAL_EVENT = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:special-event@example.com +DTSTAMP:20240101T120000Z +DTSTART:20240615T140000Z +DTEND:20240615T150000Z +SUMMARY:My Special Event +END:VEVENT +END:VCALENDAR""" + +UNRELATED_EVENT = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:unrelated-event@example.com +DTSTAMP:20240101T120000Z +DTSTART:20240615T160000Z +DTEND:20240615T170000Z +SUMMARY:Unrelated Meeting +END:VEVENT +END:VCALENDAR""" + @pytest.fixture def mock_client() -> DAVClient: @@ -867,3 +893,381 @@ def mock_is_supported(feat, type_=bool): assert result == [event] calendar._request_report_build_resultlist.assert_called_once_with(full_xml, None, None) + + +class TestCompTypeOptionalTimeRange: + """Regression tests for https://github.com/python-caldav/caldav/issues/681. + + A CALDAV:time-range filter is only valid inside a comp-filter for + VEVENT/VTODO/VJOURNAL/VFREEBUSY/VALARM (RFC 4791 section 9.7), never + directly under the VCALENDAR comp-filter. When no component type is + specified, the library must NOT emit a under VCALENDAR - + it must split the search into one query per component type instead. + + SabreDAV-based servers (Baikal, Nextcloud, ...) reject the illegal query + with HTTP 400 "You cannot add time-range filters on the VCALENDAR + component". + """ + + _NS = {"C": "urn:ietf:params:xml:ns:caldav"} + + def _vcalendar_timerange_children(self, xml): + """Return any elements that are direct children of the + VCALENDAR comp-filter (i.e. the RFC-illegal placement).""" + from lxml import etree + + x = xml.xmlelement() if hasattr(xml, "xmlelement") else None + if x is None: + return [] + return x.xpath('//C:comp-filter[@name="VCALENDAR"]/C:time-range', namespaces=self._NS) + + def test_untyped_timerange_search_splits_per_comptype( + self, mock_client: DAVClient, mock_url: str + ) -> None: + """Backward-compat mode: an untyped time-range search must split into + per-component queries rather than placing under VCALENDAR.""" + from caldav.compatibility_hints import FeatureSet + + mock_client.features = FeatureSet(None) # default / backward-compat (what end users get) + + calls = [] + calendar = mock.Mock() + calendar.client = mock_client + + def rep(xml, comp_cls, props=None): + calls.append(xml) + return (mock.Mock(), []) + + calendar._request_report_build_resultlist.side_effect = rep + + searcher = CalDAVSearcher( + start=datetime(2024, 1, 1, tzinfo=timezone.utc), + end=datetime(2024, 2, 1, tzinfo=timezone.utc), + ) + searcher.search(calendar) + + assert calls, "no REPORT was issued" + for xml in calls: + assert not self._vcalendar_timerange_children(xml), ( + "time-range must not be placed directly under VCALENDAR" + ) + ## split into one query per component type (VEVENT/VTODO/VJOURNAL) + assert len(calls) == 3 + + def test_reactive_workaround_on_vcalendar_timerange_rejection( + self, mock_client: DAVClient, mock_url: str + ) -> None: + """If the feature is (mis)configured as supported and the server rejects + the comp-type-less time-range query with a 400, the library must retry + by splitting into per-component queries.""" + from caldav.compatibility_hints import FeatureSet + from caldav.lib import error + + ## Feature explicitly configured as supported, so the library optimistically + ## sends the comp-type-less time-range query that SabreDAV rejects. + mock_client.features = FeatureSet( + {"search.time-range.comp-type-optional": {"support": "full"}} + ) + + event = Event(client=mock_client, url=mock_url, data=SIMPLE_EVENT) + calendar = mock.Mock() + calendar.client = mock_client + + def rep(xml, comp_cls, props=None): + if self._vcalendar_timerange_children(xml): + raise error.ReportError( + "400 Bad Request - You cannot add time-range filters on the VCALENDAR component" + ) + return (mock.Mock(), [event] if comp_cls is Event else []) + + calendar._request_report_build_resultlist.side_effect = rep + + searcher = CalDAVSearcher( + start=datetime(2024, 1, 1, tzinfo=timezone.utc), + end=datetime(2024, 2, 1, tzinfo=timezone.utc), + ) + result = searcher.search(calendar) + + assert result == [event] + + def test_compatibility_workarounds_false_sends_raw_query( + self, mock_client: DAVClient, mock_url: str + ) -> None: + """compatibility_workarounds=False must disable the comp-type split and send + the comp-type-less time-range query verbatim (single REPORT), so the + compatibility checker can observe the raw server behaviour.""" + from caldav.compatibility_hints import FeatureSet + + mock_client.features = FeatureSet(None) + + calls = [] + calendar = mock.Mock() + calendar.client = mock_client + + def rep(xml, comp_cls, props=None): + calls.append(xml) + return (mock.Mock(), []) + + calendar._request_report_build_resultlist.side_effect = rep + + searcher = CalDAVSearcher( + start=datetime(2024, 1, 1, tzinfo=timezone.utc), + end=datetime(2024, 2, 1, tzinfo=timezone.utc), + ) + searcher.search(calendar, post_filter=False, compatibility_workarounds=False) + + ## exactly one report, sent verbatim with the (RFC-questionable) time-range + ## directly under VCALENDAR - no splitting + assert len(calls) == 1 + assert self._vcalendar_timerange_children(calls[0]) + + +class TestCompTypeOptionalPropFilter: + """Regression tests for https://github.com/python-caldav/caldav/issues/681. + + A CALDAV:prop-filter (CATEGORIES, SUMMARY, ...) placed directly under the + VCALENDAR comp-filter filters on VCALENDAR's own properties, which do not + include component properties like CATEGORIES. Servers (e.g. Xandikos, and + SabreDAV-based servers) therefore match nothing. When no component type is + specified, the library must split the search into one query per component + type so the prop-filter lands inside a VEVENT/VTODO/VJOURNAL comp-filter. + """ + + _NS = {"C": "urn:ietf:params:xml:ns:caldav"} + + def _vcalendar_propfilter_children(self, xml): + """Return any elements that are direct children of the + VCALENDAR comp-filter (i.e. filtering on a non-existent VCALENDAR prop).""" + x = xml.xmlelement() if hasattr(xml, "xmlelement") else None + if x is None: + return [] + return x.xpath('//C:comp-filter[@name="VCALENDAR"]/C:prop-filter', namespaces=self._NS) + + def test_untyped_propfilter_search_splits_per_comptype( + self, mock_client: DAVClient, mock_url: str + ) -> None: + """Backward-compat mode: an untyped property-filter search must split into + per-component queries rather than placing under VCALENDAR.""" + from caldav.compatibility_hints import FeatureSet + + mock_client.features = FeatureSet(None) # default / backward-compat + + calls = [] + calendar = mock.Mock() + calendar.client = mock_client + + def rep(xml, comp_cls, props=None): + calls.append(xml) + return (mock.Mock(), []) + + calendar._request_report_build_resultlist.side_effect = rep + + searcher = CalDAVSearcher() + searcher.add_property_filter("SUMMARY", "meeting") + searcher.search(calendar) + + assert calls, "no REPORT was issued" + for xml in calls: + assert not self._vcalendar_propfilter_children(xml), ( + "prop-filter must not be placed directly under VCALENDAR" + ) + ## split into one query per component type (VEVENT/VTODO/VJOURNAL) + assert len(calls) == 3 + + +class TestSearchDriverExceptionHandling: + """The search() driver runs the generator's yielded actions and must feed any + exception raised by an action back INTO the generator (via gen.throw()) so the + search logic's own try/except blocks can act on it. Without this, the + generator's error-handling branches (issue #681 fallback, per-object load + error handling, ...) would be dead code. + """ + + def _mock_features_all_supported(self, mock_client): + def mock_is_supported(feat, type_=bool): + if type_ is str: + return "full" + return True + + mock_client.features.is_supported = mock.Mock(side_effect=mock_is_supported) + mock_client.features.backward_compatibility_mode = False + + def test_load_error_is_delivered_into_generator_and_skips_object( + self, mock_client: DAVClient, mock_url: str + ) -> None: + """If loading one returned object raises, the driver throws it into the + generator, whose try/except skips that object instead of failing the + whole search.""" + self._mock_features_all_supported(mock_client) + + good = Event(client=mock_client, url=mock_url + "/good", data=SIMPLE_EVENT) + bad = Event(client=mock_client, url=mock_url + "/bad", data=SIMPLE_EVENT) + ## Make the bad object raise whenever the driver tries to load it + bad.load = mock.Mock(side_effect=error.DAVError("server refuses to reveal object")) + + calendar = mock.Mock() + calendar.client = mock_client + calendar._request_report_build_resultlist.return_value = (mock.Mock(), [good, bad]) + + searcher = CalDAVSearcher(event=True) + result = searcher.search(calendar) + + assert good in result + assert bad not in result + + def test_unhandled_action_exception_propagates( + self, mock_client: DAVClient, mock_url: str + ) -> None: + """An exception the generator does NOT catch must still propagate out of + search() (gen.throw re-raises it) rather than being swallowed.""" + self._mock_features_all_supported(mock_client) + + calendar = mock.Mock() + calendar.client = mock_client + calendar._request_report_build_resultlist.side_effect = RuntimeError("boom") + + searcher = CalDAVSearcher(event=True) + with pytest.raises(RuntimeError, match="boom"): + searcher.search(calendar) + + +class TestCombinedIsLogicalAndWorkaround: + """§2.6: combined-is-logical-and workaround must apply property filters client-side. + + When search.combined-is-logical-and is False, the workaround strips all + property filters from the server query (sending only the time range) and + must apply them client-side afterward. The bug passed the ambient + post_filter=None instead of True, so _filter_search_results short-circuited + and returned all objects in the time range unfiltered. + """ + + def _make_calendar_with_features(self) -> "tuple": + """Return (client, calendar) with combined-is-logical-and: unsupported.""" + from caldav import Calendar + from caldav.compatibility_hints import FeatureSet + + features = FeatureSet( + { + "search.combined-is-logical-and": "unsupported", + "search.text": "full", + "search.text.substring": "full", + "search.text.case-sensitive": "full", + "search.time-range.accurate": "full", + "search.unlimited-time-range": "full", + } + ) + from caldav.davclient import DAVClient + + client = DAVClient(url="https://cal.example.com/") + client.features = features + cal = Calendar(client=client, url="https://cal.example.com/cal/") + return client, cal + + def test_summary_filter_applied_client_side(self) -> None: + """Time-range + SUMMARY filter on combined-is-logical-and:unsupported server + must return only events whose SUMMARY matches — not every event in the range.""" + client, cal = self._make_calendar_with_features() + + special = Event( + client=client, + url="https://cal.example.com/cal/special.ics", + data=SPECIAL_EVENT, + parent=cal, + ) + unrelated = Event( + client=client, + url="https://cal.example.com/cal/unrelated.ics", + data=UNRELATED_EVENT, + parent=cal, + ) + + mock_response = mock.MagicMock() + cal._request_report_build_resultlist = mock.Mock( + return_value=(mock_response, [special, unrelated]) + ) + + start = datetime(2024, 6, 15, 0, 0, tzinfo=timezone.utc) + end = datetime(2024, 6, 16, 0, 0, tzinfo=timezone.utc) + searcher = CalDAVSearcher(event=True, start=start, end=end) + searcher.add_property_filter("SUMMARY", "Special", operator="contains") + + results = searcher.search(cal) + + summaries = [str(r.icalendar_component["SUMMARY"]) for r in results] + assert len(results) == 1, f"Expected 1 result, got {len(results)}: {summaries}" + assert "Special" in summaries[0] + + +class TestExactMatchOperator: + """§2.8: operator='==' must be enforced client-side via post-filtering. + + The docstring documents that '==' means exact match enforced client-side, + but no code path inspected the '==' operator — post_filter was never set + for '==' searches, so server substring semantics leaked through. + """ + + _exact_match_event = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:exact-event@example.com +DTSTAMP:20240101T120000Z +DTSTART:20240615T140000Z +DTEND:20240615T150000Z +SUMMARY:rain +END:VEVENT +END:VCALENDAR""" + + _substring_event = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:substring-event@example.com +DTSTAMP:20240101T120000Z +DTSTART:20240615T160000Z +DTEND:20240615T170000Z +SUMMARY:Training session +END:VEVENT +END:VCALENDAR""" + + def _make_calendar_with_features(self): + from caldav.lib.url import URL + + client = mock.Mock(spec=DAVClient) + client.url = URL("https://cal.example.com/") + features = mock.Mock() + features.is_supported = mock.Mock(return_value=False) + client.features = features + cal = mock.Mock() + cal.client = client + cal.url = URL("https://cal.example.com/cal/") + return client, cal + + def test_exact_match_excludes_substrings(self): + """operator='==' must exclude events where the value is a substring of the summary.""" + client, cal = self._make_calendar_with_features() + + exact_ev = Event( + client=client, + url="https://cal.example.com/cal/exact.ics", + data=self._exact_match_event, + parent=cal, + ) + substring_ev = Event( + client=client, + url="https://cal.example.com/cal/substring.ics", + data=self._substring_event, + parent=cal, + ) + + cal._request_report_build_resultlist = mock.Mock( + return_value=(mock.MagicMock(), [exact_ev, substring_ev]) + ) + + searcher = CalDAVSearcher(event=True) + searcher.add_property_filter("SUMMARY", "rain", operator="==") + results = searcher.search(cal) + + summaries = [str(r.icalendar_component["SUMMARY"]) for r in results] + assert len(results) == 1, f"Expected 1 result (exact), got {len(results)}: {summaries}" + assert results[0].icalendar_component["SUMMARY"] == "rain" diff --git a/tests/test_servers.yaml.example b/tests/test_servers.yaml.example deleted file mode 100644 index c07af076..00000000 --- a/tests/test_servers.yaml.example +++ /dev/null @@ -1,138 +0,0 @@ -# Test server configuration for caldav tests -# -# Copy this file to test_servers.yaml and customize for your setup. -# See tests/README.md for documentation. -# -# Environment variables can be used with ${VAR} or ${VAR:-default} syntax. - -test-servers: - # ========================================================================= - # Embedded servers (run in-process, no external setup required) - # ========================================================================= - - radicale: - type: embedded - enabled: true - host: ${RADICALE_HOST:-localhost} - port: ${RADICALE_PORT:-5232} - username: user1 - password: "" - - xandikos: - type: embedded - enabled: true - host: ${XANDIKOS_HOST:-localhost} - port: ${XANDIKOS_PORT:-8993} - username: sometestuser - password: "" - - # ========================================================================= - # Docker servers (require docker-compose, see docker-test-servers/) - # ========================================================================= - # - # Set enabled to: - # - true: always enable - # - false: always disable - # - "auto": enable if docker is available (default for docker servers) - - baikal: - type: docker - enabled: ${TEST_BAIKAL:-auto} - host: ${BAIKAL_HOST:-localhost} - port: ${BAIKAL_PORT:-8800} - username: ${BAIKAL_USERNAME:-testuser} - password: ${BAIKAL_PASSWORD:-testpass} - # Path within the CalDAV server - # path: /dav.php - - nextcloud: - type: docker - enabled: ${TEST_NEXTCLOUD:-false} - host: ${NEXTCLOUD_HOST:-localhost} - port: ${NEXTCLOUD_PORT:-8801} - username: ${NEXTCLOUD_USERNAME:-testuser} - password: ${NEXTCLOUD_PASSWORD:-testpass} - - cyrus: - type: docker - enabled: ${TEST_CYRUS:-false} - host: ${CYRUS_HOST:-localhost} - port: ${CYRUS_PORT:-8802} - username: ${CYRUS_USERNAME:-testuser@test.local} - password: ${CYRUS_PASSWORD:-testpassword} - - sogo: - type: docker - enabled: ${TEST_SOGO:-false} - host: ${SOGO_HOST:-localhost} - port: ${SOGO_PORT:-8803} - username: ${SOGO_USERNAME:-testuser} - password: ${SOGO_PASSWORD:-testpassword} - - bedework: - type: docker - enabled: ${TEST_BEDEWORK:-false} - host: ${BEDEWORK_HOST:-localhost} - port: ${BEDEWORK_PORT:-8804} - username: ${BEDEWORK_USERNAME:-admin} - password: ${BEDEWORK_PASSWORD:-bedework} - - davical: - type: docker - enabled: ${TEST_DAVICAL:-false} - host: ${DAVICAL_HOST:-localhost} - port: ${DAVICAL_PORT:-8805} - username: ${DAVICAL_USERNAME:-admin} - password: ${DAVICAL_PASSWORD:-davical} - - # ========================================================================= - # External/private servers (your own CalDAV server) - # ========================================================================= - # - # Uncomment and configure to test against your own server: - - # my-server: - # type: external - # enabled: true - # url: ${CALDAV_URL:-https://caldav.example.com/dav/} - # username: ${CALDAV_USERNAME} - # password: ${CALDAV_PASSWORD} - # # Optional: SSL verification (default: true) - # ssl_verify: true - # # Optional: specify server limitations/features - # features: - # - no-expand # Server doesn't support EXPAND - # - no-sync-token # Server doesn't support sync tokens - # - no-freebusy # Server doesn't support freebusy queries - -# ========================================================================= -# RFC6638 scheduling test users (optional) -# ========================================================================= -# -# For testing calendar scheduling (meeting invites, etc.), define at least -# three users on the same CalDAV server that can send invites to each other. -# This section lives at the TOP LEVEL (not under test-servers). -# -# Cyrus (pre-creates user1-user5 with password 'x'): -# rfc6638_users: -# - url: http://localhost:8802/dav/calendars/user/user1 -# username: user1 -# password: x -# - url: http://localhost:8802/dav/calendars/user/user2 -# username: user2 -# password: x -# - url: http://localhost:8802/dav/calendars/user/user3 -# username: user3 -# password: x -# -# Baikal (user1-user3 are in the pre-seeded db.sqlite, passwords testpass1-3): -# rfc6638_users: -# - url: http://localhost:8800/dav.php/ -# username: user1 -# password: testpass1 -# - url: http://localhost:8800/dav.php/ -# username: user2 -# password: testpass2 -# - url: http://localhost:8800/dav.php/ -# username: user3 -# password: testpass3 diff --git a/tests/test_vcal.py b/tests/test_vcal.py index 4593864a..4d992c73 100644 --- a/tests/test_vcal.py +++ b/tests/test_vcal.py @@ -131,6 +131,23 @@ def create_and_validate(**args): ) assert re.search(b"DTSTART(;VALUE=DATE-TIME)?:20321010T101010Z", some_ical) + ## ical_fragment with alarm_* props: fragment must land in VEVENT, not VALARM (§2.2) + raw_ical = create_ical( + summary="alarm-test", + dtstart=datetime(2032, 10, 10, 10, 10, 10, tzinfo=utc), + duration=timedelta(hours=1), + alarm_action="DISPLAY", + alarm_description="reminder", + alarm_trigger=timedelta(minutes=-15), + ical_fragment="RRULE:FREQ=DAILY;COUNT=3", + ) + raw_bytes = to_wire(raw_ical) + assert b"RRULE:FREQ=DAILY" in raw_bytes, "ical_fragment must appear in output" + assert b"BEGIN:VALARM" in raw_bytes, "alarm must be present" + end_valarm_pos = raw_bytes.index(b"END:VALARM") + rrule_pos = raw_bytes.index(b"RRULE:FREQ=DAILY") + assert rrule_pos > end_valarm_pos, "RRULE must not be inside VALARM" + def test_vcal_fixups(self): """ There is an obscure function lib.vcal that attempts to fix up @@ -281,6 +298,77 @@ def test_vcal_fixups(self): for ical in non_broken_ical: assert vcal.fix(ical) == ical + def test_trailing_whitespace_stripped_per_line(self) -> None: + """Bug §2.3: re.sub(' *$', '', fixed) without re.MULTILINE only strips + trailing spaces at the very end of the document, leaving per-line + trailing spaces intact (e.g. iCloud X-APPLE-STRUCTURED-LOCATION fold + lines with trailing spaces that distort base64 content).""" + ical = ( + "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "BEGIN:VEVENT\n" + "UID:test\n" + "DTSTAMP:20190103T070319Z\n" + "DTSTART:20190117T180000Z\n" + "SUMMARY:test\n" + "X-APPLE-STRUCTURED-LOCATION;X-TITLE=Somewhere:CAESvAEaEgmX \n" + " 5esy/OVJQBGXkXpP5aQYQCJi=\n" + "END:VEVENT\n" + "END:VCALENDAR\n" + ) + import re + + fixed = vcal.fix(ical) + assert not re.search(r" +\n", fixed), ( + "fix() must strip trailing spaces from each line, not just the document end" + ) + + def test_backslash_unescape_single_and_double_quotes(self) -> None: + """Bug §2.4: re.sub(r"\\+('\")", r"\1", fixed) used a group ('\"') + which matches only the literal two-char sequence '\" — not a character + class. Backslash before a lone single quote or lone double quote was + therefore not unescaped.""" + ical_single = ( + "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "BEGIN:VEVENT\n" + "UID:test\n" + "DTSTAMP:20190103T070319Z\n" + "DTSTART:20190117T180000Z\n" + "SUMMARY:it\\'s here\n" + "END:VEVENT\n" + "END:VCALENDAR\n" + ) + ical_double = ical_single.replace("\\'", '\\"') + fixed_single = vcal.fix(ical_single) + fixed_double = vcal.fix(ical_double) + assert "SUMMARY:it's here" in fixed_single, "fix() must strip backslash before single quote" + assert 'SUMMARY:it"s here' in fixed_double, "fix() must strip backslash before double quote" + + def test_completed_date_fixup_preserves_next_property(self) -> None: + """Bug §2.1: COMPLETED date fixup regex consumed the trailing newline, + merging the next property line into COMPLETED and destroying it.""" + ical = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:20070313T123432Z-456553@example.com +DTSTAMP:20070313T123432Z +COMPLETED:20070501 +SUMMARY:Submit Quebec Income Tax Return for 2006 +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + fixed = vcal.fix(ical) + cal = icalendar.Calendar.from_ical(fixed) + todo = list(cal.walk("VTODO"))[0] + assert str(todo["SUMMARY"]) == "Submit Quebec Income Tax Return for 2006", ( + "SUMMARY was destroyed by COMPLETED fixup (newline consumed)" + ) + assert "SUMMARY" not in str(todo["COMPLETED"].dt), ( + "COMPLETED value should not contain SUMMARY text" + ) + def test_missing_dtstamp_fix(self) -> None: """ Test that missing DTSTAMP is added by the fix function. @@ -355,3 +443,14 @@ def test_missing_dtstamp_fix(self) -> None: # Verify the fixed ical is valid self.verifyICal(fixed) + + def test_fix_does_not_crash_on_truncated_input(self) -> None: + """§1.12: vcal.fix() must not raise AssertionError on truncated/garbage iCalendar. + + Truncated data (no END: line) previously triggered a bare assert on line 93 + which gave no useful error message and failed silently under python -O. + """ + truncated = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nUID:trunc@example.com\n" + # Must not raise — return something (possibly unchanged input) + result = vcal.fix(truncated) + assert result is not None