//! 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. use crate::common::*; use axum::http::{Method, StatusCode}; use pimdav::xml::{self, CALDAV, CALSERVER, CARDDAV, DAV, Name}; use serde_json::json; use xmltree::Element; const PW: &str = "secret12345"; 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 properties of the single response. fn props(r: &Resp) -> Vec<(u16, Element)> { parse_multistatus(r).remove(0).1 } 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 codes: Vec = props(&r).iter().map(|(c, _)| *c).collect(); assert_eq!(codes, [424, 403]); let root = Element::parse(r.body.as_slice()).unwrap(); let response = xml::elements(&root).next().unwrap(); let error = xml::child(response, DAV, "error").unwrap(); let condition = Name::of(xml::elements(error).next().unwrap()); assert!(condition.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), "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), 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), "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 alice_client = login(&env, "alice", PW).await; let cid = collection_id(&alice_client, cal).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), "http://apple.com/ns/ical/", "calendar-color") .unwrap() .1 ), "#ff0000" ); let ps = props(&req(&env, "PROPPATCH", &lent, &bob, &[], patch).await); assert!(ps.iter().all(|(code, _)| *code == 403), "{ps:?}"); // And another account's home is not writable. let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await; assert_eq!(r.status, StatusCode::FORBIDDEN); } #[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); }