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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ This library is tightly dependent on the CalDAV-library, particularly the `compa

### Added

- **`scheduling.calendar-user-address-set.populated`** - set if the principal's `calendar-user-address-set` actually carries an address. Xandikos 0.4.7 leaves it blank.

- **`auth.www-authenticate`, `auth.www-authenticate.usable-scheme`** - whether a 401 from the server names an authentication scheme, as RFC 7235 §3.1 requires, and whether the schemes it names include one the caldav library implements. A server that omits the header leaves the caldav library with nothing to negotiate: no auth object is built, the password is never transmitted, and the bare 401 surfaces as an `AuthorizationError` indistinguishable from a rejected one. Yahoo Calendar does this; a server naming only Negotiate or NTLM is the same dead end one step later, and pinning `auth_type` is the cure for both. See https://github.com/python-caldav/caldav/issues/713. Note that a run only reaches this probe on a server it has already authenticated against, so on an affected server it records *why* `auth_type` had to be pinned rather than diagnosing a connection that never came up.

## 1.3.0 - 2026-09-16
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The full list is currently maintained in the CalDAV library, https://github.com/
- Free-busy queries
- Principal discovery (RFC 5397)
- A `WWW-Authenticate` header on a 401, naming a usable scheme (RFC 7235)
- Scheduling identity: whether `calendar-user-address-set` holds an address (RFC 6638)
- Duplicate UID handling across calendars
- Timezone support in events

Expand Down
34 changes: 29 additions & 5 deletions src/caldav_server_tester/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5869,13 +5869,19 @@ def _run_check(self) -> None:

class CheckSchedulingDetails(Check):
"""
Checks RFC6638 scheduling sub-features: mailbox (inbox/outbox) and
calendar-user-address-set. Depends on CheckScheduling; when scheduling
is unsupported both sub-features are recorded as unsupported immediately.
Checks RFC6638 scheduling sub-features: mailbox (inbox/outbox),
calendar-user-address-set, and whether that address set actually carries
an address. Depends on CheckScheduling; when scheduling is ungood the
sub-features are left unrecorded and inherit its verdict at lookup time -
'unknown' for an unprobed parent, not 'unsupported'.
"""

depends_on = {CheckScheduling, CheckGetCurrentUserPrincipal}
features_to_be_checked = {"scheduling.mailbox", "scheduling.calendar-user-address-set"}
features_to_be_checked = {
"scheduling.mailbox",
"scheduling.calendar-user-address-set",
"scheduling.calendar-user-address-set.populated",
}

def _run_check(self) -> None:
if self.feature_ungood("scheduling"):
Expand All @@ -5884,6 +5890,8 @@ def _run_check(self) -> None:
principal = self.checker.principal
if principal is None:
self.set_feature("scheduling.mailbox", {"support": "unknown"})
## .populated is left unrecorded: it inherits the parent's
## 'unknown' at lookup time.
self.set_feature("scheduling.calendar-user-address-set", {"support": "unknown"})
return

Expand All @@ -5899,15 +5907,31 @@ def _run_check(self) -> None:

## Check calendar-user-address-set
try:
principal.calendar_user_address_set()
addresses = principal.calendar_user_address_set()
self.set_feature("scheduling.calendar-user-address-set", True)
## The property can be served and still be empty - Xandikos 0.4.7
## does that, while advertising calendar-auto-schedule and serving
## schedule-inbox/outbox. The principal then has no address of its
## own, and RFC 6638 section 2.4.1 has its URL stand in.
if addresses:
self.set_feature("scheduling.calendar-user-address-set.populated", True)
else:
self.set_feature(
"scheduling.calendar-user-address-set.populated",
{
"support": "unsupported",
"behaviour": "the property is served but empty, so the principal URL is used as the calendar user address",
},
)
except NotFoundError:
self.set_feature("scheduling.calendar-user-address-set", False)
self.set_feature("scheduling.calendar-user-address-set.populated", False)
except Exception as e:
self.set_feature(
"scheduling.calendar-user-address-set",
{"support": "broken", "behaviour": str(e)},
)
self.set_feature("scheduling.calendar-user-address-set.populated", {"support": "unknown"})


class CheckFreeBusyQueryRFC6638(Check):
Expand Down
79 changes: 79 additions & 0 deletions tests/test_scheduling_address_set.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Unit tests for the ``scheduling.calendar-user-address-set.populated`` probe.

RFC6638 section 2.4.1 has the principal's ``calendar-user-address-set`` carry
the addresses a calendar user is known by. A server can serve the property and
still leave it empty: Xandikos 0.4.7 does, while advertising
``calendar-auto-schedule`` and serving schedule-inbox and schedule-outbox. The
principal then has no address of its own, and the same section has the URI of
the principal resource stand in - which is what the caldav library puts in
ORGANIZER and ATTENDEE.

So "the property is served" and "the property has an address in it" are two
different questions, and the second is this feature. The first stays supported
on such a server, since the property does resolve.
"""

from unittest.mock import Mock

from caldav.compatibility_hints import FeatureSet
from caldav.lib.error import NotFoundError

from caldav_server_tester.checker import ServerQuirkChecker
from caldav_server_tester.checks import CheckScheduling, CheckSchedulingDetails


class TestSchedulingAddressSetPopulated:
"""A server can serve calendar-user-address-set and still leave it empty -
Xandikos 0.4.7 does - so "the property is there" and "the principal has an
address" are two different features."""

@staticmethod
def _was_recorded(checker) -> bool:
"""is_supported() walks up to the nearest recorded ancestor, so a child
that was never set still answers whatever its parent got. These tests
are about the probe, so they have to ask whether the value was
recorded, not what it resolves to."""
return "scheduling.calendar-user-address-set.populated" in (
checker.features_checked.dotted_feature_set_list(compact=False)
)

def create_checker_with_principal(self, addresses) -> tuple[ServerQuirkChecker, Mock]:
client = Mock()
client.features = FeatureSet()
client.supports_scheduling.return_value = True
checker = ServerQuirkChecker(client, debug_mode=None)

principal = Mock()
principal.calendar_user_address_set.return_value = addresses
client.principal.return_value = principal
checker.principal = principal

CheckScheduling(checker).run_check()
return checker, principal

def test_populated_address_set(self) -> None:
checker, _ = self.create_checker_with_principal(["mailto:someone@example.com"])
CheckSchedulingDetails(checker).run_check()

assert checker.features_checked.is_supported("scheduling.calendar-user-address-set")
assert self._was_recorded(checker)
assert checker.features_checked.is_supported("scheduling.calendar-user-address-set.populated")

def test_empty_address_set_is_served_but_unpopulated(self) -> None:
"""The property resolves, so the parent feature stays supported; only the
new sub-feature says there is no address in it."""
checker, _ = self.create_checker_with_principal([])
CheckSchedulingDetails(checker).run_check()

assert checker.features_checked.is_supported("scheduling.calendar-user-address-set")
assert self._was_recorded(checker)
assert not checker.features_checked.is_supported("scheduling.calendar-user-address-set.populated")

def test_absent_address_set_marks_both_unsupported(self) -> None:
checker, principal = self.create_checker_with_principal([])
principal.calendar_user_address_set.side_effect = NotFoundError("no such property")
CheckSchedulingDetails(checker).run_check()

assert not checker.features_checked.is_supported("scheduling.calendar-user-address-set")
assert self._was_recorded(checker)
assert not checker.features_checked.is_supported("scheduling.calendar-user-address-set.populated")
2 changes: 2 additions & 0 deletions tests/test_unprobed_not_unsupported.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def _run(check_cls, observed):
[
(CheckSchedulingDetails, "scheduling.mailbox"),
(CheckSchedulingDetails, "scheduling.calendar-user-address-set"),
(CheckSchedulingDetails, "scheduling.calendar-user-address-set.populated"),
(CheckFreeBusyQueryRFC6638, "scheduling.freebusy-query"),
(CheckScheduleTag, "scheduling.schedule-tag"),
],
Expand All @@ -59,6 +60,7 @@ def test_unprobed_parent_leaves_children_unknown(check_cls, feature):
[
(CheckSchedulingDetails, "scheduling.mailbox"),
(CheckSchedulingDetails, "scheduling.calendar-user-address-set"),
(CheckSchedulingDetails, "scheduling.calendar-user-address-set.populated"),
(CheckFreeBusyQueryRFC6638, "scheduling.freebusy-query"),
(CheckScheduleTag, "scheduling.schedule-tag"),
],
Expand Down
Loading