//! CalDAV and CardDAV: discovery, collections, properties and objects. mod common; use axum::http::{Method, StatusCode}; use common::*; use pimdav::xml::{self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name}; use serde_json::json; use xmltree::Element; const ALICE: &str = "alice"; const PW: &str = "alice12345"; async fn setup() -> (Env, String) { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, ALICE, PW, &[]).await; (env, basic(ALICE, PW)) } 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 } fn propfind_body(props: &[(&str, &str)]) -> String { let props: String = props .iter() .map(|(ns, l)| format!("<{l} xmlns=\"{ns}\"/>")) .collect(); format!("{props}") } /// `href -> [(status, property element)]` of a multistatus. fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> { assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); xml::elements(&root) .map(|resp| { let href = xml::text(xml::child(resp, DAV, "href").unwrap()); 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(); let prop = xml::child(ps, DAV, "prop").unwrap(); xml::elements(prop) .map(move |p| (code, p.clone())) .collect::>() }) .collect(); (href, props) }) .collect() } /// The property of `href` with status 200. fn prop( ms: &[(String, Vec<(u16, Element)>)], href: &str, ns: &str, local: &str, ) -> Option { ms.iter() .find(|(h, _)| h == href) .unwrap_or_else(|| panic!("no response for {href}")) .1 .iter() .find(|(code, p)| *code == 200 && Name::of(p).is(ns, local)) .map(|(_, p)| p.clone()) } fn prop_text( ms: &[(String, Vec<(u16, Element)>)], href: &str, ns: &str, local: &str, ) -> Option { prop(ms, href, ns, local).map(|p| xml::text(&p)) } fn hrefs_of(p: &Element) -> Vec { xml::elements(p).map(xml::text).collect() } fn error_condition(r: &Resp) -> Name { let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text())); assert!(Name::of(&root).is(DAV, "error"), "{}", r.text()); Name::of(xml::elements(&root).next().unwrap()) } const HOME: &str = "/pim/calendars/alice/"; const CAL: &str = "/pim/calendars/alice/default/"; const BOOK: &str = "/pim/addressbooks/alice/default/"; fn event(uid: &str, summary: &str) -> String { format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" ) } #[tokio::test] async fn discovery() { let (env, auth) = setup().await; let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert!(r.header("www-authenticate").is_some()); let r = req(&env, "PROPFIND", "/.well-known/caldav", &auth, &[], "").await; assert_eq!(r.status, StatusCode::MOVED_PERMANENTLY); assert_eq!(r.header("location").as_deref(), Some("/pim/")); // A Basic login spelled in another case still gets the stored spelling. let body = propfind_body(&[(DAV, "current-user-principal")]); let r = req( &env, "PROPFIND", "/pim/", &basic("ALICE", PW), &[("depth", "0")], &body, ) .await; let ms = parse_multistatus(&r); let p = prop(&ms, "/pim/", DAV, "current-user-principal").unwrap(); assert_eq!(hrefs_of(&p), ["/pim/principals/alice/"]); let body = propfind_body(&[ (CALDAV, "calendar-home-set"), (CARDDAV, "addressbook-home-set"), (CALDAV, "calendar-user-address-set"), (DAV, "displayname"), (DAV, "no-such-prop"), ]); let r = req( &env, "PROPFIND", "/pim/principals/alice/", &auth, &[("depth", "0")], &body, ) .await; let ms = parse_multistatus(&r); let p = "/pim/principals/alice/"; assert_eq!( hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()), [HOME] ); assert_eq!( hrefs_of(&prop(&ms, p, CARDDAV, "addressbook-home-set").unwrap()), ["/pim/addressbooks/alice/"] ); let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap()); assert_eq!(addresses[0], "mailto:alice@filebrowser.invalid"); assert!(addresses[2].starts_with("urn:uuid:")); assert_eq!( prop_text(&ms, p, DAV, "displayname").as_deref(), Some(ALICE) ); assert!( ms[0] .1 .iter() .any(|(c, e)| *c == 404 && Name::of(e).is(DAV, "no-such-prop")) ); // An app password works as well. let admin = login(&env, ALICE, PW).await; let r = admin .post_json("/api/auth/app-passwords", &json!({ "name": "phone" })) .await; let secret = r.json()["secret"].as_str().unwrap().to_string(); let r = req( &env, "PROPFIND", "/pim/", &basic("x", &secret), &[("depth", "0")], "", ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let r = req(&env, "OPTIONS", "/pim/", &auth, &[], "").await; assert!(r.header("dav").unwrap().contains("calendar-access")); } #[tokio::test] async fn homes_list_the_default_collections() { let (env, auth) = setup().await; let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await; let ms = parse_multistatus(&r); assert_eq!(ms.len(), 2, "{}", r.text()); let rt = prop(&ms, CAL, DAV, "resourcetype").unwrap(); assert!(xml::child(&rt, CALDAV, "calendar").is_some()); assert_eq!( prop_text(&ms, CAL, DAV, "displayname").as_deref(), Some("Calendar") ); let comps = prop(&ms, CAL, CALDAV, "supported-calendar-component-set").unwrap(); let comps: Vec<_> = xml::elements(&comps) .map(|c| c.attributes["name"].clone()) .collect(); assert_eq!(comps, ["VEVENT", "VTODO"]); assert!(prop_text(&ms, CAL, CALSERVER, "getctag").is_some()); assert!( prop_text(&ms, CAL, DAV, "sync-token") .unwrap() .starts_with("urn:") ); let r = req( &env, "PROPFIND", "/pim/addressbooks/alice/", &auth, &[("depth", "1")], "", ) .await; let ms = parse_multistatus(&r); let rt = prop(&ms, BOOK, DAV, "resourcetype").unwrap(); assert!(xml::child(&rt, CARDDAV, "addressbook").is_some()); let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "infinity")], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(DAV, "propfind-finite-depth")); } #[tokio::test] async fn other_users_are_off_limits() { let (env, auth) = setup().await; for path in ["/pim/principals/admin/", "/pim/calendars/admin/default/"] { let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}"); } } #[tokio::test] async fn make_and_patch_collections() { let (env, auth) = setup().await; let work = "/pim/calendars/alice/work/"; let body = r##" Work #00ff00 "##; let r = req(&env, "MKCALENDAR", work, &auth, &[], body).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let r = req(&env, "MKCALENDAR", work, &auth, &[], "").await; assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED); let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await; let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, work, DAV, "displayname").as_deref(), Some("Work") ); assert_eq!( prop_text(&ms, work, APPLE, "calendar-color").as_deref(), Some("#00ff00") ); let ctag = prop_text(&ms, work, CALSERVER, "getctag").unwrap(); // One bad property fails the whole request, and nothing is created. let bad = body.replace("VTODO", "VCARD"); let r = req( &env, "MKCALENDAR", "/pim/calendars/alice/bad/", &auth, &[], &bad, ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(r.text().contains("mkcalendar-response"), "{}", r.text()); let r = req( &env, "PROPFIND", "/pim/calendars/alice/bad/", &auth, &[("depth", "0")], "", ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // A plain MKCOL cannot make a calendar, an extended one makes an address book. let r = req(&env, "MKCOL", "/pim/calendars/alice/plain/", &auth, &[], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); let mkcol = r#" Friends"#; let r = req( &env, "MKCOL", "/pim/addressbooks/alice/friends/", &auth, &[], mkcol, ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let patch = r#" JobTasks "#; let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await; let ms = parse_multistatus(&r); assert!(ms[0].1.iter().all(|(c, _)| *c == 200)); let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await; let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, work, DAV, "displayname").as_deref(), Some("Job") ); assert_eq!( prop_text(&ms, work, CALDAV, "calendar-description").as_deref(), Some("Tasks") ); assert_ne!(prop_text(&ms, work, CALSERVER, "getctag").unwrap(), ctag); let patch = r#" Neverx"#; let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await; let ms = parse_multistatus(&r); let codes: Vec = ms[0].1.iter().map(|(c, _)| *c).collect(); assert_eq!(codes, [424, 403]); let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await; let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, work, DAV, "displayname").as_deref(), Some("Job") ); let r = req(&env, "DELETE", work, &auth, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn calendar_objects() { let (env, auth) = setup().await; let obj = format!("{CAL}a.ics"); let r = req( &env, "PUT", &obj, &auth, &[("if-none-match", "*")], &event("a", "One"), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let etag = r.header("etag").unwrap(); let r = req(&env, "GET", &obj, &auth, &[], "").await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), event("a", "One")); assert_eq!(r.header("etag"), Some(etag.clone())); assert!( r.header("content-type") .unwrap() .starts_with("text/calendar") ); let r = req(&env, "HEAD", &obj, &auth, &[], "").await; assert_eq!(r.status, StatusCode::OK); assert!(r.body.is_empty()); let r = req( &env, "PUT", &obj, &auth, &[("if-none-match", "*")], &event("a", "Two"), ) .await; assert_eq!(r.status, StatusCode::PRECONDITION_FAILED); let r = req( &env, "PUT", &obj, &auth, &[("if-match", "\"stale\"")], &event("a", "Two"), ) .await; assert_eq!(r.status, StatusCode::PRECONDITION_FAILED); let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await); let r = req( &env, "PUT", &obj, &auth, &[("if-match", &etag)], &event("a", "Two"), ) .await; assert_eq!(r.status, StatusCode::NO_CONTENT); let new_etag = r.header("etag").unwrap(); assert_ne!(new_etag, etag); let after = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "1")], "").await); assert_ne!( prop_text(&before, CAL, DAV, "sync-token"), prop_text(&after, CAL, DAV, "sync-token") ); assert_eq!(prop_text(&after, &obj, DAV, "getetag"), Some(new_etag)); // The UID is already stored under another name. let r = req( &env, "PUT", &format!("{CAL}b.ics"), &auth, &[], &event("a", "Dup"), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(CALDAV, "no-uid-conflict")); assert!(r.text().contains(&obj), "{}", r.text()); let journal = event("j", "x").replace("VEVENT", "VJOURNAL"); let cases = [ ("not a calendar".to_string(), "valid-calendar-data"), ( event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"), "valid-calendar-object-resource", ), (journal, "supported-calendar-component"), ]; for (body, cond) in cases { let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{cond}"); assert!(error_condition(&r).is(CALDAV, cond), "{cond}: {}", r.text()); } let r = req( &env, "PUT", "/pim/calendars/alice/nope/x.ics", &auth, &[], &event("x", "x"), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); let r = req( &env, "DELETE", &obj, &auth, &[("if-match", "\"stale\"")], "", ) .await; assert_eq!(r.status, StatusCode::PRECONDITION_FAILED); let r = req(&env, "DELETE", &obj, &auth, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); let r = req(&env, "DELETE", &obj, &auth, &[], "").await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = req(&env, "REPORT", CAL, &auth, &[], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); } #[tokio::test] async fn address_objects() { let (env, auth) = setup().await; let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Bob\r\nN:;Bob;;;\r\nEND:VCARD\r\n"; let r = req(&env, "PUT", &format!("{BOOK}c1.vcf"), &auth, &[], card).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let r = req(&env, "GET", &format!("{BOOK}c1.vcf"), &auth, &[], "").await; assert_eq!(r.text(), card); assert!(r.header("content-type").unwrap().starts_with("text/vcard")); let r = req(&env, "PUT", &format!("{BOOK}c2.vcf"), &auth, &[], card).await; assert!(error_condition(&r).is(CARDDAV, "no-uid-conflict")); let r = req( &env, "PUT", &format!("{BOOK}c3.vcf"), &auth, &[], &event("e", "x"), ) .await; assert!(error_condition(&r).is(CARDDAV, "valid-address-data")); } #[tokio::test] async fn deleting_the_last_calendar_brings_a_new_default() { let (env, auth) = setup().await; let r = req(&env, "DELETE", CAL, &auth, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await; let ms = parse_multistatus(&r); assert!(ms.iter().any(|(h, _)| h == CAL)); } #[tokio::test] async fn deleting_a_user_deletes_their_collections() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, ALICE, PW, &[]).await; let auth = basic(ALICE, PW); let r = req( &env, "PUT", &format!("{CAL}a.ics"), &auth, &[], &event("a", "x"), ) .await; assert_eq!(r.status, StatusCode::CREATED); let id = user_id(&admin, ALICE).await; let r = admin.delete(&format!("/api/admin/users/{id}")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let db = &env.state.db; assert!( db.pim_collections(id, server::db::PimKind::Calendar) .await .unwrap() .is_empty() ); }