//! What real clients need beyond the RFCs' core: discovery by GET and from
//! the server root, client properties, vCard 3.0 for Apple, and addresses
//! and logins for unusual account names.
use crate::common::*;
use axum::http::{Method, StatusCode};
use pimdav::xml::{self, CALDAV, CALSERVER, CARDDAV, DAV, Name};
use serde_json::json;
use xmltree::Element;
const PW: &str = "secret12345";
async fn setup(names: &[&str]) -> (Env, Client) {
let env = Env::new().await;
let admin = env.admin().await;
for n in names {
create_user(&admin, n, PW, &[]).await;
}
(env, admin)
}
/// The properties of the single response.
fn props(r: &Resp) -> Vec<(u16, Element)> {
parse_multistatus(r).remove(0).1
}
fn find<'a>(props: &'a [(u16, Element)], ns: &str, local: &str) -> Option<&'a (u16, Element)> {
props.iter().find(|(_, p)| Name::of(p).is(ns, local))
}
#[tokio::test]
async fn discovery_by_get_and_from_the_root() {
let (env, _) = setup(&["alice"]).await;
let auth = basic("alice", PW);
for path in [
"/pim/",
"/pim/principals/alice/",
"/pim/calendars/alice/",
"/pim/calendars/alice/default/",
"/pim/addressbooks/alice/system/",
] {
let r = req(&env, "GET", path, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK, "GET {path}");
let r = req(&env, "HEAD", path, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK, "HEAD {path}");
assert!(r.body.is_empty());
}
let r = req(&env, "GET", "/pim/calendars/alice/nope/", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Every response carries the DAV header, the challenge included.
let r = req(&env, "PROPFIND", "/pim/", &auth, &[], "").await;
assert!(r.header("dav").unwrap().contains("calendar-access"));
let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("dav").is_some());
assert!(
r.header("www-authenticate")
.unwrap()
.contains("charset=\"UTF-8\"")
);
// A client given only the server address finds the principal.
let r = req(&env, "PROPFIND", "/", &auth, &[], "").await;
assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT);
assert_eq!(r.header("location").as_deref(), Some("/pim/"));
let r = req(&env, "OPTIONS", "/", "", &[], "").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("dav").unwrap().contains("addressbook"));
// The web app is still at the root.
let r = req(&env, "GET", "/", "", &[], "").await;
assert_eq!(r.status, StatusCode::OK);
}
#[tokio::test]
async fn client_properties_are_stored() {
let (env, _) = setup(&["alice", "bob"]).await;
let alice = basic("alice", PW);
let home = "/pim/calendars/alice/";
let book_home = "/pim/addressbooks/alice/";
// macOS Calendar and Contacts store their settings on the homes.
let patch = "\
BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n\
";
let ps = props(&req(&env, "PROPPATCH", home, &alice, &[], patch).await);
assert_eq!(ps[0].0, 200);
let me_card = "\
/pim/addressbooks/alice/default/me.vcf\
";
let ps = props(&req(&env, "PROPPATCH", book_home, &alice, &[], me_card).await);
assert_eq!(ps[0].0, 200);
let custom = "hi";
let ps = props(
&req(
&env,
"PROPPATCH",
"/pim/principals/alice/",
&alice,
&[],
custom,
)
.await,
);
assert_eq!(ps[0].0, 200);
let find_body = |ns: &str, l: &str| {
format!("<{l} xmlns=\"{ns}\"/>")
};
let r = req(
&env,
"PROPFIND",
home,
&alice,
&[("depth", "0")],
&find_body(CALDAV, "default-alarm-vevent-date"),
)
.await;
let ps = props(&r);
let (code, p) = find(&ps, CALDAV, "default-alarm-vevent-date").unwrap();
assert_eq!(*code, 200);
// XML parsing made the raw CRLF of the request LF (XML 1.0, 2.11).
assert_eq!(
p.get_text().unwrap(),
"BEGIN:VALARM\nTRIGGER:-PT15M\nEND:VALARM\n"
);
let r = req(
&env,
"PROPFIND",
book_home,
&alice,
&[("depth", "0")],
&find_body(CALSERVER, "me-card"),
)
.await;
let ps = props(&r);
let href = xml::child(&find(&ps, CALSERVER, "me-card").unwrap().1, DAV, "href").map(xml::text);
assert_eq!(
href.as_deref(),
Some("/pim/addressbooks/alice/default/me.vcf")
);
let r = req(
&env,
"PROPFIND",
"/pim/principals/alice/",
&alice,
&[("depth", "0")],
"",
)
.await;
let ps = props(&r);
assert_eq!(xml::text(&find(&ps, "urn:x", "note").unwrap().1), "hi");
// A computed property is refused by name, and nothing else is stored.
let mixed = "1\
x";
let r = req(&env, "PROPPATCH", home, &alice, &[], mixed).await;
let codes: Vec = props(&r).iter().map(|(c, _)| *c).collect();
assert_eq!(codes, [424, 403]);
let root = Element::parse(r.body.as_slice()).unwrap();
let response = xml::elements(&root).next().unwrap();
let error = xml::child(response, DAV, "error").unwrap();
let condition = Name::of(xml::elements(error).next().unwrap());
assert!(condition.is(DAV, "cannot-modify-protected-property"));
let r = req(
&env,
"PROPFIND",
home,
&alice,
&[("depth", "0")],
&find_body("urn:x", "a"),
)
.await;
assert_eq!(find(&props(&r), "urn:x", "a").unwrap().0, 404);
// Unknown properties do not fail a collection's PROPPATCH or MKCALENDAR.
let cal = "/pim/calendars/alice/default/";
let patch = "\
#ff0000bar";
let ps = props(&req(&env, "PROPPATCH", cal, &alice, &[], patch).await);
assert!(ps.iter().all(|(c, _)| *c == 200), "{ps:?}");
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
let ps = props(&r);
assert_eq!(
xml::text(
&find(&ps, "http://apple.com/ns/ical/", "calendar-color")
.unwrap()
.1
),
"#ff0000"
);
assert_eq!(xml::text(&find(&ps, "urn:x", "foo").unwrap().1), "bar");
let mk = "\
Old iCal\
/pim/calendars/alice/old/\
";
let r = req(
&env,
"MKCALENDAR",
"/pim/calendars/alice/old/",
&alice,
&[],
mk,
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(
&env,
"PROPFIND",
"/pim/calendars/alice/old/",
&alice,
&[("depth", "0")],
"",
)
.await;
assert!(find(&props(&r), CALDAV, "calendar-free-busy-set").is_some());
// Removing works, and an oversized value is refused.
let remove = "";
let ps = props(&req(&env, "PROPPATCH", cal, &alice, &[], remove).await);
assert_eq!(ps[0].0, 200);
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
assert!(find(&props(&r), "urn:x", "foo").is_none());
let big = format!(
"{}",
"a".repeat(70_000)
);
let ps = props(&req(&env, "PROPPATCH", cal, &alice, &[], &big).await);
assert_eq!(ps[0].0, 507);
// A borrower reads the owner's properties and cannot change them.
let alice_client = login(&env, "alice", PW).await;
let cid = collection_id(&alice_client, cal).await;
let r = alice_client
.post_json(
&format!("/api/pim/collections/{cid}/shares"),
&json!({"user": "bob", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let bob = basic("bob", PW);
let lent = format!("/pim/calendars/bob/shared-{cid}/");
let r = req(&env, "PROPFIND", &lent, &bob, &[("depth", "0")], "").await;
assert_eq!(
xml::text(
&find(&props(&r), "http://apple.com/ns/ical/", "calendar-color")
.unwrap()
.1
),
"#ff0000"
);
let ps = props(&req(&env, "PROPPATCH", &lent, &bob, &[], patch).await);
assert!(ps.iter().all(|(code, _)| *code == 403), "{ps:?}");
// And another account's home is not writable.
let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn vcard_three_for_apple() {
let (env, _) = setup(&["alice"]).await;
let auth = basic("alice", PW);
let book = "/pim/addressbooks/alice/default/";
let r = req(&env, "PROPFIND", book, &auth, &[("depth", "0")], "").await;
let ps = props(&r);
let data = &find(&ps, CARDDAV, "supported-address-data").unwrap().1;
let versions: Vec<_> = xml::elements(data)
.filter_map(|e| e.attributes.get("version"))
.collect();
assert_eq!(versions, ["3.0"]);
// A vCard 4.0 group, as DAVx5 writes it, is stored as sent.
let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g1\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nEND:VCARD\r\n";
let path = format!("{book}g1.vcf");
let r = req(&env, "PUT", &path, &auth, &[], group).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let etag = r.header("etag").unwrap();
// Without Accept, 3.0 with the forms Apple reads; the ETag stays.
let r = req(&env, "GET", &path, &auth, &[], "").await;
let text = r.text();
assert!(
text.contains("VERSION:3.0") && text.contains("X-ADDRESSBOOKSERVER-KIND:group"),
"{text}"
);
assert!(
text.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:c1"),
"{text}"
);
assert_eq!(r.header("etag").as_deref(), Some(etag.as_str()));
let r = req(
&env,
"GET",
&path,
&auth,
&[("accept", "text/vcard; version=4.0")],
"",
)
.await;
assert_eq!(r.text(), group);
let multiget = format!(
r#"{path}"#
);
let r = req(&env, "REPORT", book, &auth, &[], &multiget).await;
assert!(
r.text().contains("X-ADDRESSBOOKSERVER-KIND:group"),
"{}",
r.text()
);
let v4 = multiget.replace(
"",
"",
);
let r = req(&env, "REPORT", book, &auth, &[], &v4).await;
assert!(
r.text().contains("MEMBER:urn:uuid:c1") && !r.text().contains("X-ADDRESSBOOK"),
"{}",
r.text()
);
}
#[tokio::test]
async fn addresses_and_logins_for_unusual_names() {
let (env, _) = setup(&["alice", "marc@example.com", "a..b"]).await;
let alice = basic("alice", PW);
let marc = basic("marc@example.com", PW);
let address = |env: &Env, auth: String, user: &'static str| {
let app = env.app.clone();
async move {
let r = Client::new(app)
.raw(
Method::from_bytes(b"PROPFIND").unwrap(),
&format!("/pim/principals/{user}/"),
&[("authorization", auth.as_str()), ("depth", "0")],
Vec::new(),
)
.await;
let ps = props(&r);
let set = &find(&ps, CALDAV, "calendar-user-address-set").unwrap().1;
xml::text(xml::elements(set).next().unwrap())
}
};
// One `@` and only characters valid in a local part.
let m = address(&env, marc.clone(), "marc@example.com").await;
assert_eq!(m, "mailto:marc%40example.com@dovenest.invalid");
let dots = address(&env, basic("a..b", PW), "a..b").await;
assert_eq!(dots, "mailto:a%2E%2Eb@dovenest.invalid");
// An invitation to that address reaches the account.
let ics = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:m1\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20261001T100000Z\r\nDTEND:20261001T110000Z\r\nSUMMARY:Meet\r\n\
ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@dovenest.invalid\r\n\
ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:{m}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
let r = req(
&env,
"PUT",
"/pim/calendars/alice/default/m1.ics",
&alice,
&[],
&ics,
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(
&env,
"PROPFIND",
"/pim/calendars/marc@example.com/inbox/",
&marc,
&[("depth", "1")],
"",
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let root = Element::parse(r.body.as_slice()).unwrap();
assert_eq!(xml::elements(&root).count(), 2, "{}", r.text());
// iOS sends `@` in the Basic user name as `%40`.
let r = req(
&env,
"PROPFIND",
"/pim/",
&basic("marc%40example.com", PW),
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let r = req(
&env,
"PROPFIND",
"/pim/",
&basic("marc%40example.com", "wrong"),
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
}