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