Add the CalDAV client and a CLI to drive it

Discovery is the three PROPFINDs RFC 4791 describes rather than a walk
through likely URLs. Queries use time-range, which v1 never did -- it
fetched whole calendars and filtered in the browser on every view change.
Every write states a precondition, so a stale ETag produces a Conflict a
caller can act on instead of silently destroying somebody's edit.

XML goes through quick-xml with namespace resolution. v1 matched prefixes
with six regexes tried in sequence and recompiled inside the loop; there
is a fixture here that is the same document under different prefixes, and
it parses identically.

Protocol parsing is split from transport so it can be tested against
responses recorded from a real Baikal -- including the second propstat
carrying 404s, which is what makes "this calendar has no colour" different
from "this calendar has an empty colour".

Live tests run against a real server, never a mock. tests/baikal/run.sh
starts a container, walks Baikal's install wizard, and runs them; each
test builds and destroys its own collection, so pointing it at a real
server touches nothing that was already there. They cover discovery,
round-trip, stale-ETag conflict, duplicate create, delete, time-range
filtering, a series returned whole with its override, and writing every
synthetic golden fixture to the server and reading it back.

libdav was evaluated first, as planned. Not adopted: its HttpClient trait
is defined over hyper::body::Incoming, so using it means replacing reqwest
everywhere, plus a DNS resolver for service discovery we do not do and a
second XML parser. Its precondition design is where Precondition's shape
comes from. Reasons are recorded in the crate docs.
This commit is contained in:
2026-08-26 15:32:08 -04:00
parent 689b78154b
commit f8e4a497fa
20 changed files with 2893 additions and 3 deletions
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Run the CalDAV integration tests against a throwaway Baikal.
#
# Starts a container, walks it through the install wizard, runs the live test
# suite against it, and tears it down again. Nothing touches a real calendar.
#
# crates/runway-caldav/tests/baikal/run.sh # start, test, stop
# KEEP=1 crates/runway-caldav/tests/baikal/run.sh # leave it running
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NAME="${NAME:-runway-baikal}"
PORT="${PORT:-8800}"
IMAGE="${IMAGE:-docker.io/ckulka/baikal:nginx}"
USERNAME="testuser"
PASSWORD="testpassword"
runtime() {
if command -v podman >/dev/null 2>&1; then echo podman
elif command -v docker >/dev/null 2>&1; then echo docker
else echo "need podman or docker" >&2; exit 1
fi
}
RUNTIME="$(runtime)"
cleanup() {
if [ "${KEEP:-0}" != "1" ]; then
"$RUNTIME" rm -f "$NAME" >/dev/null 2>&1 || true
else
echo "container $NAME left running on port $PORT"
fi
}
trap cleanup EXIT
"$RUNTIME" rm -f "$NAME" >/dev/null 2>&1 || true
"$RUNTIME" run -d --rm --name "$NAME" -p "$PORT:80" "$IMAGE" >/dev/null
echo "started $NAME ($IMAGE) on port $PORT"
# Baikal needs a moment before PHP answers.
for _ in $(seq 1 60); do
if [ "$(curl -sS -o /dev/null -w '%{http_code}' -L "http://localhost:$PORT/" 2>/dev/null)" = "200" ]; then
break
fi
sleep 1
done
python3 "$HERE/setup.py" "http://localhost:$PORT" "$USERNAME" "$PASSWORD"
export RUNWAY_CALDAV_URL="http://localhost:$PORT/dav.php/"
export RUNWAY_CALDAV_USER="$USERNAME"
export RUNWAY_CALDAV_PASSWORD="$PASSWORD"
cargo test -p runway-caldav --test live -- --test-threads=1 "$@"
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Drive a fresh Baikal container through its install wizard.
Baikal has no unattended-install path, so the integration tests would otherwise
need a hand-prepared image. This walks the same web forms a person would,
leaving a server with Basic authentication, one user, and one calendar.
Usage:
podman run -d --rm --name runway-baikal -p 8800:80 docker.io/ckulka/baikal:nginx
python3 setup.py http://localhost:8800 testuser testpassword
"""
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import http.cookiejar
ADMIN_PASSWORD = "runway-integration-admin"
def make_opener():
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def get(opener, url):
with opener.open(url, timeout=30) as response:
return response.read().decode("utf-8", "replace")
def post(opener, url, fields):
body = urllib.parse.urlencode(fields).encode()
request = urllib.request.Request(
url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}
)
with opener.open(request, timeout=30) as response:
return response.read().decode("utf-8", "replace")
def csrf(html):
"""The page's CSRF token. Not every form carries one -- the admin login
does not -- so callers that may see either use `maybe_csrf`."""
token = maybe_csrf(html)
if token is None:
raise SystemExit("no CSRF token in page; Baikal's forms have changed")
return token
def maybe_csrf(html):
match = re.search(r'name="CSRF_TOKEN"\s+value="([^"]+)"', html)
return match.group(1) if match else None
def install(base, username, password):
"""Walk the wizard until it stops offering forms.
The wizard serves every step from the same URL and decides which form to
show from its own stored state, so this is driven by what comes back rather
than by a fixed sequence of URLs -- which is also what keeps it working
across Baikal versions that add or reorder a step.
"""
opener = make_opener()
for _ in range(6):
page = get(opener, f"{base}/admin/install/")
if "Baikal_Model_Config_Standard::submitted" in page:
post(opener, f"{base}/admin/install/", system_fields(csrf(page)))
elif "Baikal_Model_Config_Database::submitted" in page:
post(opener, f"{base}/admin/install/", database_fields(csrf(page)))
else:
break
else:
raise SystemExit("the install wizard did not finish")
# Log in to the admin interface. This form has no CSRF token of its own.
page = get(opener, f"{base}/admin/")
if 'name="auth"' in page:
fields = {"auth": "1", "login": "admin", "password": ADMIN_PASSWORD}
token = maybe_csrf(page)
if token:
fields["CSRF_TOKEN"] = token
post(opener, f"{base}/admin/", fields)
# Create the test user. Baikal gives every new user a default calendar.
page = get(opener, f"{base}/admin/?/users/new/1/")
post(
opener,
f"{base}/admin/?/users/new/1/",
{
"Baikal_Model_User::submitted": "1",
"refreshed": "0",
"CSRF_TOKEN": csrf(page),
"data[username]": username,
"witness[username]": "1",
"data[displayname]": "Integration Test",
"witness[displayname]": "1",
"data[email]": f"{username}@example.org",
"witness[email]": "1",
"data[password]": password,
"witness[password]": "1",
"data[passwordconfirm]": password,
"witness[passwordconfirm]": "1",
},
)
users = get(opener, f"{base}/admin/?/users/")
if username not in users:
raise SystemExit(f"user {username} was not created")
print(f"ready: {base}/dav.php/ as {username}")
def system_fields(token):
"""Step one. Basic authentication, because that is what the client sends;
Baikal defaults to Digest."""
return {
"Baikal_Model_Config_Standard::submitted": "1",
"refreshed": "0",
"CSRF_TOKEN": token,
"data[timezone]": "UTC",
"witness[timezone]": "1",
"data[card_enabled]": "1",
"witness[card_enabled]": "1",
"data[cal_enabled]": "1",
"witness[cal_enabled]": "1",
"data[invite_from]": "noreply@example.org",
"witness[invite_from]": "1",
"data[dav_auth_type]": "Basic",
"witness[dav_auth_type]": "1",
"data[admin_passwordhash]": ADMIN_PASSWORD,
"witness[admin_passwordhash]": "1",
"data[admin_passwordhash_confirm]": ADMIN_PASSWORD,
"witness[admin_passwordhash_confirm]": "1",
}
def database_fields(token):
"""Step two. SQLite, at the path the form arrives pre-filled with."""
return {
"Baikal_Model_Config_Database::submitted": "1",
"refreshed": "0",
"CSRF_TOKEN": token,
"data[backend]": "sqlite",
"witness[backend]": "1",
"data[sqlite_file]": "/var/www/baikal/Specific/db/db.sqlite",
"witness[sqlite_file]": "1",
}
if __name__ == "__main__":
if len(sys.argv) != 4:
raise SystemExit(__doc__)
install(sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3])
@@ -0,0 +1,200 @@
<?xml version="1.0" ?>
<multistatus xmlns="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<response>
<href>/dav.php/calendars/alex/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/household-chores/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Household Chores</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/partner-chores/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Partner Chores</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/trips/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Trips</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/workouts/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Workouts</displayname>
<CAL:calendar-description>Calendar for logging workouts</CAL:calendar-description>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/birthdays/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
<cs:shared-owner/>
</resourcetype>
<displayname>Birthdays</displayname>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/personal/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Personal</displayname>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
<CAL:comp name="VTODO"/>
<CAL:comp name="VJOURNAL"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/inbox/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:schedule-inbox/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/outbox/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:schedule-outbox/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
</multistatus>
@@ -0,0 +1,200 @@
<?xml version="1.0" ?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/dav.php/calendars/alex/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
<cal:calendar-description/>
<cal:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/household-chores/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Household Chores</d:displayname>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<cal:calendar-description/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/partner-chores/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Partner Chores</d:displayname>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<cal:calendar-description/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/trips/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Trips</d:displayname>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<cal:calendar-description/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/workouts/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Workouts</d:displayname>
<cal:calendar-description>Calendar for logging workouts</cal:calendar-description>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/birthdays/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
<cs:shared-owner/>
</d:resourcetype>
<d:displayname>Birthdays</d:displayname>
<cal:calendar-description/>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/personal/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Personal</d:displayname>
<cal:calendar-description/>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
<cal:comp name="VTODO"/>
<cal:comp name="VJOURNAL"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/inbox/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:schedule-inbox/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
<cal:calendar-description/>
<cal:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/outbox/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:schedule-outbox/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
<cal:calendar-description/>
<cal:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
@@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/dav.php/</d:href>
<d:propstat>
<d:prop>
<d:current-user-principal>
<d:href>/dav.php/principals/alex/</d:href>
</d:current-user-principal>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/dav.php/calendars/alex/personal/allday.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>&quot;7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f&quot;</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.4.8-gplay ical4j/3.2.19 (com.digibites.calendar)
BEGIN:VEVENT
UID:eee51914-187b-40d5-342c-dc80c118438a
STATUS:CONFIRMED
SUMMARY:Check-in
CLASS:PUBLIC
TRANSP:OPAQUE
DTSTART;VALUE=DATE:20250331
DTEND;VALUE=DATE:20250401
RRULE:FREQ=WEEKLY;BYDAY=MO
CREATED:20250902T164854Z
DTSTAMP:20250902T164854Z
LAST-MODIFIED:20250902T164854Z
SEQUENCE:2
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Retrospective demo retro
TRIGGER:-PT4H
END:VALARM
END:VEVENT
END:VCALENDAR
</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/personal/zoned.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>&quot;1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d&quot;</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.5.19-gplay ical4j/4.3.0
BEGIN:VEVENT
DTSTAMP:20260811T142404Z
UID:3a21fd46-26c4-85b5-eee3-6d2d2256f8ef
SUMMARY:Briefing
DTSTART;TZID=America/New_York:20260818T083000
DTEND;TZID=America/New_York:20260818T093000
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU
STATUS:CONFIRMED
BEGIN:VALARM
TRIGGER:-PT1H
ACTION:DISPLAY
DESCRIPTION:Design
END:VALARM
BEGIN:VALARM
TRIGGER:-PT12H
ACTION:DISPLAY
DESCRIPTION:Design
END:VALARM
END:VEVENT
BEGIN:VTIMEZONE
TZID:America/New_York
BEGIN:STANDARD
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
END:DAYLIGHT
END:VTIMEZONE
END:VCALENDAR
</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/personal/gone.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag/>
<cal:calendar-data/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
+474
View File
@@ -0,0 +1,474 @@
//! End-to-end tests against a real CalDAV server.
//!
//! Not mocks. A mock encodes what we already believe the protocol does, which
//! is precisely the belief worth checking — the previous iteration's
//! integration suite duplicated the router instead of importing it, and rotted
//! until it no longer compiled.
//!
//! These are skipped unless a server is configured, so `cargo test` works
//! offline. To run them:
//!
//! ```sh
//! crates/runway-caldav/tests/baikal/run.sh
//! ```
//!
//! which starts a throwaway Baikal in a container, installs it, and sets the
//! three variables below. Point them at any RFC-compliant server to test
//! against that instead. **Every test creates its own calendar collection and
//! deletes it afterwards**, so nothing touches data that was already there.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use chrono::{TimeZone, Utc};
use runway_caldav::{CalDavClient, CalDavError, Credentials, Precondition, href_for};
use runway_core::ical;
use runway_core::model::{CalendarDateTime, TzId, VCalendar, VEvent};
/// A client, or `None` when no server is configured.
fn client() -> Option<(CalDavClient, String)> {
let url = std::env::var("RUNWAY_CALDAV_URL").ok()?;
let user = std::env::var("RUNWAY_CALDAV_USER").ok()?;
let password = std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?;
let client = CalDavClient::new(&url, Credentials::new(&user, password))
.expect("the configured CalDAV URL is not valid");
Some((client, user))
}
/// Runs a test body against a scratch calendar, removing it afterwards.
///
/// The calendar is created and destroyed per test so the tests cannot see each
/// other's leftovers, and so a failure never leaves rubbish behind on a real
/// server.
async fn with_calendar<F, Fut>(name: &str, body: F)
where
F: FnOnce(CalDavClient, String) -> Fut,
Fut: Future<Output = ()>,
{
let Some((client, user)) = client() else {
eprintln!(
"SKIPPED: no CalDAV server configured. Run \
crates/runway-caldav/tests/baikal/run.sh to run these against a container."
);
return;
};
let href = format!("/dav.php/calendars/{user}/{name}/");
// A previous run that died mid-test would leave this behind.
let _ = client.delete_calendar(&href).await;
client
.create_calendar(&href, &format!("Runway test {name}"), Some("#336699"))
.await
.expect("could not create the scratch calendar");
body(client.clone(), href.clone()).await;
client
.delete_calendar(&href)
.await
.expect("could not remove the scratch calendar");
}
fn event(uid: &str, summary: &str, hour: u32) -> VEvent {
let start = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 10)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
VEvent::with_uid(uid, start)
.titled(summary)
.lasting(runway_core::model::IcalDuration::hours(1).unwrap())
}
fn calendar_of(event: VEvent) -> VCalendar {
VCalendar::with_events(vec![event])
}
// --------------------------------------------------------------- discovery --
#[tokio::test]
async fn discovery_finds_the_scratch_calendar() {
with_calendar("discovery", |client, href| async move {
let principal = client.current_user_principal().await.unwrap();
assert!(principal.contains("principals"), "got {principal}");
let home = client.calendar_home(&principal).await.unwrap();
let calendars = client.calendars_in(&home).await.unwrap();
let found = calendars
.iter()
.find(|c| c.href.trim_end_matches('/') == href.trim_end_matches('/'))
.expect("the calendar just created was not listed");
assert_eq!(found.display_name.as_deref(), Some("Runway test discovery"));
assert!(found.supports_events());
assert!(
!calendars.iter().any(|c| c.href.contains("outbox")),
"scheduling collections must not appear as calendars",
);
})
.await;
}
// ------------------------------------------------------------------- writes --
#[tokio::test]
async fn an_event_survives_a_round_trip_through_the_server() {
with_calendar("roundtrip", |client, calendar| async move {
let uid = "runway-roundtrip@test";
let href = href_for(&calendar, uid);
let mut original = event(uid, "Round trip", 9);
original.description = Some("Two lines\nand a comma, kept".to_owned());
original.categories = vec!["Work".to_owned(), "Personal".to_owned()];
client
.put_object(&href, &calendar_of(original.clone()), &Precondition::New)
.await
.unwrap();
let fetched = client.get_object(&calendar, &href).await.unwrap();
let stored = &fetched.events()[0];
assert_eq!(stored.uid, original.uid);
assert_eq!(stored.summary, original.summary);
assert_eq!(stored.description, original.description);
assert_eq!(
stored.categories,
vec!["Work", "Personal"],
"the separator must survive the server, not come back as one \
category called \"Work,Personal\"",
);
assert_eq!(
stored.dtstart.tzid().map(TzId::as_str),
Some("America/Denver"),
"the zone has to make it to the server -- this is what the phone \
reads when it decides when to ring",
);
})
.await;
}
#[tokio::test]
async fn a_stale_etag_is_refused_rather_than_overwriting() {
with_calendar("conflict", |client, calendar| async move {
let uid = "runway-conflict@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "First", 9)),
&Precondition::New,
)
.await
.unwrap();
// Read it, then let somebody else write.
let first = client.get_object(&calendar, &href).await.unwrap();
let stale = first.etag.clone().expect("Baikal returns an ETag");
client
.put_object(
&href,
&calendar_of(event(uid, "Someone else's edit", 10)),
&Precondition::Force,
)
.await
.unwrap();
// Now try to save the edit built on the stale read.
let result = client
.put_object(
&href,
&calendar_of(event(uid, "My edit", 11)),
&Precondition::Unchanged(stale),
)
.await;
assert!(
matches!(result, Err(CalDavError::Conflict { .. })),
"expected a conflict, got {result:?} -- without this the second \
person to hit save silently destroys the first person's change",
);
let current = client.get_object(&calendar, &href).await.unwrap();
assert_eq!(
current.events()[0].summary.as_deref(),
Some("Someone else's edit"),
"and the refused write must not have landed",
);
})
.await;
}
#[tokio::test]
async fn creating_the_same_resource_twice_is_refused() {
with_calendar("create-twice", |client, calendar| async move {
let uid = "runway-exists@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "First", 9)),
&Precondition::New,
)
.await
.unwrap();
let result = client
.put_object(
&href,
&calendar_of(event(uid, "Second", 9)),
&Precondition::New,
)
.await;
assert!(
result.is_err(),
"If-None-Match: * must stop a create from clobbering an existing \
resource, got {result:?}",
);
})
.await;
}
#[tokio::test]
async fn an_event_can_be_deleted() {
with_calendar("delete", |client, calendar| async move {
let uid = "runway-delete@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "Doomed", 9)),
&Precondition::New,
)
.await
.unwrap();
client
.delete_object(&href, &Precondition::Force)
.await
.unwrap();
assert!(
matches!(
client.get_object(&calendar, &href).await,
Err(CalDavError::NotFound { .. })
),
"a deleted resource should be reported as gone, not as an error \
with no name",
);
})
.await;
}
// -------------------------------------------------------------- time ranges --
#[tokio::test]
async fn a_time_range_query_returns_only_what_it_should() {
with_calendar("time-range", |client, calendar| async move {
for (uid, summary, day) in [
("in-window@test", "Inside", 10),
("out-of-window@test", "Outside", 25),
] {
let start = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, day)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
let event = VEvent::with_uid(uid, start)
.titled(summary)
.lasting(runway_core::model::IcalDuration::hours(1).unwrap());
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event),
&Precondition::New,
)
.await
.unwrap();
}
let found = client
.events_in_range(
&calendar,
Utc.with_ymd_and_hms(2026, 3, 9, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap(),
)
.await
.unwrap();
let summaries: Vec<&str> = found
.iter()
.filter_map(|o| o.events().first())
.filter_map(|e| e.summary.as_deref())
.collect();
assert_eq!(
summaries,
vec!["Inside"],
"the server filters by time-range; the last iteration fetched every \
event in the calendar on every view change and filtered in the \
browser",
);
assert_eq!(
client.all_events(&calendar).await.unwrap().len(),
2,
"and both are really there",
);
})
.await;
}
#[tokio::test]
async fn a_recurring_series_is_returned_whole() {
with_calendar("series", |client, calendar| async move {
let uid = "runway-series@test";
let mut master = event(uid, "Weekly", 9);
master.rrule = Some("FREQ=WEEKLY;BYDAY=TU;COUNT=6".to_owned());
let mut moved = event(uid, "Weekly (moved)", 14);
moved.dtstart = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17)
.unwrap()
.and_hms_opt(14, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
moved.recurrence_id = Some(CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
});
let mut resource = VCalendar::with_events(vec![master, moved]);
resource.timezones.clear();
client
.put_object(&href_for(&calendar, uid), &resource, &Precondition::New)
.await
.unwrap();
let found = client
.events_in_range(
&calendar,
Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap(),
)
.await
.unwrap();
assert_eq!(
found.len(),
1,
"one UID is one resource, however many VEVENTs"
);
let object = &found[0];
assert_eq!(
object.events().len(),
2,
"the master and its override come back together; reading them as \
two unrelated events is what made a correct calendar look like it \
was full of duplicates",
);
assert!(object.master().is_some());
assert_eq!(object.overrides().count(), 1);
assert!(object.has_consistent_uid());
})
.await;
}
// -------------------------------------------------------------------- sync --
#[tokio::test]
async fn etags_can_be_listed_without_the_bodies() {
with_calendar("etags", |client, calendar| async move {
for uid in ["one@test", "two@test"] {
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event(uid, "Something", 9)),
&Precondition::New,
)
.await
.unwrap();
}
let etags = client.etags(&calendar).await.unwrap();
assert_eq!(etags.len(), 2, "the collection itself must not be listed");
assert!(etags.iter().all(|(_, etag)| !etag.is_empty()));
let hrefs: Vec<String> = etags.iter().map(|(href, _)| href.clone()).collect();
let fetched = client.multiget(&calendar, &hrefs).await.unwrap();
assert_eq!(fetched.len(), 2, "and a multiget brings back exactly those");
})
.await;
}
// --------------------------------------------------------------- rejections --
#[tokio::test]
async fn bad_credentials_are_reported_as_such() {
let Some((_, user)) = client() else {
return;
};
let url = std::env::var("RUNWAY_CALDAV_URL").unwrap();
let wrong = CalDavClient::new(&url, Credentials::new(&user, "not-the-password")).unwrap();
assert!(
matches!(
wrong.current_user_principal().await,
Err(CalDavError::Unauthorized)
),
"a rejected password has to be distinguishable from a server being down",
);
}
#[tokio::test]
async fn the_golden_corpus_can_be_written_to_a_real_server() {
// The strongest statement available about the iCalendar writer: what it
// produces is accepted by a real CalDAV server, not merely by our own
// parser. Every fixture goes up and comes back.
with_calendar("corpus", |client, calendar| async move {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../runway-core/tests/golden/synthetic");
let mut checked = 0;
for entry in std::fs::read_dir(&dir).unwrap().filter_map(Result::ok) {
let path = entry.path();
if path.extension().is_none_or(|e| e != "ics") {
continue;
}
let source = ical::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let Some(uid) = source.events.first().map(|e| e.uid.clone()) else {
continue;
};
let href = href_for(&calendar, &uid);
client
.put_object(&href, &source, &Precondition::New)
.await
.unwrap_or_else(|e| panic!("{} was rejected by the server: {e}", path.display()));
let returned = client.get_object(&calendar, &href).await.unwrap();
assert_eq!(
returned.calendar.events.len(),
source.events.len(),
"{} lost events on the server",
path.display(),
);
checked += 1;
}
assert!(checked >= 6, "only {checked} fixtures were exercised");
})
.await;
}
+286
View File
@@ -0,0 +1,286 @@
//! The CalDAV protocol layer, tested against responses recorded from a real
//! server.
//!
//! No mocks and no hand-invented XML for the main cases: the fixtures are what
//! Baikal actually sent, scrubbed of names. That matters because the awkward
//! parts of WebDAV are not in the specification's examples — they are the
//! second `propstat` carrying a 404 for properties the resource does not have,
//! the scheduling collections that look like calendars, and the fact that a
//! prefix is not a namespace.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use pretty_assertions::assert_eq;
use runway_caldav::xml::{self, CALDAV, DAV, DavResponse};
use runway_caldav::{calendars_from, href_for, objects_from, principal_from};
use std::path::Path;
fn fixture(name: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn responses(name: &str) -> Vec<DavResponse> {
xml::parse_multistatus(&fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"))
}
// ---------------------------------------------------------------- discovery --
#[test]
fn the_principal_is_read_from_a_real_response() {
assert_eq!(
principal_from(&responses("propfind-principal.xml")).unwrap(),
"/dav.php/principals/alex/",
);
}
#[test]
fn properties_the_server_reported_as_missing_are_not_treated_as_present() {
// The same response carries a second propstat with 404 listing displayname.
// Folding both propstats together would turn "this resource has no display
// name" into "this resource has an empty display name".
let responses = responses("propfind-principal.xml");
assert!(responses[0].prop(DAV, "current-user-principal").is_some());
assert!(
responses[0].prop(DAV, "displayname").is_none(),
"a property inside a 404 propstat was treated as found",
);
}
#[test]
fn the_calendar_list_matches_the_server() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
assert_eq!(
calendars.iter().map(|c| c.name()).collect::<Vec<_>>(),
vec![
"Birthdays",
"Household Chores",
"Partner Chores",
"Personal",
"Trips",
"Workouts",
],
);
}
#[test]
fn scheduling_collections_are_not_calendars() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
assert!(
!calendars
.iter()
.any(|c| c.href.contains("inbox") || c.href.contains("outbox")),
"a scheduling inbox is a collection, not something to show in a sidebar",
);
assert!(
!calendars
.iter()
.any(|c| c.href == "/dav.php/calendars/alex/"),
"the home collection itself is not a calendar",
);
}
#[test]
fn calendar_metadata_is_read_rather_than_invented() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
let personal = calendars.iter().find(|c| c.name() == "Personal").unwrap();
assert_eq!(personal.color.as_deref(), Some("#0CCE6B"));
assert!(
personal.ctag.is_some(),
"a ctag makes a cheap sync check possible"
);
assert_eq!(
personal.supported_components,
vec!["VEVENT", "VTODO", "VJOURNAL"],
"this collection really does accept all three",
);
assert!(personal.supports_events());
}
#[test]
fn a_calendar_with_no_colour_simply_has_none() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
let trips = calendars.iter().find(|c| c.name() == "Trips").unwrap();
assert_eq!(
trips.color, None,
"the server returned a 404 propstat for its colour; hashing the path to \
invent one is what made the last iteration disagree with every other \
client about what colour a calendar was",
);
assert!(trips.ctag.is_some());
}
#[test]
fn namespace_prefixes_do_not_matter() {
// The same document with DAV: as the default namespace and CalDAV under a
// differently-cased prefix. The previous backend tried six regular
// expressions in sequence to cope with this, recompiling each one inside
// the loop; a namespace-aware parser makes the question disappear.
let normal = calendars_from(&responses("propfind-calendars.xml"));
let rewritten = calendars_from(&responses("propfind-calendars-other-prefixes.xml"));
assert_eq!(normal, rewritten);
assert!(!normal.is_empty());
}
// ------------------------------------------------------------------ objects --
#[test]
fn a_calendar_query_yields_objects_with_their_etags() {
let objects = objects_from(
"/dav.php/calendars/alex/personal/",
responses("report-calendar-query.xml"),
)
.unwrap();
assert_eq!(objects.len(), 2);
assert_eq!(
objects[0].href,
"/dav.php/calendars/alex/personal/allday.ics"
);
assert_eq!(
objects[0].etag.as_deref(),
Some("\"7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f\""),
"the ETag is what makes a conditional write possible; losing it means \
every save silently overwrites whatever arrived in the meantime",
);
assert_eq!(
objects[0].calendar_path,
"/dav.php/calendars/alex/personal/"
);
}
#[test]
fn a_response_with_no_calendar_data_is_skipped_not_invented() {
let all = responses("report-calendar-query.xml");
assert_eq!(all.len(), 3, "the fixture includes a 404 response");
let objects = objects_from("/dav.php/calendars/alex/personal/", all).unwrap();
assert_eq!(
objects.len(),
2,
"a resource the server reported as gone must not become an empty event",
);
}
#[test]
fn calendar_data_survives_the_trip_through_xml() {
// XML normalises CRLF to LF, so the iCalendar arriving here has different
// line endings from the bytes on the wire. Unfolding has to cope.
let objects = objects_from(
"/dav.php/calendars/alex/personal/",
responses("report-calendar-query.xml"),
)
.unwrap();
let zoned = &objects[1];
assert_eq!(zoned.events().len(), 1);
let event = &zoned.events()[0];
assert_eq!(event.alarms.len(), 2, "both alarms survived");
assert_eq!(
event.dtstart.tzid().map(runway_core::model::TzId::as_str),
Some("America/New_York"),
);
assert_eq!(
zoned.calendar.timezones.len(),
1,
"the VTIMEZONE came through with it",
);
}
// ---------------------------------------------------------------------- xml --
#[test]
fn an_entity_reference_is_resolved_with_its_surrounding_spaces() {
let doc = r#"<d:multistatus xmlns:d="DAV:"><d:response>
<d:href>/c/</d:href>
<d:propstat><d:prop><d:displayname>Bed &amp; Breakfast</d:displayname></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response></d:multistatus>"#;
let parsed = xml::parse_multistatus(doc).unwrap();
assert_eq!(
parsed[0].prop_text(DAV, "displayname"),
Some("Bed & Breakfast"),
"trimming each text fragment instead of the whole value would give \
\"Bed&Breakfast\"",
);
}
#[test]
fn a_numeric_character_reference_is_resolved() {
let doc = r#"<d:multistatus xmlns:d="DAV:"><d:response>
<d:href>/c/</d:href>
<d:propstat><d:prop><d:displayname>caf&#233;</d:displayname></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response></d:multistatus>"#;
let parsed = xml::parse_multistatus(doc).unwrap();
assert_eq!(parsed[0].prop_text(DAV, "displayname"), Some("café"));
}
#[test]
fn nested_property_values_are_navigable() {
let doc = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:href>/c/</d:href><d:propstat><d:prop>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/><c:comp name="VTODO"/>
</c:supported-calendar-component-set>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
let parsed = xml::parse_multistatus(doc).unwrap();
let names: Vec<&str> = parsed[0]
.prop(CALDAV, "supported-calendar-component-set")
.unwrap()
.children(CALDAV, "comp")
.filter_map(|c| c.attribute("name"))
.collect();
assert_eq!(names, vec!["VEVENT", "VTODO"]);
}
#[test]
fn a_document_that_is_not_a_multistatus_is_rejected() {
let html = "<html><body>502 Bad Gateway</body></html>";
assert!(
xml::parse_multistatus(html).is_err(),
"a proxy error page must not parse as an empty calendar list",
);
}
#[test]
fn status_lines_are_read_for_their_code() {
assert_eq!(xml::status_code("HTTP/1.1 200 OK"), Some(200));
assert_eq!(xml::status_code("HTTP/1.1 404 Not Found"), Some(404));
assert_eq!(xml::status_code("nonsense"), None);
}
// ----------------------------------------------------------------- href_for --
#[test]
fn an_object_href_is_one_resource_per_uid() {
assert_eq!(
href_for("/dav.php/calendars/alex/personal/", "abc-123"),
"/dav.php/calendars/alex/personal/abc-123.ics",
);
}
#[test]
fn a_uid_that_is_not_url_safe_is_encoded() {
// Real UIDs from Google and Exchange contain characters a path segment
// cannot carry unescaped.
assert_eq!(
href_for("/c/", "26u614553d18@google.com"),
"/c/26u614553d18%40google.com.ics",
);
assert_eq!(href_for("/c/", "a/b c"), "/c/a%2Fb%20c.ics");
}