//! 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 patch = r#"
Tab bed"#;
let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
let ms = parse_multistatus(&r);
assert!(ms[0].1.iter().all(|(c, _)| *c != 200), "{}", r.text());
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 length = event("a", "One").len().to_string();
assert_eq!(r.header("content-length"), Some(length));
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);
// If-Match compares strongly: a weak tag never matches.
let weak = format!("W/{etag}");
let r = req(
&env,
"PUT",
&obj,
&auth,
&[("if-match", &weak)],
&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:?}");
// A sync stops at the same limit and resumes after its last member.
let sync = |token: &str| {
format!(
r#"{token}1{expand}"#
)
};
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
let s = statuses(&r);
assert_eq!(s.len(), 4, "{s:?}");
assert_eq!(s[3], (CAL.to_string(), Some(507)));
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&sync_token_of(&r))).await;
assert_eq!(statuses(&r), [(format!("{CAL}h3.ics"), None)]);
}
#[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/"]);
// At another principal, the own hit still names the own home.
let own = r#"
alice
"#;
let r = req(&env, "REPORT", "/pim/principals/bob/", &alice, &[], own).await;
assert!(
r.text().contains("/pim/calendars/alice/") && !r.text().contains("/pim/calendars/bob/"),
"{}",
r.text()
);
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"));
}
#[tokio::test]
async fn other_accounts_get_no_client_properties_or_loans_of_disabled_owners() {
let (env, admin, alice_auth, bob) = two_users().await;
let alice = login(&env, ALICE, PW).await;
let id = calendar_id(&alice).await;
let r = alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": BOB, "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let principal = "/pim/principals/alice/";
let patch = "\
private";
let r = req(&env, "PROPPATCH", principal, &alice_auth, &[], patch).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let body = propfind_body(&[("urn:x", "note")]);
let note = |auth: String| {
let (env, body) = (&env, &body);
async move {
req(env, "PROPFIND", principal, &auth, &[("depth", "0")], body)
.await
.text()
.contains("private")
}
};
assert!(note(alice_auth.clone()).await);
assert!(!note(bob.clone()).await);
let shared = format!("/pim/calendars/bob/shared-{id}/");
let status = || req(&env, "PROPFIND", &shared, &bob, &[("depth", "0")], "");
assert_eq!(status().await.status, StatusCode::MULTI_STATUS);
let uid = user_id(&admin, ALICE).await;
let r = admin
.put_json(
&format!("/api/admin/users/{uid}"),
&json!({"active": false}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(status().await.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn pruned_tombstones_invalidate_old_sync_tokens() {
let (env, auth) = setup().await;
let sync = |token: &str| {
format!(
r#"{token}"#
)
};
let obj = |n: &str| format!("{CAL}{n}.ics");
let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
for n in ["a", "b", "c"] {
put(&env, &auth, &obj(n), &todo(n)).await;
}
let old = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync("")).await);
for n in ["a", "b", "c"] {
req(&env, "DELETE", &obj(n), &auth, &[], "").await;
}
let mid = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await);
put(&env, &auth, &obj("d"), &todo("d")).await;
req(&env, "DELETE", &obj("d"), &auth, &[], "").await;
env.state.db.pim_prune(2).await.unwrap();
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&mid)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let parse = |t: &str| {
let (id, seq) = t.rsplit_once(':').unwrap().1.rsplit_once('-').unwrap();
(id.parse::().unwrap(), seq.parse::().unwrap())
};
let (id, old_seq) = parse(&old);
let (_, mid_seq) = parse(&mid);
let db = &env.state.db;
assert_eq!(db.pim_changes(id, Some(old_seq), None).await.unwrap(), None);
assert!(
db.pim_changes(id, Some(mid_seq), None)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn a_cut_initial_sync_resumes_past_pruned_tombstones() {
let (env, auth) = setup().await;
let sync = |token: &str| {
format!(
r#"{token}
1"#
)
};
let obj = |n: &str| format!("{CAL}{n}.ics");
let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
put(&env, &auth, &obj("x"), &todo("x")).await;
for n in ["d1", "d2"] {
put(&env, &auth, &obj(n), &todo(n)).await;
req(&env, "DELETE", &obj(n), &auth, &[], "").await;
}
put(&env, &auth, &obj("y"), &todo("y")).await;
env.state.db.pim_prune(0).await.unwrap();
// The first page ends at x, below the pruned tombstones.
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
assert!(
r.text().contains("x.ics") && !r.text().contains("y.ics"),
"{}",
r.text()
);
let cut = sync_token_of(&r);
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&cut)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
assert!(r.text().contains("y.ics"), "{}", r.text());
// Deletions after the sync began still count: pruning them refuses it.
put(&env, &auth, &obj("z"), &todo("z")).await;
req(&env, "DELETE", &obj("z"), &auth, &[], "").await;
env.state.db.pim_prune(0).await.unwrap();
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&cut)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert!(error_condition(&r).is(DAV, "valid-sync-token"));
}
#[tokio::test]
async fn a_cut_initial_sync_survives_writes_between_its_pages() {
let (env, auth) = setup().await;
let sync = |token: &str| {
format!(
r#"{token}
1"#
)
};
let obj = |n: &str| format!("{CAL}{n}.ics");
let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
for n in ["a", "b"] {
put(&env, &auth, &obj(n), &todo(n)).await;
}
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
let mut token = sync_token_of(&r);
for n in ["c", "d"] {
put(&env, &auth, &obj(n), &todo(n)).await;
}
// b, then c (written after the sync began), then d.
for n in ["b", "c", "d"] {
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{n}: {}", r.text());
assert!(r.text().contains(&format!("{n}.ics")), "{}", r.text());
token = sync_token_of(&r);
}
}
#[tokio::test]
async fn mkcol_checks_target_and_values() {
let (env, auth) = setup().await;
let r = req(&env, "MKCOL", &format!("{CAL}x.ics"), &auth, &[], "").await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
let body = |color: &str| {
format!(
r#"{color}"#
)
};
let work = "/pim/calendars/alice/work/";
let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("red")).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("#FF8800AA")).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
async fn alice_pid(env: &Env) -> i64 {
let user = env
.state
.db
.find_user_by_name(ALICE)
.await
.unwrap()
.unwrap();
env.state.db.principal_of(user.id).await.unwrap()
}
#[tokio::test]
async fn a_taken_slug_leaves_the_first_collection_alone() {
use server::db::{PimCollection, PimKind};
let (env, _) = setup().await;
let db = &env.state.db;
let pid = alice_pid(&env).await;
let make = |name: &str| PimCollection {
slug: "work".into(),
displayname: Some(name.into()),
components: "VEVENT".into(),
..Default::default()
};
let kind = PimKind::Calendar;
assert!(
db.pim_create_collection(pid, kind, &make("First"), &[])
.await
.unwrap()
);
assert!(
!db.pim_create_collection(pid, kind, &make("Second"), &[])
.await
.unwrap()
);
let kept = db.pim_collection(pid, kind, "work").await.unwrap().unwrap();
assert_eq!(kept.displayname.as_deref(), Some("First"));
}
#[tokio::test]
async fn moving_an_object_onto_itself_changes_nothing() {
use server::db::{PimCollection, PimKind, PimObject, PimOp, PimWrite, Precondition};
let (env, _) = setup().await;
let db = &env.state.db;
let pid = alice_pid(&env).await;
db.pim_ensure_defaults(pid).await.unwrap();
let col: PimCollection = db
.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap();
let obj = PimObject {
name: "a.ics".into(),
uid: "a".into(),
component: "VEVENT".into(),
etag: "\"e\"".into(),
..Default::default()
};
db.pim_apply(&[PimOp::Put {
collection_id: col.id,
obj,
data: b"data".to_vec(),
}])
.await
.unwrap();
let before = db
.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap()
.seq;
let none = Precondition {
if_match: None,
if_none_match: None,
};
let r = db
.pim_move_object(col.id, "a.ics", col.id, "a.ics", true, &none)
.await
.unwrap();
assert_eq!(r, PimWrite::Updated);
let (_, data) = db.pim_object(col.id, "a.ics").await.unwrap().unwrap();
assert_eq!(data, b"data");
let changes = db
.pim_changes(col.id, Some(before), None)
.await
.unwrap()
.unwrap();
assert!(changes.is_empty(), "{changes:?}");
}
#[tokio::test]
async fn a_deleted_user_gets_the_login_challenge() {
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, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
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 r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("www-authenticate").is_some());
}
#[tokio::test]
async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
use server::db::PimKind;
let (env, auth) = setup().await;
let long = format!("/pim/calendars/alice/{}/", "a".repeat(256));
let r = req(&env, "MKCALENDAR", &long, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let ok = format!("/pim/calendars/alice/{}/", "a".repeat(255));
let r = req(&env, "MKCALENDAR", &ok, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
// The cap counts the default calendar, not the inbox.
let mut n = 0;
loop {
let r = req(&env, "MKCALENDAR", &format!("{HOME}c{n}/"), &auth, &[], "").await;
if r.status != StatusCode::CREATED {
assert_eq!(r.status, StatusCode::FORBIDDEN);
break;
}
n += 1;
assert!(n <= 100, "no limit");
}
let pid = alice_pid(&env).await;
let all = env.state.db.pim_collections(pid, PimKind::Calendar).await;
assert_eq!(all.unwrap().len(), 101);
let r = req(
&env,
"MKCOL",
"/pim/addressbooks/alice/other/",
&auth,
&[],
"",
)
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
#[tokio::test]
async fn multiget_answers_each_href_once_and_caps_the_count() {
let (env, auth) = setup().await;
put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
let multiget = |hrefs: &[String]| {
let hrefs: String = hrefs
.iter()
.map(|h| format!("{h}"))
.collect();
format!(
r#"{hrefs}"#
)
};
let todo = format!("{CAL}todo.ics");
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[],
&multiget(&[todo.clone(), todo.clone()]),
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
assert_eq!(statuses(&r).len(), 1, "{}", r.text());
let many: Vec = (0..1001).map(|i| format!("{CAL}{i}.ics")).collect();
let r = req(&env, "REPORT", CAL, &auth, &[], &multiget(&many)).await;
let got = statuses(&r);
assert_eq!(got.len(), 1001);
assert_eq!(got.last().unwrap(), &(CAL.to_string(), Some(507)));
}
#[tokio::test]
async fn a_put_racing_the_collection_delete_never_fails_with_500() {
let (env, auth) = setup().await;
let col = "/pim/calendars/alice/race/";
for i in 0..20 {
let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let todo = ics(&TODO.replace("UID:todo", &format!("UID:race{i}")));
let path = format!("{col}{i}.ics");
let (put, delete) = tokio::join!(
req(&env, "PUT", &path, &auth, &[], &todo),
req(&env, "DELETE", col, &auth, &[], ""),
);
assert_eq!(delete.status, StatusCode::NO_CONTENT);
assert!(
[StatusCode::CREATED, StatusCode::CONFLICT].contains(&put.status),
"{}",
put.status
);
}
}
#[tokio::test]
async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500() {
use tower::ServiceExt;
let (env, auth) = setup().await;
let col = "/pim/calendars/alice/race/";
let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let patch = r#"
dead"#;
let (send, arrive) = tokio::sync::oneshot::channel::<()>();
let body = axum::body::Body::from_stream(futures_util::stream::once(async move {
arrive.await.ok();
Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(patch))
}));
let request = axum::http::Request::builder()
.method("PROPPATCH")
.uri(col)
.header("host", "files.example.com")
.header("authorization", &auth)
.body(body)
.unwrap();
let pending = tokio::spawn(env.app.clone().oneshot(request));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let r = req(&env, "DELETE", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
send.send(()).unwrap();
let r = pending.await.unwrap().unwrap();
assert_eq!(r.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn deleting_a_missing_object_records_no_change() {
use server::db::{PimKind, PimOp};
let (env, _) = setup().await;
let db = &env.state.db;
let user = db.find_user_by_name(ALICE).await.unwrap().unwrap();
let pid = db.principal_of(user.id).await.unwrap();
db.pim_ensure_defaults(pid).await.unwrap();
let seq = || async {
db.pim_collection(pid, PimKind::Calendar, "default")
.await
.unwrap()
.unwrap()
};
let before = seq().await;
let op = PimOp::Delete {
collection_id: before.id,
name: "missing.ics".into(),
};
db.pim_apply(&[op]).await.unwrap();
assert_eq!(seq().await.seq, before.seq);
assert!(
db.pim_changes(before.id, Some(before.seq), None)
.await
.unwrap()
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn dead_properties_are_capped_in_total() {
let (env, auth) = setup().await;
let big = "x".repeat(60 * 1024);
let patch = |names: &[&str]| {
let props: String = names
.iter()
.map(|n| format!("{big}"))
.collect();
format!(
"{props}"
)
};
let codes =
|r: &Resp| -> Vec { parse_multistatus(r)[0].1.iter().map(|(c, _)| *c).collect() };
let r = req(
&env,
"PROPPATCH",
CAL,
&auth,
&[],
&patch(&["a", "b", "c", "d"]),
)
.await;
assert_eq!(codes(&r), [200; 4], "{}", r.text());
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &patch(&["e"])).await;
assert_eq!(codes(&r), [507], "{}", r.text());
// Replacing one keeps the total.
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &patch(&["a"])).await;
assert_eq!(codes(&r), [200], "{}", r.text());
let tz = format!(
"\
{}",
"y".repeat(70 * 1024)
);
// Checked as a time zone first, then for size.
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &tz).await;
assert_eq!(codes(&r), [403], "{}", r.text());
let tz = format!(
"\
BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nBEGIN:VTIMEZONE\r\n\
TZID:Europe/Berlin\r\nX-PAD:{}\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\n\
TZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\
END:VCALENDAR\r\n",
"y".repeat(70 * 1024)
);
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &tz).await;
assert_eq!(codes(&r), [507], "{}", r.text());
// A protected property stays protected, whatever its size.
let etag = format!(
"{}\
",
"z".repeat(70 * 1024)
);
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &etag).await;
assert_eq!(codes(&r), [403], "{}", r.text());
}
#[tokio::test]
async fn proppatch_follows_document_order() {
let (env, auth) = setup().await;
let body = |first: &str, second: &str| {
let op = |o: &str| format!("v");
format!(
"{}{}",
op(first),
op(second)
)
};
let props =
"";
for (first, second, kept) in [("set", "remove", false), ("remove", "set", true)] {
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &body(first, second)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let r = req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], props).await;
let ms = parse_multistatus(&r);
assert_eq!(
prop(&ms, CAL, "urn:x", "note").is_some(),
kept,
"{first} then {second}"
);
}
}
#[tokio::test]
async fn long_names_and_405s() {
let (env, auth) = setup().await;
let long = format!("{CAL}{}.ics", "n".repeat(300));
let r = req(&env, "PUT", &long, &auth, &[], &ics(LUNCH)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let short = format!("{CAL}short.ics");
let r = req(&env, "PUT", &short, &auth, &[], &ics(LUNCH)).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(&env, "MOVE", &short, &auth, &[("destination", &long)], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// An object stored under a long name before the limit can still be updated.
let db = &env.state.db;
let pid = db.pim_principal("alice").await.unwrap().unwrap().id;
let col = db
.pim_collection(pid, server::db::PimKind::Calendar, "default")
.await
.unwrap()
.unwrap();
let (mut obj, data) = db.pim_object(col.id, "short.ics").await.unwrap().unwrap();
obj.name = long.rsplit('/').next().unwrap().to_string();
db.pim_apply(&[
server::db::PimOp::Delete {
collection_id: col.id,
name: "short.ics".into(),
},
server::db::PimOp::Put {
collection_id: col.id,
obj,
data,
},
])
.await
.unwrap();
let r = req(
&env,
"PUT",
&long,
&auth,
&[],
&ics(&LUNCH.replace("Team", "Long")),
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
let r = req(&env, "PUT", CAL, &auth, &[], &ics(LUNCH)).await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
let allow = r.header("allow").unwrap();
assert!(
allow.contains("PROPFIND") && !allow.contains("PUT"),
"{allow}"
);
}