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