test: full caldav + carddav test suite
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Shared pytest fixtures for the pycaldav conformance suite.
|
||||
|
||||
Environment (injected by `run-pycaldav.sh`):
|
||||
OXICLOUD_CALDAV_URL — base CalDAV URL, e.g. http://localhost:8091/caldav/
|
||||
OXICLOUD_CALDAV_USERNAME — admin username
|
||||
OXICLOUD_CALDAV_APP_PASSWORD — app password (NOT the account password)
|
||||
|
||||
The suite deliberately talks to the same URL a real CalDAV client
|
||||
would — via HTTP Basic + an app password, no JWT. That's how
|
||||
Thunderbird, Apple Calendar, DAVx⁵ and Gnome Calendar all connect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import caldav
|
||||
import pytest
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Silence pycaldav's chatty logging during test setup.
|
||||
#
|
||||
# python-caldav's `make_calendar()` internally does MKCALENDAR +
|
||||
# PROPPATCH-displayname. OxiCloud's MKCALENDAR assigns its own
|
||||
# server-side UUID (spec deviation, see fresh_calendar fixture),
|
||||
# so the follow-up PROPPATCH lands on a URL the server doesn't
|
||||
# know → 500 / 404. pycaldav catches and moves on ("calendar
|
||||
# server does not support display name on calendar? Ignoring"),
|
||||
# but its handler logs at CRITICAL with `exc_info=True`, dumping
|
||||
# a full XMLSyntaxError traceback under pytest's "Captured log
|
||||
# setup" section on every test. That noise dwarfed real
|
||||
# assertion output.
|
||||
#
|
||||
# Filtering at logger level here has nothing to capture, so the
|
||||
# traceback disappears from the pytest output.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
logging.getLogger("caldav").setLevel(logging.ERROR)
|
||||
logging.getLogger("caldav.davclient").setLevel(logging.ERROR)
|
||||
# pycaldav uses `logging.critical(..., exc_info=True)` on the ROOT
|
||||
# logger for the "expected XML, got JSON" case. `setLevel(ERROR)`
|
||||
# does NOT hide CRITICAL (CRITICAL > ERROR), so use the override
|
||||
# switch instead: `logging.disable(CRITICAL)` disables every level
|
||||
# up to and INCLUDING CRITICAL, killing pycaldav's setup traceback
|
||||
# spam outright. run-pycaldav.sh also passes `--show-capture=no`
|
||||
# so any remaining captured output is hidden on failure — defence
|
||||
# in depth, since one clean-output knob is easier to forget than two.
|
||||
logging.getLogger().setLevel(logging.ERROR)
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
f"Missing required env var {name}. Run this suite via "
|
||||
"tests/caldav/run-pycaldav.sh (or `just test-caldav`) which "
|
||||
"bootstraps admin + app password before invoking pytest."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def caldav_url() -> str:
|
||||
return _env("OXICLOUD_CALDAV_URL")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def caldav_username() -> str:
|
||||
return _env("OXICLOUD_CALDAV_USERNAME")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def caldav_app_password() -> str:
|
||||
return _env("OXICLOUD_CALDAV_APP_PASSWORD")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dav_client(
|
||||
caldav_url: str, caldav_username: str, caldav_app_password: str
|
||||
) -> caldav.DAVClient:
|
||||
"""The single DAVClient used across the session — python-caldav
|
||||
reuses one requests.Session under the hood."""
|
||||
return caldav.DAVClient(
|
||||
url=caldav_url,
|
||||
username=caldav_username,
|
||||
password=caldav_app_password,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_calendar(dav_client: caldav.DAVClient):
|
||||
"""A brand-new calendar per test. The name is randomised so parallel
|
||||
workers (`pytest -n auto` in the future) don't collide, and every
|
||||
test teardown drops the calendar — no cross-test bleed.
|
||||
|
||||
Server-URL rebind: OxiCloud's MKCALENDAR assigns its own UUID and
|
||||
ignores the URL slug the client PUT to (design choice — the URL
|
||||
slug becomes the display name when the request body is empty; the
|
||||
canonical URL is `/caldav/<server-uuid>/`). python-caldav's
|
||||
`make_calendar()` returns a Calendar bound to the client-derived
|
||||
URL, which then 404s on every subsequent op. Re-discover the
|
||||
server-authoritative URL by listing the principal's calendars and
|
||||
matching by displayname."""
|
||||
principal = dav_client.principal()
|
||||
name = f"pycaldav-{uuid.uuid4().hex[:12]}"
|
||||
principal.make_calendar(name=name)
|
||||
|
||||
calendar = next(
|
||||
(c for c in principal.calendars() if c.get_display_name() == name),
|
||||
None,
|
||||
)
|
||||
if calendar is None:
|
||||
raise RuntimeError(
|
||||
f"MKCALENDAR completed but the new calendar '{name}' did not "
|
||||
"appear in principal.calendars() — server-side provisioning "
|
||||
"issue."
|
||||
)
|
||||
|
||||
yield calendar
|
||||
try:
|
||||
calendar.delete()
|
||||
except Exception:
|
||||
# Teardown is best-effort — if a test crashed the server, we
|
||||
# don't want the teardown crash to mask the real failure.
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CardDAV fixtures — python-caldav has no first-class CardDAV
|
||||
# support, so these drive the server via raw HTTP through the
|
||||
# same authenticated DAVClient session. Kept in this conftest
|
||||
# (not a sibling tests/carddav/ dir) for now — one venv, one
|
||||
# `just test-caldav` entry point. If the CardDAV coverage
|
||||
# grows past ~one file's worth, promote to tests/carddav/ with
|
||||
# its own runner.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def carddav_url(caldav_url: str) -> str:
|
||||
"""CardDAV base URL derived from the CalDAV URL — the
|
||||
orchestrator only exports `OXICLOUD_CALDAV_URL`, but the
|
||||
server mounts both under the same origin. Swap `/caldav/`
|
||||
for `/carddav/`."""
|
||||
if "/caldav/" not in caldav_url:
|
||||
raise RuntimeError(
|
||||
f"OXICLOUD_CALDAV_URL={caldav_url!r} does not contain "
|
||||
"'/caldav/'; can't derive the CardDAV counterpart."
|
||||
)
|
||||
return caldav_url.replace("/caldav/", "/carddav/", 1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_addressbook(dav_client: caldav.DAVClient, carddav_url: str):
|
||||
"""Create a fresh CardDAV address book and return its
|
||||
server-authoritative URL as a string.
|
||||
|
||||
Same URL-rebind hazard as `fresh_calendar`: OxiCloud's MKCOL
|
||||
assigns its own UUID and ignores the URL slug we PUT to
|
||||
(RFC 6352 leaves this implementation-defined). Discover the
|
||||
canonical URL via PROPFIND Depth 1 on the CardDAV root and
|
||||
match by displayname.
|
||||
|
||||
Yields the URL (string, trailing `/`); teardown DELETEs it
|
||||
on best-effort."""
|
||||
name = f"pycarddav-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
mkcol_url = carddav_url.rstrip("/") + f"/{name}/"
|
||||
r = dav_client.request(mkcol_url, method="MKCOL", body="")
|
||||
if r.status not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"MKCOL {mkcol_url} → HTTP {r.status}\n{r.raw!r}"
|
||||
)
|
||||
|
||||
propfind_body = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<D:propfind xmlns:D="DAV:">'
|
||||
"<D:prop><D:displayname/><D:resourcetype/></D:prop>"
|
||||
"</D:propfind>"
|
||||
)
|
||||
r = dav_client.request(
|
||||
carddav_url,
|
||||
method="PROPFIND",
|
||||
body=propfind_body,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
)
|
||||
if r.status < 200 or r.status >= 300:
|
||||
raise RuntimeError(
|
||||
f"PROPFIND {carddav_url} → HTTP {r.status}\n{r.raw!r}"
|
||||
)
|
||||
xml = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
|
||||
|
||||
# Naive but sufficient: iterate <D:response> blocks; pick the
|
||||
# one whose block text contains our chosen displayname; pull
|
||||
# its <D:href> as the canonical URL slug.
|
||||
href = None
|
||||
for block in re.finditer(
|
||||
r"<D:response>(.*?)</D:response>", xml, flags=re.DOTALL
|
||||
):
|
||||
chunk = block.group(1)
|
||||
if name in chunk:
|
||||
m = re.search(r"<D:href>(/carddav/[^<]+/)</D:href>", chunk)
|
||||
if m:
|
||||
href = m.group(1)
|
||||
break
|
||||
if href is None:
|
||||
raise RuntimeError(
|
||||
f"MKCOL succeeded but PROPFIND did not surface an address "
|
||||
f"book with displayname '{name}':\n{xml}"
|
||||
)
|
||||
|
||||
# href from the server is a path (e.g. `/carddav/<uuid>/`);
|
||||
# combine with the URL origin to get an absolute URL usable in
|
||||
# subsequent `dav_client.request()` calls.
|
||||
origin = re.match(r"^(https?://[^/]+)", carddav_url).group(1)
|
||||
ab_url = f"{origin}{href}"
|
||||
|
||||
yield ab_url
|
||||
try:
|
||||
dav_client.request(ab_url, method="DELETE")
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user