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