//! 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/"]
);
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);
// The home, the calendar, and the scheduling inbox and outbox.
assert_eq!(ms.len(), 4, "{}", 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@filebrowser.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 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 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:fbng: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; bob moves one out.
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::CREATED, "{}", r.text());
let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
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.filebrowser.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"));
}