Skip to content
Merged
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 @@ -39,6 +39,8 @@ The JMAP support was declared experimental in 3.0, hence the changes below are d

### Fixed

* The async connection-abort workaround now tries the existing one-shot Basic-auth fallback when its probe returns a bare 401 over TLS. Unsafe or unsuccessful probes still propagate the original connection error. See https://github.com/python-caldav/caldav/issues/717.

* `Principal.get_vcal_address()` raised `IndexError: list index out of range` when the server returned an empty `calendar-user-address-set`. It now falls back to the principal URL, as RFC 6638 section 2.4.1 provides for a user with no well-defined calendar user address. `add_organizer()` and `add_attendee()` go through the same method, so they were affected too. Seen on Xandikos 0.4.7, which advertises `calendar-auto-schedule` and serves schedule-inbox/outbox, but leaves the address set empty. `change_attendee_status()` accepts that same URL back, so an event the library invited a principal to can still have its PARTSTAT changed. A property that is *absent* still raises `NotFoundError`; per the same section that means the user is not enabled for scheduling. En passant, the Xandikos profile is regraded for 0.4.7: scheduling is no longer declared unsupported, and `create-calendar.with-supported-component-types` no longer unsupported either, so `is_supported()` may answer differently with `features: xandikos` configured.


Expand Down
9 changes: 8 additions & 1 deletion caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,15 @@ async def _async_request(
request_kwargs["auth"] = self.auth
r = await self.session.request(**request_kwargs)
response = DAVResponse(r, self)
elif self._should_attempt_unprompted_basic(
r.status_code,
r.headers,
lambda: self._response_scheme(r, url_obj),
):
self._build_unprompted_basic_auth()
return await self._async_request(url, method, body, headers)
else:
# Probe GET did not give us a 401+WWW-Authenticate challenge —
# Probe GET offered neither a challenge nor a safe Basic-auth guess —
# auth negotiation failed; re-raise the original connection error
raise

Expand Down
53 changes: 53 additions & 0 deletions tests/test_async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,59 @@ def _make_response(self, status_code, headers=None):
resp.reason_phrase = resp.reason
return resp

@pytest.mark.asyncio
@pytest.mark.parametrize("retry_status", [200, 401])
async def test_connection_abort_probe_can_guess_basic_once(self, retry_status):
client = AsyncDAVClient(url="https://cal.example.com/", username="user", password="pass")
client.session.request = AsyncMock(
side_effect=[
ConnectionError("server aborted connection"),
self._make_response(401),
self._make_response(retry_status),
]
)

if retry_status == 200:
response = await client.request("/calendar/item.ics", "PUT", "calendar data")
assert response.status == 200
else:
with pytest.raises(error.AuthorizationError):
await client.request("/calendar/item.ics", "PUT", "calendar data")
assert client.auth is None

calls = client.session.request.call_args_list
assert [call.kwargs["method"] for call in calls] == ["PUT", "GET", "PUT"]
assert calls[0].kwargs["auth"] is None
assert calls[1].kwargs.get("auth") is None
assert calls[2].kwargs["auth"] is not None
assert {key: value for key, value in calls[2].kwargs.items() if key != "auth"} == {
key: value for key, value in calls[0].kwargs.items() if key != "auth"
}
assert client._unprompted_basic_tried is True

@pytest.mark.asyncio
@pytest.mark.parametrize(
"probe_status, scheme, response_url",
[(401, "http", None), (401, "https", "http://cal.example.com/"), (200, "https", None)],
)
async def test_connection_abort_probe_without_safe_auth_reraises(
self, probe_status, scheme, response_url
):
client = AsyncDAVClient(
url=f"{scheme}://cal.example.com/", username="user", password="pass"
)
original_error = ConnectionError("server aborted connection")
probe = self._make_response(probe_status)
probe.url = response_url
client.session.request = AsyncMock(side_effect=[original_error, probe])

with pytest.raises(ConnectionError) as exc_info:
await client.request("/calendar/item.ics", "PUT", "calendar data")

assert exc_info.value is original_error
assert client.session.request.call_count == 2
assert client.auth is None

@pytest.mark.asyncio
async def test_401_without_www_authenticate_retries_with_basic_over_tls(self):
client = AsyncDAVClient(url="https://cal.example.com/", username="user", password="pass")
Expand Down
Loading