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:
Executable
+53
@@ -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 "$@"
|
||||
@@ -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>"7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f"</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>"1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d"</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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é</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");
|
||||
}
|
||||
Reference in New Issue
Block a user