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/";
114const INBOX: &str = "/pim/calendars/alice/inbox/";
115const OUTBOX: &str = "/pim/calendars/alice/outbox/";
116
117fn event(uid: &str, summary: &str) -> String {
118 format!(
119 "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"
120 )
121}
122
123#[tokio::test]
124async fn discovery() {
125 let (env, auth) = setup().await;
126
127 let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
128 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
129 assert!(r.header("www-authenticate").is_some());
130
131 let r = req(&env, "PROPFIND", "/.well-known/caldav", &auth, &[], "").await;
132 assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT);
133 assert_eq!(r.header("location").as_deref(), Some("/pim/"));
134
135 // A Basic login spelled in another case still gets the stored spelling.
136 let body = propfind_body(&[(DAV, "current-user-principal")]);
137 let r = req(
138 &env,
139 "PROPFIND",
140 "/pim/",
141 &basic("ALICE", PW),
142 &[("depth", "0")],
143 &body,
144 )
145 .await;
146 let ms = parse_multistatus(&r);
147 let p = prop(&ms, "/pim/", DAV, "current-user-principal").unwrap();
148 assert_eq!(hrefs_of(&p), ["/pim/principals/alice/"]);
149
150 let body = propfind_body(&[
151 (CALDAV, "calendar-home-set"),
152 (CARDDAV, "addressbook-home-set"),
153 (CALDAV, "calendar-user-address-set"),
154 (DAV, "displayname"),
155 (DAV, "no-such-prop"),
156 ]);
157 let r = req(
158 &env,
159 "PROPFIND",
160 "/pim/principals/alice/",
161 &auth,
162 &[("depth", "0")],
163 &body,
164 )
165 .await;
166 let ms = parse_multistatus(&r);
167 let p = "/pim/principals/alice/";
168 assert_eq!(
169 hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
170 [HOME]
171 );
172 assert_eq!(
173 hrefs_of(&prop(&ms, p, CARDDAV, "addressbook-home-set").unwrap()),
174 ["/pim/addressbooks/alice/"]
175 );
176 // Only the mailto address, preferred, so Apple picks it as the identity.
177 let set = prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap();
178 assert_eq!(hrefs_of(&set), ["mailto:alice@dovenest.invalid"]);
179 let href = xml::child(&set, DAV, "href").unwrap();
180 assert_eq!(
181 href.attributes.get("preferred").map(String::as_str),
182 Some("1")
183 );
184 assert_eq!(
185 prop_text(&ms, p, DAV, "displayname").as_deref(),
186 Some(ALICE)
187 );
188 assert!(
189 ms[0]
190 .1
191 .iter()
192 .any(|(c, e)| *c == 404 && Name::of(e).is(DAV, "no-such-prop"))
193 );
194
195 // An app password works as well.
196 let admin = login(&env, ALICE, PW).await;
197 let r = admin
198 .post_json("/api/auth/app-passwords", &json!({ "name": "phone" }))
199 .await;
200 let secret = r.json()["secret"].as_str().unwrap().to_string();
201 let r = req(
202 &env,
203 "PROPFIND",
204 "/pim/",
205 &basic("x", &secret),
206 &[("depth", "0")],
207 "",
208 )
209 .await;
210 assert_eq!(r.status, StatusCode::MULTI_STATUS);
211
212 let r = req(&env, "OPTIONS", "/pim/", &auth, &[], "").await;
213 assert!(r.header("dav").unwrap().contains("calendar-access"));
214}
215
216#[tokio::test]
217async fn homes_list_the_default_collections() {
218 let (env, auth) = setup().await;
219 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
220 let ms = parse_multistatus(&r);
221 // The home, the calendar, the birthday calendar, and the scheduling
222 // inbox and outbox.
223 assert_eq!(ms.len(), 5, "{}", r.text());
224 for (href, kind) in [(INBOX, "schedule-inbox"), (OUTBOX, "schedule-outbox")] {
225 let rt = prop(&ms, href, DAV, "resourcetype").unwrap();
226 assert!(xml::child(&rt, CALDAV, kind).is_some(), "{href}");
227 }
228 assert_eq!(
229 prop(&ms, INBOX, CALDAV, "schedule-default-calendar-URL").map(|p| hrefs_of(&p)),
230 Some(vec![CAL.to_string()])
231 );
232 let rt = prop(&ms, CAL, DAV, "resourcetype").unwrap();
233 assert!(xml::child(&rt, CALDAV, "calendar").is_some());
234 assert_eq!(
235 prop_text(&ms, CAL, DAV, "displayname").as_deref(),
236 Some("Calendar")
237 );
238 let comps = prop(&ms, CAL, CALDAV, "supported-calendar-component-set").unwrap();
239 let comps: Vec<_> = xml::elements(&comps)
240 .map(|c| c.attributes["name"].clone())
241 .collect();
242 assert_eq!(comps, ["VEVENT", "VTODO", "VJOURNAL"]);
243 assert!(prop_text(&ms, CAL, CALSERVER, "getctag").is_some());
244 assert!(
245 prop_text(&ms, CAL, DAV, "sync-token")
246 .unwrap()
247 .starts_with("urn:")
248 );
249
250 let r = req(
251 &env,
252 "PROPFIND",
253 "/pim/addressbooks/alice/",
254 &auth,
255 &[("depth", "1")],
256 "",
257 )
258 .await;
259 let ms = parse_multistatus(&r);
260 let rt = prop(&ms, BOOK, DAV, "resourcetype").unwrap();
261 assert!(xml::child(&rt, CARDDAV, "addressbook").is_some());
262
263 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "infinity")], "").await;
264 assert_eq!(r.status, StatusCode::FORBIDDEN);
265 assert!(error_condition(&r).is(DAV, "propfind-finite-depth"));
266}
267
268#[tokio::test]
269async fn other_users_are_off_limits() {
270 let (env, auth) = setup().await;
271 for path in ["/pim/calendars/admin/", "/pim/calendars/admin/default/"] {
272 let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await;
273 assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}");
274 }
275 // Another account's principal is readable, for scheduling.
276 let body = propfind_body(&[
277 (DAV, "displayname"),
278 (CALDAV, "calendar-user-address-set"),
279 (CARDDAV, "addressbook-home-set"),
280 ]);
281 let p = "/pim/principals/admin/";
282 let r = req(&env, "PROPFIND", p, &auth, &[("depth", "0")], &body).await;
283 let ms = parse_multistatus(&r);
284 assert_eq!(
285 prop_text(&ms, p, DAV, "displayname").as_deref(),
286 Some("admin")
287 );
288 let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
289 assert!(addresses.contains(&"mailto:admin@dovenest.invalid".to_string()));
290 assert!(prop(&ms, p, CARDDAV, "addressbook-home-set").is_none());
291
292 let r = req(&env, "PROPFIND", "/pim/principals/nobody/", &auth, &[], "").await;
293 assert_eq!(r.status, StatusCode::NOT_FOUND);
294}
295
296#[tokio::test]
297async fn make_and_patch_collections() {
298 let (env, auth) = setup().await;
299 let work = "/pim/calendars/alice/work/";
300 let body = r##"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:i="http://apple.com/ns/ical/">
301 <d:set><d:prop>
302 <d:displayname>Work</d:displayname>
303 <i:calendar-color>#00ff00</i:calendar-color>
304 <c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>
305 </d:prop></d:set></c:mkcalendar>"##;
306 let r = req(&env, "MKCALENDAR", work, &auth, &[], body).await;
307 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
308 let r = req(&env, "MKCALENDAR", work, &auth, &[], "").await;
309 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
310
311 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
312 let ms = parse_multistatus(&r);
313 assert_eq!(
314 prop_text(&ms, work, DAV, "displayname").as_deref(),
315 Some("Work")
316 );
317 assert_eq!(
318 prop_text(&ms, work, APPLE, "calendar-color").as_deref(),
319 Some("#00ff00")
320 );
321 let ctag = prop_text(&ms, work, CALSERVER, "getctag").unwrap();
322
323 // One bad property fails the whole request, and nothing is created.
324 let bad = body.replace("VTODO", "VCARD");
325 let r = req(
326 &env,
327 "MKCALENDAR",
328 "/pim/calendars/alice/bad/",
329 &auth,
330 &[],
331 &bad,
332 )
333 .await;
334 assert_eq!(r.status, StatusCode::FORBIDDEN);
335 assert!(r.text().contains("mkcalendar-response"), "{}", r.text());
336 let r = req(
337 &env,
338 "PROPFIND",
339 "/pim/calendars/alice/bad/",
340 &auth,
341 &[("depth", "0")],
342 "",
343 )
344 .await;
345 assert_eq!(r.status, StatusCode::NOT_FOUND);
346
347 // A plain MKCOL cannot make a calendar, an extended one makes an address book.
348 let r = req(&env, "MKCOL", "/pim/calendars/alice/plain/", &auth, &[], "").await;
349 assert_eq!(r.status, StatusCode::FORBIDDEN);
350 let mkcol = r#"<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:set><d:prop>
351 <d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>
352 <d:displayname>Friends</d:displayname></d:prop></d:set></d:mkcol>"#;
353 let r = req(
354 &env,
355 "MKCOL",
356 "/pim/addressbooks/alice/friends/",
357 &auth,
358 &[],
359 mkcol,
360 )
361 .await;
362 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
363
364 let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
365 <d:set><d:prop><d:displayname>Job</d:displayname><c:calendar-description>Tasks</c:calendar-description></d:prop></d:set>
366 </d:propertyupdate>"#;
367 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
368 let ms = parse_multistatus(&r);
369 assert!(ms[0].1.iter().all(|(c, _)| *c == 200));
370 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
371 let ms = parse_multistatus(&r);
372 assert_eq!(
373 prop_text(&ms, work, DAV, "displayname").as_deref(),
374 Some("Job")
375 );
376 assert_eq!(
377 prop_text(&ms, work, CALDAV, "calendar-description").as_deref(),
378 Some("Tasks")
379 );
380 assert_ne!(prop_text(&ms, work, CALSERVER, "getctag").unwrap(), ctag);
381
382 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop>
383 <d:displayname>Never</d:displayname><d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>"#;
384 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
385 let ms = parse_multistatus(&r);
386 let codes: Vec<u16> = ms[0].1.iter().map(|(c, _)| *c).collect();
387 assert_eq!(codes, [424, 403]);
388 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
389 let ms = parse_multistatus(&r);
390 assert_eq!(
391 prop_text(&ms, work, DAV, "displayname").as_deref(),
392 Some("Job")
393 );
394
395 let r = req(&env, "DELETE", work, &auth, &[], "").await;
396 assert_eq!(r.status, StatusCode::NO_CONTENT);
397 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
398 assert_eq!(r.status, StatusCode::NOT_FOUND);
399}
400
401#[tokio::test]
402async fn missing_dtstamp_is_added() {
403 let (env, auth) = setup().await;
404 let obj = format!("{CAL}s.ics");
405 let sent = event("s", "One").replace("DTSTAMP:20260101T000000Z\r\n", "");
406 let r = req(&env, "PUT", &obj, &auth, &[], &sent).await;
407 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
408 assert_eq!(r.header("etag"), None);
409 let stored = req(&env, "GET", &obj, &auth, &[], "").await.text();
410 let (head, rest) = stored.split_once("BEGIN:VEVENT\r\nDTSTAMP:").unwrap();
411 let (stamp, tail) = rest.split_once("\r\n").unwrap();
412 assert_eq!(stamp.len(), 16, "{stored}");
413 assert_eq!(format!("{head}BEGIN:VEVENT\r\n{tail}"), sent);
414}
415
416#[tokio::test]
417async fn calendar_objects() {
418 let (env, auth) = setup().await;
419 let obj = format!("{CAL}a.ics");
420
421 let r = req(
422 &env,
423 "PUT",
424 &obj,
425 &auth,
426 &[("if-none-match", "*")],
427 &event("a", "One"),
428 )
429 .await;
430 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
431 let etag = r.header("etag").unwrap();
432
433 let r = req(&env, "GET", &obj, &auth, &[], "").await;
434 assert_eq!(r.status, StatusCode::OK);
435 assert_eq!(r.text(), event("a", "One"));
436 assert_eq!(r.header("etag"), Some(etag.clone()));
437 assert!(
438 r.header("content-type")
439 .unwrap()
440 .starts_with("text/calendar")
441 );
442 let r = req(&env, "HEAD", &obj, &auth, &[], "").await;
443 assert_eq!(r.status, StatusCode::OK);
444 assert!(r.body.is_empty());
445
446 let r = req(
447 &env,
448 "PUT",
449 &obj,
450 &auth,
451 &[("if-none-match", "*")],
452 &event("a", "Two"),
453 )
454 .await;
455 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
456 let r = req(
457 &env,
458 "PUT",
459 &obj,
460 &auth,
461 &[("if-match", "\"stale\"")],
462 &event("a", "Two"),
463 )
464 .await;
465 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
466
467 let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await);
468 let r = req(
469 &env,
470 "PUT",
471 &obj,
472 &auth,
473 &[("if-match", &etag)],
474 &event("a", "Two"),
475 )
476 .await;
477 assert_eq!(r.status, StatusCode::NO_CONTENT);
478 let new_etag = r.header("etag").unwrap();
479 assert_ne!(new_etag, etag);
480 let after = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "1")], "").await);
481 assert_ne!(
482 prop_text(&before, CAL, DAV, "sync-token"),
483 prop_text(&after, CAL, DAV, "sync-token")
484 );
485 assert_eq!(prop_text(&after, &obj, DAV, "getetag"), Some(new_etag));
486
487 // The UID is already stored under another name.
488 let r = req(
489 &env,
490 "PUT",
491 &format!("{CAL}b.ics"),
492 &auth,
493 &[],
494 &event("a", "Dup"),
495 )
496 .await;
497 assert_eq!(r.status, StatusCode::FORBIDDEN);
498 assert!(error_condition(&r).is(CALDAV, "no-uid-conflict"));
499 assert!(r.text().contains(&obj), "{}", r.text());
500
501 let freebusy = event("j", "x").replace("VEVENT", "VFREEBUSY");
502 let cases = [
503 ("not a calendar".to_string(), "valid-calendar-data"),
504 (
505 event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"),
506 "valid-calendar-object-resource",
507 ),
508 (freebusy, "supported-calendar-component"),
509 ];
510 for (body, cond) in cases {
511 let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await;
512 assert_eq!(r.status, StatusCode::FORBIDDEN, "{cond}");
513 assert!(error_condition(&r).is(CALDAV, cond), "{cond}: {}", r.text());
514 }
515
516 let r = req(
517 &env,
518 "PUT",
519 "/pim/calendars/alice/nope/x.ics",
520 &auth,
521 &[],
522 &event("x", "x"),
523 )
524 .await;
525 assert_eq!(r.status, StatusCode::CONFLICT);
526
527 let r = req(
528 &env,
529 "DELETE",
530 &obj,
531 &auth,
532 &[("if-match", "\"stale\"")],
533 "",
534 )
535 .await;
536 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
537 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
538 assert_eq!(r.status, StatusCode::NO_CONTENT);
539 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
540 assert_eq!(r.status, StatusCode::NOT_FOUND);
541
542 let r = req(&env, "REPORT", CAL, &auth, &[], "").await;
543 assert_eq!(r.status, StatusCode::BAD_REQUEST);
544}
545
546#[tokio::test]
547async fn address_objects() {
548 let (env, auth) = setup().await;
549 let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Bob\r\nN:;Bob;;;\r\nEND:VCARD\r\n";
550 let r = req(&env, "PUT", &format!("{BOOK}c1.vcf"), &auth, &[], card).await;
551 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
552 let r = req(&env, "GET", &format!("{BOOK}c1.vcf"), &auth, &[], "").await;
553 assert_eq!(r.text(), card);
554 assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
555
556 let r = req(&env, "PUT", &format!("{BOOK}c2.vcf"), &auth, &[], card).await;
557 assert!(error_condition(&r).is(CARDDAV, "no-uid-conflict"));
558 let r = req(
559 &env,
560 "PUT",
561 &format!("{BOOK}c3.vcf"),
562 &auth,
563 &[],
564 &event("e", "x"),
565 )
566 .await;
567 assert!(error_condition(&r).is(CARDDAV, "valid-address-data"));
568}
569
570#[tokio::test]
571async fn the_default_calendar_stays() {
572 let (env, auth) = setup().await;
573 // Invitations arrive there (RFC 6638, 4.3).
574 let r = req(&env, "DELETE", CAL, &auth, &[], "").await;
575 assert_eq!(r.status, StatusCode::FORBIDDEN);
576 assert!(error_condition(&r).is(CALDAV, "default-calendar-needed"));
577 let other = format!("{HOME}work/");
578 assert_eq!(
579 req(&env, "MKCALENDAR", &other, &auth, &[], "").await.status,
580 StatusCode::CREATED
581 );
582 assert_eq!(
583 req(&env, "DELETE", &other, &auth, &[], "").await.status,
584 StatusCode::NO_CONTENT
585 );
586 // Nor can the inbox be made or removed by a client.
587 assert_eq!(
588 req(&env, "DELETE", INBOX, &auth, &[], "").await.status,
589 StatusCode::FORBIDDEN
590 );
591 assert_eq!(
592 req(
593 &env,
594 "MKCALENDAR",
595 "/pim/calendars/alice/outbox/",
596 &auth,
597 &[],
598 ""
599 )
600 .await
601 .status,
602 StatusCode::FORBIDDEN
603 );
604}
605
606#[tokio::test]
607async fn deleting_a_user_deletes_their_collections() {
608 let env = Env::new().await;
609 let admin = env.admin().await;
610 create_user(&admin, ALICE, PW, &[]).await;
611 let auth = basic(ALICE, PW);
612 let r = req(
613 &env,
614 "PUT",
615 &format!("{CAL}a.ics"),
616 &auth,
617 &[],
618 &event("a", "x"),
619 )
620 .await;
621 assert_eq!(r.status, StatusCode::CREATED);
622 let id = user_id(&admin, ALICE).await;
623 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
624 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
625 let db = &env.state.db;
626 assert!(
627 db.pim_collections(id, server::db::PimKind::Calendar)
628 .await
629 .unwrap()
630 .is_empty()
631 );
632}
633
634// ---------------------------------------------------------------------------
635// REPORT and MOVE
636// ---------------------------------------------------------------------------
637
638/// `(href, status of the response itself)` of every response.
639fn statuses(r: &Resp) -> Vec<(String, Option<u16>)> {
640 let root = Element::parse(r.body.as_slice()).unwrap();
641 xml::elements(&root)
642 .filter(|e| Name::of(e).is(DAV, "response"))
643 .map(|resp| {
644 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
645 let code = xml::child(resp, DAV, "status")
646 .map(|s| xml::text(s).split(' ').nth(1).unwrap().parse().unwrap());
647 (href, code)
648 })
649 .collect()
650}
651
652fn sync_token_of(r: &Resp) -> String {
653 let root = Element::parse(r.body.as_slice()).unwrap();
654 xml::text(xml::child(&root, DAV, "sync-token").unwrap())
655}
656
657fn ics(body: &str) -> String {
658 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
659}
660
661const 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";
662const 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";
663const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n";
664
665async fn put(env: &Env, auth: &str, path: &str, body: &str) {
666 let r = req(env, "PUT", path, auth, &[], body).await;
667 assert!(r.status.is_success(), "{path}: {}", r.text());
668}
669
670fn query(filter: &str, data: &str) -> String {
671 format!(
672 r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
673 <d:prop><d:getetag/>{data}</d:prop>
674 <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
675 </c:calendar-query>"#
676 )
677}
678
679fn hrefs_in(r: &Resp) -> Vec<String> {
680 let mut h: Vec<_> = statuses(r).into_iter().map(|(h, _)| h).collect();
681 h.sort();
682 h
683}
684
685#[tokio::test]
686async fn calendar_reports() {
687 let (env, auth) = setup().await;
688 put(&env, &auth, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
689 put(&env, &auth, &format!("{CAL}weekly.ics"), &ics(WEEKLY)).await;
690 put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
691
692 let r = req(
693 &env,
694 "PROPFIND",
695 CAL,
696 &auth,
697 &[("depth", "0")],
698 &propfind_body(&[(DAV, "supported-report-set")]),
699 )
700 .await;
701 let reports = prop(&parse_multistatus(&r), CAL, DAV, "supported-report-set").unwrap();
702 assert_eq!(xml::elements(&reports).count(), 4);
703
704 // multiget: stored bytes back, a miss as 404.
705 let body = format!(
706 r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
707 <d:prop><d:getetag/><c:calendar-data/></d:prop>
708 <d:href>{CAL}lunch.ics</d:href><d:href>{CAL}gone.ics</d:href>
709 </c:calendar-multiget>"#
710 );
711 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await;
712 let ms = parse_multistatus(&r);
713 let lunch = format!("{CAL}lunch.ics");
714 // The CR of each CRLF goes out as `&#13;`, which XML parsing keeps.
715 assert!(r.text().contains("&#13;\n"), "{}", r.text());
716 let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap();
717 assert_eq!(data, ics(LUNCH).trim());
718 assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404))));
719
720 // Time range: the Monday 2026-01-05 holds only an instance of the weekly
721 // series, which started a month earlier in Berlin time.
722 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260105T000000Z" end="20260106T000000Z"/></c:comp-filter>"#;
723 let r = req(
724 &env,
725 "REPORT",
726 CAL,
727 &auth,
728 &[("depth", "1")],
729 &query(range, ""),
730 )
731 .await;
732 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
733
734 // The same with expand: one instance in UTC, without the RRULE.
735 let expand = r#"<c:calendar-data><c:expand start="20260105T000000Z" end="20260106T000000Z"/></c:calendar-data>"#;
736 let r = req(
737 &env,
738 "REPORT",
739 CAL,
740 &auth,
741 &[("depth", "1")],
742 &query(range, expand),
743 )
744 .await;
745 let data = prop_text(
746 &parse_multistatus(&r),
747 &format!("{CAL}weekly.ics"),
748 CALDAV,
749 "calendar-data",
750 )
751 .unwrap();
752 assert!(data.contains("DTSTART:20260105T080000Z"), "{data}");
753 assert!(data.contains("RECURRENCE-ID:20260105T080000Z"), "{data}");
754 assert!(!data.contains("RRULE"), "{data}");
755
756 // text-match folds ASCII case by default, and negates on request.
757 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>"#;
758 let r = req(
759 &env,
760 "REPORT",
761 CAL,
762 &auth,
763 &[("depth", "1")],
764 &query(text, ""),
765 )
766 .await;
767 assert_eq!(hrefs_in(&r), std::slice::from_ref(&lunch));
768 let negated = text.replace("<c:text-match>", r#"<c:text-match negate-condition="yes">"#);
769 let r = req(
770 &env,
771 "REPORT",
772 CAL,
773 &auth,
774 &[("depth", "1")],
775 &query(&negated, ""),
776 )
777 .await;
778 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
779 let odd = text.replace("<c:text-match>", r#"<c:text-match collation="i;klingon">"#);
780 let r = req(
781 &env,
782 "REPORT",
783 CAL,
784 &auth,
785 &[("depth", "1")],
786 &query(&odd, ""),
787 )
788 .await;
789 assert_eq!(r.status, StatusCode::FORBIDDEN);
790 assert_eq!(
791 error_condition(&r),
792 Name::new(CALDAV, "supported-collation")
793 );
794
795 // A VTODO with only DUE matches the range that holds DUE.
796 let todo = r#"<c:comp-filter name="VTODO"><c:time-range start="20260110T000000Z" end="20260111T000000Z"/></c:comp-filter>"#;
797 let r = req(
798 &env,
799 "REPORT",
800 CAL,
801 &auth,
802 &[("depth", "1")],
803 &query(todo, ""),
804 )
805 .await;
806 assert_eq!(hrefs_in(&r), [format!("{CAL}todo.ics")]);
807
808 // Only the components asked for.
809 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>"#;
810 let r = req(
811 &env,
812 "REPORT",
813 CAL,
814 &auth,
815 &[("depth", "1")],
816 &query(text, comp),
817 )
818 .await;
819 let data = prop_text(&parse_multistatus(&r), &lunch, CALDAV, "calendar-data").unwrap();
820 assert!(
821 data.contains("SUMMARY:Team Lunch")
822 && !data.contains("DTSTART")
823 && !data.contains("VERSION"),
824 "{data}"
825 );
826
827 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>"#;
828 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], fb).await;
829 assert_eq!(r.status, StatusCode::OK);
830 assert!(
831 r.header("content-type")
832 .unwrap()
833 .starts_with("text/calendar")
834 );
835 assert!(
836 r.text()
837 .contains("FREEBUSY;FBTYPE=BUSY:20260105T080000Z/20260105T083000Z"),
838 "{}",
839 r.text()
840 );
841 assert!(
842 r.text()
843 .contains("FREEBUSY;FBTYPE=BUSY:20260101T120000Z/20260101T130000Z"),
844 "{}",
845 r.text()
846 );
847
848 // An address book report on a calendar is refused.
849 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;
850 assert_eq!(error_condition(&r), Name::new(DAV, "supported-report"));
851}
852
853#[tokio::test]
854async fn expand_answers_are_capped() {
855 let (env, auth) = setup().await;
856 for i in 0..4 {
857 let hourly = format!(
858 "BEGIN:VEVENT\r\nUID:h{i}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T000000Z\r\n\
859 DURATION:PT10M\r\nRRULE:FREQ=HOURLY\r\nSUMMARY:tick\r\nEND:VEVENT\r\n"
860 );
861 put(&env, &auth, &format!("{CAL}h{i}.ics"), &ics(&hourly)).await;
862 }
863 // 7200 instances each: three together pass the limit of one answer, one
864 // alone stays under that of one object. The fourth is cut off.
865 let expand = r#"<c:calendar-data><c:expand start="20260101T000000Z" end="20261028T000000Z"/></c:calendar-data>"#;
866 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260101T000000Z" end="20261028T000000Z"/></c:comp-filter>"#;
867 let r = req(&env, "REPORT", CAL, &auth, &[], &query(range, expand)).await;
868 assert_eq!(r.status, StatusCode::MULTI_STATUS);
869 let s = statuses(&r);
870 assert!(s.contains(&(CAL.to_string(), Some(507))), "{s:?}");
871 assert_eq!(s.len(), 4, "{s:?}");
872}
873
874#[tokio::test]
875async fn sync_collection() {
876 let (env, auth) = setup().await;
877 let sync = |token: &str, limit: &str| {
878 format!(
879 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>"#
880 )
881 };
882 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
883 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
884
885 let r = req(&env, "REPORT", CAL, &auth, &[], &sync("", "")).await;
886 assert_eq!(hrefs_in(&r), [format!("{CAL}a.ics"), format!("{CAL}b.ics")]);
887 let token = sync_token_of(&r);
888
889 put(&env, &auth, &format!("{CAL}c.ics"), &event("c", "C")).await;
890 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A2")).await;
891 let r = req(&env, "DELETE", &format!("{CAL}b.ics"), &auth, &[], "").await;
892 assert_eq!(r.status, StatusCode::NO_CONTENT);
893
894 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, "")).await;
895 let mut got = statuses(&r);
896 got.sort();
897 assert_eq!(
898 got,
899 [
900 (format!("{CAL}a.ics"), None),
901 (format!("{CAL}b.ics"), Some(404)),
902 (format!("{CAL}c.ics"), None),
903 ]
904 );
905 let latest = sync_token_of(&r);
906 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&latest, "")).await;
907 assert!(statuses(&r).is_empty());
908
909 // A limit hands out the token of the last change it returned.
910 let limit = "<d:limit><d:nresults>1</d:nresults></d:limit>";
911 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, limit)).await;
912 let got = statuses(&r);
913 assert_eq!(got.len(), 2);
914 assert_eq!(got[1], (CAL.to_string(), Some(507)));
915 let r = req(
916 &env,
917 "REPORT",
918 CAL,
919 &auth,
920 &[],
921 &sync(&sync_token_of(&r), ""),
922 )
923 .await;
924 assert_eq!(statuses(&r).len(), 2);
925
926 for bad in ["urn:dovenest:sync:999-1", "nonsense", &format!("{latest}0")] {
927 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(bad, "")).await;
928 assert_eq!(
929 error_condition(&r),
930 Name::new(DAV, "valid-sync-token"),
931 "{bad}"
932 );
933 }
934}
935
936#[tokio::test]
937async fn addressbook_reports() {
938 let (env, auth) = setup().await;
939 let card = |uid: &str, name: &str, mail: &str| {
940 format!(
941 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{name}\r\nEMAIL;TYPE=WORK:{mail}\r\nEND:VCARD\r\n"
942 )
943 };
944 put(
945 &env,
946 &auth,
947 &format!("{BOOK}bob.vcf"),
948 &card("bob", "Bob Builder", "bob@example.com"),
949 )
950 .await;
951 put(
952 &env,
953 &auth,
954 &format!("{BOOK}ann.vcf"),
955 &card("ann", "Ann Äpfel", "ann@example.org"),
956 )
957 .await;
958 let query = |filter: &str, data: &str| {
959 format!(
960 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>"#
961 )
962 };
963 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>"#;
964 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(email, "")).await;
965 assert_eq!(hrefs_in(&r), [format!("{BOOK}ann.vcf")]);
966
967 // Unicode case folding is the CardDAV default.
968 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>"#;
969 let r = req(
970 &env,
971 "REPORT",
972 BOOK,
973 &auth,
974 &[],
975 &query(
976 fname,
977 "<card:address-data><card:prop name=\"FN\"/></card:address-data>",
978 ),
979 )
980 .await;
981 let data = prop_text(
982 &parse_multistatus(&r),
983 &format!("{BOOK}ann.vcf"),
984 CARDDAV,
985 "address-data",
986 )
987 .unwrap();
988 assert!(
989 data.contains("Ann Äpfel") && !data.contains("EMAIL"),
990 "{data}"
991 );
992
993 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>"#;
994 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(param, "")).await;
995 assert_eq!(hrefs_in(&r).len(), 2);
996
997 let limited = query(
998 "<card:filter/><card:limit><card:nresults>1</card:nresults></card:limit>",
999 "",
1000 );
1001 let r = req(&env, "REPORT", BOOK, &auth, &[], &limited).await;
1002 let got = statuses(&r);
1003 assert_eq!(got.len(), 2);
1004 assert_eq!(got[1], (BOOK.to_string(), Some(507)));
1005
1006 let body = format!(
1007 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>"#
1008 );
1009 let r = req(&env, "REPORT", BOOK, &auth, &[], &body).await;
1010 let data = prop_text(
1011 &parse_multistatus(&r),
1012 &format!("{BOOK}bob.vcf"),
1013 CARDDAV,
1014 "address-data",
1015 )
1016 .unwrap();
1017 assert!(data.contains("FN:Bob Builder"));
1018}
1019
1020#[tokio::test]
1021async fn move_objects() {
1022 let (env, auth) = setup().await;
1023 let r = req(&env, "MKCALENDAR", &format!("{HOME}work/"), &auth, &[], "").await;
1024 assert_eq!(r.status, StatusCode::CREATED);
1025 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
1026 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
1027
1028 let dest = |p: &str| format!("http://localhost{p}");
1029 let r = req(
1030 &env,
1031 "MOVE",
1032 &format!("{CAL}a.ics"),
1033 &auth,
1034 &[("destination", &dest(&format!("{HOME}work/a.ics")))],
1035 "",
1036 )
1037 .await;
1038 assert_eq!(r.status, StatusCode::CREATED);
1039 assert_eq!(
1040 req(&env, "GET", &format!("{CAL}a.ics"), &auth, &[], "")
1041 .await
1042 .status,
1043 StatusCode::NOT_FOUND
1044 );
1045 assert_eq!(
1046 req(&env, "GET", &format!("{HOME}work/a.ics"), &auth, &[], "")
1047 .await
1048 .text(),
1049 event("a", "A")
1050 );
1051
1052 // Overwrite: F refuses an existing destination.
1053 put(&env, &auth, &format!("{CAL}a2.ics"), &event("a2", "A2")).await;
1054 let r = req(
1055 &env,
1056 "MOVE",
1057 &format!("{CAL}a2.ics"),
1058 &auth,
1059 &[("destination", &format!("{CAL}b.ics")), ("overwrite", "F")],
1060 "",
1061 )
1062 .await;
1063 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
1064 let r = req(
1065 &env,
1066 "MOVE",
1067 &format!("{CAL}a2.ics"),
1068 &auth,
1069 &[("destination", &format!("{CAL}b.ics"))],
1070 "",
1071 )
1072 .await;
1073 assert_eq!(r.status, StatusCode::NO_CONTENT);
1074
1075 // The same UID under another name in the destination.
1076 put(&env, &auth, &format!("{CAL}dup.ics"), &event("a", "again")).await;
1077 let r = req(
1078 &env,
1079 "MOVE",
1080 &format!("{CAL}dup.ics"),
1081 &auth,
1082 &[("destination", &format!("{HOME}work/other.ics"))],
1083 "",
1084 )
1085 .await;
1086 assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
1087
1088 // Across kinds is refused.
1089 let r = req(
1090 &env,
1091 "MOVE",
1092 &format!("{CAL}b.ics"),
1093 &auth,
1094 &[("destination", &format!("{BOOK}b.vcf"))],
1095 "",
1096 )
1097 .await;
1098 assert_eq!(r.status, StatusCode::FORBIDDEN);
1099}
1100
1101#[tokio::test]
1102async fn hrefs_follow_the_requested_spelling() {
1103 let (env, auth) = setup().await;
1104 let body = propfind_body(&[(DAV, "displayname")]);
1105 let r = req(
1106 &env,
1107 "PROPFIND",
1108 "/pim/calendars/ALICE/",
1109 &auth,
1110 &[("depth", "1")],
1111 &body,
1112 )
1113 .await;
1114 let hrefs = hrefs_in(&r);
1115 assert!(
1116 hrefs.iter().all(|h| h.starts_with("/pim/calendars/ALICE/")),
1117 "{hrefs:?}"
1118 );
1119}
1120
1121// ---------------------------------------------------------------------------
1122// Sharing, the system address book, rooms and principal search
1123// ---------------------------------------------------------------------------
1124
1125const BOB: &str = "bob";
1126const BOB_PW: &str = "bob12345678";
1127
1128/// alice with an event in her default calendar, and bob.
1129async fn two_users() -> (Env, Client, String, String) {
1130 let env = Env::new().await;
1131 let admin = env.admin().await;
1132 create_user(&admin, ALICE, PW, &[]).await;
1133 create_user(&admin, BOB, BOB_PW, &[]).await;
1134 let alice = basic(ALICE, PW);
1135 put(&env, &alice, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
1136 (env, admin, alice, basic(BOB, BOB_PW))
1137}
1138
1139/// The id of alice's default calendar, from the JSON API.
1140async fn calendar_id(alice: &Client) -> i64 {
1141 let r = alice.get("/api/pim/collections").await;
1142 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1143 r.json()
1144 .as_array()
1145 .unwrap()
1146 .iter()
1147 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
1148 .unwrap()["id"]
1149 .as_i64()
1150 .unwrap()
1151}
1152
1153fn privilege_names(p: &Element) -> Vec<String> {
1154 xml::elements(p)
1155 .flat_map(xml::elements)
1156 .map(|e| e.name.clone())
1157 .collect()
1158}
1159
1160#[tokio::test]
1161async fn lent_collections() {
1162 let (env, admin, alice_auth, bob) = two_users().await;
1163 let alice = login(&env, ALICE, PW).await;
1164 let id = calendar_id(&alice).await;
1165 let shares = format!("/api/pim/collections/{id}/shares");
1166
1167 let r = alice
1168 .post_json(&shares, &json!({"user": "nobody", "mode": "ro"}))
1169 .await;
1170 assert_eq!(r.status, StatusCode::NOT_FOUND);
1171 let r = alice
1172 .post_json(&shares, &json!({"user": ALICE, "mode": "ro"}))
1173 .await;
1174 assert_eq!(r.status, StatusCode::BAD_REQUEST);
1175 let r = alice
1176 .post_json(&shares, &json!({"user": "BOB", "mode": "ro"}))
1177 .await;
1178 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1179 let bob_client = login(&env, BOB, BOB_PW).await;
1180 assert_eq!(bob_client.get(&shares).await.status, StatusCode::NOT_FOUND);
1181 let listed = bob_client.get("/api/pim/collections").await.json();
1182 let lent = listed
1183 .as_array()
1184 .unwrap()
1185 .iter()
1186 .find(|c| c["id"] == id)
1187 .unwrap()
1188 .clone();
1189 assert_eq!(lent["mode"], "ro");
1190 assert_eq!(lent["owner"], ALICE);
1191 let shared = format!("/pim/calendars/bob/shared-{id}/");
1192 assert_eq!(lent["url"], shared.as_str());
1193
1194 // bob's home shows it, read-only, with alice as the owner.
1195 let body = propfind_body(&[
1196 (DAV, "displayname"),
1197 (DAV, "owner"),
1198 (DAV, "current-user-privilege-set"),
1199 ]);
1200 let r = req(
1201 &env,
1202 "PROPFIND",
1203 "/pim/calendars/bob/",
1204 &bob,
1205 &[("depth", "1")],
1206 &body,
1207 )
1208 .await;
1209 let ms = parse_multistatus(&r);
1210 assert_eq!(
1211 prop_text(&ms, &shared, DAV, "displayname").as_deref(),
1212 Some("Calendar (alice)")
1213 );
1214 assert_eq!(
1215 hrefs_of(&prop(&ms, &shared, DAV, "owner").unwrap()),
1216 ["/pim/principals/alice/"]
1217 );
1218 let privs = privilege_names(&prop(&ms, &shared, DAV, "current-user-privilege-set").unwrap());
1219 assert_eq!(privs, ["read", "read-current-user-privilege-set"]);
1220
1221 // Read works through every method, writes do not.
1222 let lunch = format!("{shared}lunch.ics");
1223 let r = req(&env, "GET", &lunch, &bob, &[], "").await;
1224 assert_eq!(r.status, StatusCode::OK);
1225 let r = req(&env, "REPORT", &shared, &bob, &[], &query("", "")).await;
1226 assert_eq!(hrefs_in(&r), [lunch.as_str()]);
1227 let sync = r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
1228 let r = req(&env, "REPORT", &shared, &bob, &[], sync).await;
1229 assert_eq!(hrefs_in(&r), [lunch.as_str()]);
1230 let r = req(
1231 &env,
1232 "PUT",
1233 &format!("{shared}new.ics"),
1234 &bob,
1235 &[],
1236 &event("new", "x"),
1237 )
1238 .await;
1239 assert_eq!(r.status, StatusCode::FORBIDDEN);
1240 assert!(error_condition(&r).is(DAV, "need-privileges"));
1241 let r = req(&env, "DELETE", &lunch, &bob, &[], "").await;
1242 assert_eq!(r.status, StatusCode::FORBIDDEN);
1243 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Mine</d:displayname></d:prop></d:set></d:propertyupdate>"#;
1244 let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
1245 assert_eq!(r.status, StatusCode::FORBIDDEN);
1246
1247 // Read-write: bob adds an event, alice sees it; bob moves one out.
1248 let r = alice
1249 .post_json(&shares, &json!({"user": BOB, "mode": "rw"}))
1250 .await;
1251 assert_eq!(r.status, StatusCode::OK);
1252 put(
1253 &env,
1254 &bob,
1255 &format!("{shared}new.ics"),
1256 &event("new", "from bob"),
1257 )
1258 .await;
1259 let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
1260 assert_eq!(r.status, StatusCode::OK);
1261 let r = req(
1262 &env,
1263 "MOVE",
1264 &format!("{shared}new.ics"),
1265 &bob,
1266 &[("destination", "/pim/calendars/bob/default/new.ics")],
1267 "",
1268 )
1269 .await;
1270 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1271 let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
1272 assert_eq!(r.status, StatusCode::NOT_FOUND);
1273 let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
1274 assert_eq!(r.status, StatusCode::FORBIDDEN);
1275
1276 // bob deleting it only takes it out of his home.
1277 let r = req(&env, "DELETE", &shared, &bob, &[], "").await;
1278 assert_eq!(r.status, StatusCode::NO_CONTENT);
1279 assert_eq!(
1280 req(&env, "GET", &lunch, &bob, &[], "").await.status,
1281 StatusCode::NOT_FOUND
1282 );
1283 assert!(
1284 alice
1285 .get(&shares)
1286 .await
1287 .json()
1288 .as_array()
1289 .unwrap()
1290 .is_empty()
1291 );
1292 let r = req(
1293 &env,
1294 "GET",
1295 &format!("{CAL}lunch.ics"),
1296 &alice_auth,
1297 &[],
1298 "",
1299 )
1300 .await;
1301 assert_eq!(r.status, StatusCode::OK);
1302
1303 // Revoking, and deleting the borrower, end the loan.
1304 alice
1305 .post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
1306 .await;
1307 let r = alice
1308 .delete(&format!("{shares}/{}", user_id(&admin, BOB).await))
1309 .await;
1310 assert_eq!(r.status, StatusCode::OK);
1311 assert_eq!(
1312 req(&env, "GET", &lunch, &bob, &[], "").await.status,
1313 StatusCode::NOT_FOUND
1314 );
1315 alice
1316 .post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
1317 .await;
1318 let r = admin
1319 .delete(&format!("/api/admin/users/{}", user_id(&admin, BOB).await))
1320 .await;
1321 assert_eq!(r.status, StatusCode::OK);
1322 assert!(
1323 alice
1324 .get(&shares)
1325 .await
1326 .json()
1327 .as_array()
1328 .unwrap()
1329 .is_empty()
1330 );
1331}
1332
1333const DIR: &str = "/pim/addressbooks/alice/system/";
1334
1335#[tokio::test]
1336async fn system_address_book() {
1337 let (env, admin, alice, _) = two_users().await;
1338 let body = propfind_body(&[(DAV, "getetag"), (CALSERVER, "getctag")]);
1339 let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "1")], &body).await;
1340 let ms = parse_multistatus(&r);
1341 // admin, alice and bob.
1342 assert_eq!(ms.len(), 4);
1343 let ctag = prop_text(&ms, DIR, CALSERVER, "getctag").unwrap();
1344 let card = ms.iter().find(|(h, _)| h != DIR).unwrap().0.clone();
1345 let r = req(&env, "GET", &card, &alice, &[], "").await;
1346 assert_eq!(r.status, StatusCode::OK);
1347 assert!(r.text().contains("EMAIL;TYPE=INTERNET:"), "{}", r.text());
1348
1349 let q = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
1350 <d:prop><card:address-data/></d:prop>
1351 <card:filter><card:prop-filter name="FN"><card:text-match>BOB</card:text-match></card:prop-filter></card:filter>
1352 </card:addressbook-query>"#;
1353 let r = req(&env, "REPORT", DIR, &alice, &[], q).await;
1354 assert_eq!(statuses(&r).len(), 1);
1355 assert!(r.text().contains("FN:bob"));
1356
1357 let sync = |token: &str| {
1358 format!(
1359 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
1360 )
1361 };
1362 let r = req(&env, "REPORT", DIR, &alice, &[], &sync("")).await;
1363 assert_eq!(statuses(&r).len(), 3);
1364 let token = sync_token_of(&r);
1365 let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
1366 assert!(statuses(&r).is_empty());
1367
1368 // A new account changes the CTag, and the old token no longer works.
1369 create_user(&admin, "carol", "carol12345", &[]).await;
1370 let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
1371 assert_eq!(r.status, StatusCode::FORBIDDEN);
1372 assert!(error_condition(&r).is(DAV, "valid-sync-token"));
1373 let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "0")], &body).await;
1374 assert_ne!(
1375 prop_text(&parse_multistatus(&r), DIR, CALSERVER, "getctag").unwrap(),
1376 ctag
1377 );
1378
1379 let r = req(
1380 &env,
1381 "PUT",
1382 &format!("{DIR}x.vcf"),
1383 &alice,
1384 &[],
1385 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:x\r\nEND:VCARD\r\n",
1386 )
1387 .await;
1388 assert_eq!(r.status, StatusCode::FORBIDDEN);
1389 assert!(error_condition(&r).is(DAV, "need-privileges"));
1390 assert_eq!(
1391 req(&env, "DELETE", &card, &alice, &[], "").await.status,
1392 StatusCode::FORBIDDEN
1393 );
1394 assert_eq!(
1395 req(&env, "DELETE", DIR, &alice, &[], "").await.status,
1396 StatusCode::FORBIDDEN
1397 );
1398 let r = req(&env, "MKCOL", DIR, &alice, &[], "").await;
1399 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
1400}
1401
1402#[tokio::test]
1403async fn rooms_and_resources() {
1404 let (env, admin, alice, _) = two_users().await;
1405 let alice_client = login(&env, ALICE, PW).await;
1406 let room = json!({"name": "board", "display_name": "Board Room", "kind": "room"});
1407 assert_eq!(
1408 alice_client
1409 .post_json("/api/admin/rooms", &room)
1410 .await
1411 .status,
1412 StatusCode::FORBIDDEN
1413 );
1414 let r = admin.post_json("/api/admin/rooms", &room).await;
1415 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1416 let id = r.json()["id"].as_i64().unwrap();
1417 assert_eq!(r.json()["url"], "/pim/principals/board/");
1418 let taken = json!({"name": "ALICE", "kind": "resource"});
1419 assert_eq!(
1420 admin.post_json("/api/admin/rooms", &taken).await.status,
1421 StatusCode::CONFLICT
1422 );
1423 let r = admin
1424 .post_json(
1425 "/api/admin/users",
1426 &json!({"name": "Board", "password": "x1234567", "is_admin": false, "roots": []}),
1427 )
1428 .await;
1429 assert_eq!(r.status, StatusCode::CONFLICT);
1430 // Not an account: not listed, not editable, no sign-in.
1431 let users = admin.get("/api/admin/users").await.json();
1432 assert!(
1433 users
1434 .as_array()
1435 .unwrap()
1436 .iter()
1437 .all(|u| u["name"] != "board")
1438 );
1439 let r = admin
1440 .put_json(
1441 &format!("/api/admin/users/{id}"),
1442 &json!({"password": "x1234567"}),
1443 )
1444 .await;
1445 assert_eq!(r.status, StatusCode::NOT_FOUND);
1446 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
1447 assert_eq!(r.status, StatusCode::NOT_FOUND);
1448 let r = req(&env, "PROPFIND", "/pim/", &basic("board", ""), &[], "").await;
1449 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1450 let r = Client::new(env.app.clone())
1451 .post_json("/api/auth/login", &json!({"name": "board", "password": ""}))
1452 .await;
1453 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1454
1455 let p = "/pim/principals/board/";
1456 let body = propfind_body(&[
1457 (DAV, "displayname"),
1458 (CALDAV, "calendar-user-type"),
1459 (CALDAV, "calendar-user-address-set"),
1460 (CALDAV, "calendar-home-set"),
1461 ]);
1462 let r = req(&env, "PROPFIND", p, &alice, &[], &body).await;
1463 let ms = parse_multistatus(&r);
1464 assert_eq!(
1465 prop_text(&ms, p, DAV, "displayname").as_deref(),
1466 Some("Board Room")
1467 );
1468 assert_eq!(
1469 prop_text(&ms, p, CALDAV, "calendar-user-type").as_deref(),
1470 Some("ROOM")
1471 );
1472 let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
1473 assert!(addresses.contains(&"mailto:board@rooms.dovenest.invalid".to_string()));
1474 assert_eq!(
1475 hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
1476 ["/pim/calendars/board/"]
1477 );
1478
1479 // Everyone reads the bookings; only admins write them.
1480 let cal = "/pim/calendars/board/default/";
1481 let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
1482 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1483 let r = req(
1484 &env,
1485 "PUT",
1486 &format!("{cal}b.ics"),
1487 &alice,
1488 &[],
1489 &event("b", "x"),
1490 )
1491 .await;
1492 assert_eq!(r.status, StatusCode::FORBIDDEN);
1493 put(
1494 &env,
1495 &basic("admin", "admin1234"),
1496 &format!("{cal}b.ics"),
1497 &event("b", "x"),
1498 )
1499 .await;
1500 assert_eq!(
1501 req(&env, "GET", &format!("{cal}b.ics"), &alice, &[], "")
1502 .await
1503 .status,
1504 StatusCode::OK
1505 );
1506
1507 // In the system address book as a location.
1508 let r = req(&env, "REPORT", DIR, &alice, &[], r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:address-data/></d:prop><card:filter><card:prop-filter name="KIND"><card:text-match match-type="equals">location</card:text-match></card:prop-filter></card:filter></card:addressbook-query>"#).await;
1509 assert!(r.text().contains("FN:Board Room"), "{}", r.text());
1510
1511 let r = admin
1512 .put_json(
1513 &format!("/api/admin/rooms/{id}"),
1514 &json!({"display_name": "Boardroom"}),
1515 )
1516 .await;
1517 assert_eq!(r.json()["display_name"], "Boardroom");
1518 assert_eq!(
1519 admin
1520 .get("/api/admin/rooms")
1521 .await
1522 .json()
1523 .as_array()
1524 .unwrap()
1525 .len(),
1526 1
1527 );
1528 assert_eq!(
1529 admin.delete(&format!("/api/admin/rooms/{id}")).await.status,
1530 StatusCode::OK
1531 );
1532 assert_eq!(
1533 req(&env, "PROPFIND", p, &alice, &[], "").await.status,
1534 StatusCode::NOT_FOUND
1535 );
1536}
1537
1538#[tokio::test]
1539async fn principal_search() {
1540 let (env, admin, alice, _) = two_users().await;
1541 admin
1542 .post_json(
1543 "/api/admin/rooms",
1544 &json!({"name": "board", "display_name": "Board Room", "kind": "room"}),
1545 )
1546 .await;
1547 let principals = "/pim/principals/";
1548
1549 let r = req(&env, "PROPFIND", principals, &alice, &[("depth", "1")], "").await;
1550 // The collection, admin, alice, bob and the room.
1551 assert_eq!(parse_multistatus(&r).len(), 5);
1552
1553 let pps = r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" test="anyof">
1554 <d:property-search><d:prop><d:displayname/></d:prop><d:match>BO</d:match></d:property-search>
1555 <d:prop><d:displayname/><c:calendar-user-type/></d:prop>
1556 </d:principal-property-search>"#;
1557 let r = req(&env, "REPORT", principals, &alice, &[("depth", "0")], pps).await;
1558 assert_eq!(
1559 hrefs_in(&r),
1560 ["/pim/principals/board/", "/pim/principals/bob/"]
1561 );
1562 let ms = parse_multistatus(&r);
1563 assert_eq!(
1564 prop_text(&ms, "/pim/principals/board/", CALDAV, "calendar-user-type").as_deref(),
1565 Some("ROOM")
1566 );
1567
1568 let cs = r#"<cs:calendarserver-principal-search xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" context="location">
1569 <cs:search-token>bo</cs:search-token><d:prop><d:displayname/></d:prop>
1570 </cs:calendarserver-principal-search>"#;
1571 let r = req(&env, "REPORT", principals, &alice, &[], cs).await;
1572 assert_eq!(hrefs_in(&r), ["/pim/principals/board/"]);
1573
1574 let set = r#"<d:principal-search-property-set xmlns:d="DAV:"/>"#;
1575 let r = req(&env, "REPORT", principals, &alice, &[], set).await;
1576 assert_eq!(r.status, StatusCode::OK);
1577 assert!(r.text().contains("calendar-user-address-set"));
1578
1579 // Not a collection report.
1580 let r = req(&env, "REPORT", CAL, &alice, &[], pps).await;
1581 assert_eq!(r.status, StatusCode::FORBIDDEN);
1582}
1583
1584#[tokio::test]
1585async fn bad_filters_are_refused_by_name() {
1586 let (env, auth) = setup().await;
1587 let bad = query(
1588 r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
1589 "",
1590 );
1591 let r = req(&env, "REPORT", CAL, &auth, &[], &bad).await;
1592 assert_eq!(r.status, StatusCode::FORBIDDEN);
1593 assert!(error_condition(&r).is(CALDAV, "valid-filter"));
1594 let bad = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><card:filter test="sometimes"/></card:addressbook-query>"#;
1595 let r = req(&env, "REPORT", BOOK, &auth, &[], bad).await;
1596 assert!(error_condition(&r).is(CARDDAV, "valid-filter"));
1597}
1598