//! 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 { 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![ "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#" /c/ Bed & Breakfast HTTP/1.1 200 OK "#; 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#" /c/ café HTTP/1.1 200 OK "#; 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#" /c/ HTTP/1.1 200 OK"#; 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 = "502 Bad Gateway"; 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"); }