Files
runway/crates/runway-caldav/tests/protocol.rs
T
connor f8e4a497fa 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.
2026-08-26 15:32:08 -04:00

287 lines
9.4 KiB
Rust

//! 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");
}