//! What real clients need beyond the RFCs' core: discovery by GET and from //! the server root, client properties, vCard 3.0 for Apple, and addresses //! and logins for unusual account names. mod common; use axum::http::{Method, StatusCode}; use common::*; use pimdav::xml::{self, CALDAV, CALSERVER, CARDDAV, DAV, Name}; use serde_json::json; use xmltree::Element; const PW: &str = "secret12345"; async fn req( env: &Env, verb: &str, path: &str, auth: &str, extra: &[(&str, &str)], body: &str, ) -> Resp { let mut headers = vec![("authorization", auth)]; headers.extend_from_slice(extra); Client::new(env.app.clone()) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), path, &headers, body.as_bytes().to_vec(), ) .await } async fn setup(names: &[&str]) -> (Env, Client) { let env = Env::new().await; let admin = env.admin().await; for n in names { create_user(&admin, n, PW, &[]).await; } (env, admin) } /// The 200 properties of the single response, and its ``. fn props(r: &Resp) -> (Vec<(u16, Element)>, Option) { assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); let resp = xml::elements(&root).next().unwrap(); let error = xml::child(resp, DAV, "error").and_then(|e| xml::elements(e).next().map(Name::of)); let props = xml::elements(resp) .filter(|e| Name::of(e).is(DAV, "propstat")) .flat_map(|ps| { let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap()) .split(' ') .nth(1) .unwrap() .parse() .unwrap(); xml::elements(xml::child(ps, DAV, "prop").unwrap()) .map(move |p| (code, p.clone())) .collect::>() }) .collect(); (props, error) } fn find<'a>(props: &'a [(u16, Element)], ns: &str, local: &str) -> Option<&'a (u16, Element)> { props.iter().find(|(_, p)| Name::of(p).is(ns, local)) } #[tokio::test] async fn discovery_by_get_and_from_the_root() { let (env, _) = setup(&["alice"]).await; let auth = basic("alice", PW); for path in [ "/pim/", "/pim/principals/alice/", "/pim/calendars/alice/", "/pim/calendars/alice/default/", "/pim/addressbooks/alice/system/", ] { let r = req(&env, "GET", path, &auth, &[], "").await; assert_eq!(r.status, StatusCode::OK, "GET {path}"); let r = req(&env, "HEAD", path, &auth, &[], "").await; assert_eq!(r.status, StatusCode::OK, "HEAD {path}"); assert!(r.body.is_empty()); } let r = req(&env, "GET", "/pim/calendars/alice/nope/", &auth, &[], "").await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Every response carries the DAV header, the challenge included. let r = req(&env, "PROPFIND", "/pim/", &auth, &[], "").await; assert!(r.header("dav").unwrap().contains("calendar-access")); let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert!(r.header("dav").is_some()); assert!( r.header("www-authenticate") .unwrap() .contains("charset=\"UTF-8\"") ); // A client given only the server address finds the principal. let r = req(&env, "PROPFIND", "/", &auth, &[], "").await; assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT); assert_eq!(r.header("location").as_deref(), Some("/pim/")); let r = req(&env, "OPTIONS", "/", "", &[], "").await; assert_eq!(r.status, StatusCode::OK); assert!(r.header("dav").unwrap().contains("addressbook")); // The web app is still at the root. let r = req(&env, "GET", "/", "", &[], "").await; assert_eq!(r.status, StatusCode::OK); } #[tokio::test] async fn client_properties_are_stored() { let (env, _) = setup(&["alice", "bob"]).await; let alice = basic("alice", PW); let home = "/pim/calendars/alice/"; let book_home = "/pim/addressbooks/alice/"; // macOS Calendar and Contacts store their settings on the homes. let patch = "\ BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n\ "; let (ps, _) = props(&req(&env, "PROPPATCH", home, &alice, &[], patch).await); assert_eq!(ps[0].0, 200); let me_card = "\ /pim/addressbooks/alice/default/me.vcf\ "; let (ps, _) = props(&req(&env, "PROPPATCH", book_home, &alice, &[], me_card).await); assert_eq!(ps[0].0, 200); let custom = "hi"; let (ps, _) = props( &req( &env, "PROPPATCH", "/pim/principals/alice/", &alice, &[], custom, ) .await, ); assert_eq!(ps[0].0, 200); let find_body = |ns: &str, l: &str| { format!("<{l} xmlns=\"{ns}\"/>") }; let r = req( &env, "PROPFIND", home, &alice, &[("depth", "0")], &find_body(CALDAV, "default-alarm-vevent-date"), ) .await; let (ps, _) = props(&r); let (code, p) = find(&ps, CALDAV, "default-alarm-vevent-date").unwrap(); assert_eq!(*code, 200); // XML parsing made the raw CRLF of the request LF (XML 1.0, 2.11). assert_eq!( p.get_text().unwrap(), "BEGIN:VALARM\nTRIGGER:-PT15M\nEND:VALARM\n" ); let r = req( &env, "PROPFIND", book_home, &alice, &[("depth", "0")], &find_body(CALSERVER, "me-card"), ) .await; let (ps, _) = props(&r); let href = xml::child(&find(&ps, CALSERVER, "me-card").unwrap().1, DAV, "href").map(xml::text); assert_eq!( href.as_deref(), Some("/pim/addressbooks/alice/default/me.vcf") ); let r = req( &env, "PROPFIND", "/pim/principals/alice/", &alice, &[("depth", "0")], "", ) .await; let (ps, _) = props(&r); assert_eq!(xml::text(&find(&ps, "urn:x", "note").unwrap().1), "hi"); // A computed property is refused by name, and nothing else is stored. let mixed = "1\ x"; let r = req(&env, "PROPPATCH", home, &alice, &[], mixed).await; let (ps, error) = props(&r); let codes: Vec = ps.iter().map(|(c, _)| *c).collect(); assert_eq!(codes, [424, 403]); assert!(error.unwrap().is(DAV, "cannot-modify-protected-property")); let r = req( &env, "PROPFIND", home, &alice, &[("depth", "0")], &find_body("urn:x", "a"), ) .await; assert_eq!(find(&props(&r).0, "urn:x", "a").unwrap().0, 404); // Unknown properties no longer fail a collection's PROPPATCH or MKCALENDAR. let cal = "/pim/calendars/alice/default/"; let patch = "\ #ff0000bar"; let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], patch).await); assert!(ps.iter().all(|(c, _)| *c == 200), "{ps:?}"); let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await; let (ps, _) = props(&r); assert_eq!( xml::text( &find(&ps, "http://apple.com/ns/ical/", "calendar-color") .unwrap() .1 ), "#ff0000" ); assert_eq!(xml::text(&find(&ps, "urn:x", "foo").unwrap().1), "bar"); let mk = "\ Old iCal\ /pim/calendars/alice/old/\ "; let r = req( &env, "MKCALENDAR", "/pim/calendars/alice/old/", &alice, &[], mk, ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let r = req( &env, "PROPFIND", "/pim/calendars/alice/old/", &alice, &[("depth", "0")], "", ) .await; assert!(find(&props(&r).0, CALDAV, "calendar-free-busy-set").is_some()); // Removing works, and an oversized value is refused. let remove = ""; let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], remove).await); assert_eq!(ps[0].0, 200); let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await; assert!(find(&props(&r).0, "urn:x", "foo").is_none()); let big = format!( "{}", "a".repeat(70_000) ); let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], &big).await); assert_eq!(ps[0].0, 507); // A borrower reads the owner's properties and cannot change them. let cid = alice_calendar_id(&env).await; let alice_client = login(&env, "alice", PW).await; let r = alice_client .post_json( &format!("/api/pim/collections/{cid}/shares"), &json!({"user": "bob", "mode": "rw"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let bob = basic("bob", PW); let lent = format!("/pim/calendars/bob/shared-{cid}/"); let r = req(&env, "PROPFIND", &lent, &bob, &[("depth", "0")], "").await; assert_eq!( xml::text( &find(&props(&r).0, "http://apple.com/ns/ical/", "calendar-color") .unwrap() .1 ), "#ff0000" ); let r = req(&env, "PROPPATCH", &lent, &bob, &[], patch).await; assert_eq!(r.status, StatusCode::FORBIDDEN); // And another account's home is not writable. let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await; assert_eq!(r.status, StatusCode::FORBIDDEN); } async fn alice_calendar_id(env: &Env) -> i64 { let alice = login(env, "alice", PW).await; let r = alice.get("/api/pim/collections").await; r.json() .as_array() .unwrap() .iter() .find(|c| { c["kind"] == "calendar" && c["url"] .as_str() .is_some_and(|u| u.ends_with("/calendars/alice/default/")) }) .unwrap()["id"] .as_i64() .unwrap() } #[tokio::test] async fn vcard_three_for_apple() { let (env, _) = setup(&["alice"]).await; let auth = basic("alice", PW); let book = "/pim/addressbooks/alice/default/"; let r = req(&env, "PROPFIND", book, &auth, &[("depth", "0")], "").await; let (ps, _) = props(&r); let data = &find(&ps, CARDDAV, "supported-address-data").unwrap().1; let versions: Vec<_> = xml::elements(data) .filter_map(|e| e.attributes.get("version")) .collect(); assert_eq!(versions, ["3.0"]); // A vCard 4.0 group, as DAVx5 writes it, is stored as sent. let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g1\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nEND:VCARD\r\n"; let path = format!("{book}g1.vcf"); let r = req(&env, "PUT", &path, &auth, &[], group).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let etag = r.header("etag").unwrap(); // Without Accept, 3.0 with the forms Apple reads; the ETag stays. let r = req(&env, "GET", &path, &auth, &[], "").await; let text = r.text(); assert!( text.contains("VERSION:3.0") && text.contains("X-ADDRESSBOOKSERVER-KIND:group"), "{text}" ); assert!( text.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:c1"), "{text}" ); assert_eq!(r.header("etag").as_deref(), Some(etag.as_str())); let r = req( &env, "GET", &path, &auth, &[("accept", "text/vcard; version=4.0")], "", ) .await; assert_eq!(r.text(), group); let multiget = format!( r#"{path}"# ); let r = req(&env, "REPORT", book, &auth, &[], &multiget).await; assert!( r.text().contains("X-ADDRESSBOOKSERVER-KIND:group"), "{}", r.text() ); let v4 = multiget.replace( "", "", ); let r = req(&env, "REPORT", book, &auth, &[], &v4).await; assert!( r.text().contains("MEMBER:urn:uuid:c1") && !r.text().contains("X-ADDRESSBOOK"), "{}", r.text() ); } #[tokio::test] async fn addresses_and_logins_for_unusual_names() { let (env, _) = setup(&["alice", "marc@example.com", "a..b"]).await; let alice = basic("alice", PW); let marc = basic("marc@example.com", PW); let address = |env: &Env, auth: String, user: &'static str| { let app = env.app.clone(); async move { let r = Client::new(app) .raw( Method::from_bytes(b"PROPFIND").unwrap(), &format!("/pim/principals/{user}/"), &[("authorization", auth.as_str()), ("depth", "0")], Vec::new(), ) .await; let (ps, _) = props(&r); let set = &find(&ps, CALDAV, "calendar-user-address-set").unwrap().1; xml::text(xml::elements(set).next().unwrap()) } }; // One `@` and only characters valid in a local part. let m = address(&env, marc.clone(), "marc@example.com").await; assert_eq!(m, "mailto:marc%40example.com@dovenest.invalid"); let dots = address(&env, basic("a..b", PW), "a..b").await; assert_eq!(dots, "mailto:a%2E%2Eb@dovenest.invalid"); // An invitation to that address reaches the account. let ics = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:m1\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART:20261001T100000Z\r\nDTEND:20261001T110000Z\r\nSUMMARY:Meet\r\n\ ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@dovenest.invalid\r\n\ ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:{m}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" ); let r = req( &env, "PUT", "/pim/calendars/alice/default/m1.ics", &alice, &[], &ics, ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let r = req( &env, "PROPFIND", "/pim/calendars/marc@example.com/inbox/", &marc, &[("depth", "1")], "", ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let root = Element::parse(r.body.as_slice()).unwrap(); assert_eq!(xml::elements(&root).count(), 2, "{}", r.text()); // iOS sends `@` in the Basic user name as `%40`. let r = req( &env, "PROPFIND", "/pim/", &basic("marc%40example.com", PW), &[], "", ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let r = req( &env, "PROPFIND", "/pim/", &basic("marc%40example.com", "wrong"), &[], "", ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); }