api_pim.rs
⎇
Raw
1//! CalDAV and CardDAV: discovery, collections, properties and objects.
2
3mod common;
4
5use axum::http::{Method, StatusCode};
6use common::*;
7use pimdav::xml::{self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name};
8use serde_json::json;
9use xmltree::Element;
10
11const ALICE: &str = "alice";
12const PW: &str = "alice12345";
13
14async fn setup() -> (Env, String) {
15 let env = Env::new().await;
16 let admin = env.admin().await;
17 create_user(&admin, ALICE, PW, &[]).await;
18 (env, basic(ALICE, PW))
19}
20
21async fn req(
22 env: &Env,
23 verb: &str,
24 path: &str,
25 auth: &str,
26 extra: &[(&str, &str)],
27 body: &str,
28) -> Resp {
29 let mut headers = vec![("authorization", auth)];
30 headers.extend_from_slice(extra);
31 Client::new(env.app.clone())
32 .raw(
33 Method::from_bytes(verb.as_bytes()).unwrap(),
34 path,
35 &headers,
36 body.as_bytes().to_vec(),
37 )
38 .await
39}
40
41fn propfind_body(props: &[(&str, &str)]) -> String {
42 let props: String = props
43 .iter()
44 .map(|(ns, l)| format!("<{l} xmlns=\"{ns}\"/>"))
45 .collect();
46 format!("<d:propfind xmlns:d=\"DAV:\"><d:prop>{props}</d:prop></d:propfind>")
47}
48
49/// `href -> [(status, property element)]` of a multistatus.
50fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> {
51 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
52 let root = Element::parse(r.body.as_slice()).unwrap();
53 xml::elements(&root)
54 .map(|resp| {
55 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
56 let props = xml::elements(resp)
57 .filter(|e| Name::of(e).is(DAV, "propstat"))
58 .flat_map(|ps| {
59 let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
60 .split(' ')
61 .nth(1)
62 .unwrap()
63 .parse()
64 .unwrap();
65 let prop = xml::child(ps, DAV, "prop").unwrap();
66 xml::elements(prop)
67 .map(move |p| (code, p.clone()))
68 .collect::<Vec<_>>()
69 })
70 .collect();
71 (href, props)
72 })
73 .collect()
74}
75
76/// The property of `href` with status 200.
77fn prop(
78 ms: &[(String, Vec<(u16, Element)>)],
79 href: &str,
80 ns: &str,
81 local: &str,
82) -> Option<Element> {
83 ms.iter()
84 .find(|(h, _)| h == href)
85 .unwrap_or_else(|| panic!("no response for {href}"))
86 .1
87 .iter()
88 .find(|(code, p)| *code == 200 && Name::of(p).is(ns, local))
89 .map(|(_, p)| p.clone())
90}
91
92fn prop_text(
93 ms: &[(String, Vec<(u16, Element)>)],
94 href: &str,
95 ns: &str,
96 local: &str,
97) -> Option<String> {
98 prop(ms, href, ns, local).map(|p| xml::text(&p))
99}
100
101fn hrefs_of(p: &Element) -> Vec<String> {
102 xml::elements(p).map(xml::text).collect()
103}
104
105fn error_condition(r: &Resp) -> Name {
106 let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
107 assert!(Name::of(&root).is(DAV, "error"), "{}", r.text());
108 Name::of(xml::elements(&root).next().unwrap())
109}
110
111const HOME: &str = "/pim/calendars/alice/";
112const CAL: &str = "/pim/calendars/alice/default/";
113const BOOK: &str = "/pim/addressbooks/alice/default/";
114const INBOX: &str = "/pim/calendars/alice/inbox/";
115const OUTBOX: &str = "/pim/calendars/alice/outbox/";
116
117fn event(uid: &str, summary: &str) -> String {
118 format!(
119 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
120 )
121}
122
123#[tokio::test]
124async fn discovery() {
125 let (env, auth) = setup().await;
126
127 let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
128 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
129 assert!(r.header("www-authenticate").is_some());
130
131 let r = req(&env, "PROPFIND", "/.well-known/caldav", &auth, &[], "").await;
132 assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT);
133 assert_eq!(r.header("location").as_deref(), Some("/pim/"));
134
135 // A Basic login spelled in another case still gets the stored spelling.
136 let body = propfind_body(&[(DAV, "current-user-principal")]);
137 let r = req(
138 &env,
139 "PROPFIND",
140 "/pim/",
141 &basic("ALICE", PW),
142 &[("depth", "0")],
143 &body,
144 )
145 .await;
146 let ms = parse_multistatus(&r);
147 let p = prop(&ms, "/pim/", DAV, "current-user-principal").unwrap();
148 assert_eq!(hrefs_of(&p), ["/pim/principals/alice/"]);
149
150 let body = propfind_body(&[
151 (CALDAV, "calendar-home-set"),
152 (CARDDAV, "addressbook-home-set"),
153 (CALDAV, "calendar-user-address-set"),
154 (DAV, "displayname"),
155 (DAV, "no-such-prop"),
156 ]);
157 let r = req(
158 &env,
159 "PROPFIND",
160 "/pim/principals/alice/",
161 &auth,
162 &[("depth", "0")],
163 &body,
164 )
165 .await;
166 let ms = parse_multistatus(&r);
167 let p = "/pim/principals/alice/";
168 assert_eq!(
169 hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
170 [HOME]
171 );
172 assert_eq!(
173 hrefs_of(&prop(&ms, p, CARDDAV, "addressbook-home-set").unwrap()),
174 ["/pim/addressbooks/alice/"]
175 );
176 // Only the mailto address, preferred, so Apple picks it as the identity.
177 let set = prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap();
178 assert_eq!(hrefs_of(&set), ["mailto:alice@dovenest.invalid"]);
179 let href = xml::child(&set, DAV, "href").unwrap();
180 assert_eq!(
181 href.attributes.get("preferred").map(String::as_str),
182 Some("1")
183 );
184 assert_eq!(
185 prop_text(&ms, p, DAV, "displayname").as_deref(),
186 Some(ALICE)
187 );
188 assert!(
189 ms[0]
190 .1
191 .iter()
192 .any(|(c, e)| *c == 404 && Name::of(e).is(DAV, "no-such-prop"))
193 );
194
195 // An app password works as well.
196 let admin = login(&env, ALICE, PW).await;
197 let r = admin
198 .post_json("/api/auth/app-passwords", &json!({ "name": "phone" }))
199 .await;
200 let secret = r.json()["secret"].as_str().unwrap().to_string();
201 let r = req(
202 &env,
203 "PROPFIND",
204 "/pim/",
205 &basic("x", &secret),
206 &[("depth", "0")],
207 "",
208 )
209 .await;
210 assert_eq!(r.status, StatusCode::MULTI_STATUS);
211
212 let r = req(&env, "OPTIONS", "/pim/", &auth, &[], "").await;
213 assert!(r.header("dav").unwrap().contains("calendar-access"));
214}
215
216#[tokio::test]
217async fn homes_list_the_default_collections() {
218 let (env, auth) = setup().await;
219 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "1")], "").await;
220 let ms = parse_multistatus(&r);
221 // The home, the calendar, the birthday calendar, and the scheduling
222 // inbox and outbox.
223 assert_eq!(ms.len(), 5, "{}", r.text());
224 for (href, kind) in [(INBOX, "schedule-inbox"), (OUTBOX, "schedule-outbox")] {
225 let rt = prop(&ms, href, DAV, "resourcetype").unwrap();
226 assert!(xml::child(&rt, CALDAV, kind).is_some(), "{href}");
227 }
228 assert_eq!(
229 prop(&ms, INBOX, CALDAV, "schedule-default-calendar-URL").map(|p| hrefs_of(&p)),
230 Some(vec![CAL.to_string()])
231 );
232 let rt = prop(&ms, CAL, DAV, "resourcetype").unwrap();
233 assert!(xml::child(&rt, CALDAV, "calendar").is_some());
234 assert_eq!(
235 prop_text(&ms, CAL, DAV, "displayname").as_deref(),
236 Some("Calendar")
237 );
238 let comps = prop(&ms, CAL, CALDAV, "supported-calendar-component-set").unwrap();
239 let comps: Vec<_> = xml::elements(&comps)
240 .map(|c| c.attributes["name"].clone())
241 .collect();
242 assert_eq!(comps, ["VEVENT", "VTODO", "VJOURNAL"]);
243 assert!(prop_text(&ms, CAL, CALSERVER, "getctag").is_some());
244 assert!(
245 prop_text(&ms, CAL, DAV, "sync-token")
246 .unwrap()
247 .starts_with("urn:")
248 );
249
250 let r = req(
251 &env,
252 "PROPFIND",
253 "/pim/addressbooks/alice/",
254 &auth,
255 &[("depth", "1")],
256 "",
257 )
258 .await;
259 let ms = parse_multistatus(&r);
260 let rt = prop(&ms, BOOK, DAV, "resourcetype").unwrap();
261 assert!(xml::child(&rt, CARDDAV, "addressbook").is_some());
262
263 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "infinity")], "").await;
264 assert_eq!(r.status, StatusCode::FORBIDDEN);
265 assert!(error_condition(&r).is(DAV, "propfind-finite-depth"));
266}
267
268#[tokio::test]
269async fn other_users_are_off_limits() {
270 let (env, auth) = setup().await;
271 for path in ["/pim/calendars/admin/", "/pim/calendars/admin/default/"] {
272 let r = req(&env, "PROPFIND", path, &auth, &[("depth", "0")], "").await;
273 assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}");
274 }
275 // Another account's principal is readable, for scheduling.
276 let body = propfind_body(&[
277 (DAV, "displayname"),
278 (CALDAV, "calendar-user-address-set"),
279 (CARDDAV, "addressbook-home-set"),
280 ]);
281 let p = "/pim/principals/admin/";
282 let r = req(&env, "PROPFIND", p, &auth, &[("depth", "0")], &body).await;
283 let ms = parse_multistatus(&r);
284 assert_eq!(
285 prop_text(&ms, p, DAV, "displayname").as_deref(),
286 Some("admin")
287 );
288 let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
289 assert!(addresses.contains(&"mailto:admin@dovenest.invalid".to_string()));
290 assert!(prop(&ms, p, CARDDAV, "addressbook-home-set").is_none());
291
292 let r = req(&env, "PROPFIND", "/pim/principals/nobody/", &auth, &[], "").await;
293 assert_eq!(r.status, StatusCode::NOT_FOUND);
294}
295
296#[tokio::test]
297async fn make_and_patch_collections() {
298 let (env, auth) = setup().await;
299 let work = "/pim/calendars/alice/work/";
300 let body = r##"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:i="http://apple.com/ns/ical/">
301 <d:set><d:prop>
302 <d:displayname>Work</d:displayname>
303 <i:calendar-color>#00ff00</i:calendar-color>
304 <c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>
305 </d:prop></d:set></c:mkcalendar>"##;
306 let r = req(&env, "MKCALENDAR", work, &auth, &[], body).await;
307 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
308 let r = req(&env, "MKCALENDAR", work, &auth, &[], "").await;
309 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
310
311 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
312 let ms = parse_multistatus(&r);
313 assert_eq!(
314 prop_text(&ms, work, DAV, "displayname").as_deref(),
315 Some("Work")
316 );
317 assert_eq!(
318 prop_text(&ms, work, APPLE, "calendar-color").as_deref(),
319 Some("#00ff00")
320 );
321 let ctag = prop_text(&ms, work, CALSERVER, "getctag").unwrap();
322
323 // One bad property fails the whole request, and nothing is created.
324 let bad = body.replace("VTODO", "VCARD");
325 let r = req(
326 &env,
327 "MKCALENDAR",
328 "/pim/calendars/alice/bad/",
329 &auth,
330 &[],
331 &bad,
332 )
333 .await;
334 assert_eq!(r.status, StatusCode::FORBIDDEN);
335 assert!(r.text().contains("mkcalendar-response"), "{}", r.text());
336 let r = req(
337 &env,
338 "PROPFIND",
339 "/pim/calendars/alice/bad/",
340 &auth,
341 &[("depth", "0")],
342 "",
343 )
344 .await;
345 assert_eq!(r.status, StatusCode::NOT_FOUND);
346
347 // A plain MKCOL cannot make a calendar, an extended one makes an address book.
348 let r = req(&env, "MKCOL", "/pim/calendars/alice/plain/", &auth, &[], "").await;
349 assert_eq!(r.status, StatusCode::FORBIDDEN);
350 let mkcol = r#"<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:set><d:prop>
351 <d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>
352 <d:displayname>Friends</d:displayname></d:prop></d:set></d:mkcol>"#;
353 let r = req(
354 &env,
355 "MKCOL",
356 "/pim/addressbooks/alice/friends/",
357 &auth,
358 &[],
359 mkcol,
360 )
361 .await;
362 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
363
364 let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
365 <d:set><d:prop><d:displayname>Job</d:displayname><c:calendar-description>Tasks</c:calendar-description></d:prop></d:set>
366 </d:propertyupdate>"#;
367 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
368 let ms = parse_multistatus(&r);
369 assert!(ms[0].1.iter().all(|(c, _)| *c == 200));
370 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
371 let ms = parse_multistatus(&r);
372 assert_eq!(
373 prop_text(&ms, work, DAV, "displayname").as_deref(),
374 Some("Job")
375 );
376 assert_eq!(
377 prop_text(&ms, work, CALDAV, "calendar-description").as_deref(),
378 Some("Tasks")
379 );
380 assert_ne!(prop_text(&ms, work, CALSERVER, "getctag").unwrap(), ctag);
381
382 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop>
383 <d:displayname>Never</d:displayname><d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>"#;
384 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
385 let ms = parse_multistatus(&r);
386 let codes: Vec<u16> = ms[0].1.iter().map(|(c, _)| *c).collect();
387 assert_eq!(codes, [424, 403]);
388 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
389 let ms = parse_multistatus(&r);
390 assert_eq!(
391 prop_text(&ms, work, DAV, "displayname").as_deref(),
392 Some("Job")
393 );
394
395 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop>
396 <d:displayname>Tab&#9;bed</d:displayname></d:prop></d:set></d:propertyupdate>"#;
397 let r = req(&env, "PROPPATCH", work, &auth, &[], patch).await;
398 let ms = parse_multistatus(&r);
399 assert!(ms[0].1.iter().all(|(c, _)| *c != 200), "{}", r.text());
400 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
401 let ms = parse_multistatus(&r);
402 assert_eq!(
403 prop_text(&ms, work, DAV, "displayname").as_deref(),
404 Some("Job")
405 );
406
407 let r = req(&env, "DELETE", work, &auth, &[], "").await;
408 assert_eq!(r.status, StatusCode::NO_CONTENT);
409 let r = req(&env, "PROPFIND", work, &auth, &[("depth", "0")], "").await;
410 assert_eq!(r.status, StatusCode::NOT_FOUND);
411}
412
413#[tokio::test]
414async fn missing_dtstamp_is_added() {
415 let (env, auth) = setup().await;
416 let obj = format!("{CAL}s.ics");
417 let sent = event("s", "One").replace("DTSTAMP:20260101T000000Z\r\n", "");
418 let r = req(&env, "PUT", &obj, &auth, &[], &sent).await;
419 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
420 assert_eq!(r.header("etag"), None);
421 let stored = req(&env, "GET", &obj, &auth, &[], "").await.text();
422 let (head, rest) = stored.split_once("BEGIN:VEVENT\r\nDTSTAMP:").unwrap();
423 let (stamp, tail) = rest.split_once("\r\n").unwrap();
424 assert_eq!(stamp.len(), 16, "{stored}");
425 assert_eq!(format!("{head}BEGIN:VEVENT\r\n{tail}"), sent);
426}
427
428#[tokio::test]
429async fn calendar_objects() {
430 let (env, auth) = setup().await;
431 let obj = format!("{CAL}a.ics");
432
433 let r = req(
434 &env,
435 "PUT",
436 &obj,
437 &auth,
438 &[("if-none-match", "*")],
439 &event("a", "One"),
440 )
441 .await;
442 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
443 let etag = r.header("etag").unwrap();
444
445 let r = req(&env, "GET", &obj, &auth, &[], "").await;
446 assert_eq!(r.status, StatusCode::OK);
447 assert_eq!(r.text(), event("a", "One"));
448 assert_eq!(r.header("etag"), Some(etag.clone()));
449 assert!(
450 r.header("content-type")
451 .unwrap()
452 .starts_with("text/calendar")
453 );
454 let r = req(&env, "HEAD", &obj, &auth, &[], "").await;
455 assert_eq!(r.status, StatusCode::OK);
456 assert!(r.body.is_empty());
457
458 let r = req(
459 &env,
460 "PUT",
461 &obj,
462 &auth,
463 &[("if-none-match", "*")],
464 &event("a", "Two"),
465 )
466 .await;
467 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
468 let r = req(
469 &env,
470 "PUT",
471 &obj,
472 &auth,
473 &[("if-match", "\"stale\"")],
474 &event("a", "Two"),
475 )
476 .await;
477 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
478 // If-Match compares strongly: a weak tag never matches.
479 let weak = format!("W/{etag}");
480 let r = req(
481 &env,
482 "PUT",
483 &obj,
484 &auth,
485 &[("if-match", &weak)],
486 &event("a", "Two"),
487 )
488 .await;
489 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
490
491 let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await);
492 let r = req(
493 &env,
494 "PUT",
495 &obj,
496 &auth,
497 &[("if-match", &etag)],
498 &event("a", "Two"),
499 )
500 .await;
501 assert_eq!(r.status, StatusCode::NO_CONTENT);
502 let new_etag = r.header("etag").unwrap();
503 assert_ne!(new_etag, etag);
504 let after = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "1")], "").await);
505 assert_ne!(
506 prop_text(&before, CAL, DAV, "sync-token"),
507 prop_text(&after, CAL, DAV, "sync-token")
508 );
509 assert_eq!(prop_text(&after, &obj, DAV, "getetag"), Some(new_etag));
510
511 // The UID is already stored under another name.
512 let r = req(
513 &env,
514 "PUT",
515 &format!("{CAL}b.ics"),
516 &auth,
517 &[],
518 &event("a", "Dup"),
519 )
520 .await;
521 assert_eq!(r.status, StatusCode::FORBIDDEN);
522 assert!(error_condition(&r).is(CALDAV, "no-uid-conflict"));
523 assert!(r.text().contains(&obj), "{}", r.text());
524
525 let freebusy = event("j", "x").replace("VEVENT", "VFREEBUSY");
526 let cases = [
527 ("not a calendar".to_string(), "valid-calendar-data"),
528 (
529 event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"),
530 "valid-calendar-object-resource",
531 ),
532 (freebusy, "supported-calendar-component"),
533 ];
534 for (body, cond) in cases {
535 let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await;
536 assert_eq!(r.status, StatusCode::FORBIDDEN, "{cond}");
537 assert!(error_condition(&r).is(CALDAV, cond), "{cond}: {}", r.text());
538 }
539
540 let r = req(
541 &env,
542 "PUT",
543 "/pim/calendars/alice/nope/x.ics",
544 &auth,
545 &[],
546 &event("x", "x"),
547 )
548 .await;
549 assert_eq!(r.status, StatusCode::CONFLICT);
550
551 let r = req(
552 &env,
553 "DELETE",
554 &obj,
555 &auth,
556 &[("if-match", "\"stale\"")],
557 "",
558 )
559 .await;
560 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
561 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
562 assert_eq!(r.status, StatusCode::NO_CONTENT);
563 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
564 assert_eq!(r.status, StatusCode::NOT_FOUND);
565
566 let r = req(&env, "REPORT", CAL, &auth, &[], "").await;
567 assert_eq!(r.status, StatusCode::BAD_REQUEST);
568}
569
570#[tokio::test]
571async fn address_objects() {
572 let (env, auth) = setup().await;
573 let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Bob\r\nN:;Bob;;;\r\nEND:VCARD\r\n";
574 let r = req(&env, "PUT", &format!("{BOOK}c1.vcf"), &auth, &[], card).await;
575 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
576 let r = req(&env, "GET", &format!("{BOOK}c1.vcf"), &auth, &[], "").await;
577 assert_eq!(r.text(), card);
578 assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
579
580 let r = req(&env, "PUT", &format!("{BOOK}c2.vcf"), &auth, &[], card).await;
581 assert!(error_condition(&r).is(CARDDAV, "no-uid-conflict"));
582 let r = req(
583 &env,
584 "PUT",
585 &format!("{BOOK}c3.vcf"),
586 &auth,
587 &[],
588 &event("e", "x"),
589 )
590 .await;
591 assert!(error_condition(&r).is(CARDDAV, "valid-address-data"));
592}
593
594#[tokio::test]
595async fn the_default_calendar_stays() {
596 let (env, auth) = setup().await;
597 // Invitations arrive there (RFC 6638, 4.3).
598 let r = req(&env, "DELETE", CAL, &auth, &[], "").await;
599 assert_eq!(r.status, StatusCode::FORBIDDEN);
600 assert!(error_condition(&r).is(CALDAV, "default-calendar-needed"));
601 let other = format!("{HOME}work/");
602 assert_eq!(
603 req(&env, "MKCALENDAR", &other, &auth, &[], "").await.status,
604 StatusCode::CREATED
605 );
606 assert_eq!(
607 req(&env, "DELETE", &other, &auth, &[], "").await.status,
608 StatusCode::NO_CONTENT
609 );
610 // Nor can the inbox be made or removed by a client.
611 assert_eq!(
612 req(&env, "DELETE", INBOX, &auth, &[], "").await.status,
613 StatusCode::FORBIDDEN
614 );
615 assert_eq!(
616 req(
617 &env,
618 "MKCALENDAR",
619 "/pim/calendars/alice/outbox/",
620 &auth,
621 &[],
622 ""
623 )
624 .await
625 .status,
626 StatusCode::FORBIDDEN
627 );
628}
629
630#[tokio::test]
631async fn deleting_a_user_deletes_their_collections() {
632 let env = Env::new().await;
633 let admin = env.admin().await;
634 create_user(&admin, ALICE, PW, &[]).await;
635 let auth = basic(ALICE, PW);
636 let r = req(
637 &env,
638 "PUT",
639 &format!("{CAL}a.ics"),
640 &auth,
641 &[],
642 &event("a", "x"),
643 )
644 .await;
645 assert_eq!(r.status, StatusCode::CREATED);
646 let id = user_id(&admin, ALICE).await;
647 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
648 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
649 let db = &env.state.db;
650 assert!(
651 db.pim_collections(id, server::db::PimKind::Calendar)
652 .await
653 .unwrap()
654 .is_empty()
655 );
656}
657
658// ---------------------------------------------------------------------------
659// REPORT and MOVE
660// ---------------------------------------------------------------------------
661
662/// `(href, status of the response itself)` of every response.
663fn statuses(r: &Resp) -> Vec<(String, Option<u16>)> {
664 let root = Element::parse(r.body.as_slice()).unwrap();
665 xml::elements(&root)
666 .filter(|e| Name::of(e).is(DAV, "response"))
667 .map(|resp| {
668 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
669 let code = xml::child(resp, DAV, "status")
670 .map(|s| xml::text(s).split(' ').nth(1).unwrap().parse().unwrap());
671 (href, code)
672 })
673 .collect()
674}
675
676fn sync_token_of(r: &Resp) -> String {
677 let root = Element::parse(r.body.as_slice()).unwrap();
678 xml::text(xml::child(&root, DAV, "sync-token").unwrap())
679}
680
681fn ics(body: &str) -> String {
682 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
683}
684
685const 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";
686const 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";
687const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n";
688
689async fn put(env: &Env, auth: &str, path: &str, body: &str) {
690 let r = req(env, "PUT", path, auth, &[], body).await;
691 assert!(r.status.is_success(), "{path}: {}", r.text());
692}
693
694fn query(filter: &str, data: &str) -> String {
695 format!(
696 r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
697 <d:prop><d:getetag/>{data}</d:prop>
698 <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
699 </c:calendar-query>"#
700 )
701}
702
703fn hrefs_in(r: &Resp) -> Vec<String> {
704 let mut h: Vec<_> = statuses(r).into_iter().map(|(h, _)| h).collect();
705 h.sort();
706 h
707}
708
709#[tokio::test]
710async fn calendar_reports() {
711 let (env, auth) = setup().await;
712 put(&env, &auth, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
713 put(&env, &auth, &format!("{CAL}weekly.ics"), &ics(WEEKLY)).await;
714 put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
715
716 let r = req(
717 &env,
718 "PROPFIND",
719 CAL,
720 &auth,
721 &[("depth", "0")],
722 &propfind_body(&[(DAV, "supported-report-set")]),
723 )
724 .await;
725 let reports = prop(&parse_multistatus(&r), CAL, DAV, "supported-report-set").unwrap();
726 assert_eq!(xml::elements(&reports).count(), 4);
727
728 // multiget: stored bytes back, a miss as 404.
729 let body = format!(
730 r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
731 <d:prop><d:getetag/><c:calendar-data/></d:prop>
732 <d:href>{CAL}lunch.ics</d:href><d:href>{CAL}gone.ics</d:href>
733 </c:calendar-multiget>"#
734 );
735 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await;
736 let ms = parse_multistatus(&r);
737 let lunch = format!("{CAL}lunch.ics");
738 // The CR of each CRLF goes out as `&#13;`, which XML parsing keeps.
739 assert!(r.text().contains("&#13;\n"), "{}", r.text());
740 let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap();
741 assert_eq!(data, ics(LUNCH).trim());
742 assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404))));
743
744 // Time range: the Monday 2026-01-05 holds only an instance of the weekly
745 // series, which started a month earlier in Berlin time.
746 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260105T000000Z" end="20260106T000000Z"/></c:comp-filter>"#;
747 let r = req(
748 &env,
749 "REPORT",
750 CAL,
751 &auth,
752 &[("depth", "1")],
753 &query(range, ""),
754 )
755 .await;
756 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
757
758 // The same with expand: one instance in UTC, without the RRULE.
759 let expand = r#"<c:calendar-data><c:expand start="20260105T000000Z" end="20260106T000000Z"/></c:calendar-data>"#;
760 let r = req(
761 &env,
762 "REPORT",
763 CAL,
764 &auth,
765 &[("depth", "1")],
766 &query(range, expand),
767 )
768 .await;
769 let data = prop_text(
770 &parse_multistatus(&r),
771 &format!("{CAL}weekly.ics"),
772 CALDAV,
773 "calendar-data",
774 )
775 .unwrap();
776 assert!(data.contains("DTSTART:20260105T080000Z"), "{data}");
777 assert!(data.contains("RECURRENCE-ID:20260105T080000Z"), "{data}");
778 assert!(!data.contains("RRULE"), "{data}");
779
780 // text-match folds ASCII case by default, and negates on request.
781 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>"#;
782 let r = req(
783 &env,
784 "REPORT",
785 CAL,
786 &auth,
787 &[("depth", "1")],
788 &query(text, ""),
789 )
790 .await;
791 assert_eq!(hrefs_in(&r), std::slice::from_ref(&lunch));
792 let negated = text.replace("<c:text-match>", r#"<c:text-match negate-condition="yes">"#);
793 let r = req(
794 &env,
795 "REPORT",
796 CAL,
797 &auth,
798 &[("depth", "1")],
799 &query(&negated, ""),
800 )
801 .await;
802 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
803 let odd = text.replace("<c:text-match>", r#"<c:text-match collation="i;klingon">"#);
804 let r = req(
805 &env,
806 "REPORT",
807 CAL,
808 &auth,
809 &[("depth", "1")],
810 &query(&odd, ""),
811 )
812 .await;
813 assert_eq!(r.status, StatusCode::FORBIDDEN);
814 assert_eq!(
815 error_condition(&r),
816 Name::new(CALDAV, "supported-collation")
817 );
818
819 // A VTODO with only DUE matches the range that holds DUE.
820 let todo = r#"<c:comp-filter name="VTODO"><c:time-range start="20260110T000000Z" end="20260111T000000Z"/></c:comp-filter>"#;
821 let r = req(
822 &env,
823 "REPORT",
824 CAL,
825 &auth,
826 &[("depth", "1")],
827 &query(todo, ""),
828 )
829 .await;
830 assert_eq!(hrefs_in(&r), [format!("{CAL}todo.ics")]);
831
832 // Only the components asked for.
833 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>"#;
834 let r = req(
835 &env,
836 "REPORT",
837 CAL,
838 &auth,
839 &[("depth", "1")],
840 &query(text, comp),
841 )
842 .await;
843 let data = prop_text(&parse_multistatus(&r), &lunch, CALDAV, "calendar-data").unwrap();
844 assert!(
845 data.contains("SUMMARY:Team Lunch")
846 && !data.contains("DTSTART")
847 && !data.contains("VERSION"),
848 "{data}"
849 );
850
851 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>"#;
852 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], fb).await;
853 assert_eq!(r.status, StatusCode::OK);
854 assert!(
855 r.header("content-type")
856 .unwrap()
857 .starts_with("text/calendar")
858 );
859 assert!(
860 r.text()
861 .contains("FREEBUSY;FBTYPE=BUSY:20260105T080000Z/20260105T083000Z"),
862 "{}",
863 r.text()
864 );
865 assert!(
866 r.text()
867 .contains("FREEBUSY;FBTYPE=BUSY:20260101T120000Z/20260101T130000Z"),
868 "{}",
869 r.text()
870 );
871
872 // An address book report on a calendar is refused.
873 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;
874 assert_eq!(error_condition(&r), Name::new(DAV, "supported-report"));
875}
876
877#[tokio::test]
878async fn expand_answers_are_capped() {
879 let (env, auth) = setup().await;
880 for i in 0..4 {
881 let hourly = format!(
882 "BEGIN:VEVENT\r\nUID:h{i}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T000000Z\r\n\
883 DURATION:PT10M\r\nRRULE:FREQ=HOURLY\r\nSUMMARY:tick\r\nEND:VEVENT\r\n"
884 );
885 put(&env, &auth, &format!("{CAL}h{i}.ics"), &ics(&hourly)).await;
886 }
887 // 7200 instances each: three together pass the limit of one answer, one
888 // alone stays under that of one object. The fourth is cut off.
889 let expand = r#"<c:calendar-data><c:expand start="20260101T000000Z" end="20261028T000000Z"/></c:calendar-data>"#;
890 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260101T000000Z" end="20261028T000000Z"/></c:comp-filter>"#;
891 let r = req(&env, "REPORT", CAL, &auth, &[], &query(range, expand)).await;
892 assert_eq!(r.status, StatusCode::MULTI_STATUS);
893 let s = statuses(&r);
894 assert!(s.contains(&(CAL.to_string(), Some(507))), "{s:?}");
895 assert_eq!(s.len(), 4, "{s:?}");
896
897 // A sync stops at the same limit and resumes after its last member.
898 let sync = |token: &str| {
899 format!(
900 r#"<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level><d:prop>{expand}</d:prop></d:sync-collection>"#
901 )
902 };
903 let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
904 let s = statuses(&r);
905 assert_eq!(s.len(), 4, "{s:?}");
906 assert_eq!(s[3], (CAL.to_string(), Some(507)));
907 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&sync_token_of(&r))).await;
908 assert_eq!(statuses(&r), [(format!("{CAL}h3.ics"), None)]);
909}
910
911#[tokio::test]
912async fn sync_collection() {
913 let (env, auth) = setup().await;
914 let sync = |token: &str, limit: &str| {
915 format!(
916 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>"#
917 )
918 };
919 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
920 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
921
922 let r = req(&env, "REPORT", CAL, &auth, &[], &sync("", "")).await;
923 assert_eq!(hrefs_in(&r), [format!("{CAL}a.ics"), format!("{CAL}b.ics")]);
924 let token = sync_token_of(&r);
925
926 put(&env, &auth, &format!("{CAL}c.ics"), &event("c", "C")).await;
927 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A2")).await;
928 let r = req(&env, "DELETE", &format!("{CAL}b.ics"), &auth, &[], "").await;
929 assert_eq!(r.status, StatusCode::NO_CONTENT);
930
931 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, "")).await;
932 let mut got = statuses(&r);
933 got.sort();
934 assert_eq!(
935 got,
936 [
937 (format!("{CAL}a.ics"), None),
938 (format!("{CAL}b.ics"), Some(404)),
939 (format!("{CAL}c.ics"), None),
940 ]
941 );
942 let latest = sync_token_of(&r);
943 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&latest, "")).await;
944 assert!(statuses(&r).is_empty());
945
946 // A limit hands out the token of the last change it returned.
947 let limit = "<d:limit><d:nresults>1</d:nresults></d:limit>";
948 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, limit)).await;
949 let got = statuses(&r);
950 assert_eq!(got.len(), 2);
951 assert_eq!(got[1], (CAL.to_string(), Some(507)));
952 let r = req(
953 &env,
954 "REPORT",
955 CAL,
956 &auth,
957 &[],
958 &sync(&sync_token_of(&r), ""),
959 )
960 .await;
961 assert_eq!(statuses(&r).len(), 2);
962
963 for bad in ["urn:dovenest:sync:999-1", "nonsense", &format!("{latest}0")] {
964 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(bad, "")).await;
965 assert_eq!(
966 error_condition(&r),
967 Name::new(DAV, "valid-sync-token"),
968 "{bad}"
969 );
970 }
971}
972
973#[tokio::test]
974async fn addressbook_reports() {
975 let (env, auth) = setup().await;
976 let card = |uid: &str, name: &str, mail: &str| {
977 format!(
978 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{name}\r\nEMAIL;TYPE=WORK:{mail}\r\nEND:VCARD\r\n"
979 )
980 };
981 put(
982 &env,
983 &auth,
984 &format!("{BOOK}bob.vcf"),
985 &card("bob", "Bob Builder", "bob@example.com"),
986 )
987 .await;
988 put(
989 &env,
990 &auth,
991 &format!("{BOOK}ann.vcf"),
992 &card("ann", "Ann Äpfel", "ann@example.org"),
993 )
994 .await;
995 let query = |filter: &str, data: &str| {
996 format!(
997 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>"#
998 )
999 };
1000 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>"#;
1001 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(email, "")).await;
1002 assert_eq!(hrefs_in(&r), [format!("{BOOK}ann.vcf")]);
1003
1004 // Unicode case folding is the CardDAV default.
1005 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>"#;
1006 let r = req(
1007 &env,
1008 "REPORT",
1009 BOOK,
1010 &auth,
1011 &[],
1012 &query(
1013 fname,
1014 "<card:address-data><card:prop name=\"FN\"/></card:address-data>",
1015 ),
1016 )
1017 .await;
1018 let data = prop_text(
1019 &parse_multistatus(&r),
1020 &format!("{BOOK}ann.vcf"),
1021 CARDDAV,
1022 "address-data",
1023 )
1024 .unwrap();
1025 assert!(
1026 data.contains("Ann Äpfel") && !data.contains("EMAIL"),
1027 "{data}"
1028 );
1029
1030 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>"#;
1031 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(param, "")).await;
1032 assert_eq!(hrefs_in(&r).len(), 2);
1033
1034 let limited = query(
1035 "<card:filter/><card:limit><card:nresults>1</card:nresults></card:limit>",
1036 "",
1037 );
1038 let r = req(&env, "REPORT", BOOK, &auth, &[], &limited).await;
1039 let got = statuses(&r);
1040 assert_eq!(got.len(), 2);
1041 assert_eq!(got[1], (BOOK.to_string(), Some(507)));
1042
1043 let body = format!(
1044 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>"#
1045 );
1046 let r = req(&env, "REPORT", BOOK, &auth, &[], &body).await;
1047 let data = prop_text(
1048 &parse_multistatus(&r),
1049 &format!("{BOOK}bob.vcf"),
1050 CARDDAV,
1051 "address-data",
1052 )
1053 .unwrap();
1054 assert!(data.contains("FN:Bob Builder"));
1055}
1056
1057#[tokio::test]
1058async fn move_objects() {
1059 let (env, auth) = setup().await;
1060 let r = req(&env, "MKCALENDAR", &format!("{HOME}work/"), &auth, &[], "").await;
1061 assert_eq!(r.status, StatusCode::CREATED);
1062 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
1063 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
1064
1065 let dest = |p: &str| format!("http://localhost{p}");
1066 let r = req(
1067 &env,
1068 "MOVE",
1069 &format!("{CAL}a.ics"),
1070 &auth,
1071 &[("destination", &dest(&format!("{HOME}work/a.ics")))],
1072 "",
1073 )
1074 .await;
1075 assert_eq!(r.status, StatusCode::CREATED);
1076 assert_eq!(
1077 req(&env, "GET", &format!("{CAL}a.ics"), &auth, &[], "")
1078 .await
1079 .status,
1080 StatusCode::NOT_FOUND
1081 );
1082 assert_eq!(
1083 req(&env, "GET", &format!("{HOME}work/a.ics"), &auth, &[], "")
1084 .await
1085 .text(),
1086 event("a", "A")
1087 );
1088
1089 // Overwrite: F refuses an existing destination.
1090 put(&env, &auth, &format!("{CAL}a2.ics"), &event("a2", "A2")).await;
1091 let r = req(
1092 &env,
1093 "MOVE",
1094 &format!("{CAL}a2.ics"),
1095 &auth,
1096 &[("destination", &format!("{CAL}b.ics")), ("overwrite", "F")],
1097 "",
1098 )
1099 .await;
1100 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
1101 let r = req(
1102 &env,
1103 "MOVE",
1104 &format!("{CAL}a2.ics"),
1105 &auth,
1106 &[("destination", &format!("{CAL}b.ics"))],
1107 "",
1108 )
1109 .await;
1110 assert_eq!(r.status, StatusCode::NO_CONTENT);
1111
1112 // The same UID under another name in the destination.
1113 put(&env, &auth, &format!("{CAL}dup.ics"), &event("a", "again")).await;
1114 let r = req(
1115 &env,
1116 "MOVE",
1117 &format!("{CAL}dup.ics"),
1118 &auth,
1119 &[("destination", &format!("{HOME}work/other.ics"))],
1120 "",
1121 )
1122 .await;
1123 assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
1124
1125 // Across kinds is refused.
1126 let r = req(
1127 &env,
1128 "MOVE",
1129 &format!("{CAL}b.ics"),
1130 &auth,
1131 &[("destination", &format!("{BOOK}b.vcf"))],
1132 "",
1133 )
1134 .await;
1135 assert_eq!(r.status, StatusCode::FORBIDDEN);
1136}
1137
1138#[tokio::test]
1139async fn hrefs_follow_the_requested_spelling() {
1140 let (env, auth) = setup().await;
1141 let body = propfind_body(&[(DAV, "displayname")]);
1142 let r = req(
1143 &env,
1144 "PROPFIND",
1145 "/pim/calendars/ALICE/",
1146 &auth,
1147 &[("depth", "1")],
1148 &body,
1149 )
1150 .await;
1151 let hrefs = hrefs_in(&r);
1152 assert!(
1153 hrefs.iter().all(|h| h.starts_with("/pim/calendars/ALICE/")),
1154 "{hrefs:?}"
1155 );
1156}
1157
1158// ---------------------------------------------------------------------------
1159// Sharing, the system address book, rooms and principal search
1160// ---------------------------------------------------------------------------
1161
1162const BOB: &str = "bob";
1163const BOB_PW: &str = "bob12345678";
1164
1165/// alice with an event in her default calendar, and bob.
1166async fn two_users() -> (Env, Client, String, String) {
1167 let env = Env::new().await;
1168 let admin = env.admin().await;
1169 create_user(&admin, ALICE, PW, &[]).await;
1170 create_user(&admin, BOB, BOB_PW, &[]).await;
1171 let alice = basic(ALICE, PW);
1172 put(&env, &alice, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
1173 (env, admin, alice, basic(BOB, BOB_PW))
1174}
1175
1176/// The id of alice's default calendar, from the JSON API.
1177async fn calendar_id(alice: &Client) -> i64 {
1178 let r = alice.get("/api/pim/collections").await;
1179 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1180 r.json()
1181 .as_array()
1182 .unwrap()
1183 .iter()
1184 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
1185 .unwrap()["id"]
1186 .as_i64()
1187 .unwrap()
1188}
1189
1190fn privilege_names(p: &Element) -> Vec<String> {
1191 xml::elements(p)
1192 .flat_map(xml::elements)
1193 .map(|e| e.name.clone())
1194 .collect()
1195}
1196
1197#[tokio::test]
1198async fn lent_collections() {
1199 let (env, admin, alice_auth, bob) = two_users().await;
1200 let alice = login(&env, ALICE, PW).await;
1201 let id = calendar_id(&alice).await;
1202 let shares = format!("/api/pim/collections/{id}/shares");
1203
1204 let r = alice
1205 .post_json(&shares, &json!({"user": "nobody", "mode": "ro"}))
1206 .await;
1207 assert_eq!(r.status, StatusCode::NOT_FOUND);
1208 let r = alice
1209 .post_json(&shares, &json!({"user": ALICE, "mode": "ro"}))
1210 .await;
1211 assert_eq!(r.status, StatusCode::BAD_REQUEST);
1212 let r = alice
1213 .post_json(&shares, &json!({"user": "BOB", "mode": "ro"}))
1214 .await;
1215 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1216 let bob_client = login(&env, BOB, BOB_PW).await;
1217 assert_eq!(bob_client.get(&shares).await.status, StatusCode::NOT_FOUND);
1218 let listed = bob_client.get("/api/pim/collections").await.json();
1219 let lent = listed
1220 .as_array()
1221 .unwrap()
1222 .iter()
1223 .find(|c| c["id"] == id)
1224 .unwrap()
1225 .clone();
1226 assert_eq!(lent["mode"], "ro");
1227 assert_eq!(lent["owner"], ALICE);
1228 let shared = format!("/pim/calendars/bob/shared-{id}/");
1229 assert_eq!(lent["url"], shared.as_str());
1230
1231 // bob's home shows it, read-only, with alice as the owner.
1232 let body = propfind_body(&[
1233 (DAV, "displayname"),
1234 (DAV, "owner"),
1235 (DAV, "current-user-privilege-set"),
1236 ]);
1237 let r = req(
1238 &env,
1239 "PROPFIND",
1240 "/pim/calendars/bob/",
1241 &bob,
1242 &[("depth", "1")],
1243 &body,
1244 )
1245 .await;
1246 let ms = parse_multistatus(&r);
1247 assert_eq!(
1248 prop_text(&ms, &shared, DAV, "displayname").as_deref(),
1249 Some("Calendar (alice)")
1250 );
1251 assert_eq!(
1252 hrefs_of(&prop(&ms, &shared, DAV, "owner").unwrap()),
1253 ["/pim/principals/alice/"]
1254 );
1255 let privs = privilege_names(&prop(&ms, &shared, DAV, "current-user-privilege-set").unwrap());
1256 assert_eq!(privs, ["read", "read-current-user-privilege-set"]);
1257
1258 // Read works through every method, writes do not.
1259 let lunch = format!("{shared}lunch.ics");
1260 let r = req(&env, "GET", &lunch, &bob, &[], "").await;
1261 assert_eq!(r.status, StatusCode::OK);
1262 let r = req(&env, "REPORT", &shared, &bob, &[], &query("", "")).await;
1263 assert_eq!(hrefs_in(&r), [lunch.as_str()]);
1264 let sync = r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
1265 let r = req(&env, "REPORT", &shared, &bob, &[], sync).await;
1266 assert_eq!(hrefs_in(&r), [lunch.as_str()]);
1267 let r = req(
1268 &env,
1269 "PUT",
1270 &format!("{shared}new.ics"),
1271 &bob,
1272 &[],
1273 &event("new", "x"),
1274 )
1275 .await;
1276 assert_eq!(r.status, StatusCode::FORBIDDEN);
1277 assert!(error_condition(&r).is(DAV, "need-privileges"));
1278 let r = req(&env, "DELETE", &lunch, &bob, &[], "").await;
1279 assert_eq!(r.status, StatusCode::FORBIDDEN);
1280 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Mine</d:displayname></d:prop></d:set></d:propertyupdate>"#;
1281 let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
1282 assert_eq!(r.status, StatusCode::FORBIDDEN);
1283
1284 // Read-write: bob adds an event, alice sees it. Moving it into his own
1285 // calendar is refused: an object never changes owner.
1286 let r = alice
1287 .post_json(&shares, &json!({"user": BOB, "mode": "rw"}))
1288 .await;
1289 assert_eq!(r.status, StatusCode::OK);
1290 put(
1291 &env,
1292 &bob,
1293 &format!("{shared}new.ics"),
1294 &event("new", "from bob"),
1295 )
1296 .await;
1297 let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
1298 assert_eq!(r.status, StatusCode::OK);
1299 let r = req(
1300 &env,
1301 "MOVE",
1302 &format!("{shared}new.ics"),
1303 &bob,
1304 &[("destination", "/pim/calendars/bob/default/new.ics")],
1305 "",
1306 )
1307 .await;
1308 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1309 let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
1310 assert_eq!(r.status, StatusCode::OK);
1311 let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
1312 assert_eq!(r.status, StatusCode::FORBIDDEN);
1313
1314 // bob deleting it only takes it out of his home.
1315 let r = req(&env, "DELETE", &shared, &bob, &[], "").await;
1316 assert_eq!(r.status, StatusCode::NO_CONTENT);
1317 assert_eq!(
1318 req(&env, "GET", &lunch, &bob, &[], "").await.status,
1319 StatusCode::NOT_FOUND
1320 );
1321 assert!(
1322 alice
1323 .get(&shares)
1324 .await
1325 .json()
1326 .as_array()
1327 .unwrap()
1328 .is_empty()
1329 );
1330 let r = req(
1331 &env,
1332 "GET",
1333 &format!("{CAL}lunch.ics"),
1334 &alice_auth,
1335 &[],
1336 "",
1337 )
1338 .await;
1339 assert_eq!(r.status, StatusCode::OK);
1340
1341 // Revoking, and deleting the borrower, end the loan.
1342 alice
1343 .post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
1344 .await;
1345 let r = alice
1346 .delete(&format!("{shares}/{}", user_id(&admin, BOB).await))
1347 .await;
1348 assert_eq!(r.status, StatusCode::OK);
1349 assert_eq!(
1350 req(&env, "GET", &lunch, &bob, &[], "").await.status,
1351 StatusCode::NOT_FOUND
1352 );
1353 alice
1354 .post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
1355 .await;
1356 let r = admin
1357 .delete(&format!("/api/admin/users/{}", user_id(&admin, BOB).await))
1358 .await;
1359 assert_eq!(r.status, StatusCode::OK);
1360 assert!(
1361 alice
1362 .get(&shares)
1363 .await
1364 .json()
1365 .as_array()
1366 .unwrap()
1367 .is_empty()
1368 );
1369}
1370
1371const DIR: &str = "/pim/addressbooks/alice/system/";
1372
1373#[tokio::test]
1374async fn system_address_book() {
1375 let (env, admin, alice, _) = two_users().await;
1376 let body = propfind_body(&[(DAV, "getetag"), (CALSERVER, "getctag")]);
1377 let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "1")], &body).await;
1378 let ms = parse_multistatus(&r);
1379 // admin, alice and bob.
1380 assert_eq!(ms.len(), 4);
1381 let ctag = prop_text(&ms, DIR, CALSERVER, "getctag").unwrap();
1382 let card = ms.iter().find(|(h, _)| h != DIR).unwrap().0.clone();
1383 let r = req(&env, "GET", &card, &alice, &[], "").await;
1384 assert_eq!(r.status, StatusCode::OK);
1385 assert!(r.text().contains("EMAIL;TYPE=INTERNET:"), "{}", r.text());
1386
1387 let q = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
1388 <d:prop><card:address-data/></d:prop>
1389 <card:filter><card:prop-filter name="FN"><card:text-match>BOB</card:text-match></card:prop-filter></card:filter>
1390 </card:addressbook-query>"#;
1391 let r = req(&env, "REPORT", DIR, &alice, &[], q).await;
1392 assert_eq!(statuses(&r).len(), 1);
1393 assert!(r.text().contains("FN:bob"));
1394
1395 let sync = |token: &str| {
1396 format!(
1397 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
1398 )
1399 };
1400 let r = req(&env, "REPORT", DIR, &alice, &[], &sync("")).await;
1401 assert_eq!(statuses(&r).len(), 3);
1402 let token = sync_token_of(&r);
1403 let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
1404 assert!(statuses(&r).is_empty());
1405
1406 // A new account changes the CTag, and the old token no longer works.
1407 create_user(&admin, "carol", "carol12345", &[]).await;
1408 let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
1409 assert_eq!(r.status, StatusCode::FORBIDDEN);
1410 assert!(error_condition(&r).is(DAV, "valid-sync-token"));
1411 let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "0")], &body).await;
1412 assert_ne!(
1413 prop_text(&parse_multistatus(&r), DIR, CALSERVER, "getctag").unwrap(),
1414 ctag
1415 );
1416
1417 let r = req(
1418 &env,
1419 "PUT",
1420 &format!("{DIR}x.vcf"),
1421 &alice,
1422 &[],
1423 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:x\r\nEND:VCARD\r\n",
1424 )
1425 .await;
1426 assert_eq!(r.status, StatusCode::FORBIDDEN);
1427 assert!(error_condition(&r).is(DAV, "need-privileges"));
1428 assert_eq!(
1429 req(&env, "DELETE", &card, &alice, &[], "").await.status,
1430 StatusCode::FORBIDDEN
1431 );
1432 assert_eq!(
1433 req(&env, "DELETE", DIR, &alice, &[], "").await.status,
1434 StatusCode::FORBIDDEN
1435 );
1436 let r = req(&env, "MKCOL", DIR, &alice, &[], "").await;
1437 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
1438}
1439
1440#[tokio::test]
1441async fn rooms_and_resources() {
1442 let (env, admin, alice, _) = two_users().await;
1443 let alice_client = login(&env, ALICE, PW).await;
1444 let room = json!({"name": "board", "display_name": "Board Room", "kind": "room"});
1445 assert_eq!(
1446 alice_client
1447 .post_json("/api/admin/rooms", &room)
1448 .await
1449 .status,
1450 StatusCode::FORBIDDEN
1451 );
1452 let r = admin.post_json("/api/admin/rooms", &room).await;
1453 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1454 let id = r.json()["id"].as_i64().unwrap();
1455 assert_eq!(r.json()["url"], "/pim/principals/board/");
1456 let taken = json!({"name": "ALICE", "kind": "resource"});
1457 assert_eq!(
1458 admin.post_json("/api/admin/rooms", &taken).await.status,
1459 StatusCode::CONFLICT
1460 );
1461 let r = admin
1462 .post_json(
1463 "/api/admin/users",
1464 &json!({"name": "Board", "password": "x1234567", "is_admin": false, "roots": []}),
1465 )
1466 .await;
1467 assert_eq!(r.status, StatusCode::CONFLICT);
1468 // Not an account: not listed, not editable, no sign-in.
1469 let users = admin.get("/api/admin/users").await.json();
1470 assert!(
1471 users
1472 .as_array()
1473 .unwrap()
1474 .iter()
1475 .all(|u| u["name"] != "board")
1476 );
1477 let r = admin
1478 .put_json(
1479 &format!("/api/admin/users/{id}"),
1480 &json!({"password": "x1234567"}),
1481 )
1482 .await;
1483 assert_eq!(r.status, StatusCode::NOT_FOUND);
1484 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
1485 assert_eq!(r.status, StatusCode::NOT_FOUND);
1486 let r = req(&env, "PROPFIND", "/pim/", &basic("board", ""), &[], "").await;
1487 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1488 let r = Client::new(env.app.clone())
1489 .post_json("/api/auth/login", &json!({"name": "board", "password": ""}))
1490 .await;
1491 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1492
1493 let p = "/pim/principals/board/";
1494 let body = propfind_body(&[
1495 (DAV, "displayname"),
1496 (CALDAV, "calendar-user-type"),
1497 (CALDAV, "calendar-user-address-set"),
1498 (CALDAV, "calendar-home-set"),
1499 ]);
1500 let r = req(&env, "PROPFIND", p, &alice, &[], &body).await;
1501 let ms = parse_multistatus(&r);
1502 assert_eq!(
1503 prop_text(&ms, p, DAV, "displayname").as_deref(),
1504 Some("Board Room")
1505 );
1506 assert_eq!(
1507 prop_text(&ms, p, CALDAV, "calendar-user-type").as_deref(),
1508 Some("ROOM")
1509 );
1510 let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
1511 assert!(addresses.contains(&"mailto:board@rooms.dovenest.invalid".to_string()));
1512 assert_eq!(
1513 hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
1514 ["/pim/calendars/board/"]
1515 );
1516
1517 // Everyone reads the bookings; only admins write them.
1518 let cal = "/pim/calendars/board/default/";
1519 let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
1520 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1521 let r = req(
1522 &env,
1523 "PUT",
1524 &format!("{cal}b.ics"),
1525 &alice,
1526 &[],
1527 &event("b", "x"),
1528 )
1529 .await;
1530 assert_eq!(r.status, StatusCode::FORBIDDEN);
1531 put(
1532 &env,
1533 &basic("admin", "admin1234"),
1534 &format!("{cal}b.ics"),
1535 &event("b", "x"),
1536 )
1537 .await;
1538 assert_eq!(
1539 req(&env, "GET", &format!("{cal}b.ics"), &alice, &[], "")
1540 .await
1541 .status,
1542 StatusCode::OK
1543 );
1544
1545 // In the system address book as a location.
1546 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;
1547 assert!(r.text().contains("FN:Board Room"), "{}", r.text());
1548
1549 let r = admin
1550 .put_json(
1551 &format!("/api/admin/rooms/{id}"),
1552 &json!({"display_name": "Boardroom"}),
1553 )
1554 .await;
1555 assert_eq!(r.json()["display_name"], "Boardroom");
1556 assert_eq!(
1557 admin
1558 .get("/api/admin/rooms")
1559 .await
1560 .json()
1561 .as_array()
1562 .unwrap()
1563 .len(),
1564 1
1565 );
1566 assert_eq!(
1567 admin.delete(&format!("/api/admin/rooms/{id}")).await.status,
1568 StatusCode::OK
1569 );
1570 assert_eq!(
1571 req(&env, "PROPFIND", p, &alice, &[], "").await.status,
1572 StatusCode::NOT_FOUND
1573 );
1574}
1575
1576#[tokio::test]
1577async fn principal_search() {
1578 let (env, admin, alice, _) = two_users().await;
1579 admin
1580 .post_json(
1581 "/api/admin/rooms",
1582 &json!({"name": "board", "display_name": "Board Room", "kind": "room"}),
1583 )
1584 .await;
1585 let principals = "/pim/principals/";
1586
1587 let r = req(&env, "PROPFIND", principals, &alice, &[("depth", "1")], "").await;
1588 // The collection, admin, alice, bob and the room.
1589 assert_eq!(parse_multistatus(&r).len(), 5);
1590
1591 let pps = r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" test="anyof">
1592 <d:property-search><d:prop><d:displayname/></d:prop><d:match>BO</d:match></d:property-search>
1593 <d:prop><d:displayname/><c:calendar-user-type/></d:prop>
1594 </d:principal-property-search>"#;
1595 let r = req(&env, "REPORT", principals, &alice, &[("depth", "0")], pps).await;
1596 assert_eq!(
1597 hrefs_in(&r),
1598 ["/pim/principals/board/", "/pim/principals/bob/"]
1599 );
1600 let ms = parse_multistatus(&r);
1601 assert_eq!(
1602 prop_text(&ms, "/pim/principals/board/", CALDAV, "calendar-user-type").as_deref(),
1603 Some("ROOM")
1604 );
1605
1606 let cs = r#"<cs:calendarserver-principal-search xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" context="location">
1607 <cs:search-token>bo</cs:search-token><d:prop><d:displayname/></d:prop>
1608 </cs:calendarserver-principal-search>"#;
1609 let r = req(&env, "REPORT", principals, &alice, &[], cs).await;
1610 assert_eq!(hrefs_in(&r), ["/pim/principals/board/"]);
1611
1612 let set = r#"<d:principal-search-property-set xmlns:d="DAV:"/>"#;
1613 let r = req(&env, "REPORT", principals, &alice, &[], set).await;
1614 assert_eq!(r.status, StatusCode::OK);
1615 assert!(r.text().contains("calendar-user-address-set"));
1616
1617 // Not a collection report.
1618 let r = req(&env, "REPORT", CAL, &alice, &[], pps).await;
1619 assert_eq!(r.status, StatusCode::FORBIDDEN);
1620}
1621
1622#[tokio::test]
1623async fn bad_filters_are_refused_by_name() {
1624 let (env, auth) = setup().await;
1625 let bad = query(
1626 r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
1627 "",
1628 );
1629 let r = req(&env, "REPORT", CAL, &auth, &[], &bad).await;
1630 assert_eq!(r.status, StatusCode::FORBIDDEN);
1631 assert!(error_condition(&r).is(CALDAV, "valid-filter"));
1632 let bad = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><card:filter test="sometimes"/></card:addressbook-query>"#;
1633 let r = req(&env, "REPORT", BOOK, &auth, &[], bad).await;
1634 assert!(error_condition(&r).is(CARDDAV, "valid-filter"));
1635}
1636
1637#[tokio::test]
1638async fn other_accounts_get_no_client_properties_or_loans_of_disabled_owners() {
1639 let (env, admin, alice_auth, bob) = two_users().await;
1640 let alice = login(&env, ALICE, PW).await;
1641 let id = calendar_id(&alice).await;
1642 let r = alice
1643 .post_json(
1644 &format!("/api/pim/collections/{id}/shares"),
1645 &json!({"user": BOB, "mode": "rw"}),
1646 )
1647 .await;
1648 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1649
1650 let principal = "/pim/principals/alice/";
1651 let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:x=\"urn:x\"><d:set><d:prop>\
1652 <x:note>private</x:note></d:prop></d:set></d:propertyupdate>";
1653 let r = req(&env, "PROPPATCH", principal, &alice_auth, &[], patch).await;
1654 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1655 let body = propfind_body(&[("urn:x", "note")]);
1656 let note = |auth: String| {
1657 let (env, body) = (&env, &body);
1658 async move {
1659 req(env, "PROPFIND", principal, &auth, &[("depth", "0")], body)
1660 .await
1661 .text()
1662 .contains("private")
1663 }
1664 };
1665 assert!(note(alice_auth.clone()).await);
1666 assert!(!note(bob.clone()).await);
1667
1668 let shared = format!("/pim/calendars/bob/shared-{id}/");
1669 let status = || req(&env, "PROPFIND", &shared, &bob, &[("depth", "0")], "");
1670 assert_eq!(status().await.status, StatusCode::MULTI_STATUS);
1671 let uid = user_id(&admin, ALICE).await;
1672 let r = admin
1673 .put_json(
1674 &format!("/api/admin/users/{uid}"),
1675 &json!({"active": false}),
1676 )
1677 .await;
1678 assert_eq!(r.status, StatusCode::OK);
1679 assert_eq!(status().await.status, StatusCode::NOT_FOUND);
1680}
1681
1682#[tokio::test]
1683async fn pruned_tombstones_invalidate_old_sync_tokens() {
1684 let (env, auth) = setup().await;
1685 let sync = |token: &str| {
1686 format!(
1687 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
1688 )
1689 };
1690 let obj = |n: &str| format!("{CAL}{n}.ics");
1691 let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
1692 for n in ["a", "b", "c"] {
1693 put(&env, &auth, &obj(n), &todo(n)).await;
1694 }
1695 let old = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync("")).await);
1696 for n in ["a", "b", "c"] {
1697 req(&env, "DELETE", &obj(n), &auth, &[], "").await;
1698 }
1699 let mid = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await);
1700 put(&env, &auth, &obj("d"), &todo("d")).await;
1701 req(&env, "DELETE", &obj("d"), &auth, &[], "").await;
1702
1703 env.state.db.pim_prune(2).await.unwrap();
1704 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await;
1705 assert_eq!(r.status, StatusCode::FORBIDDEN);
1706 assert!(error_condition(&r).is(DAV, "valid-sync-token"));
1707 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&mid)).await;
1708 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1709
1710 let parse = |t: &str| {
1711 let (id, seq) = t.rsplit_once(':').unwrap().1.rsplit_once('-').unwrap();
1712 (id.parse::<i64>().unwrap(), seq.parse::<i64>().unwrap())
1713 };
1714 let (id, old_seq) = parse(&old);
1715 let (_, mid_seq) = parse(&mid);
1716 let db = &env.state.db;
1717 assert_eq!(db.pim_changes(id, Some(old_seq), None).await.unwrap(), None);
1718 assert!(
1719 db.pim_changes(id, Some(mid_seq), None)
1720 .await
1721 .unwrap()
1722 .is_some()
1723 );
1724}
1725
1726#[tokio::test]
1727async fn a_cut_initial_sync_resumes_past_pruned_tombstones() {
1728 let (env, auth) = setup().await;
1729 let sync = |token: &str| {
1730 format!(
1731 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token>
1732 <d:limit><d:nresults>1</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#
1733 )
1734 };
1735 let obj = |n: &str| format!("{CAL}{n}.ics");
1736 let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
1737 put(&env, &auth, &obj("x"), &todo("x")).await;
1738 for n in ["d1", "d2"] {
1739 put(&env, &auth, &obj(n), &todo(n)).await;
1740 req(&env, "DELETE", &obj(n), &auth, &[], "").await;
1741 }
1742 put(&env, &auth, &obj("y"), &todo("y")).await;
1743 env.state.db.pim_prune(0).await.unwrap();
1744
1745 // The first page ends at x, below the pruned tombstones.
1746 let r = req(&env, "REPORT", CAL, &auth, &[], &sync("")).await;
1747 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1748 assert!(
1749 r.text().contains("x.ics") && !r.text().contains("y.ics"),
1750 "{}",
1751 r.text()
1752 );
1753 let cut = sync_token_of(&r);
1754 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&cut)).await;
1755 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1756 assert!(r.text().contains("y.ics"), "{}", r.text());
1757
1758 // Deletions after the sync began still count: pruning them refuses it.
1759 put(&env, &auth, &obj("z"), &todo("z")).await;
1760 req(&env, "DELETE", &obj("z"), &auth, &[], "").await;
1761 env.state.db.pim_prune(0).await.unwrap();
1762 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&cut)).await;
1763 assert_eq!(r.status, StatusCode::FORBIDDEN);
1764 assert!(error_condition(&r).is(DAV, "valid-sync-token"));
1765}
1766
1767#[tokio::test]
1768async fn mkcol_checks_target_and_values() {
1769 let (env, auth) = setup().await;
1770 let r = req(&env, "MKCOL", &format!("{CAL}x.ics"), &auth, &[], "").await;
1771 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
1772 let body = |color: &str| {
1773 format!(
1774 r#"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop><a:calendar-color>{color}</a:calendar-color></d:prop></d:set></c:mkcalendar>"#
1775 )
1776 };
1777 let work = "/pim/calendars/alice/work/";
1778 let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("red")).await;
1779 assert_eq!(r.status, StatusCode::FORBIDDEN);
1780 let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("#FF8800AA")).await;
1781 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1782}
1783
1784async fn alice_pid(env: &Env) -> i64 {
1785 let user = env
1786 .state
1787 .db
1788 .find_user_by_name(ALICE)
1789 .await
1790 .unwrap()
1791 .unwrap();
1792 env.state.db.principal_of(user.id).await.unwrap()
1793}
1794
1795#[tokio::test]
1796async fn a_taken_slug_leaves_the_first_collection_alone() {
1797 use server::db::{PimCollection, PimKind};
1798 let (env, _) = setup().await;
1799 let db = &env.state.db;
1800 let pid = alice_pid(&env).await;
1801 let make = |name: &str| PimCollection {
1802 slug: "work".into(),
1803 displayname: Some(name.into()),
1804 components: "VEVENT".into(),
1805 ..Default::default()
1806 };
1807 let kind = PimKind::Calendar;
1808 assert!(
1809 db.pim_create_collection(pid, kind, &make("First"), &[])
1810 .await
1811 .unwrap()
1812 );
1813 assert!(
1814 !db.pim_create_collection(pid, kind, &make("Second"), &[])
1815 .await
1816 .unwrap()
1817 );
1818 let kept = db.pim_collection(pid, kind, "work").await.unwrap().unwrap();
1819 assert_eq!(kept.displayname.as_deref(), Some("First"));
1820}
1821
1822#[tokio::test]
1823async fn moving_an_object_onto_itself_changes_nothing() {
1824 use server::db::{PimCollection, PimKind, PimObject, PimOp, PimWrite, Precondition};
1825 let (env, _) = setup().await;
1826 let db = &env.state.db;
1827 let pid = alice_pid(&env).await;
1828 db.pim_ensure_defaults(pid).await.unwrap();
1829 let col: PimCollection = db
1830 .pim_collection(pid, PimKind::Calendar, "default")
1831 .await
1832 .unwrap()
1833 .unwrap();
1834 let obj = PimObject {
1835 name: "a.ics".into(),
1836 uid: "a".into(),
1837 component: "VEVENT".into(),
1838 etag: "\"e\"".into(),
1839 ..Default::default()
1840 };
1841 db.pim_apply(&[PimOp::Put {
1842 collection_id: col.id,
1843 obj,
1844 data: b"data".to_vec(),
1845 }])
1846 .await
1847 .unwrap();
1848 let before = db
1849 .pim_collection(pid, PimKind::Calendar, "default")
1850 .await
1851 .unwrap()
1852 .unwrap()
1853 .seq;
1854
1855 let none = Precondition {
1856 if_match: None,
1857 if_none_match: None,
1858 };
1859 let r = db
1860 .pim_move_object(col.id, "a.ics", col.id, "a.ics", true, &none)
1861 .await
1862 .unwrap();
1863 assert_eq!(r, PimWrite::Updated);
1864 let (_, data) = db.pim_object(col.id, "a.ics").await.unwrap().unwrap();
1865 assert_eq!(data, b"data");
1866 let changes = db
1867 .pim_changes(col.id, Some(before), None)
1868 .await
1869 .unwrap()
1870 .unwrap();
1871 assert!(changes.is_empty(), "{changes:?}");
1872}
1873
1874#[tokio::test]
1875async fn a_deleted_user_gets_the_login_challenge() {
1876 let env = Env::new().await;
1877 let admin = env.admin().await;
1878 create_user(&admin, ALICE, PW, &[]).await;
1879 let auth = basic(ALICE, PW);
1880 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
1881 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1882 let id = user_id(&admin, ALICE).await;
1883 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
1884 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1885 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
1886 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1887 assert!(r.header("www-authenticate").is_some());
1888}
1889
1890#[tokio::test]
1891async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
1892 use server::db::PimKind;
1893 let (env, auth) = setup().await;
1894 let long = format!("/pim/calendars/alice/{}/", "a".repeat(256));
1895 let r = req(&env, "MKCALENDAR", &long, &auth, &[], "").await;
1896 assert_eq!(r.status, StatusCode::FORBIDDEN);
1897 let ok = format!("/pim/calendars/alice/{}/", "a".repeat(255));
1898 let r = req(&env, "MKCALENDAR", &ok, &auth, &[], "").await;
1899 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1900
1901 // The cap counts the default calendar and the inbox as well.
1902 let mut n = 0;
1903 loop {
1904 let r = req(&env, "MKCALENDAR", &format!("{HOME}c{n}/"), &auth, &[], "").await;
1905 if r.status != StatusCode::CREATED {
1906 assert_eq!(r.status, StatusCode::FORBIDDEN);
1907 break;
1908 }
1909 n += 1;
1910 assert!(n < 100, "no limit");
1911 }
1912 let pid = alice_pid(&env).await;
1913 let all = env.state.db.pim_collections(pid, PimKind::Calendar).await;
1914 assert_eq!(all.unwrap().len(), 100);
1915 let r = req(
1916 &env,
1917 "MKCOL",
1918 "/pim/addressbooks/alice/other/",
1919 &auth,
1920 &[],
1921 "",
1922 )
1923 .await;
1924 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1925}
1926
1927#[tokio::test]
1928async fn multiget_answers_each_href_once_and_caps_the_count() {
1929 let (env, auth) = setup().await;
1930 put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
1931 let multiget = |hrefs: &[String]| {
1932 let hrefs: String = hrefs
1933 .iter()
1934 .map(|h| format!("<d:href>{h}</d:href>"))
1935 .collect();
1936 format!(
1937 r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/></d:prop>{hrefs}</c:calendar-multiget>"#
1938 )
1939 };
1940 let todo = format!("{CAL}todo.ics");
1941 let r = req(
1942 &env,
1943 "REPORT",
1944 CAL,
1945 &auth,
1946 &[],
1947 &multiget(&[todo.clone(), todo.clone()]),
1948 )
1949 .await;
1950 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1951 assert_eq!(statuses(&r).len(), 1, "{}", r.text());
1952
1953 let many: Vec<String> = (0..1001).map(|i| format!("{CAL}{i}.ics")).collect();
1954 let r = req(&env, "REPORT", CAL, &auth, &[], &multiget(&many)).await;
1955 let got = statuses(&r);
1956 assert_eq!(got.len(), 1001);
1957 assert_eq!(got.last().unwrap(), &(CAL.to_string(), Some(507)));
1958}
1959
1960#[tokio::test]
1961async fn a_put_racing_the_collection_delete_never_fails_with_500() {
1962 let (env, auth) = setup().await;
1963 let col = "/pim/calendars/alice/race/";
1964 for i in 0..20 {
1965 let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
1966 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1967 let todo = ics(&TODO.replace("UID:todo", &format!("UID:race{i}")));
1968 let path = format!("{col}{i}.ics");
1969 let (put, delete) = tokio::join!(
1970 req(&env, "PUT", &path, &auth, &[], &todo),
1971 req(&env, "DELETE", col, &auth, &[], ""),
1972 );
1973 assert_eq!(delete.status, StatusCode::NO_CONTENT);
1974 assert!(
1975 [StatusCode::CREATED, StatusCode::CONFLICT].contains(&put.status),
1976 "{}",
1977 put.status
1978 );
1979 }
1980}
1981
1982#[tokio::test]
1983async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500() {
1984 use tower::ServiceExt;
1985 let (env, auth) = setup().await;
1986 let col = "/pim/calendars/alice/race/";
1987 let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
1988 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1989 let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:x="urn:x"><d:set><d:prop>
1990 <x:note>dead</x:note></d:prop></d:set></d:propertyupdate>"#;
1991 let (send, arrive) = tokio::sync::oneshot::channel::<()>();
1992 let body = axum::body::Body::from_stream(futures_util::stream::once(async move {
1993 arrive.await.ok();
1994 Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(patch))
1995 }));
1996 let request = axum::http::Request::builder()
1997 .method("PROPPATCH")
1998 .uri(col)
1999 .header("host", "files.example.com")
2000 .header("authorization", &auth)
2001 .body(body)
2002 .unwrap();
2003 let pending = tokio::spawn(env.app.clone().oneshot(request));
2004 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2005 let r = req(&env, "DELETE", col, &auth, &[], "").await;
2006 assert_eq!(r.status, StatusCode::NO_CONTENT);
2007 send.send(()).unwrap();
2008 let r = pending.await.unwrap().unwrap();
2009 assert_eq!(r.status(), StatusCode::NOT_FOUND);
2010}
2011
2012#[tokio::test]
2013async fn deleting_a_missing_object_records_no_change() {
2014 use server::db::{PimKind, PimOp};
2015 let (env, _) = setup().await;
2016 let db = &env.state.db;
2017 let user = db.find_user_by_name(ALICE).await.unwrap().unwrap();
2018 let pid = db.principal_of(user.id).await.unwrap();
2019 db.pim_ensure_defaults(pid).await.unwrap();
2020 let seq = || async {
2021 db.pim_collection(pid, PimKind::Calendar, "default")
2022 .await
2023 .unwrap()
2024 .unwrap()
2025 };
2026 let before = seq().await;
2027 let op = PimOp::Delete {
2028 collection_id: before.id,
2029 name: "missing.ics".into(),
2030 };
2031 db.pim_apply(&[op]).await.unwrap();
2032 assert_eq!(seq().await.seq, before.seq);
2033 assert!(
2034 db.pim_changes(before.id, Some(before.seq), None)
2035 .await
2036 .unwrap()
2037 .unwrap()
2038 .is_empty()
2039 );
2040}
2041