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])