//! 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/"; const INBOX: &str = "/pim/calendars/alice/inbox/"; const OUTBOX: &str = "/pim/calendars/alice/outbox/"; 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::TEMPORARY_REDIRECT); 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/"] ); // Only the mailto address, preferred, so Apple picks it as the identity. let set = prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap(); assert_eq!(hrefs_of(&set), ["mailto:alice@dovenest.invalid"]); let href = xml::child(&set, DAV, "href").unwrap(); assert_eq!( href.attributes.get("preferred").map(String::as_str), Some("1") ); 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); // The home, the calendar, the birthday calendar, and the scheduling // inbox and outbox. assert_eq!(ms.len(), 5, "{}", r.text()); for (href, kind) in [(INBOX, "schedule-inbox"), (OUTBOX, "schedule-outbox")] { let rt = prop(&ms, href, DAV, "resourcetype").unwrap(); assert!(xml::child(&rt, CALDAV, kind).is_some(), "{href}"); } assert_eq!( prop(&ms, INBOX, CALDAV, "schedule-default-calendar-URL").map(|p| hrefs_of(&p)), Some(vec![CAL.to_string()]) ); 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", "VJOURNAL"]); 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/calendars/admin/", "/pim/calendars/admin/default/"] { let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}"); } // Another account's principal is readable, for scheduling. let body = propfind_body(&[ (DAV, "displayname"), (CALDAV, "calendar-user-address-set"), (CARDDAV, "addressbook-home-set"), ]); let p = "/pim/principals/admin/"; let r = req(&env, "PROPFIND", p, &auth, &[("depth", "0")], &body).await; let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, p, DAV, "displayname").as_deref(), Some("admin") ); let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap()); assert!(addresses.contains(&"mailto:admin@dovenest.invalid".to_string())); assert!(prop(&ms, p, CARDDAV, "addressbook-home-set").is_none()); let r = req(&env, "PROPFIND", "/pim/principals/nobody/", &auth, &[], "").await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[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 missing_dtstamp_is_added() { let (env, auth) = setup().await; let obj = format!("{CAL}s.ics"); let sent = event("s", "One").replace("DTSTAMP:20260101T000000Z\r\n", ""); let r = req(&env, "PUT", &obj, &auth, &[], &sent).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); assert_eq!(r.header("etag"), None); let stored = req(&env, "GET", &obj, &auth, &[], "").await.text(); let (head, rest) = stored.split_once("BEGIN:VEVENT\r\nDTSTAMP:").unwrap(); let (stamp, tail) = rest.split_once("\r\n").unwrap(); assert_eq!(stamp.len(), 16, "{stored}"); assert_eq!(format!("{head}BEGIN:VEVENT\r\n{tail}"), sent); } #[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 freebusy = event("j", "x").replace("VEVENT", "VFREEBUSY"); 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", ), (freebusy, "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::BAD_REQUEST); } #[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 the_default_calendar_stays() { let (env, auth) = setup().await; // Invitations arrive there (RFC 6638, 4.3). let r = req(&env, "DELETE", CAL, &auth, &[], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(CALDAV, "default-calendar-needed")); let other = format!("{HOME}work/"); assert_eq!( req(&env, "MKCALENDAR", &other, &auth, &[], "").await.status, StatusCode::CREATED ); assert_eq!( req(&env, "DELETE", &other, &auth, &[], "").await.status, StatusCode::NO_CONTENT ); // Nor can the inbox be made or removed by a client. assert_eq!( req(&env, "DELETE", INBOX, &auth, &[], "").await.status, StatusCode::FORBIDDEN ); assert_eq!( req( &env, "MKCALENDAR", "/pim/calendars/alice/outbox/", &auth, &[], "" ) .await .status, StatusCode::FORBIDDEN ); } #[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() ); } // --------------------------------------------------------------------------- // REPORT and MOVE // --------------------------------------------------------------------------- /// `(href, status of the response itself)` of every response. fn statuses(r: &Resp) -> Vec<(String, Option)> { let root = Element::parse(r.body.as_slice()).unwrap(); xml::elements(&root) .filter(|e| Name::of(e).is(DAV, "response")) .map(|resp| { let href = xml::text(xml::child(resp, DAV, "href").unwrap()); let code = xml::child(resp, DAV, "status") .map(|s| xml::text(s).split(' ').nth(1).unwrap().parse().unwrap()); (href, code) }) .collect() } fn sync_token_of(r: &Resp) -> String { let root = Element::parse(r.body.as_slice()).unwrap(); xml::text(xml::child(&root, DAV, "sync-token").unwrap()) } fn ics(body: &str) -> String { format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n") } const LUNCH: &str = "BEGIN:VEVENT\r\nUID:lunch\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nSUMMARY:Team Lunch\r\nEND:VEVENT\r\n"; const WEEKLY: &str = "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Berlin:20251201T090000\r\nDTEND;TZID=Europe/Berlin:20251201T093000\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n"; const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n"; async fn put(env: &Env, auth: &str, path: &str, body: &str) { let r = req(env, "PUT", path, auth, &[], body).await; assert!(r.status.is_success(), "{path}: {}", r.text()); } fn query(filter: &str, data: &str) -> String { format!( r#" {data} {filter} "# ) } fn hrefs_in(r: &Resp) -> Vec { let mut h: Vec<_> = statuses(r).into_iter().map(|(h, _)| h).collect(); h.sort(); h } #[tokio::test] async fn calendar_reports() { let (env, auth) = setup().await; put(&env, &auth, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await; put(&env, &auth, &format!("{CAL}weekly.ics"), &ics(WEEKLY)).await; put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await; let r = req( &env, "PROPFIND", CAL, &auth, &[("depth", "0")], &propfind_body(&[(DAV, "supported-report-set")]), ) .await; let reports = prop(&parse_multistatus(&r), CAL, DAV, "supported-report-set").unwrap(); assert_eq!(xml::elements(&reports).count(), 4); // multiget: stored bytes back, a miss as 404. let body = format!( r#" {CAL}lunch.ics{CAL}gone.ics "# ); let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await; let ms = parse_multistatus(&r); let lunch = format!("{CAL}lunch.ics"); // The CR of each CRLF goes out as ` `, which XML parsing keeps. assert!(r.text().contains(" \n"), "{}", r.text()); let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap(); assert_eq!(data, ics(LUNCH).trim()); assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404)))); // Time range: the Monday 2026-01-05 holds only an instance of the weekly // series, which started a month earlier in Berlin time. let range = r#""#; let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(range, ""), ) .await; assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]); // The same with expand: one instance in UTC, without the RRULE. let expand = r#""#; let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(range, expand), ) .await; let data = prop_text( &parse_multistatus(&r), &format!("{CAL}weekly.ics"), CALDAV, "calendar-data", ) .unwrap(); assert!(data.contains("DTSTART:20260105T080000Z"), "{data}"); assert!(data.contains("RECURRENCE-ID:20260105T080000Z"), "{data}"); assert!(!data.contains("RRULE"), "{data}"); // text-match folds ASCII case by default, and negates on request. let text = r#"team lunch"#; let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(text, ""), ) .await; assert_eq!(hrefs_in(&r), std::slice::from_ref(&lunch)); let negated = text.replace("", r#""#); let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(&negated, ""), ) .await; assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]); let odd = text.replace("", r#""#); let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(&odd, ""), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert_eq!( error_condition(&r), Name::new(CALDAV, "supported-collation") ); // A VTODO with only DUE matches the range that holds DUE. let todo = r#""#; let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(todo, ""), ) .await; assert_eq!(hrefs_in(&r), [format!("{CAL}todo.ics")]); // Only the components asked for. let comp = r#""#; let r = req( &env, "REPORT", CAL, &auth, &[("depth", "1")], &query(text, comp), ) .await; let data = prop_text(&parse_multistatus(&r), &lunch, CALDAV, "calendar-data").unwrap(); assert!( data.contains("SUMMARY:Team Lunch") && !data.contains("DTSTART") && !data.contains("VERSION"), "{data}" ); let fb = r#""#; let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], fb).await; assert_eq!(r.status, StatusCode::OK); assert!( r.header("content-type") .unwrap() .starts_with("text/calendar") ); assert!( r.text() .contains("FREEBUSY;FBTYPE=BUSY:20260105T080000Z/20260105T083000Z"), "{}", r.text() ); assert!( r.text() .contains("FREEBUSY;FBTYPE=BUSY:20260101T120000Z/20260101T130000Z"), "{}", r.text() ); // An address book report on a calendar is refused. let r = req(&env, "REPORT", CAL, &auth, &[], r#""#).await; assert_eq!(error_condition(&r), Name::new(DAV, "supported-report")); } #[tokio::test] async fn expand_answers_are_capped() { let (env, auth) = setup().await; for i in 0..4 { let hourly = format!( "BEGIN:VEVENT\r\nUID:h{i}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T000000Z\r\n\ DURATION:PT10M\r\nRRULE:FREQ=HOURLY\r\nSUMMARY:tick\r\nEND:VEVENT\r\n" ); put(&env, &auth, &format!("{CAL}h{i}.ics"), &ics(&hourly)).await; } // 7200 instances each: three together pass the limit of one answer, one // alone stays under that of one object. The fourth is cut off. let expand = r#""#; let range = r#""#; let r = req(&env, "REPORT", CAL, &auth, &[], &query(range, expand)).await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let s = statuses(&r); assert!(s.contains(&(CAL.to_string(), Some(507))), "{s:?}"); assert_eq!(s.len(), 4, "{s:?}"); } #[tokio::test] async fn sync_collection() { let (env, auth) = setup().await; let sync = |token: &str, limit: &str| { format!( r#"{token}1{limit}"# ) }; put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await; put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await; let r = req(&env, "REPORT", CAL, &auth, &[], &sync("", "")).await; assert_eq!(hrefs_in(&r), [format!("{CAL}a.ics"), format!("{CAL}b.ics")]); let token = sync_token_of(&r); put(&env, &auth, &format!("{CAL}c.ics"), &event("c", "C")).await; put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A2")).await; let r = req(&env, "DELETE", &format!("{CAL}b.ics"), &auth, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, "")).await; let mut got = statuses(&r); got.sort(); assert_eq!( got, [ (format!("{CAL}a.ics"), None), (format!("{CAL}b.ics"), Some(404)), (format!("{CAL}c.ics"), None), ] ); let latest = sync_token_of(&r); let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&latest, "")).await; assert!(statuses(&r).is_empty()); // A limit hands out the token of the last change it returned. let limit = "1"; let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, limit)).await; let got = statuses(&r); assert_eq!(got.len(), 2); assert_eq!(got[1], (CAL.to_string(), Some(507))); let r = req( &env, "REPORT", CAL, &auth, &[], &sync(&sync_token_of(&r), ""), ) .await; assert_eq!(statuses(&r).len(), 2); for bad in ["urn:dovenest:sync:999-1", "nonsense", &format!("{latest}0")] { let r = req(&env, "REPORT", CAL, &auth, &[], &sync(bad, "")).await; assert_eq!( error_condition(&r), Name::new(DAV, "valid-sync-token"), "{bad}" ); } } #[tokio::test] async fn addressbook_reports() { let (env, auth) = setup().await; let card = |uid: &str, name: &str, mail: &str| { format!( "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{name}\r\nEMAIL;TYPE=WORK:{mail}\r\nEND:VCARD\r\n" ) }; put( &env, &auth, &format!("{BOOK}bob.vcf"), &card("bob", "Bob Builder", "bob@example.com"), ) .await; put( &env, &auth, &format!("{BOOK}ann.vcf"), &card("ann", "Ann Äpfel", "ann@example.org"), ) .await; let query = |filter: &str, data: &str| { format!( r#"{data}{filter}"# ) }; let email = r#".ORG"#; let r = req(&env, "REPORT", BOOK, &auth, &[], &query(email, "")).await; assert_eq!(hrefs_in(&r), [format!("{BOOK}ann.vcf")]); // Unicode case folding is the CardDAV default. let fname = r#"ann äpfel"#; let r = req( &env, "REPORT", BOOK, &auth, &[], &query( fname, "", ), ) .await; let data = prop_text( &parse_multistatus(&r), &format!("{BOOK}ann.vcf"), CARDDAV, "address-data", ) .unwrap(); assert!( data.contains("Ann Äpfel") && !data.contains("EMAIL"), "{data}" ); let param = r#"work"#; let r = req(&env, "REPORT", BOOK, &auth, &[], &query(param, "")).await; assert_eq!(hrefs_in(&r).len(), 2); let limited = query( "1", "", ); let r = req(&env, "REPORT", BOOK, &auth, &[], &limited).await; let got = statuses(&r); assert_eq!(got.len(), 2); assert_eq!(got[1], (BOOK.to_string(), Some(507))); let body = format!( r#"{BOOK}bob.vcf"# ); let r = req(&env, "REPORT", BOOK, &auth, &[], &body).await; let data = prop_text( &parse_multistatus(&r), &format!("{BOOK}bob.vcf"), CARDDAV, "address-data", ) .unwrap(); assert!(data.contains("FN:Bob Builder")); } #[tokio::test] async fn move_objects() { let (env, auth) = setup().await; let r = req(&env, "MKCALENDAR", &format!("{HOME}work/"), &auth, &[], "").await; assert_eq!(r.status, StatusCode::CREATED); put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await; put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await; let dest = |p: &str| format!("http://localhost{p}"); let r = req( &env, "MOVE", &format!("{CAL}a.ics"), &auth, &[("destination", &dest(&format!("{HOME}work/a.ics")))], "", ) .await; assert_eq!(r.status, StatusCode::CREATED); assert_eq!( req(&env, "GET", &format!("{CAL}a.ics"), &auth, &[], "") .await .status, StatusCode::NOT_FOUND ); assert_eq!( req(&env, "GET", &format!("{HOME}work/a.ics"), &auth, &[], "") .await .text(), event("a", "A") ); // Overwrite: F refuses an existing destination. put(&env, &auth, &format!("{CAL}a2.ics"), &event("a2", "A2")).await; let r = req( &env, "MOVE", &format!("{CAL}a2.ics"), &auth, &[("destination", &format!("{CAL}b.ics")), ("overwrite", "F")], "", ) .await; assert_eq!(r.status, StatusCode::PRECONDITION_FAILED); let r = req( &env, "MOVE", &format!("{CAL}a2.ics"), &auth, &[("destination", &format!("{CAL}b.ics"))], "", ) .await; assert_eq!(r.status, StatusCode::NO_CONTENT); // The same UID under another name in the destination. put(&env, &auth, &format!("{CAL}dup.ics"), &event("a", "again")).await; let r = req( &env, "MOVE", &format!("{CAL}dup.ics"), &auth, &[("destination", &format!("{HOME}work/other.ics"))], "", ) .await; assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict")); // Across kinds is refused. let r = req( &env, "MOVE", &format!("{CAL}b.ics"), &auth, &[("destination", &format!("{BOOK}b.vcf"))], "", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); } #[tokio::test] async fn hrefs_follow_the_requested_spelling() { let (env, auth) = setup().await; let body = propfind_body(&[(DAV, "displayname")]); let r = req( &env, "PROPFIND", "/pim/calendars/ALICE/", &auth, &[("depth", "1")], &body, ) .await; let hrefs = hrefs_in(&r); assert!( hrefs.iter().all(|h| h.starts_with("/pim/calendars/ALICE/")), "{hrefs:?}" ); } // --------------------------------------------------------------------------- // Sharing, the system address book, rooms and principal search // --------------------------------------------------------------------------- const BOB: &str = "bob"; const BOB_PW: &str = "bob12345678"; /// alice with an event in her default calendar, and bob. async fn two_users() -> (Env, Client, String, String) { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, ALICE, PW, &[]).await; create_user(&admin, BOB, BOB_PW, &[]).await; let alice = basic(ALICE, PW); put(&env, &alice, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await; (env, admin, alice, basic(BOB, BOB_PW)) } /// The id of alice's default calendar, from the JSON API. async fn calendar_id(alice: &Client) -> i64 { let r = alice.get("/api/pim/collections").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); r.json() .as_array() .unwrap() .iter() .find(|c| c["kind"] == "calendar" && c["mode"].is_null()) .unwrap()["id"] .as_i64() .unwrap() } fn privilege_names(p: &Element) -> Vec { xml::elements(p) .flat_map(xml::elements) .map(|e| e.name.clone()) .collect() } #[tokio::test] async fn lent_collections() { let (env, admin, alice_auth, bob) = two_users().await; let alice = login(&env, ALICE, PW).await; let id = calendar_id(&alice).await; let shares = format!("/api/pim/collections/{id}/shares"); let r = alice .post_json(&shares, &json!({"user": "nobody", "mode": "ro"})) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = alice .post_json(&shares, &json!({"user": ALICE, "mode": "ro"})) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = alice .post_json(&shares, &json!({"user": "BOB", "mode": "ro"})) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let bob_client = login(&env, BOB, BOB_PW).await; assert_eq!(bob_client.get(&shares).await.status, StatusCode::NOT_FOUND); let listed = bob_client.get("/api/pim/collections").await.json(); let lent = listed .as_array() .unwrap() .iter() .find(|c| c["id"] == id) .unwrap() .clone(); assert_eq!(lent["mode"], "ro"); assert_eq!(lent["owner"], ALICE); let shared = format!("/pim/calendars/bob/shared-{id}/"); assert_eq!(lent["url"], shared.as_str()); // bob's home shows it, read-only, with alice as the owner. let body = propfind_body(&[ (DAV, "displayname"), (DAV, "owner"), (DAV, "current-user-privilege-set"), ]); let r = req( &env, "PROPFIND", "/pim/calendars/bob/", &bob, &[("depth", "1")], &body, ) .await; let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, &shared, DAV, "displayname").as_deref(), Some("Calendar (alice)") ); assert_eq!( hrefs_of(&prop(&ms, &shared, DAV, "owner").unwrap()), ["/pim/principals/alice/"] ); let privs = privilege_names(&prop(&ms, &shared, DAV, "current-user-privilege-set").unwrap()); assert_eq!(privs, ["read", "read-current-user-privilege-set"]); // Read works through every method, writes do not. let lunch = format!("{shared}lunch.ics"); let r = req(&env, "GET", &lunch, &bob, &[], "").await; assert_eq!(r.status, StatusCode::OK); let r = req(&env, "REPORT", &shared, &bob, &[], &query("", "")).await; assert_eq!(hrefs_in(&r), [lunch.as_str()]); let sync = r#""#; let r = req(&env, "REPORT", &shared, &bob, &[], sync).await; assert_eq!(hrefs_in(&r), [lunch.as_str()]); let r = req( &env, "PUT", &format!("{shared}new.ics"), &bob, &[], &event("new", "x"), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(DAV, "need-privileges")); let r = req(&env, "DELETE", &lunch, &bob, &[], "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); let patch = r#"Mine"#; let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await; assert_eq!(r.status, StatusCode::FORBIDDEN); // Read-write: bob adds an event, alice sees it. Moving it into his own // calendar is refused: an object never changes owner. let r = alice .post_json(&shares, &json!({"user": BOB, "mode": "rw"})) .await; assert_eq!(r.status, StatusCode::OK); put( &env, &bob, &format!("{shared}new.ics"), &event("new", "from bob"), ) .await; let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await; assert_eq!(r.status, StatusCode::OK); let r = req( &env, "MOVE", &format!("{shared}new.ics"), &bob, &[("destination", "/pim/calendars/bob/default/new.ics")], "", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await; assert_eq!(r.status, StatusCode::OK); let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await; assert_eq!(r.status, StatusCode::FORBIDDEN); // bob deleting it only takes it out of his home. let r = req(&env, "DELETE", &shared, &bob, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); assert_eq!( req(&env, "GET", &lunch, &bob, &[], "").await.status, StatusCode::NOT_FOUND ); assert!( alice .get(&shares) .await .json() .as_array() .unwrap() .is_empty() ); let r = req( &env, "GET", &format!("{CAL}lunch.ics"), &alice_auth, &[], "", ) .await; assert_eq!(r.status, StatusCode::OK); // Revoking, and deleting the borrower, end the loan. alice .post_json(&shares, &json!({"user": BOB, "mode": "ro"})) .await; let r = alice .delete(&format!("{shares}/{}", user_id(&admin, BOB).await)) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( req(&env, "GET", &lunch, &bob, &[], "").await.status, StatusCode::NOT_FOUND ); alice .post_json(&shares, &json!({"user": BOB, "mode": "ro"})) .await; let r = admin .delete(&format!("/api/admin/users/{}", user_id(&admin, BOB).await)) .await; assert_eq!(r.status, StatusCode::OK); assert!( alice .get(&shares) .await .json() .as_array() .unwrap() .is_empty() ); } const DIR: &str = "/pim/addressbooks/alice/system/"; #[tokio::test] async fn system_address_book() { let (env, admin, alice, _) = two_users().await; let body = propfind_body(&[(DAV, "getetag"), (CALSERVER, "getctag")]); let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "1")], &body).await; let ms = parse_multistatus(&r); // admin, alice and bob. assert_eq!(ms.len(), 4); let ctag = prop_text(&ms, DIR, CALSERVER, "getctag").unwrap(); let card = ms.iter().find(|(h, _)| h != DIR).unwrap().0.clone(); let r = req(&env, "GET", &card, &alice, &[], "").await; assert_eq!(r.status, StatusCode::OK); assert!(r.text().contains("EMAIL;TYPE=INTERNET:"), "{}", r.text()); let q = r#" BOB "#; let r = req(&env, "REPORT", DIR, &alice, &[], q).await; assert_eq!(statuses(&r).len(), 1); assert!(r.text().contains("FN:bob")); let sync = |token: &str| { format!( r#"{token}"# ) }; let r = req(&env, "REPORT", DIR, &alice, &[], &sync("")).await; assert_eq!(statuses(&r).len(), 3); let token = sync_token_of(&r); let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await; assert!(statuses(&r).is_empty()); // A new account changes the CTag, and the old token no longer works. create_user(&admin, "carol", "carol12345", &[]).await; let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(DAV, "valid-sync-token")); let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "0")], &body).await; assert_ne!( prop_text(&parse_multistatus(&r), DIR, CALSERVER, "getctag").unwrap(), ctag ); let r = req( &env, "PUT", &format!("{DIR}x.vcf"), &alice, &[], "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:x\r\nEND:VCARD\r\n", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(DAV, "need-privileges")); assert_eq!( req(&env, "DELETE", &card, &alice, &[], "").await.status, StatusCode::FORBIDDEN ); assert_eq!( req(&env, "DELETE", DIR, &alice, &[], "").await.status, StatusCode::FORBIDDEN ); let r = req(&env, "MKCOL", DIR, &alice, &[], "").await; assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn rooms_and_resources() { let (env, admin, alice, _) = two_users().await; let alice_client = login(&env, ALICE, PW).await; let room = json!({"name": "board", "display_name": "Board Room", "kind": "room"}); assert_eq!( alice_client .post_json("/api/admin/rooms", &room) .await .status, StatusCode::FORBIDDEN ); let r = admin.post_json("/api/admin/rooms", &room).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let id = r.json()["id"].as_i64().unwrap(); assert_eq!(r.json()["url"], "/pim/principals/board/"); let taken = json!({"name": "ALICE", "kind": "resource"}); assert_eq!( admin.post_json("/api/admin/rooms", &taken).await.status, StatusCode::CONFLICT ); let r = admin .post_json( "/api/admin/users", &json!({"name": "Board", "password": "x1234567", "is_admin": false, "roots": []}), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); // Not an account: not listed, not editable, no sign-in. let users = admin.get("/api/admin/users").await.json(); assert!( users .as_array() .unwrap() .iter() .all(|u| u["name"] != "board") ); let r = admin .put_json( &format!("/api/admin/users/{id}"), &json!({"password": "x1234567"}), ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = admin.delete(&format!("/api/admin/users/{id}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = req(&env, "PROPFIND", "/pim/", &basic("board", ""), &[], "").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); let r = Client::new(env.app.clone()) .post_json("/api/auth/login", &json!({"name": "board", "password": ""})) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); let p = "/pim/principals/board/"; let body = propfind_body(&[ (DAV, "displayname"), (CALDAV, "calendar-user-type"), (CALDAV, "calendar-user-address-set"), (CALDAV, "calendar-home-set"), ]); let r = req(&env, "PROPFIND", p, &alice, &[], &body).await; let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, p, DAV, "displayname").as_deref(), Some("Board Room") ); assert_eq!( prop_text(&ms, p, CALDAV, "calendar-user-type").as_deref(), Some("ROOM") ); let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap()); assert!(addresses.contains(&"mailto:board@rooms.dovenest.invalid".to_string())); assert_eq!( hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()), ["/pim/calendars/board/"] ); // Everyone reads the bookings; only admins write them. let cal = "/pim/calendars/board/default/"; let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let r = req( &env, "PUT", &format!("{cal}b.ics"), &alice, &[], &event("b", "x"), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); put( &env, &basic("admin", "admin1234"), &format!("{cal}b.ics"), &event("b", "x"), ) .await; assert_eq!( req(&env, "GET", &format!("{cal}b.ics"), &alice, &[], "") .await .status, StatusCode::OK ); // In the system address book as a location. let r = req(&env, "REPORT", DIR, &alice, &[], r#"location"#).await; assert!(r.text().contains("FN:Board Room"), "{}", r.text()); let r = admin .put_json( &format!("/api/admin/rooms/{id}"), &json!({"display_name": "Boardroom"}), ) .await; assert_eq!(r.json()["display_name"], "Boardroom"); assert_eq!( admin .get("/api/admin/rooms") .await .json() .as_array() .unwrap() .len(), 1 ); assert_eq!( admin.delete(&format!("/api/admin/rooms/{id}")).await.status, StatusCode::OK ); assert_eq!( req(&env, "PROPFIND", p, &alice, &[], "").await.status, StatusCode::NOT_FOUND ); } #[tokio::test] async fn principal_search() { let (env, admin, alice, _) = two_users().await; admin .post_json( "/api/admin/rooms", &json!({"name": "board", "display_name": "Board Room", "kind": "room"}), ) .await; let principals = "/pim/principals/"; let r = req(&env, "PROPFIND", principals, &alice, &[("depth", "1")], "").await; // The collection, admin, alice, bob and the room. assert_eq!(parse_multistatus(&r).len(), 5); let pps = r#" BO "#; let r = req(&env, "REPORT", principals, &alice, &[("depth", "0")], pps).await; assert_eq!( hrefs_in(&r), ["/pim/principals/board/", "/pim/principals/bob/"] ); let ms = parse_multistatus(&r); assert_eq!( prop_text(&ms, "/pim/principals/board/", CALDAV, "calendar-user-type").as_deref(), Some("ROOM") ); let cs = r#" bo "#; let r = req(&env, "REPORT", principals, &alice, &[], cs).await; assert_eq!(hrefs_in(&r), ["/pim/principals/board/"]); let set = r#""#; let r = req(&env, "REPORT", principals, &alice, &[], set).await; assert_eq!(r.status, StatusCode::OK); assert!(r.text().contains("calendar-user-address-set")); // Not a collection report. let r = req(&env, "REPORT", CAL, &alice, &[], pps).await; assert_eq!(r.status, StatusCode::FORBIDDEN); } #[tokio::test] async fn bad_filters_are_refused_by_name() { let (env, auth) = setup().await; let bad = query( r#""#, "", ); let r = req(&env, "REPORT", CAL, &auth, &[], &bad).await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(CALDAV, "valid-filter")); let bad = r#""#; let r = req(&env, "REPORT", BOOK, &auth, &[], bad).await; assert!(error_condition(&r).is(CARDDAV, "valid-filter")); }