From a9e05ec47c93068c89b8492735763cd271ba1d6d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 14 Jul 2026 20:34:19 +0200 Subject: [PATCH] test: full caldav + carddav test suite --- .gitignore | 5 + justfile | 21 ++ tests/caldav/conftest.py | 226 ++++++++++++++++++++ tests/caldav/run-pycaldav.sh | 215 +++++++++++++++++++ tests/caldav/test.env | 11 + tests/caldav/test_carddav.py | 290 +++++++++++++++++++++++++ tests/caldav/test_ical_coverage.py | 287 +++++++++++++++++++++++++ tests/caldav/test_recurring.py | 328 +++++++++++++++++++++++++++++ tests/caldav/test_report.py | 312 +++++++++++++++++++++++++++ 9 files changed, 1695 insertions(+) create mode 100644 tests/caldav/conftest.py create mode 100755 tests/caldav/run-pycaldav.sh create mode 100644 tests/caldav/test.env create mode 100644 tests/caldav/test_carddav.py create mode 100644 tests/caldav/test_ical_coverage.py create mode 100644 tests/caldav/test_recurring.py create mode 100644 tests/caldav/test_report.py diff --git a/.gitignore b/.gitignore index 981eb369..689de6d5 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,11 @@ tests/e2e/blob-report/ tests/e2e/playwright/.cache/ tests/e2e/playwright/.auth/ tests/webdav/storage-litmus/ +tests/caldav/storage/ +tests/caldav/.venv/ +tests/caldav/__pycache__/ +tests/caldav/.pytest_cache/ +tests/caldav/server.log # Test fixtures generated on-the-fly by tests/api/run.sh tests/fixtures/chunk-over-cap-*.bin diff --git a/justfile b/justfile index 57e29598..669177e7 100644 --- a/justfile +++ b/justfile @@ -199,6 +199,27 @@ api-test: echo "XXX litmus webdav not found, ignore test" fi +# CalDAV client-driven conformance suite. +# +# Drives OxiCloud through the maintained `python-caldav` client library +# — the same VObject/RFC 5545 stack Thunderbird / DAVx⁵ / Gnome Calendar +# use. Complements Hurl coverage (which exercises raw HTTP) by proving +# a real client can round-trip recurring events, per-instance overrides +# (RFC 5545 §3.8.4.4), and all-day masters (the shape #528 was filed +# against). +# +# Not chained into `api-test` because it needs python3; run explicitly. +# The orchestrator spawns its own postgres + server on port 8091 so it +# can run in parallel with api-test/webdav. +test-caldav: + #!/usr/bin/env bash + set -euo pipefail + if ! command -v python3 >/dev/null 2>&1; then + echo "XXX python3 not found — skipping CalDAV client-driven tests" + exit 0 + fi + ./tests/caldav/run-pycaldav.sh + # --------------------------------------------------------------------------- # SvelteKit frontend (frontend/) — the only frontend. These `fe-*` recipes # drive its dev server, build, lint and tests. diff --git a/tests/caldav/conftest.py b/tests/caldav/conftest.py new file mode 100644 index 00000000..7df1237b --- /dev/null +++ b/tests/caldav/conftest.py @@ -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//`). 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 = ( + '' + '' + "" + "" + ) + 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 blocks; pick the + # one whose block text contains our chosen displayname; pull + # its as the canonical URL slug. + href = None + for block in re.finditer( + r"(.*?)", xml, flags=re.DOTALL + ): + chunk = block.group(1) + if name in chunk: + m = re.search(r"(/carddav/[^<]+/)", 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//`); + # 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 diff --git a/tests/caldav/run-pycaldav.sh b/tests/caldav/run-pycaldav.sh new file mode 100755 index 00000000..7a6d689a --- /dev/null +++ b/tests/caldav/run-pycaldav.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# CalDAV end-to-end conformance test using python-caldav. +# +# python-caldav (https://github.com/python-caldav/caldav) is the same +# maintained client library used to test radicale, xandikos, davical. +# Driving OxiCloud through it exercises the code paths that real +# clients (Thunderbird, Apple Calendar, Gnome Calendar, DAVx⁵) hit — +# it's the closest cognate to what `litmus` does for WebDAV, but for +# the CalDAV surface. +# +# Usage (from repo root via justfile): +# just test-caldav +# +# Or directly: +# bash tests/caldav/run-pycaldav.sh +# +# Requires: python3 (>= 3.10 for python-caldav 1.x), curl, jq, docker +# The `caldav` library + pytest are installed into a per-run venv at +# `tests/caldav/.venv/`, gitignored. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +CALDAV_DIR="$REPO_ROOT/tests/caldav" + +# shellcheck source=test.env +source "$CALDAV_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[caldav] $*"; } +die() { echo "[caldav] ERROR: $*" >&2; exit 1; } + +# ── Dependency checks ───────────────────────────────────────────────────────── + +if ! command -v python3 >/dev/null 2>&1; then + die "python3 not found. Install a recent Python 3." +fi +if ! command -v jq >/dev/null 2>&1; then + die "jq not found." +fi +if ! command -v curl >/dev/null 2>&1; then + die "curl not found." +fi + +# ── Teardown (always runs on exit) ──────────────────────────────────────────── + +SERVER_PID="" + +SUITE_EXIT=0 + +cleanup() { + # If pytest failed, show the last chunk of server log so + # someone debugging doesn't have to hunt for the file. + if [[ $SUITE_EXIT -ne 0 && -n "${SERVER_LOG:-}" && -f "$SERVER_LOG" ]]; then + log "── server log tail (last 40 lines) ─────────────────────────" + tail -n 40 "$SERVER_LOG" >&2 + log "── end server log tail ─────────────────────────────────────" + fi + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" +} + +trap cleanup EXIT + +# ── 1. Start postgres ──────────────────────────────────────────────────────── + +bash "$COMMON/spawn-db.sh" + +# ── 2. Start OxiCloud ──────────────────────────────────────────────────────── + +set -a +# shellcheck source=../common/server.env +source "$COMMON/server.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$CALDAV_DIR/storage" +set +a + +# Wipe storage between runs so a stale run doesn't leak into fresh state. +# Regex-gated via wipe-storage.sh so we can never `rm -rf /`. +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +# ALWAYS build — a `cargo check` / `cargo clippy` during development +# leaves the target/ metadata fresh but NEVER produces or updates the +# binary at target//oxicloud. Skipping the rebuild on +# "binary already exists" then runs pytest against a stale binary, +# which manifests as impossible-looking test failures (e.g. "phase 3 +# routing broken" when the binary is from phase 2). Cargo's +# incremental compile makes this near-free when nothing changed. +log "Building OxiCloud ($BUILD_TARGET) — incremental compile, fast when up-to-date..." +case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; +esac + +[[ -x "$OXICLOUD_BIN" ]] || die "Build completed but $OXICLOUD_BIN is missing" + +log "Starting OxiCloud ($BUILD_TARGET) on port $SERVER_PORT..." +# `--config` pins the env file, suppressing the default `.env` probe so +# a developer's repo-root `.env` can never leak into a test run. +# +# Redirect server stdout/stderr to a log file — otherwise every audit +# line + tower-http error line interleaves with pytest's per-test +# output, drowning PASSED/XFAIL markers under log spam. Cat the tail +# of the log on cleanup so failures still surface the last events. +SERVER_LOG="$CALDAV_DIR/server.log" +: > "$SERVER_LOG" +"$OXICLOUD_BIN" --config "$COMMON/server.env" >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! +log "Server log: $SERVER_LOG (tail -f to watch live)" + +log "Waiting for server at $base_url..." +deadline=$(( $(date +%s) + 60 )) +until curl -sf "$base_url/ready" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Server did not become ready within 60s" + sleep 1 +done +log "Server ready." + +# ── 3. Bootstrap admin + app password ──────────────────────────────────────── + +SETUP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$username\",\"email\":\"$email\",\"password\":\"$password\"}" \ + "$base_url/api/setup") +case "$SETUP_STATUS" in + 201) log "Admin account created." ;; + 403) log "Admin account already exists." ;; + *) die "Unexpected /api/setup status: $SETUP_STATUS" ;; +esac + +LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$username\",\"password\":\"$password\"}" \ + "$base_url/api/auth/login") +JWT=$(jq -r '.access_token' <<<"$LOGIN_RESP") +[[ -z "$JWT" || "$JWT" == "null" ]] && die "Login failed: $LOGIN_RESP" +log "Logged in as $username." + +# Real CalDAV clients authenticate via app password (Basic Auth), not +# JWT — same rule as WebDAV. Session/account passwords are deliberately +# refused on DAV surfaces (memory: DAV surfaces require app passwords +# only). python-caldav uses HTTP Basic; the app password IS the credential. +APP_PW_RESP=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $JWT" \ + -d '{"label":"pycaldav-test"}' \ + "$base_url/api/auth/app-passwords") +APP_PASSWORD=$(jq -r '.password' <<<"$APP_PW_RESP") +[[ -z "$APP_PASSWORD" || "$APP_PASSWORD" == "null" ]] && die "App password creation failed: $APP_PW_RESP" +log "App password created." + +# ── 4. Python venv + install caldav + pytest ───────────────────────────────── + +VENV="$CALDAV_DIR/.venv" +if [[ ! -d "$VENV" ]]; then + log "Creating Python venv at $VENV..." + python3 -m venv "$VENV" +fi +# shellcheck source=/dev/null +source "$VENV/bin/activate" + +# Pin the major to avoid a surprise API break on `caldav` 2.x if/when +# that lands. `pytest` version is loose — no reason to over-constrain +# a test-only dep. +if ! python3 -c "import caldav" 2>/dev/null; then + log "Installing python-caldav + pytest into venv..." + pip install --quiet 'caldav>=1.3,<2.0' 'pytest>=7,<9' +fi + +# ── 5. Run pytest ──────────────────────────────────────────────────────────── + +log "Running pytest suite in $CALDAV_DIR/" +export OXICLOUD_CALDAV_URL="$base_url/caldav/" +export OXICLOUD_CALDAV_USERNAME="$username" +export OXICLOUD_CALDAV_APP_PASSWORD="$APP_PASSWORD" + +cd "$CALDAV_DIR" +# `--show-capture=no` hides pytest's "Captured log setup/call" section +# entirely on failure. pycaldav emits a full lxml XMLSyntaxError +# traceback via `logging.critical(..., exc_info=True)` on every +# make_calendar() when the server ignores the URL slug — genuine +# assertion output was drowning in it. Real test failures still show +# the assertion line + short traceback via --tb=short. +# +# Don't let a pytest non-zero exit skip the cleanup trap — capture +# the status, invoke cleanup (which tails the server log on failure), +# then re-emit the exit code. +set +e +pytest -v --tb=short --show-capture=no "$@" +SUITE_EXIT=$? +set -e + +if [[ $SUITE_EXIT -eq 0 ]]; then + log "pycaldav suite passed." +else + log "pycaldav suite failed (exit $SUITE_EXIT)." +fi +# Always show where the server log is — useful for post-mortem +# ("why did the server log an error next to that XFAIL?") even +# on green runs. On failure the cleanup trap has already dumped +# the tail; the file itself sticks around until the next run +# truncates it. +log "Server log preserved at: $SERVER_LOG" +exit "$SUITE_EXIT" diff --git a/tests/caldav/test.env b/tests/caldav/test.env new file mode 100644 index 00000000..deefaae4 --- /dev/null +++ b/tests/caldav/test.env @@ -0,0 +1,11 @@ +# Test credentials for local/CI CalDAV client-driven tests — NOT real secrets. +# +# Uses a distinct port from api-test/webdav (8087) and webdav-drive-root +# (8089) so it can run concurrently with those suites if the developer +# opens multiple terminals. The orchestrator (`run-pycaldav.sh`) spawns +# its own postgres + server tied to this port. +base_url=http://localhost:8091 +username=admin +email=admin@example.com +# gitguardian:ignore +password=TestPassword1! diff --git a/tests/caldav/test_carddav.py b/tests/caldav/test_carddav.py new file mode 100644 index 00000000..3965d1d4 --- /dev/null +++ b/tests/caldav/test_carddav.py @@ -0,0 +1,290 @@ +"""CardDAV (RFC 6352) surface coverage. + +python-caldav has no CardDAV support (the library name is a bit +misleading — it's CalDAV-only). These tests drive the server via +raw HTTP through the SAME authenticated `dav_client` session used +by the CalDAV tests, so credentials + connection reuse stay +consistent with the rest of the suite. + +Fixtures: + * `carddav_url` — CardDAV base URL, derived from OXICLOUD_CALDAV_URL + by replacing `/caldav/` with `/carddav/`. + * `fresh_addressbook` — a brand-new address book per test; yields + the server-authoritative URL as a string; teardown DELETEs it. + +Same emitter-gap caveats as `test_ical_coverage.py`: the server +regenerates vCard bodies from stored DTO fields on GET, so +properties beyond FN / N / EMAIL may be silently dropped. Tests +here split into sanity (must round-trip) vs xfail (documented +gaps). +""" + +from __future__ import annotations + +import textwrap +import uuid + +import caldav +import pytest + + +# ───────────────────────────────────────────────────────────── +# Helpers — mirror the CalDAV pattern. Raw HTTP through the +# authenticated pycaldav session; no client-library abstractions. +# ───────────────────────────────────────────────────────────── + + +def _dedent_vcard(body: str) -> str: + """RFC 6350 §3.2 mandates CRLF between properties, same as + iCalendar. Normalise text-block indentation and line endings.""" + return textwrap.dedent(body).strip().replace("\n", "\r\n") + "\r\n" + + +def _put_vcard( + dav_client: caldav.DAVClient, addressbook_url: str, uid: str, body: str +) -> None: + url = addressbook_url.rstrip("/") + f"/{uid}.vcf" + r = dav_client.request( + url, + method="PUT", + body=body, + headers={"Content-Type": "text/vcard; charset=utf-8"}, + ) + if r.status < 200 or r.status >= 300: + raise AssertionError( + f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}" + ) + + +def _get_vcard( + dav_client: caldav.DAVClient, addressbook_url: str, uid: str +) -> str: + url = addressbook_url.rstrip("/") + f"/{uid}.vcf" + r = dav_client.request(url, method="GET") + if r.status < 200 or r.status >= 300: + raise AssertionError(f"GET {url} → HTTP {r.status}\n{r.raw!r}") + return r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw + + +def _delete_vcard( + dav_client: caldav.DAVClient, addressbook_url: str, uid: str +) -> int: + url = addressbook_url.rstrip("/") + f"/{uid}.vcf" + r = dav_client.request(url, method="DELETE") + return r.status + + +def _minimal_vcard(uid: str, **extras: str) -> str: + """Build a minimal RFC 6350 vCard 4.0 body with the given + extra property lines injected before END:VCARD.""" + base = f"""\ + BEGIN:VCARD + VERSION:4.0 + UID:{uid} + FN:Coverage Contact + N:Coverage;Contact;;; + """ + body = textwrap.dedent(base).rstrip() + "\n" + for line in extras.values(): + body += line + "\n" + body += "END:VCARD\n" + return body.replace("\n", "\r\n") + + +# ───────────────────────────────────────────────────────────── +# Sanity — properties the server round-trips. +# ───────────────────────────────────────────────────────────── + + +def test_vcard_basic_round_trip( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """The core CardDAV contract: PUT a vCard, GET it back, body + contains at least the UID + FN we sent. FN (formatted name) + is RFC 6350 §6.2.1 REQUIRED — a vCard without it is invalid, + and the server must preserve it verbatim.""" + uid = f"cov-basic-{uuid.uuid4().hex[:8]}" + body = _minimal_vcard(uid) + _put_vcard(dav_client, fresh_addressbook, uid, body) + + fetched = _get_vcard(dav_client, fresh_addressbook, uid) + assert f"UID:{uid}" in fetched, f"UID missing from GET:\n{fetched}" + assert "FN:Coverage Contact" in fetched, ( + f"FN dropped on round-trip:\n{fetched}" + ) + + +def test_vcard_email_survives_round_trip( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """EMAIL (RFC 6350 §6.4.2) — one of the two properties most + real contact clients set. Loss here would break sync with + every address-book UI.""" + uid = f"cov-email-{uuid.uuid4().hex[:8]}" + body = _minimal_vcard( + uid, + email="EMAIL;TYPE=work:coverage.contact@example.com", + ) + _put_vcard(dav_client, fresh_addressbook, uid, body) + + fetched = _get_vcard(dav_client, fresh_addressbook, uid) + assert "coverage.contact@example.com" in fetched, ( + f"EMAIL dropped on round-trip:\n{fetched}" + ) + + +def test_vcard_delete_removes_it( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """PUT → DELETE → GET must 404. Regression guard against + delete-doesn't-actually-delete bugs (which have surfaced in + other DAV surfaces during D7 work).""" + uid = f"cov-del-{uuid.uuid4().hex[:8]}" + _put_vcard(dav_client, fresh_addressbook, uid, _minimal_vcard(uid)) + + status = _delete_vcard(dav_client, fresh_addressbook, uid) + assert 200 <= status < 300, f"DELETE returned HTTP {status}" + + # Re-fetch should 404. `_get_vcard` raises on non-2xx; catch it. + url = fresh_addressbook.rstrip("/") + f"/{uid}.vcf" + r = dav_client.request(url, method="GET") + assert r.status == 404, ( + f"GET after DELETE expected 404; got HTTP {r.status}" + ) + + +def test_addressbook_shows_up_in_propfind( + dav_client: caldav.DAVClient, + carddav_url: str, + fresh_addressbook: str, +) -> None: + """Sanity: the just-created address book is listed by a + PROPFIND Depth 1 on the CardDAV root. Same shape a real + client uses to enumerate address books at login.""" + propfind_body = ( + '' + '' + "" + "" + ) + r = dav_client.request( + carddav_url, + method="PROPFIND", + body=propfind_body, + headers={"Depth": "1", "Content-Type": "application/xml"}, + ) + assert 200 <= r.status < 300, f"PROPFIND → HTTP {r.status}" + xml = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw + + # `fresh_addressbook` is an absolute URL; the href in the + # PROPFIND response is the path portion. Extract and check. + import urllib.parse + + ab_path = urllib.parse.urlparse(fresh_addressbook).path + assert ab_path in xml, ( + f"Fresh address book path {ab_path} missing from PROPFIND:\n{xml}" + ) + + +# ───────────────────────────────────────────────────────────── +# Documented gaps — vCard properties the server currently drops +# on GET. Same shape as the CalDAV emitter gap: server rebuilds +# the response body from stored DTO fields; properties not in +# the DTO surface are silently dropped. +# ───────────────────────────────────────────────────────────── + +_TEL_URI_PARSER_BUG_REASON = ( + "contact_service.rs::parse_vcard splits the TEL line by ':' " + "and takes .nth(1) as the number — a URI-form value like " + "`TEL;TYPE=cell;VALUE=uri:tel:+15551234567` gets sliced to " + "'tel' (the middle segment), losing the actual phone number. " + "Real clients (Apple Contacts, DAVx⁵) commonly emit the URI " + "form. Fix: split on the FIRST ':' only, or parse the " + "parameter list properly. Own fix branch." +) + +_ADR_UNPARSED_REASON = ( + "contact_service.rs::parse_vcard has NO handler for ADR — the " + "structured-address property (RFC 6350 §6.3.1) is silently " + "dropped at PUT time. DTO carries an `address: Vec
` " + "field the emitter honours; parser just never populates it. " + "Fix: extend the match with an ADR branch that splits on ';' " + "into (pobox, ext, street, city, region, postal, country) — " + "mirror the emitter's format at contact_service.rs::195-ish." +) + + +def test_vcard_org_and_title_survive_round_trip( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """ORG + TITLE (RFC 6350 §6.6.4 / §6.6.1). Business-card + fields — losing them means everyone's job title disappears + from address-book UIs after the first sync. + + Passes today: parse_vcard has ORG / TITLE branches; the + emitter (contact_to_vcard) rewrites both from DTO fields.""" + uid = f"cov-org-{uuid.uuid4().hex[:8]}" + body = _minimal_vcard( + uid, + org="ORG:Acme Corporation;R&D", + title="TITLE:Principal Engineer", + ) + _put_vcard(dav_client, fresh_addressbook, uid, body) + + fetched = _get_vcard(dav_client, fresh_addressbook, uid) + assert "Acme Corporation" in fetched + assert "Principal Engineer" in fetched + + +def test_vcard_note_survives_round_trip( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """NOTE (RFC 6350 §6.7.2). Free-form text field every contact + UI exposes. Passes today: parse_vcard strips NOTE:, emitter + re-emits with newline escaping.""" + uid = f"cov-note-{uuid.uuid4().hex[:8]}" + body = _minimal_vcard( + uid, + note="NOTE:Met at KubeCon 2026. Prefers email over phone.", + ) + _put_vcard(dav_client, fresh_addressbook, uid, body) + + fetched = _get_vcard(dav_client, fresh_addressbook, uid) + assert "KubeCon 2026" in fetched + + +@pytest.mark.xfail(reason=_TEL_URI_PARSER_BUG_REASON, strict=False) +def test_vcard_tel_uri_form_survives_round_trip( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """TEL (RFC 6350 §6.4.1) with URI-form value + TYPE parameter — + the shape Apple Contacts / DAVx⁵ send for every phone number. + See _TEL_URI_PARSER_BUG_REASON.""" + uid = f"cov-tel-{uuid.uuid4().hex[:8]}" + body = _minimal_vcard( + uid, + tel="TEL;TYPE=cell;VALUE=uri:tel:+15551234567", + ) + _put_vcard(dav_client, fresh_addressbook, uid, body) + + fetched = _get_vcard(dav_client, fresh_addressbook, uid) + assert "+15551234567" in fetched + + +@pytest.mark.xfail(reason=_ADR_UNPARSED_REASON, strict=False) +def test_vcard_adr_survives_round_trip( + dav_client: caldav.DAVClient, fresh_addressbook: str +) -> None: + """ADR (RFC 6350 §6.3.1) with structured components. Semicolon + is the structured-value separator. See _ADR_UNPARSED_REASON — + parser has no ADR branch at all.""" + uid = f"cov-adr-{uuid.uuid4().hex[:8]}" + body = _minimal_vcard( + uid, + adr="ADR;TYPE=home:;;42 Rue de Rivoli;Paris;;75001;France", + ) + _put_vcard(dav_client, fresh_addressbook, uid, body) + + fetched = _get_vcard(dav_client, fresh_addressbook, uid) + assert "Rue de Rivoli" in fetched + assert "Paris" in fetched diff --git a/tests/caldav/test_ical_coverage.py b/tests/caldav/test_ical_coverage.py new file mode 100644 index 00000000..2d011a99 --- /dev/null +++ b/tests/caldav/test_ical_coverage.py @@ -0,0 +1,287 @@ +"""Non-recurring iCalendar property coverage via python-caldav. + +Complements `test_recurring.py` (the #528 regression suite) by +sweeping the property surface of a single, non-recurring VEVENT. +Real CalDAV clients send many properties beyond DTSTART/DTEND + +SUMMARY; whether those survive a PUT → GET round-trip is what +this file measures. + +The GET path in `caldav_handler.rs::write_vevent` regenerates +the response body from the stored DTO fields (UID / SUMMARY / +DTSTART / DTEND / DESCRIPTION / LOCATION / RRULE / DTSTAMP / +CREATED / LAST-MODIFIED). Anything not in that list is silently +dropped even though the original `ical_data` is stored intact. + +Tests split into two groups: + + * **Sanity** — properties the server emits on GET; they must + round-trip. Regressions here would be genuine server bugs. + + * **xfail (documented gaps)** — properties the server currently + drops. `@pytest.mark.xfail(strict=False)` lets the suite stay + green while making the gap visible in the pytest summary. If + a future server fix makes one of these survive, pytest + reports it as `XPASS` — an alert to remove the marker. +""" + +from __future__ import annotations + +import textwrap +import uuid + +import caldav +import pytest + + +# ───────────────────────────────────────────────────────────── +# Helpers (mirror the raw-HTTP-PUT / master-URL-GET pattern +# from test_recurring.py). Kept local to this file for now; +# fold into conftest.py if a third test file wants them. +# ───────────────────────────────────────────────────────────── + + +def _dedent(ical: str) -> str: + return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n" + + +def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None: + url = str(calendar.url).rstrip("/") + f"/{uid}.ics" + r = calendar.client.request( + url, + method="PUT", + body=body, + headers={"Content-Type": "text/calendar; charset=utf-8"}, + ) + if r.status < 200 or r.status >= 300: + raise AssertionError( + f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}" + ) + + +def _get_ical(calendar: caldav.Calendar, uid: str) -> str: + url = str(calendar.url).rstrip("/") + f"/{uid}.ics" + r = calendar.client.request(url, method="GET") + if r.status < 200 or r.status >= 300: + raise AssertionError(f"GET {url} → HTTP {r.status}") + return r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw + + +def _minimal_event(uid: str, **extra_lines: str) -> str: + """Build a minimal VEVENT with the given extra iCal property lines + injected before END:VEVENT. Values in `extra_lines` should be full + property lines (name+value), one per key. The key exists only so + tests can override without clobbering; it isn't emitted.""" + base = f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav coverage//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260101T090000Z + DTEND:20260101T093000Z + SUMMARY:Coverage event + """ + body = textwrap.dedent(base).rstrip() + "\n" + for line in extra_lines.values(): + body += line + "\n" + body += "END:VEVENT\nEND:VCALENDAR\n" + return body.replace("\n", "\r\n") + + +# ───────────────────────────────────────────────────────────── +# Sanity — properties the server DOES emit on GET. +# ───────────────────────────────────────────────────────────── + + +def test_description_with_escaped_chars_round_trips( + fresh_calendar: caldav.Calendar, +) -> None: + """RFC 5545 §3.3.11 mandates comma / semicolon / newline + escaping in TEXT values. A Description with all three must + survive PUT → GET. + + Note: our own generate_event_ical only escapes newlines + (`\\n`), not commas or semicolons — this test guards the + minimum bar. A stricter test could assert exact escape + handling; deferred until the emitter is RFC-strict.""" + uid = f"cov-desc-{uuid.uuid4().hex[:8]}" + # RFC 5545 escapes: `\n` for newline, `\,` for comma, `\;` for + # semicolon. Client sends them ALREADY escaped in the wire body. + body = _minimal_event( + uid, + description=r"DESCRIPTION:multi-line\ntext with a comma\, and a semi\;colon.", + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "multi-line" in fetched + # Server currently emits `\n` back but may drop `\,` / `\;` + # escapes — accept either the escaped or unescaped form here so + # the sanity check tolerates the current emitter without failing + # on the strict spec detail. + assert ( + "comma" in fetched.lower() + ), f"DESCRIPTION body lost the comma text entirely:\n{fetched}" + + +def test_location_survives_round_trip(fresh_calendar: caldav.Calendar) -> None: + uid = f"cov-loc-{uuid.uuid4().hex[:8]}" + body = _minimal_event( + uid, + location="LOCATION:Room 3B\\, Building 42", + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "Room 3B" in fetched, f"LOCATION lost:\n{fetched}" + + +def test_uid_and_dtstamp_are_preserved(fresh_calendar: caldav.Calendar) -> None: + """Belt-and-braces sanity — UID is the resource identifier and + DTSTAMP is required by RFC 5545 §3.8.7.2 on every VEVENT. Both + are emitted from DTO fields, so both round-trip cleanly.""" + uid = f"cov-uid-{uuid.uuid4().hex[:8]}" + body = _minimal_event(uid) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert f"UID:{uid}" in fetched + assert "DTSTAMP:" in fetched + + +# ───────────────────────────────────────────────────────────── +# Documented gaps — properties the server currently drops on +# GET. `xfail(strict=False)` means "expected to fail; don't fail +# the suite, but flag XPASS if it starts passing". When the +# read-side fix lands, remove the marker. +# ───────────────────────────────────────────────────────────── + +_EMITTER_GAP_REASON = ( + "GET regenerates the body from DTO fields via write_vevent " + "(caldav_handler.rs:~770) which only emits UID / SUMMARY / " + "DTSTART / DTEND / DESCRIPTION / LOCATION / RRULE / DTSTAMP / " + "CREATED / LAST-MODIFIED. Every other iCal property is stored " + "in ical_data on the row but silently dropped on read. " + "Fix path: either serve ical_data verbatim on GET, or extend " + "the DTO to carry the full property set." +) + + +@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False) +def test_attendee_survives_round_trip(fresh_calendar: caldav.Calendar) -> None: + uid = f"cov-attendee-{uuid.uuid4().hex[:8]}" + body = _minimal_event( + uid, + attendee=( + "ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED;RSVP=TRUE:" + "mailto:alice@example.com" + ), + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "ATTENDEE" in fetched, f"ATTENDEE dropped:\n{fetched}" + assert "alice@example.com" in fetched + + +@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False) +def test_organizer_survives_round_trip(fresh_calendar: caldav.Calendar) -> None: + uid = f"cov-organizer-{uuid.uuid4().hex[:8]}" + body = _minimal_event( + uid, + organizer="ORGANIZER;CN=Bob:mailto:bob@example.com", + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "ORGANIZER" in fetched + assert "bob@example.com" in fetched + + +@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False) +def test_categories_survive_round_trip(fresh_calendar: caldav.Calendar) -> None: + uid = f"cov-cats-{uuid.uuid4().hex[:8]}" + body = _minimal_event( + uid, + categories="CATEGORIES:MEETING,ENGINEERING,SPRINT-42", + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "CATEGORIES" in fetched + assert "ENGINEERING" in fetched + + +@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False) +def test_status_and_transp_survive_round_trip( + fresh_calendar: caldav.Calendar, +) -> None: + """STATUS (RFC 5545 §3.8.1.11) and TRANSP (§3.8.2.7) drive + "tentative vs confirmed" and "shows as busy vs free" in every + calendar client UI. Losing them silently is user-visible.""" + uid = f"cov-status-{uuid.uuid4().hex[:8]}" + body = _minimal_event( + uid, + status="STATUS:TENTATIVE", + transp="TRANSP:TRANSPARENT", + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "STATUS:TENTATIVE" in fetched + assert "TRANSP:TRANSPARENT" in fetched + + +@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False) +def test_valarm_survives_round_trip(fresh_calendar: caldav.Calendar) -> None: + """VALARM is a nested sub-component of VEVENT (RFC 5545 §3.6.6) + and drives every "remind me 15 min before" popup. It lives + entirely in ical_data on the row and is invisible to the DTO. + Dropping it on GET means alarms silently disappear after the + first client sync.""" + uid = f"cov-alarm-{uuid.uuid4().hex[:8]}" + body = _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav coverage//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260101T090000Z + DTEND:20260101T093000Z + SUMMARY:Event with alarm + BEGIN:VALARM + ACTION:DISPLAY + TRIGGER:-PT15M + DESCRIPTION:15 min reminder + END:VALARM + END:VEVENT + END:VCALENDAR + """ + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "BEGIN:VALARM" in fetched, f"VALARM block dropped:\n{fetched}" + assert "TRIGGER:-PT15M" in fetched + + +@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False) +def test_custom_x_property_survives_round_trip( + fresh_calendar: caldav.Calendar, +) -> None: + """Custom `X-*` properties (RFC 5545 §3.8.8.2). Apple Calendar + uses `X-APPLE-*`, DAVx⁵ uses `X-MOZ-*`, and Nextcloud uses + `X-NEXTCLOUD-*`. Dropping them breaks client-specific UI cues + without corrupting core interop.""" + uid = f"cov-xprop-{uuid.uuid4().hex[:8]}" + body = _minimal_event( + uid, + xprop="X-MOZ-LASTACK:20260101T090000Z", + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_ical(fresh_calendar, uid) + assert "X-MOZ-LASTACK" in fetched diff --git a/tests/caldav/test_recurring.py b/tests/caldav/test_recurring.py new file mode 100644 index 00000000..dd3fe31a --- /dev/null +++ b/tests/caldav/test_recurring.py @@ -0,0 +1,328 @@ +"""End-to-end regression for AtalayaLabs/OxiCloud#528 via python-caldav. + +The Hurl coverage in `tests/api/caldav_recurring.hurl` exercises the +raw HTTP surface; this file drives the SAME behaviour through the +python-caldav client library — the same VObject + RFC 5545 stack that +Thunderbird, DAVx⁵ and Gnome Calendar use. If a real client's shape +diverges from what our Hurl fixtures send, this suite catches it. + +Two access paths need distinguishing: + + * URL GET on `/caldav//.ics` — routes through + `find_event_by_ical_uid` which is master-only. This is what + single-file iCal clients (older Thunderbird, Apple Reminders' + quick-lookup) hit. + + * calendar-query REPORT — returns every calendar-object-resource + matching the filter, so a UID with both a master AND per-instance + overrides yields multiple entries. This is what modern CalDAV + clients (Thunderbird 2024+, DAVx⁵, Apple Calendar) use for + initial sync and delta refresh. + +The suite exercises both paths — mixing them up is what tripped the +first draft (calendar.event_by_uid → REPORT under the hood, returned +the exception, tests failed). +""" + +from __future__ import annotations + +import textwrap +import uuid + +import caldav + + +# ───────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────── + + +def _dedent(ical: str) -> str: + """Strip test-source indentation and normalise line endings to + CRLF, which RFC 5545 §3.1 mandates.""" + return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n" + + +def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None: + """PUT the raw iCalendar body directly via pycaldav's authenticated + session — bypassing pycaldav's `save_event()`. + + Empirically, `save_event(body)` re-parses the body through pycaldav's + icalendar/vobject stack and re-serialises before PUTting. When the + body contains a master VEVENT + a per-instance override sharing the + same UID, that internal re-serialisation dropped the master and only + sent the override — the exact behaviour the #528 fix must defend + against. Bypassing that layer sends the bytes verbatim, mirroring + what a real client (Thunderbird / DAVx⁵ / Apple Calendar) puts on + the wire. + """ + url = str(calendar.url).rstrip("/") + f"/{uid}.ics" + response = calendar.client.request( + url, + method="PUT", + body=body, + headers={"Content-Type": "text/calendar; charset=utf-8"}, + ) + if response.status < 200 or response.status >= 300: + raise AssertionError( + f"PUT {url} → HTTP {response.status}\nbody sent: {body!r}\n" + f"response: {response.raw!r}" + ) + + +def _get_master_ical(calendar: caldav.Calendar, uid: str) -> str: + """Direct URL GET on `/caldav//.ics` — routes through + the master-only lookup on the server. Returns the raw response + body (text/calendar). + + This bypasses pycaldav's REPORT-based `event_by_uid()` which + would return every row matching the UID (master + exceptions) + and force the caller to filter. + """ + url = str(calendar.url).rstrip("/") + f"/{uid}.ics" + response = calendar.client.request(url, method="GET") + if response.status < 200 or response.status >= 300: + raise AssertionError( + f"GET {url} → HTTP {response.status}\n" + f"body: {response.raw!r}" + ) + return response.raw.decode("utf-8") if isinstance(response.raw, bytes) else response.raw + + +# ───────────────────────────────────────────────────────────── +# Baseline: prove the pipe works before we push it +# ───────────────────────────────────────────────────────────── + + +def test_non_recurring_event_round_trip(fresh_calendar: caldav.Calendar) -> None: + uid = f"e2e-baseline-{uuid.uuid4().hex[:8]}" + body = _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav e2e//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260101T090000Z + DTEND:20260101T093000Z + SUMMARY:Baseline event + END:VEVENT + END:VCALENDAR + """ + ) + _put_ical(fresh_calendar, uid, body) + + fetched = _get_master_ical(fresh_calendar, uid) + assert "SUMMARY:Baseline event" in fetched + assert f"UID:{uid}" in fetched + + +# ───────────────────────────────────────────────────────────── +# #528 timed flavour +# ───────────────────────────────────────────────────────────── + + +def test_recurring_master_plus_exception_preserves_master( + fresh_calendar: caldav.Calendar, +) -> None: + uid = f"e2e-daily-{uuid.uuid4().hex[:8]}" + + # (1) Master only — the shape a client sends when the user first + # creates a recurring event. + master_only = _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav e2e//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260101T090000Z + DTEND:20260101T093000Z + SUMMARY:Daily standup + RRULE:FREQ=DAILY;COUNT=10 + END:VEVENT + END:VCALENDAR + """ + ) + _put_ical(fresh_calendar, uid, master_only) + + # (2) Master + per-instance override — the shape a client sends + # when the user modifies a single occurrence in the UI. + with_exception = _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav e2e//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260101T090000Z + DTEND:20260101T093000Z + SUMMARY:Daily standup + RRULE:FREQ=DAILY;COUNT=10 + END:VEVENT + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260103T110000Z + DTEND:20260103T120000Z + SUMMARY:Daily standup — rescheduled + RECURRENCE-ID:20260103T090000Z + END:VEVENT + END:VCALENDAR + """ + ) + _put_ical(fresh_calendar, uid, with_exception) + + # Master URL GET must return the master row. Pre-fix this would + # have returned the exception's data (the last VEVENT in the + # body clobbered the row). + body = _get_master_ical(fresh_calendar, uid) + assert "RRULE:FREQ=DAILY;COUNT=10" in body, ( + "Master row lost its RRULE — the exception overwrote the master. " + "This is the exact regression from #528.\nMaster body: " + body + ) + assert "SUMMARY:Daily standup" in body + + # NOTE: not asserting the exception row is client-visible here. + # RFC 4791 §4.1 + RFC 5545 §3.8.4.4 model a recurring event with + # per-instance overrides as ONE calendar-object-resource whose + # VCALENDAR contains the master VEVENT + all exception VEVENTs. + # OxiCloud currently persists them as separate rows but the + # GET/PROPFIND emitter returns only the master (see phase-4 + # follow-up on branch feat/caldav-read-side). Once phase 4 + # lands, add: assert "RECURRENCE-ID" in body and + # assert "rescheduled" in body. + + +def test_exception_only_put_does_not_wipe_master( + fresh_calendar: caldav.Calendar, +) -> None: + uid = f"e2e-daily-{uuid.uuid4().hex[:8]}" + + # Seed: master + override. + _put_ical( + fresh_calendar, + uid, + _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav e2e//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260101T090000Z + DTEND:20260101T093000Z + SUMMARY:Daily standup + RRULE:FREQ=DAILY;COUNT=10 + END:VEVENT + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART:20260103T110000Z + DTEND:20260103T120000Z + SUMMARY:Daily standup — rescheduled + RECURRENCE-ID:20260103T090000Z + END:VEVENT + END:VCALENDAR + """ + ), + ) + + # Client's next action: user edits the same overridden occurrence + # again. Thunderbird / Apple Calendar re-send ONLY the exception. + _put_ical( + fresh_calendar, + uid, + _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav e2e//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T110000Z + DTSTART:20260103T120000Z + DTEND:20260103T130000Z + SUMMARY:Daily standup — rescheduled AGAIN + RECURRENCE-ID:20260103T090000Z + END:VEVENT + END:VCALENDAR + """ + ), + ) + + # Master URL GET must still return the master. Pre-fix the + # exception-only PUT would have replaced the master (keyed by + # UID with no recurrence_id filter) — this is the data-loss + # half of #528. + body = _get_master_ical(fresh_calendar, uid) + assert "RRULE:FREQ=DAILY;COUNT=10" in body + assert "SUMMARY:Daily standup" in body + assert "rescheduled" not in body, ( + "GET on the master URL returned the exception's data — the " + "master was clobbered by the exception-only PUT." + ) + + # NOTE: exception-row survival is not asserted client-side + # today — the emitter only surfaces the master. Phase 4 + # (feat/caldav-read-side) will fold master + exceptions into a + # single VCALENDAR body; once landed, add an assertion that the + # updated exception's SUMMARY ("rescheduled AGAIN") is present + # in the same GET body as the master's RRULE. + + +# ───────────────────────────────────────────────────────────── +# #528 all-day flavour — the exact shape the ticket was filed +# against. The DATE-form `DTSTART;VALUE=DATE:...` line was +# invisible to the pre-fix substring parser, so the whole +# body 500'd. +# ───────────────────────────────────────────────────────────── + + +def test_all_day_recurring_master_plus_exception( + fresh_calendar: caldav.Calendar, +) -> None: + uid = f"e2e-allday-{uuid.uuid4().hex[:8]}" + body = _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav e2e//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART;VALUE=DATE:20260105 + DTEND;VALUE=DATE:20260106 + SUMMARY:Weekly review + RRULE:FREQ=WEEKLY;COUNT=4 + END:VEVENT + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T100000Z + DTSTART;VALUE=DATE:20260113 + DTEND;VALUE=DATE:20260114 + SUMMARY:Weekly review — moved + RECURRENCE-ID;VALUE=DATE:20260112 + END:VEVENT + END:VCALENDAR + """ + ) + _put_ical(fresh_calendar, uid, body) + + # Master URL GET returns the master row with the RRULE intact. + # Pre-parser-rewrite the whole PUT 500'd because the param- + # carrying DTSTART line was invisible to the scanner. + data = _get_master_ical(fresh_calendar, uid) + assert "RRULE:FREQ=WEEKLY;COUNT=4" in data, ( + "Master lost its RRULE (or the whole PUT was rejected).\n" + f"Master body: {data}" + ) + assert "SUMMARY:Weekly review" in data + + # NOTE: exception row is stored server-side but not yet visible + # in the GET body. Phase 4 will fold it in — assertion to add + # once that lands: assert "RECURRENCE-ID;VALUE=DATE:20260112" in data. diff --git a/tests/caldav/test_report.py b/tests/caldav/test_report.py new file mode 100644 index 00000000..38348812 --- /dev/null +++ b/tests/caldav/test_report.py @@ -0,0 +1,312 @@ +"""CalDAV REPORT method coverage via python-caldav. + +The REPORT verb (RFC 4791 §7) is how clients do bulk sync + filtered +lookup. Three subtypes matter for OxiCloud's server surface: + + * `calendar-query` (§7.8) — filter events by time-range / + property. `search(start=..., end=...)` in pycaldav emits this. + * `calendar-multiget` (§7.9) — batch fetch by href list. Used + when the client already knows which UIDs it wants. + * `sync-collection` (§7.9 / RFC 6578) — token-based delta sync. + Not exercised here yet — the server delegates to `list_events` + (no per-token filtering), so a coverage test would just + replicate the calendar-query case. Leave for later once real + sync-token support lands. + +The tests seed a fresh calendar with three timed events an hour +apart, then exercise each REPORT shape. Row-count assertions are +safe here because the seeded events are all masters (non-recurring), +so master/exception folding doesn't apply — one URL per UID matches +one row in DB. +""" + +from __future__ import annotations + +import textwrap +import uuid +from datetime import datetime, timezone + +import caldav +import pytest + + +_TIME_RANGE_PARSER_BUG_REASON = ( + "caldav_adapter.rs:~105 + ~172 parses time-range start/end as " + "RFC 3339 (`2026-01-01T09:30:00Z`), but CalDAV clients send " + "iCalendar DATE-TIME (`20260101T093000Z` — RFC 4791 §9.9). " + "Parse fails, time_range becomes None, handle_report falls " + "through to list_events → returns every event regardless of " + "window. Fix: chrono::NaiveDateTime::parse_from_str with " + "`%Y%m%dT%H%M%SZ` (RFC 3339 as fallback). Own fix branch, " + "e.g. fix/caldav-time-range-parser." +) + + +# ───────────────────────────────────────────────────────────── +# Helpers — mirror the pattern from test_recurring.py / +# test_ical_coverage.py. Deliberately duplicated for now; +# promote to conftest.py once a fourth test file shows up. +# ───────────────────────────────────────────────────────────── + + +def _dedent(ical: str) -> str: + return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n" + + +def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None: + url = str(calendar.url).rstrip("/") + f"/{uid}.ics" + r = calendar.client.request( + url, + method="PUT", + body=body, + headers={"Content-Type": "text/calendar; charset=utf-8"}, + ) + if r.status < 200 or r.status >= 300: + raise AssertionError( + f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}" + ) + + +def _seed_three_events(calendar: caldav.Calendar) -> list[str]: + """Seed three non-recurring events, one hour apart, starting + 2026-01-01T09:00 UTC. Returns the list of UIDs in wall-clock + order (index 0 = earliest). + + Non-recurring is deliberate: it isolates REPORT semantics from + master/exception folding (which is phase-4 territory).""" + uids: list[str] = [] + times = [ + ("20260101T090000Z", "20260101T093000Z", "Morning standup"), + ("20260101T100000Z", "20260101T110000Z", "Mid-morning sync"), + ("20260101T140000Z", "20260101T150000Z", "Afternoon review"), + ] + for start, end, summary in times: + uid = f"report-{uuid.uuid4().hex[:8]}" + _put_ical( + calendar, + uid, + _dedent( + f"""\ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//pycaldav report coverage//EN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:20260101T080000Z + DTSTART:{start} + DTEND:{end} + SUMMARY:{summary} + END:VEVENT + END:VCALENDAR + """ + ), + ) + uids.append(uid) + return uids + + +# ───────────────────────────────────────────────────────────── +# calendar-query REPORT +# ───────────────────────────────────────────────────────────── + + +@pytest.mark.xfail(reason=_TIME_RANGE_PARSER_BUG_REASON, strict=False) +def test_calendar_query_time_range_returns_events_in_window( + fresh_calendar: caldav.Calendar, +) -> None: + """A time-range filter that spans the middle of the seeded + day should return only the events whose (DTSTART, DTEND) + overlaps the window. RFC 4791 §9.9 defines overlap: an event + overlaps a range if DTSTART < range_end AND DTEND > range_start.""" + uids = _seed_three_events(fresh_calendar) + + # Window: 09:30 → 12:00 UTC. Overlaps events 0 (09:00–09:30 + # touches the boundary at 09:30; RFC excludes exact touch) + # and event 1 (10:00–11:00, wholly inside). Excludes event 2 + # (14:00–15:00, well outside). + window_start = datetime(2026, 1, 1, 9, 30, tzinfo=timezone.utc) + window_end = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + + found = fresh_calendar.search( + start=window_start, + end=window_end, + event=True, + expand=False, + ) + found_uids = {_uid_from_event_data(e.data) for e in found} + + # Event 1 (10:00–11:00) is definitely in-window; event 2 (14:00– + # 15:00) is definitely out. Event 0's overlap is boundary- + # dependent (server interpretation varies at exact-touch). The + # strong invariant: event 1 in, event 2 out. + assert uids[1] in found_uids, ( + f"Event 1 (mid-morning, wholly inside window) missing from " + f"time-range REPORT. Got: {found_uids}" + ) + assert uids[2] not in found_uids, ( + f"Event 2 (afternoon, wholly outside window) leaked into " + f"time-range REPORT. Got: {found_uids}" + ) + + +@pytest.mark.xfail(reason=_TIME_RANGE_PARSER_BUG_REASON, strict=False) +def test_calendar_query_time_range_after_all_events_returns_empty( + fresh_calendar: caldav.Calendar, +) -> None: + """A window that starts after every seeded event returns + zero results — proves the range filter is actually applied, + not silently ignored (which would surface as "all events + returned regardless of window").""" + _seed_three_events(fresh_calendar) + + window_start = datetime(2027, 1, 1, 0, 0, tzinfo=timezone.utc) + window_end = datetime(2027, 1, 2, 0, 0, tzinfo=timezone.utc) + + found = fresh_calendar.search( + start=window_start, + end=window_end, + event=True, + expand=False, + ) + assert found == [], ( + f"Expected empty result for window one year past all seeded " + f"events; got {len(found)} entries." + ) + + +@pytest.mark.xfail(reason=_TIME_RANGE_PARSER_BUG_REASON, strict=False) +def test_calendar_query_time_range_before_all_events_returns_empty( + fresh_calendar: caldav.Calendar, +) -> None: + """Symmetric to the after-window case.""" + _seed_three_events(fresh_calendar) + + window_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) + window_end = datetime(2025, 1, 2, 0, 0, tzinfo=timezone.utc) + + found = fresh_calendar.search( + start=window_start, + end=window_end, + event=True, + expand=False, + ) + assert found == [] + + +def test_calendar_query_no_filter_returns_every_event( + fresh_calendar: caldav.Calendar, +) -> None: + """`calendar.events()` (pycaldav) issues a calendar-query without + a time-range — the server routes this via `list_events`, so + every event in the calendar surfaces. Row count = 3 seeded + events (all non-recurring, so 1 URL per row).""" + uids = _seed_three_events(fresh_calendar) + + all_events = fresh_calendar.events() + found_uids = {_uid_from_event_data(e.data) for e in all_events} + + for expected in uids: + assert expected in found_uids, ( + f"Seeded event {expected} missing from unfiltered " + f"calendar-query REPORT. Got: {found_uids}" + ) + + +# ───────────────────────────────────────────────────────────── +# calendar-multiget REPORT +# ───────────────────────────────────────────────────────────── + + +def test_calendar_multiget_by_href_returns_the_targeted_events( + fresh_calendar: caldav.Calendar, +) -> None: + """calendar-multiget takes an explicit href list and returns + exactly those. Two hrefs → two responses. The server's + `get_events_by_ical_uids` (indexed `ical_uid = ANY(...)`) is + what pays for this instead of listing the whole calendar.""" + uids = _seed_three_events(fresh_calendar) + + base = str(fresh_calendar.url).rstrip("/") + "/" + # Target the first two events; skip event 2. + hrefs = [f"{base}{uids[0]}.ics", f"{base}{uids[1]}.ics"] + xml = _multiget_body(hrefs) + + r = fresh_calendar.client.request( + str(fresh_calendar.url), + method="REPORT", + body=xml, + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + ) + assert 200 <= r.status < 300, ( + f"REPORT calendar-multiget → HTTP {r.status}\nbody: {r.raw!r}" + ) + xml_body = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw + + assert uids[0] in xml_body, ( + f"Requested UID {uids[0]} missing from multiget response." + ) + assert uids[1] in xml_body, ( + f"Requested UID {uids[1]} missing from multiget response." + ) + assert uids[2] not in xml_body, ( + f"UID {uids[2]} (not requested) leaked into multiget response." + ) + + +def test_calendar_multiget_unknown_href_is_silently_absent( + fresh_calendar: caldav.Calendar, +) -> None: + """CalDAV multiget semantics: a requested href that doesn't + exist is silently absent from the response (not an error). + Some servers emit a `404` per-href entry; + the minimum bar is that the server must NOT 500 and must NOT + invent data.""" + uids = _seed_three_events(fresh_calendar) + + base = str(fresh_calendar.url).rstrip("/") + "/" + ghost_uid = f"does-not-exist-{uuid.uuid4().hex[:8]}" + hrefs = [f"{base}{uids[0]}.ics", f"{base}{ghost_uid}.ics"] + + r = fresh_calendar.client.request( + str(fresh_calendar.url), + method="REPORT", + body=_multiget_body(hrefs), + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + ) + assert 200 <= r.status < 300, ( + f"REPORT multiget with an unknown href must not 500 — got " + f"HTTP {r.status}\nresponse: {r.raw!r}" + ) + xml_body = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw + assert uids[0] in xml_body, ( + "Existing UID missing from multiget that also targeted a ghost href." + ) + + +# ───────────────────────────────────────────────────────────── +# Low-level helpers +# ───────────────────────────────────────────────────────────── + + +def _uid_from_event_data(data: str) -> str | None: + """Pull the UID out of a raw iCalendar body. Cheap enough for a + handful of events per test.""" + for line in data.replace("\r\n", "\n").split("\n"): + if line.startswith("UID:"): + return line[4:].strip() + return None + + +def _multiget_body(hrefs: list[str]) -> str: + """Assemble a minimal RFC 4791 §7.9 calendar-multiget REPORT + XML body for the given href list.""" + href_xml = "\n ".join(f"{h}" for h in hrefs) + return f""" + + + + + + {href_xml} + +"""