api_pim.rs
⎇
Raw
1//! CalDAV and CardDAV: discovery, collections, properties and objects.
2
3mod common;
4
5use axum::http::{Method, StatusCode};
6use common::*;
7use pimdav::xml::{self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name};
8use serde_json::json;
9use xmltree::Element;
10
11const ALICE: &str = "alice";
12const PW: &str = "alice12345";
13
14async fn setup() -> (Env, String) {
15 let env = Env::new().await;
16 let admin = env.admin().await;
17 create_user(&admin, ALICE, PW, &[]).await;
18 (env, basic(ALICE, PW))
19}
20
21async fn req(
22 env: &Env,
23 verb: &str,
24 path: &str,
25 auth: &str,
26 extra: &[(&str, &str)],
27 body: &str,
28) -> Resp {
29 let mut headers = vec![("authorization", auth)];
30 headers.extend_from_slice(extra);
31 Client::new(env.app.clone())
32 .raw(
33 Method::from_bytes(verb.as_bytes()).unwrap(),
34 path,
35 &headers,
36 body.as_bytes().to_vec(),
37 )
38 .await
39}
40
41fn propfind_body(props: &[(&str, &str)]) -> String {
42 let props: String = props
43 .iter()
44 .map(|(ns, l)| format!("<{l} xmlns=\"{ns}\"/>"))
45 .collect();
46 format!("<d:propfind xmlns:d=\"DAV:\"><d:prop>{props}</d:prop></d:propfind>")
47}
48
49/// `href -> [(status, property element)]` of a multistatus.
50fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> {
51 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
52 let root = Element::parse(r.body.as_slice()).unwrap();
53 xml::elements(&root)
54 .map(|resp| {
55 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
56 let props = xml::elements(resp)
57 .filter(|e| Name::of(e).is(DAV, "propstat"))
58 .flat_map(|ps| {
59 let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
60 .split(' ')
61 .nth(1)
62 .unwrap()
63 .parse()
64 .unwrap();
65 let prop = xml::child(ps, DAV, "prop").unwrap();
66 xml::elements(prop)
67 .map(move |p| (code, p.clone()))
68 .collect::<Vec<_>>()
69 })
70 .collect();
71 (href, props)
72 })
73 .collect()
74}
75
76/// The property of `href` with status 200.
77fn prop(
78 ms: &[(String, Vec<(u16, Element)>)],
79 href: &str,
80 ns: &str,
81 local: &str,
82) -> Option<Element> {
83 ms.iter()
84 .find(|(h, _)| h == href)
85 .unwrap_or_else(|| panic!("no response for {href}"))
86 .1
87 .iter()
88 .find(|(code, p)| *code == 200 && Name::of(p).is(ns, local))
89 .map(|(_, p)| p.clone())
90}
91
92fn prop_text(
93 ms: &[(String, Vec<(u16, Element)>)],
94 href: &str,
95 ns: &str,
96 local: &str,
97) -> Option<String> {
98 prop(ms, href, ns, local).map(|p| xml::text(&p))
99}
100
101fn hrefs_of(p: &Element) -> Vec<String> {
102 xml::elements(p).map(xml::text).collect()
103}
104
105fn error_condition(r: &Resp) -> Name {
106 let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
107 assert!(Name::of(&root).is(DAV, "error"), "{}", r.text());
108 Name::of(xml::elements(&root).next().unwrap())
109}
110
111const HOME: &str = "/pim/calendars/alice/";
112const CAL: &str = "/pim/calendars/alice/default/";
113const BOOK: &str = "/pim/addressbooks/alice/default/";
114
115fn event(uid: &str, summary: &str) -> String {
116 format!(
117 "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"
118 )
119}
120
121#[tokio::test]
122async fn discovery() {
123 let (env, auth) = setup().await;
124
125 let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
126 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
127 assert!(r.header("www-authenticate").is_some());
128
129 let r = req(&env, "PROPFIND", "/.well-known/caldav", &auth, &[], "").await;
130 assert_eq!(r.status, StatusCode::MOVED_PERMANENTLY);
131 assert_eq!(r.header("location").as_deref(), Some("/pim/"));
132
133 // A Basic login spelled in another case still gets the stored spelling.
134 let body = propfind_body(&[(DAV, "current-user-principal")]);
135 let r = req(
136 &env,
137 "PROPFIND",
138 "/pim/",
139 &basic("ALICE", PW),
140 &[("depth", "0")],
141 &body,
142 )
143 .await;
144 let ms = parse_multistatus(&r);
145 let p = prop(&ms, "/pim/", DAV, "current-user-principal").unwrap();
146 assert_eq!(hrefs_of(&p), ["/pim/principals/alice/"]);
147
148 let body = propfind_body(&[
149 (CALDAV, "calendar-home-set"),
150 (CARDDAV, "addressbook-home-set"),
151 (CALDAV, "calendar-user-address-set"),
152 (DAV, "displayname"),
153 (DAV, "no-such-prop"),
154 ]);
155 let r = req(
156 &env,
157 "PROPFIND",
158 "/pim/principals/alice/",
159 &auth,
160 &[("depth", "0")],
161 &body,
162 )
163 .await;
164 let ms = parse_multistatus(&r);
165 let p = "/pim/principals/alice/";
166 assert_eq!(
167 hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
168 [HOME]
169 );
170 assert_eq!(
171 hrefs_of(&prop(&ms, p, CARDDAV, "addressbook-home-set").unwrap()),
172 ["/pim/addressbooks/alice/"]
173 );
174 let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
175 assert_eq!(addresses[0], "mailto:alice@filebrowser.invalid");
176 assert!(addresses[2].starts_with("urn:uuid:"));
177 assert_eq!(
178 prop_text(&ms, p, DAV, "displayname").as_deref(),
179 Some(ALICE)
180 );
181 assert!(
182 ms[0]
183 .1
184 .iter()
185 .any(|(c, e)| *c == 404 && Name::of(e).is(DAV, "no-such-prop"))
186 );
187
188 // An app password works as well.
189 let admin = login(&env, ALICE, PW).await;
190 let r = admin
191 .post_json("/api/auth/app-passwords", &json!({ "name": "phone" }))
192 .await;
193 let secret = r.json()["secret"].as_str().unwrap().to_string();
194 let r = req(
195 &env,
196 "PROPFIND",
197 "/pim/",
198 &basic("x", &secret),
199 &[("depth", "0")],
200 "",
201 )
202 .await;
203 assert_eq!(r.status, StatusCode::MULTI_STATUS);
204
205 let r = req(&env, "OPTIONS", "/pim/", &auth, &[], "").await;
206 assert!(r.header("dav").unwrap().contains("calendar-access"));
207}
208
209#[tokio::test]
210async fn homes_list_the_default_collections() {
211 let (env, auth) = setup().await;
212 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
213 let ms = parse_multistatus(&r);
214 assert_eq!(ms.len(), 2, "{}", r.text());
215 let rt = prop(&ms, CAL, DAV, "resourcetype").unwrap();
216 assert!(xml::child(&rt, CALDAV, "calendar").is_some());
217 assert_eq!(
218 prop_text(&ms, CAL, DAV, "displayname").as_deref(),
219 Some("Calendar")
220 );
221 let comps = prop(&ms, CAL, CALDAV, "supported-calendar-component-set").unwrap();
222 let comps: Vec<_> = xml::elements(&comps)
223 .map(|c| c.attributes["name"].clone())
224 .collect();
225 assert_eq!(comps, ["VEVENT", "VTODO", "VJOURNAL"]);
226 assert!(prop_text(&ms, CAL, CALSERVER, "getctag").is_some());
227 assert!(
228 prop_text(&ms, CAL, DAV, "sync-token")
229 .unwrap()
230 .starts_with("urn:")
231 );
232
233 let r = req(
234 &env,
235 "PROPFIND",
236 "/pim/addressbooks/alice/",
237 &auth,
238 &[("depth", "1")],
239 "",
240 )
241 .await;
242 let ms = parse_multistatus(&r);
243 let rt = prop(&ms, BOOK, DAV, "resourcetype").unwrap();
244 assert!(xml::child(&rt, CARDDAV, "addressbook").is_some());
245
246 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "infinity")], "").await;
247 assert_eq!(r.status, StatusCode::FORBIDDEN);
248 assert!(error_condition(&r).is(DAV, "propfind-finite-depth"));
249}
250
251#[tokio::test]
252async fn other_users_are_off_limits() {
253 let (env, auth) = setup().await;
254 for path in ["/pim/principals/admin/", "/pim/calendars/admin/default/"] {
255 let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await;
256 assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}");
257 }
258}
259
260#[tokio::test]
261async fn make_and_patch_collections() {
262 let (env, auth) = setup().await;
263 let work = "/pim/calendars/alice/work/";
264 let body = r##"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:i="http://apple.com/ns/ical/">
265 <d:set><d:prop>
266 <d:displayname>Work</d:displayname>
267 <i:calendar-color>#00ff00</i:calendar-color>
268 <c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>
269 </d:prop></d:set></c:mkcalendar>"##;
270 let r = req(&env, "MKCALENDAR", work, &auth, &[], body).await;
271 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
272 let r = req(&env, "MKCALENDAR", work, &auth, &[], "").await;
273 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
274
275 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
276 let ms = parse_multistatus(&r);
277 assert_eq!(
278 prop_text(&ms, work, DAV, "displayname").as_deref(),
279 Some("Work")
280 );
281 assert_eq!(
282 prop_text(&ms, work, APPLE, "calendar-color").as_deref(),
283 Some("#00ff00")
284 );
285 let ctag = prop_text(&ms, work, CALSERVER, "getctag").unwrap();
286
287 // One bad property fails the whole request, and nothing is created.
288 let bad = body.replace("VTODO", "VCARD");
289 let r = req(
290 &env,
291 "MKCALENDAR",
292 "/pim/calendars/alice/bad/",
293 &auth,
294 &[],
295 &bad,
296 )
297 .await;
298 assert_eq!(r.status, StatusCode::FORBIDDEN);
299 assert!(r.text().contains("mkcalendar-response"), "{}", r.text());
300 let r = req(
301 &env,
302 "PROPFIND",
303 "/pim/calendars/alice/bad/",
304 &auth,
305 &[("depth", "0")],
306 "",
307 )
308 .await;
309 assert_eq!(r.status, StatusCode::NOT_FOUND);
310
311 // A plain MKCOL cannot make a calendar, an extended one makes an address book.
312 let r = req(&env, "MKCOL", "/pim/calendars/alice/plain/", &auth, &[], "").await;
313 assert_eq!(r.status, StatusCode::FORBIDDEN);
314 let mkcol = r#"<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:set><d:prop>
315 <d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>
316 <d:displayname>Friends</d:displayname></d:prop></d:set></d:mkcol>"#;
317 let r = req(
318 &env,
319 "MKCOL",
320 "/pim/addressbooks/alice/friends/",
321 &auth,
322 &[],
323 mkcol,
324 )
325 .await;
326 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
327
328 let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
329 <d:set><d:prop><d:displayname>Job</d:displayname><c:calendar-description>Tasks</c:calendar-description></d:prop></d:set>
330 </d:propertyupdate>"#;
331 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
332 let ms = parse_multistatus(&r);
333 assert!(ms[0].1.iter().all(|(c, _)| *c == 200));
334 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
335 let ms = parse_multistatus(&r);
336 assert_eq!(
337 prop_text(&ms, work, DAV, "displayname").as_deref(),
338 Some("Job")
339 );
340 assert_eq!(
341 prop_text(&ms, work, CALDAV, "calendar-description").as_deref(),
342 Some("Tasks")
343 );
344 assert_ne!(prop_text(&ms, work, CALSERVER, "getctag").unwrap(), ctag);
345
346 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop>
347 <d:displayname>Never</d:displayname><d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>"#;
348 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
349 let ms = parse_multistatus(&r);
350 let codes: Vec<u16> = ms[0].1.iter().map(|(c, _)| *c).collect();
351 assert_eq!(codes, [424, 403]);
352 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
353 let ms = parse_multistatus(&r);
354 assert_eq!(
355 prop_text(&ms, work, DAV, "displayname").as_deref(),
356 Some("Job")
357 );
358
359 let r = req(&env, "DELETE", work, &auth, &[], "").await;
360 assert_eq!(r.status, StatusCode::NO_CONTENT);
361 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
362 assert_eq!(r.status, StatusCode::NOT_FOUND);
363}
364
365#[tokio::test]
366async fn calendar_objects() {
367 let (env, auth) = setup().await;
368 let obj = format!("{CAL}a.ics");
369
370 let r = req(
371 &env,
372 "PUT",
373 &obj,
374 &auth,
375 &[("if-none-match", "*")],
376 &event("a", "One"),
377 )
378 .await;
379 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
380 let etag = r.header("etag").unwrap();
381
382 let r = req(&env, "GET", &obj, &auth, &[], "").await;
383 assert_eq!(r.status, StatusCode::OK);
384 assert_eq!(r.text(), event("a", "One"));
385 assert_eq!(r.header("etag"), Some(etag.clone()));
386 assert!(
387 r.header("content-type")
388 .unwrap()
389 .starts_with("text/calendar")
390 );
391 let r = req(&env, "HEAD", &obj, &auth, &[], "").await;
392 assert_eq!(r.status, StatusCode::OK);
393 assert!(r.body.is_empty());
394
395 let r = req(
396 &env,
397 "PUT",
398 &obj,
399 &auth,
400 &[("if-none-match", "*")],
401 &event("a", "Two"),
402 )
403 .await;
404 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
405 let r = req(
406 &env,
407 "PUT",
408 &obj,
409 &auth,
410 &[("if-match", "\"stale\"")],
411 &event("a", "Two"),
412 )
413 .await;
414 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
415
416 let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await);
417 let r = req(
418 &env,
419 "PUT",
420 &obj,
421 &auth,
422 &[("if-match", &etag)],
423 &event("a", "Two"),
424 )
425 .await;
426 assert_eq!(r.status, StatusCode::NO_CONTENT);
427 let new_etag = r.header("etag").unwrap();
428 assert_ne!(new_etag, etag);
429 let after = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "1")], "").await);
430 assert_ne!(
431 prop_text(&before, CAL, DAV, "sync-token"),
432 prop_text(&after, CAL, DAV, "sync-token")
433 );
434 assert_eq!(prop_text(&after, &obj, DAV, "getetag"), Some(new_etag));
435
436 // The UID is already stored under another name.
437 let r = req(
438 &env,
439 "PUT",
440 &format!("{CAL}b.ics"),
441 &auth,
442 &[],
443 &event("a", "Dup"),
444 )
445 .await;
446 assert_eq!(r.status, StatusCode::FORBIDDEN);
447 assert!(error_condition(&r).is(CALDAV, "no-uid-conflict"));
448 assert!(r.text().contains(&obj), "{}", r.text());
449
450 let freebusy = event("j", "x").replace("VEVENT", "VFREEBUSY");
451 let cases = [
452 ("not a calendar".to_string(), "valid-calendar-data"),
453 (
454 event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"),
455 "valid-calendar-object-resource",
456 ),
457 (freebusy, "supported-calendar-component"),
458 ];
459 for (body, cond) in cases {
460 let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await;
461 assert_eq!(r.status, StatusCode::FORBIDDEN, "{cond}");
462 assert!(error_condition(&r).is(CALDAV, cond), "{cond}: {}", r.text());
463 }
464
465 let r = req(
466 &env,
467 "PUT",
468 "/pim/calendars/alice/nope/x.ics",
469 &auth,
470 &[],
471 &event("x", "x"),
472 )
473 .await;
474 assert_eq!(r.status, StatusCode::CONFLICT);
475
476 let r = req(
477 &env,
478 "DELETE",
479 &obj,
480 &auth,
481 &[("if-match", "\"stale\"")],
482 "",
483 )
484 .await;
485 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
486 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
487 assert_eq!(r.status, StatusCode::NO_CONTENT);
488 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
489 assert_eq!(r.status, StatusCode::NOT_FOUND);
490
491 let r = req(&env, "REPORT", CAL, &auth, &[], "").await;
492 assert_eq!(r.status, StatusCode::BAD_REQUEST);
493}
494
495#[tokio::test]
496async fn address_objects() {
497 let (env, auth) = setup().await;
498 let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Bob\r\nN:;Bob;;;\r\nEND:VCARD\r\n";
499 let r = req(&env, "PUT", &format!("{BOOK}c1.vcf"), &auth, &[], card).await;
500 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
501 let r = req(&env, "GET", &format!("{BOOK}c1.vcf"), &auth, &[], "").await;
502 assert_eq!(r.text(), card);
503 assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
504
505 let r = req(&env, "PUT", &format!("{BOOK}c2.vcf"), &auth, &[], card).await;
506 assert!(error_condition(&r).is(CARDDAV, "no-uid-conflict"));
507 let r = req(
508 &env,
509 "PUT",
510 &format!("{BOOK}c3.vcf"),
511 &auth,
512 &[],
513 &event("e", "x"),
514 )
515 .await;
516 assert!(error_condition(&r).is(CARDDAV, "valid-address-data"));
517}
518
519#[tokio::test]
520async fn deleting_the_last_calendar_brings_a_new_default() {
521 let (env, auth) = setup().await;
522 let r = req(&env, "DELETE", CAL, &auth, &[], "").await;
523 assert_eq!(r.status, StatusCode::NO_CONTENT);
524 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
525 let ms = parse_multistatus(&r);
526 assert!(ms.iter().any(|(h, _)| h == CAL));
527}
528
529#[tokio::test]
530async fn deleting_a_user_deletes_their_collections() {
531 let env = Env::new().await;
532 let admin = env.admin().await;
533 create_user(&admin, ALICE, PW, &[]).await;
534 let auth = basic(ALICE, PW);
535 let r = req(
536 &env,
537 "PUT",
538 &format!("{CAL}a.ics"),
539 &auth,
540 &[],
541 &event("a", "x"),
542 )
543 .await;
544 assert_eq!(r.status, StatusCode::CREATED);
545 let id = user_id(&admin, ALICE).await;
546 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
547 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
548 let db = &env.state.db;
549 assert!(
550 db.pim_collections(id, server::db::PimKind::Calendar)
551 .await
552 .unwrap()
553 .is_empty()
554 );
555}
556
557// ---------------------------------------------------------------------------
558// REPORT and MOVE
559// ---------------------------------------------------------------------------
560
561/// `(href, status of the response itself)` of every response.
562fn statuses(r: &Resp) -> Vec<(String, Option<u16>)> {
563 let root = Element::parse(r.body.as_slice()).unwrap();
564 xml::elements(&root)
565 .filter(|e| Name::of(e).is(DAV, "response"))
566 .map(|resp| {
567 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
568 let code = xml::child(resp, DAV, "status")
569 .map(|s| xml::text(s).split(' ').nth(1).unwrap().parse().unwrap());
570 (href, code)
571 })
572 .collect()
573}
574
575fn sync_token_of(r: &Resp) -> String {
576 let root = Element::parse(r.body.as_slice()).unwrap();
577 xml::text(xml::child(&root, DAV, "sync-token").unwrap())
578}
579
580fn ics(body: &str) -> String {
581 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
582}
583
584const 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";
585const 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";
586const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n";
587
588async fn put(env: &Env, auth: &str, path: &str, body: &str) {
589 let r = req(env, "PUT", path, auth, &[], body).await;
590 assert!(r.status.is_success(), "{path}: {}", r.text());
591}
592
593fn query(filter: &str, data: &str) -> String {
594 format!(
595 r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
596 <d:prop><d:getetag/>{data}</d:prop>
597 <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
598 </c:calendar-query>"#
599 )
600}
601
602fn hrefs_in(r: &Resp) -> Vec<String> {
603 let mut h: Vec<_> = statuses(r).into_iter().map(|(h, _)| h).collect();
604 h.sort();
605 h
606}
607
608#[tokio::test]
609async fn calendar_reports() {
610 let (env, auth) = setup().await;
611 put(&env, &auth, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
612 put(&env, &auth, &format!("{CAL}weekly.ics"), &ics(WEEKLY)).await;
613 put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
614
615 let r = req(
616 &env,
617 "PROPFIND",
618 CAL,
619 &auth,
620 &[("depth", "0")],
621 &propfind_body(&[(DAV, "supported-report-set")]),
622 )
623 .await;
624 let reports = prop(&parse_multistatus(&r), CAL, DAV, "supported-report-set").unwrap();
625 assert_eq!(xml::elements(&reports).count(), 4);
626
627 // multiget: stored bytes back, a miss as 404.
628 let body = format!(
629 r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
630 <d:prop><d:getetag/><c:calendar-data/></d:prop>
631 <d:href>{CAL}lunch.ics</d:href><d:href>{CAL}gone.ics</d:href>
632 </c:calendar-multiget>"#
633 );
634 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await;
635 let ms = parse_multistatus(&r);
636 let lunch = format!("{CAL}lunch.ics");
637 // XML parsing turns CRLF into LF.
638 let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap();
639 assert_eq!(data, ics(LUNCH).replace("\r\n", "\n").trim());
640 assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404))));
641
642 // Time range: the Monday 2026-01-05 holds only an instance of the weekly
643 // series, which started a month earlier in Berlin time.
644 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260105T000000Z" end="20260106T000000Z"/></c:comp-filter>"#;
645 let r = req(
646 &env,
647 "REPORT",
648 CAL,
649 &auth,
650 &[("depth", "1")],
651 &query(range, ""),
652 )
653 .await;
654 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
655
656 // The same with expand: one instance in UTC, without the RRULE.
657 let expand = r#"<c:calendar-data><c:expand start="20260105T000000Z" end="20260106T000000Z"/></c:calendar-data>"#;
658 let r = req(
659 &env,
660 "REPORT",
661 CAL,
662 &auth,
663 &[("depth", "1")],
664 &query(range, expand),
665 )
666 .await;
667 let data = prop_text(
668 &parse_multistatus(&r),
669 &format!("{CAL}weekly.ics"),
670 CALDAV,
671 "calendar-data",
672 )
673 .unwrap();
674 assert!(data.contains("DTSTART:20260105T080000Z"), "{data}");
675 assert!(data.contains("RECURRENCE-ID:20260105T080000Z"), "{data}");
676 assert!(!data.contains("RRULE"), "{data}");
677
678 // text-match folds ASCII case by default, and negates on request.
679 let text = r#"<c:comp-filter name="VEVENT"><c:prop-filter name="SUMMARY"><c:text-match>team lunch</c:text-match></c:prop-filter></c:comp-filter>"#;
680 let r = req(
681 &env,
682 "REPORT",
683 CAL,
684 &auth,
685 &[("depth", "1")],
686 &query(text, ""),
687 )
688 .await;
689 assert_eq!(hrefs_in(&r), std::slice::from_ref(&lunch));
690 let negated = text.replace("<c:text-match>", r#"<c:text-match negate-condition="yes">"#);
691 let r = req(
692 &env,
693 "REPORT",
694 CAL,
695 &auth,
696 &[("depth", "1")],
697 &query(&negated, ""),
698 )
699 .await;
700 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
701 let odd = text.replace("<c:text-match>", r#"<c:text-match collation="i;klingon">"#);
702 let r = req(
703 &env,
704 "REPORT",
705 CAL,
706 &auth,
707 &[("depth", "1")],
708 &query(&odd, ""),
709 )
710 .await;
711 assert_eq!(r.status, StatusCode::FORBIDDEN);
712 assert_eq!(
713 error_condition(&r),
714 Name::new(CALDAV, "supported-collation")
715 );
716
717 // A VTODO with only DUE matches the range that holds DUE.
718 let todo = r#"<c:comp-filter name="VTODO"><c:time-range start="20260110T000000Z" end="20260111T000000Z"/></c:comp-filter>"#;
719 let r = req(
720 &env,
721 "REPORT",
722 CAL,
723 &auth,
724 &[("depth", "1")],
725 &query(todo, ""),
726 )
727 .await;
728 assert_eq!(hrefs_in(&r), [format!("{CAL}todo.ics")]);
729
730 // Only the components asked for.
731 let comp = r#"<c:calendar-data><c:comp name="VCALENDAR"><c:comp name="VEVENT"><c:prop name="SUMMARY"/></c:comp></c:comp></c:calendar-data>"#;
732 let r = req(
733 &env,
734 "REPORT",
735 CAL,
736 &auth,
737 &[("depth", "1")],
738 &query(text, comp),
739 )
740 .await;
741 let data = prop_text(&parse_multistatus(&r), &lunch, CALDAV, "calendar-data").unwrap();
742 assert!(
743 data.contains("SUMMARY:Team Lunch")
744 && !data.contains("DTSTART")
745 && !data.contains("VERSION"),
746 "{data}"
747 );
748
749 let fb = r#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z" end="20260106T000000Z"/></c:free-busy-query>"#;
750 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], fb).await;
751 assert_eq!(r.status, StatusCode::OK);
752 assert!(
753 r.header("content-type")
754 .unwrap()
755 .starts_with("text/calendar")
756 );
757 assert!(
758 r.text()
759 .contains("FREEBUSY;FBTYPE=BUSY:20260105T080000Z/20260105T083000Z"),
760 "{}",
761 r.text()
762 );
763 assert!(
764 r.text()
765 .contains("FREEBUSY;FBTYPE=BUSY:20260101T120000Z/20260101T130000Z"),
766 "{}",
767 r.text()
768 );
769
770 // An address book report on a calendar is refused.
771 let r = req(&env, "REPORT", CAL, &auth, &[], r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav"><card:filter/></card:addressbook-query>"#).await;
772 assert_eq!(error_condition(&r), Name::new(DAV, "supported-report"));
773}
774
775#[tokio::test]
776async fn sync_collection() {
777 let (env, auth) = setup().await;
778 let sync = |token: &str, limit: &str| {
779 format!(
780 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level>{limit}<d:prop><d:getetag/></d:prop></d:sync-collection>"#
781 )
782 };
783 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
784 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
785
786 let r = req(&env, "REPORT", CAL, &auth, &[], &sync("", "")).await;
787 assert_eq!(hrefs_in(&r), [format!("{CAL}a.ics"), format!("{CAL}b.ics")]);
788 let token = sync_token_of(&r);
789
790 put(&env, &auth, &format!("{CAL}c.ics"), &event("c", "C")).await;
791 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A2")).await;
792 let r = req(&env, "DELETE", &format!("{CAL}b.ics"), &auth, &[], "").await;
793 assert_eq!(r.status, StatusCode::NO_CONTENT);
794
795 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, "")).await;
796 let mut got = statuses(&r);
797 got.sort();
798 assert_eq!(
799 got,
800 [
801 (format!("{CAL}a.ics"), None),
802 (format!("{CAL}b.ics"), Some(404)),
803 (format!("{CAL}c.ics"), None),
804 ]
805 );
806 let latest = sync_token_of(&r);
807 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&latest, "")).await;
808 assert!(statuses(&r).is_empty());
809
810 // A limit hands out the token of the last change it returned.
811 let limit = "<d:limit><d:nresults>1</d:nresults></d:limit>";
812 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, limit)).await;
813 let got = statuses(&r);
814 assert_eq!(got.len(), 2);
815 assert_eq!(got[1], (CAL.to_string(), Some(507)));
816 let r = req(
817 &env,
818 "REPORT",
819 CAL,
820 &auth,
821 &[],
822 &sync(&sync_token_of(&r), ""),
823 )
824 .await;
825 assert_eq!(statuses(&r).len(), 2);
826
827 for bad in ["urn:fbng:sync:999-1", "nonsense", &format!("{latest}0")] {
828 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(bad, "")).await;
829 assert_eq!(
830 error_condition(&r),
831 Name::new(DAV, "valid-sync-token"),
832 "{bad}"
833 );
834 }
835}
836
837#[tokio::test]
838async fn addressbook_reports() {
839 let (env, auth) = setup().await;
840 let card = |uid: &str, name: &str, mail: &str| {
841 format!(
842 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{name}\r\nEMAIL;TYPE=WORK:{mail}\r\nEND:VCARD\r\n"
843 )
844 };
845 put(
846 &env,
847 &auth,
848 &format!("{BOOK}bob.vcf"),
849 &card("bob", "Bob Builder", "bob@example.com"),
850 )
851 .await;
852 put(
853 &env,
854 &auth,
855 &format!("{BOOK}ann.vcf"),
856 &card("ann", "Ann Äpfel", "ann@example.org"),
857 )
858 .await;
859 let query = |filter: &str, data: &str| {
860 format!(
861 r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/>{data}</d:prop>{filter}</card:addressbook-query>"#
862 )
863 };
864 let email = r#"<card:filter><card:prop-filter name="EMAIL"><card:text-match match-type="ends-with">.ORG</card:text-match></card:prop-filter></card:filter>"#;
865 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(email, "")).await;
866 assert_eq!(hrefs_in(&r), [format!("{BOOK}ann.vcf")]);
867
868 // Unicode case folding is the CardDAV default.
869 let fname = r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="equals">ann äpfel</card:text-match></card:prop-filter></card:filter>"#;
870 let r = req(
871 &env,
872 "REPORT",
873 BOOK,
874 &auth,
875 &[],
876 &query(
877 fname,
878 "<card:address-data><card:prop name=\"FN\"/></card:address-data>",
879 ),
880 )
881 .await;
882 let data = prop_text(
883 &parse_multistatus(&r),
884 &format!("{BOOK}ann.vcf"),
885 CARDDAV,
886 "address-data",
887 )
888 .unwrap();
889 assert!(
890 data.contains("Ann Äpfel") && !data.contains("EMAIL"),
891 "{data}"
892 );
893
894 let param = r#"<card:filter test="allof"><card:prop-filter name="EMAIL"><card:param-filter name="TYPE"><card:text-match match-type="equals">work</card:text-match></card:param-filter></card:prop-filter><card:prop-filter name="NICKNAME"><card:is-not-defined/></card:prop-filter></card:filter>"#;
895 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(param, "")).await;
896 assert_eq!(hrefs_in(&r).len(), 2);
897
898 let limited = query(
899 "<card:filter/><card:limit><card:nresults>1</card:nresults></card:limit>",
900 "",
901 );
902 let r = req(&env, "REPORT", BOOK, &auth, &[], &limited).await;
903 let got = statuses(&r);
904 assert_eq!(got.len(), 2);
905 assert_eq!(got[1], (BOOK.to_string(), Some(507)));
906
907 let body = format!(
908 r#"<card:addressbook-multiget xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:address-data/></d:prop><d:href>{BOOK}bob.vcf</d:href></card:addressbook-multiget>"#
909 );
910 let r = req(&env, "REPORT", BOOK, &auth, &[], &body).await;
911 let data = prop_text(
912 &parse_multistatus(&r),
913 &format!("{BOOK}bob.vcf"),
914 CARDDAV,
915 "address-data",
916 )
917 .unwrap();
918 assert!(data.contains("FN:Bob Builder"));
919}
920
921#[tokio::test]
922async fn move_objects() {
923 let (env, auth) = setup().await;
924 let r = req(&env, "MKCALENDAR", &format!("{HOME}work/"), &auth, &[], "").await;
925 assert_eq!(r.status, StatusCode::CREATED);
926 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
927 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
928
929 let dest = |p: &str| format!("http://localhost{p}");
930 let r = req(
931 &env,
932 "MOVE",
933 &format!("{CAL}a.ics"),
934 &auth,
935 &[("destination", &dest(&format!("{HOME}work/a.ics")))],
936 "",
937 )
938 .await;
939 assert_eq!(r.status, StatusCode::CREATED);
940 assert_eq!(
941 req(&env, "GET", &format!("{CAL}a.ics"), &auth, &[], "")
942 .await
943 .status,
944 StatusCode::NOT_FOUND
945 );
946 assert_eq!(
947 req(&env, "GET", &format!("{HOME}work/a.ics"), &auth, &[], "")
948 .await
949 .text(),
950 event("a", "A")
951 );
952
953 // Overwrite: F refuses an existing destination.
954 put(&env, &auth, &format!("{CAL}a2.ics"), &event("a2", "A2")).await;
955 let r = req(
956 &env,
957 "MOVE",
958 &format!("{CAL}a2.ics"),
959 &auth,
960 &[("destination", &format!("{CAL}b.ics")), ("overwrite", "F")],
961 "",
962 )
963 .await;
964 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
965 let r = req(
966 &env,
967 "MOVE",
968 &format!("{CAL}a2.ics"),
969 &auth,
970 &[("destination", &format!("{CAL}b.ics"))],
971 "",
972 )
973 .await;
974 assert_eq!(r.status, StatusCode::NO_CONTENT);
975
976 // The same UID under another name in the destination.
977 put(&env, &auth, &format!("{CAL}dup.ics"), &event("a", "again")).await;
978 let r = req(
979 &env,
980 "MOVE",
981 &format!("{CAL}dup.ics"),
982 &auth,
983 &[("destination", &format!("{HOME}work/other.ics"))],
984 "",
985 )
986 .await;
987 assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
988
989 // Across kinds is refused.
990 let r = req(
991 &env,
992 "MOVE",
993 &format!("{CAL}b.ics"),
994 &auth,
995 &[("destination", &format!("{BOOK}b.vcf"))],
996 "",
997 )
998 .await;
999 assert_eq!(r.status, StatusCode::FORBIDDEN);
1000}
1001
1002#[tokio::test]
1003async fn hrefs_follow_the_requested_spelling() {
1004 let (env, auth) = setup().await;
1005 let body = propfind_body(&[(DAV, "displayname")]);
1006 let r = req(
1007 &env,
1008 "PROPFIND",
1009 "/pim/calendars/ALICE/",
1010 &auth,
1011 &[("depth", "1")],
1012 &body,
1013 )
1014 .await;
1015 let hrefs = hrefs_in(&r);
1016 assert!(
1017 hrefs.iter().all(|h| h.starts_with("/pim/calendars/ALICE/")),
1018 "{hrefs:?}"
1019 );
1020}
1021