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.
287 lines
9.4 KiB
Rust
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 & 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");
|
|
}
|