//! 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()
);
}