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
479 let before = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], "").await);
480 let r = req(
481 &env,
482 "PUT",
483 &obj,
484 &auth,
485 &[("if-match", &etag)],
486 &event("a", "Two"),
487 )
488 .await;
489 assert_eq!(r.status, StatusCode::NO_CONTENT);
490 let new_etag = r.header("etag").unwrap();
491 assert_ne!(new_etag, etag);
492 let after = parse_multistatus(&req(&env, "PROPFIND", CAL, &auth, &[("depth", "1")], "").await);
493 assert_ne!(
494 prop_text(&before, CAL, DAV, "sync-token"),
495 prop_text(&after, CAL, DAV, "sync-token")
496 );
497 assert_eq!(prop_text(&after, &obj, DAV, "getetag"), Some(new_etag));
498
499 // The UID is already stored under another name.
500 let r = req(
501 &env,
502 "PUT",
503 &format!("{CAL}b.ics"),
504 &auth,
505 &[],
506 &event("a", "Dup"),
507 )
508 .await;
509 assert_eq!(r.status, StatusCode::FORBIDDEN);
510 assert!(error_condition(&r).is(CALDAV, "no-uid-conflict"));
511 assert!(r.text().contains(&obj), "{}", r.text());
512
513 let freebusy = event("j", "x").replace("VEVENT", "VFREEBUSY");
514 let cases = [
515 ("not a calendar".to_string(), "valid-calendar-data"),
516 (
517 event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"),
518 "valid-calendar-object-resource",
519 ),
520 (freebusy, "supported-calendar-component"),
521 ];
522 for (body, cond) in cases {
523 let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await;
524 assert_eq!(r.status, StatusCode::FORBIDDEN, "{cond}");
525 assert!(error_condition(&r).is(CALDAV, cond), "{cond}: {}", r.text());
526 }
527
528 let r = req(
529 &env,
530 "PUT",
531 "/pim/calendars/alice/nope/x.ics",
532 &auth,
533 &[],
534 &event("x", "x"),
535 )
536 .await;
537 assert_eq!(r.status, StatusCode::CONFLICT);
538
539 let r = req(
540 &env,
541 "DELETE",
542 &obj,
543 &auth,
544 &[("if-match", "\"stale\"")],
545 "",
546 )
547 .await;
548 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
549 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
550 assert_eq!(r.status, StatusCode::NO_CONTENT);
551 let r = req(&env, "DELETE", &obj, &auth, &[], "").await;
552 assert_eq!(r.status, StatusCode::NOT_FOUND);
553
554 let r = req(&env, "REPORT", CAL, &auth, &[], "").await;
555 assert_eq!(r.status, StatusCode::BAD_REQUEST);
556}
557
558#[tokio::test]
559async fn address_objects() {
560 let (env, auth) = setup().await;
561 let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Bob\r\nN:;Bob;;;\r\nEND:VCARD\r\n";
562 let r = req(&env, "PUT", &format!("{BOOK}c1.vcf"), &auth, &[], card).await;
563 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
564 let r = req(&env, "GET", &format!("{BOOK}c1.vcf"), &auth, &[], "").await;
565 assert_eq!(r.text(), card);
566 assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
567
568 let r = req(&env, "PUT", &format!("{BOOK}c2.vcf"), &auth, &[], card).await;
569 assert!(error_condition(&r).is(CARDDAV, "no-uid-conflict"));
570 let r = req(
571 &env,
572 "PUT",
573 &format!("{BOOK}c3.vcf"),
574 &auth,
575 &[],
576 &event("e", "x"),
577 )
578 .await;
579 assert!(error_condition(&r).is(CARDDAV, "valid-address-data"));
580}
581
582#[tokio::test]
583async fn the_default_calendar_stays() {
584 let (env, auth) = setup().await;
585 // Invitations arrive there (RFC 6638, 4.3).
586 let r = req(&env, "DELETE", CAL, &auth, &[], "").await;
587 assert_eq!(r.status, StatusCode::FORBIDDEN);
588 assert!(error_condition(&r).is(CALDAV, "default-calendar-needed"));
589 let other = format!("{HOME}work/");
590 assert_eq!(
591 req(&env, "MKCALENDAR", &other, &auth, &[], "").await.status,
592 StatusCode::CREATED
593 );
594 assert_eq!(
595 req(&env, "DELETE", &other, &auth, &[], "").await.status,
596 StatusCode::NO_CONTENT
597 );
598 // Nor can the inbox be made or removed by a client.
599 assert_eq!(
600 req(&env, "DELETE", INBOX, &auth, &[], "").await.status,
601 StatusCode::FORBIDDEN
602 );
603 assert_eq!(
604 req(
605 &env,
606 "MKCALENDAR",
607 "/pim/calendars/alice/outbox/",
608 &auth,
609 &[],
610 ""
611 )
612 .await
613 .status,
614 StatusCode::FORBIDDEN
615 );
616}
617
618#[tokio::test]
619async fn deleting_a_user_deletes_their_collections() {
620 let env = Env::new().await;
621 let admin = env.admin().await;
622 create_user(&admin, ALICE, PW, &[]).await;
623 let auth = basic(ALICE, PW);
624 let r = req(
625 &env,
626 "PUT",
627 &format!("{CAL}a.ics"),
628 &auth,
629 &[],
630 &event("a", "x"),
631 )
632 .await;
633 assert_eq!(r.status, StatusCode::CREATED);
634 let id = user_id(&admin, ALICE).await;
635 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
636 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
637 let db = &env.state.db;
638 assert!(
639 db.pim_collections(id, server::db::PimKind::Calendar)
640 .await
641 .unwrap()
642 .is_empty()
643 );
644}
645
646// ---------------------------------------------------------------------------
647// REPORT and MOVE
648// ---------------------------------------------------------------------------
649
650/// `(href, status of the response itself)` of every response.
651fn statuses(r: &Resp) -> Vec<(String, Option<u16>)> {
652 let root = Element::parse(r.body.as_slice()).unwrap();
653 xml::elements(&root)
654 .filter(|e| Name::of(e).is(DAV, "response"))
655 .map(|resp| {
656 let href = xml::text(xml::child(resp, DAV, "href").unwrap());
657 let code = xml::child(resp, DAV, "status")
658 .map(|s| xml::text(s).split(' ').nth(1).unwrap().parse().unwrap());
659 (href, code)
660 })
661 .collect()
662}
663
664fn sync_token_of(r: &Resp) -> String {
665 let root = Element::parse(r.body.as_slice()).unwrap();
666 xml::text(xml::child(&root, DAV, "sync-token").unwrap())
667}
668
669fn ics(body: &str) -> String {
670 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
671}
672
673const 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";
674const 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";
675const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n";
676
677async fn put(env: &Env, auth: &str, path: &str, body: &str) {
678 let r = req(env, "PUT", path, auth, &[], body).await;
679 assert!(r.status.is_success(), "{path}: {}", r.text());
680}
681
682fn query(filter: &str, data: &str) -> String {
683 format!(
684 r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
685 <d:prop><d:getetag/>{data}</d:prop>
686 <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
687 </c:calendar-query>"#
688 )
689}
690
691fn hrefs_in(r: &Resp) -> Vec<String> {
692 let mut h: Vec<_> = statuses(r).into_iter().map(|(h, _)| h).collect();
693 h.sort();
694 h
695}
696
697#[tokio::test]
698async fn calendar_reports() {
699 let (env, auth) = setup().await;
700 put(&env, &auth, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
701 put(&env, &auth, &format!("{CAL}weekly.ics"), &ics(WEEKLY)).await;
702 put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
703
704 let r = req(
705 &env,
706 "PROPFIND",
707 CAL,
708 &auth,
709 &[("depth", "0")],
710 &propfind_body(&[(DAV, "supported-report-set")]),
711 )
712 .await;
713 let reports = prop(&parse_multistatus(&r), CAL, DAV, "supported-report-set").unwrap();
714 assert_eq!(xml::elements(&reports).count(), 4);
715
716 // multiget: stored bytes back, a miss as 404.
717 let body = format!(
718 r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
719 <d:prop><d:getetag/><c:calendar-data/></d:prop>
720 <d:href>{CAL}lunch.ics</d:href><d:href>{CAL}gone.ics</d:href>
721 </c:calendar-multiget>"#
722 );
723 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await;
724 let ms = parse_multistatus(&r);
725 let lunch = format!("{CAL}lunch.ics");
726 // The CR of each CRLF goes out as `&#13;`, which XML parsing keeps.
727 assert!(r.text().contains("&#13;\n"), "{}", r.text());
728 let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap();
729 assert_eq!(data, ics(LUNCH).trim());
730 assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404))));
731
732 // Time range: the Monday 2026-01-05 holds only an instance of the weekly
733 // series, which started a month earlier in Berlin time.
734 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260105T000000Z" end="20260106T000000Z"/></c:comp-filter>"#;
735 let r = req(
736 &env,
737 "REPORT",
738 CAL,
739 &auth,
740 &[("depth", "1")],
741 &query(range, ""),
742 )
743 .await;
744 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
745
746 // The same with expand: one instance in UTC, without the RRULE.
747 let expand = r#"<c:calendar-data><c:expand start="20260105T000000Z" end="20260106T000000Z"/></c:calendar-data>"#;
748 let r = req(
749 &env,
750 "REPORT",
751 CAL,
752 &auth,
753 &[("depth", "1")],
754 &query(range, expand),
755 )
756 .await;
757 let data = prop_text(
758 &parse_multistatus(&r),
759 &format!("{CAL}weekly.ics"),
760 CALDAV,
761 "calendar-data",
762 )
763 .unwrap();
764 assert!(data.contains("DTSTART:20260105T080000Z"), "{data}");
765 assert!(data.contains("RECURRENCE-ID:20260105T080000Z"), "{data}");
766 assert!(!data.contains("RRULE"), "{data}");
767
768 // text-match folds ASCII case by default, and negates on request.
769 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>"#;
770 let r = req(
771 &env,
772 "REPORT",
773 CAL,
774 &auth,
775 &[("depth", "1")],
776 &query(text, ""),
777 )
778 .await;
779 assert_eq!(hrefs_in(&r), std::slice::from_ref(&lunch));
780 let negated = text.replace("<c:text-match>", r#"<c:text-match negate-condition="yes">"#);
781 let r = req(
782 &env,
783 "REPORT",
784 CAL,
785 &auth,
786 &[("depth", "1")],
787 &query(&negated, ""),
788 )
789 .await;
790 assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
791 let odd = text.replace("<c:text-match>", r#"<c:text-match collation="i;klingon">"#);
792 let r = req(
793 &env,
794 "REPORT",
795 CAL,
796 &auth,
797 &[("depth", "1")],
798 &query(&odd, ""),
799 )
800 .await;
801 assert_eq!(r.status, StatusCode::FORBIDDEN);
802 assert_eq!(
803 error_condition(&r),
804 Name::new(CALDAV, "supported-collation")
805 );
806
807 // A VTODO with only DUE matches the range that holds DUE.
808 let todo = r#"<c:comp-filter name="VTODO"><c:time-range start="20260110T000000Z" end="20260111T000000Z"/></c:comp-filter>"#;
809 let r = req(
810 &env,
811 "REPORT",
812 CAL,
813 &auth,
814 &[("depth", "1")],
815 &query(todo, ""),
816 )
817 .await;
818 assert_eq!(hrefs_in(&r), [format!("{CAL}todo.ics")]);
819
820 // Only the components asked for.
821 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>"#;
822 let r = req(
823 &env,
824 "REPORT",
825 CAL,
826 &auth,
827 &[("depth", "1")],
828 &query(text, comp),
829 )
830 .await;
831 let data = prop_text(&parse_multistatus(&r), &lunch, CALDAV, "calendar-data").unwrap();
832 assert!(
833 data.contains("SUMMARY:Team Lunch")
834 && !data.contains("DTSTART")
835 && !data.contains("VERSION"),
836 "{data}"
837 );
838
839 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>"#;
840 let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], fb).await;
841 assert_eq!(r.status, StatusCode::OK);
842 assert!(
843 r.header("content-type")
844 .unwrap()
845 .starts_with("text/calendar")
846 );
847 assert!(
848 r.text()
849 .contains("FREEBUSY;FBTYPE=BUSY:20260105T080000Z/20260105T083000Z"),
850 "{}",
851 r.text()
852 );
853 assert!(
854 r.text()
855 .contains("FREEBUSY;FBTYPE=BUSY:20260101T120000Z/20260101T130000Z"),
856 "{}",
857 r.text()
858 );
859
860 // An address book report on a calendar is refused.
861 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;
862 assert_eq!(error_condition(&r), Name::new(DAV, "supported-report"));
863}
864
865#[tokio::test]
866async fn expand_answers_are_capped() {
867 let (env, auth) = setup().await;
868 for i in 0..4 {
869 let hourly = format!(
870 "BEGIN:VEVENT\r\nUID:h{i}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T000000Z\r\n\
871 DURATION:PT10M\r\nRRULE:FREQ=HOURLY\r\nSUMMARY:tick\r\nEND:VEVENT\r\n"
872 );
873 put(&env, &auth, &format!("{CAL}h{i}.ics"), &ics(&hourly)).await;
874 }
875 // 7200 instances each: three together pass the limit of one answer, one
876 // alone stays under that of one object. The fourth is cut off.
877 let expand = r#"<c:calendar-data><c:expand start="20260101T000000Z" end="20261028T000000Z"/></c:calendar-data>"#;
878 let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260101T000000Z" end="20261028T000000Z"/></c:comp-filter>"#;
879 let r = req(&env, "REPORT", CAL, &auth, &[], &query(range, expand)).await;
880 assert_eq!(r.status, StatusCode::MULTI_STATUS);
881 let s = statuses(&r);
882 assert!(s.contains(&(CAL.to_string(), Some(507))), "{s:?}");
883 assert_eq!(s.len(), 4, "{s:?}");
884}
885
886#[tokio::test]
887async fn sync_collection() {
888 let (env, auth) = setup().await;
889 let sync = |token: &str, limit: &str| {
890 format!(
891 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>"#
892 )
893 };
894 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
895 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
896
897 let r = req(&env, "REPORT", CAL, &auth, &[], &sync("", "")).await;
898 assert_eq!(hrefs_in(&r), [format!("{CAL}a.ics"), format!("{CAL}b.ics")]);
899 let token = sync_token_of(&r);
900
901 put(&env, &auth, &format!("{CAL}c.ics"), &event("c", "C")).await;
902 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A2")).await;
903 let r = req(&env, "DELETE", &format!("{CAL}b.ics"), &auth, &[], "").await;
904 assert_eq!(r.status, StatusCode::NO_CONTENT);
905
906 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, "")).await;
907 let mut got = statuses(&r);
908 got.sort();
909 assert_eq!(
910 got,
911 [
912 (format!("{CAL}a.ics"), None),
913 (format!("{CAL}b.ics"), Some(404)),
914 (format!("{CAL}c.ics"), None),
915 ]
916 );
917 let latest = sync_token_of(&r);
918 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&latest, "")).await;
919 assert!(statuses(&r).is_empty());
920
921 // A limit hands out the token of the last change it returned.
922 let limit = "<d:limit><d:nresults>1</d:nresults></d:limit>";
923 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, limit)).await;
924 let got = statuses(&r);
925 assert_eq!(got.len(), 2);
926 assert_eq!(got[1], (CAL.to_string(), Some(507)));
927 let r = req(
928 &env,
929 "REPORT",
930 CAL,
931 &auth,
932 &[],
933 &sync(&sync_token_of(&r), ""),
934 )
935 .await;
936 assert_eq!(statuses(&r).len(), 2);
937
938 for bad in ["urn:dovenest:sync:999-1", "nonsense", &format!("{latest}0")] {
939 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(bad, "")).await;
940 assert_eq!(
941 error_condition(&r),
942 Name::new(DAV, "valid-sync-token"),
943 "{bad}"
944 );
945 }
946}
947
948#[tokio::test]
949async fn addressbook_reports() {
950 let (env, auth) = setup().await;
951 let card = |uid: &str, name: &str, mail: &str| {
952 format!(
953 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{name}\r\nEMAIL;TYPE=WORK:{mail}\r\nEND:VCARD\r\n"
954 )
955 };
956 put(
957 &env,
958 &auth,
959 &format!("{BOOK}bob.vcf"),
960 &card("bob", "Bob Builder", "bob@example.com"),
961 )
962 .await;
963 put(
964 &env,
965 &auth,
966 &format!("{BOOK}ann.vcf"),
967 &card("ann", "Ann Äpfel", "ann@example.org"),
968 )
969 .await;
970 let query = |filter: &str, data: &str| {
971 format!(
972 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>"#
973 )
974 };
975 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>"#;
976 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(email, "")).await;
977 assert_eq!(hrefs_in(&r), [format!("{BOOK}ann.vcf")]);
978
979 // Unicode case folding is the CardDAV default.
980 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>"#;
981 let r = req(
982 &env,
983 "REPORT",
984 BOOK,
985 &auth,
986 &[],
987 &query(
988 fname,
989 "<card:address-data><card:prop name=\"FN\"/></card:address-data>",
990 ),
991 )
992 .await;
993 let data = prop_text(
994 &parse_multistatus(&r),
995 &format!("{BOOK}ann.vcf"),
996 CARDDAV,
997 "address-data",
998 )
999 .unwrap();
1000 assert!(
1001 data.contains("Ann Äpfel") && !data.contains("EMAIL"),
1002 "{data}"
1003 );
1004
1005 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>"#;
1006 let r = req(&env, "REPORT", BOOK, &auth, &[], &query(param, "")).await;
1007 assert_eq!(hrefs_in(&r).len(), 2);
1008
1009 let limited = query(
1010 "<card:filter/><card:limit><card:nresults>1</card:nresults></card:limit>",
1011 "",
1012 );
1013 let r = req(&env, "REPORT", BOOK, &auth, &[], &limited).await;
1014 let got = statuses(&r);
1015 assert_eq!(got.len(), 2);
1016 assert_eq!(got[1], (BOOK.to_string(), Some(507)));
1017
1018 let body = format!(
1019 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>"#
1020 );
1021 let r = req(&env, "REPORT", BOOK, &auth, &[], &body).await;
1022 let data = prop_text(
1023 &parse_multistatus(&r),
1024 &format!("{BOOK}bob.vcf"),
1025 CARDDAV,
1026 "address-data",
1027 )
1028 .unwrap();
1029 assert!(data.contains("FN:Bob Builder"));
1030}
1031
1032#[tokio::test]
1033async fn move_objects() {
1034 let (env, auth) = setup().await;
1035 let r = req(&env, "MKCALENDAR", &format!("{HOME}work/"), &auth, &[], "").await;
1036 assert_eq!(r.status, StatusCode::CREATED);
1037 put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
1038 put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
1039
1040 let dest = |p: &str| format!("http://localhost{p}");
1041 let r = req(
1042 &env,
1043 "MOVE",
1044 &format!("{CAL}a.ics"),
1045 &auth,
1046 &[("destination", &dest(&format!("{HOME}work/a.ics")))],
1047 "",
1048 )
1049 .await;
1050 assert_eq!(r.status, StatusCode::CREATED);
1051 assert_eq!(
1052 req(&env, "GET", &format!("{CAL}a.ics"), &auth, &[], "")
1053 .await
1054 .status,
1055 StatusCode::NOT_FOUND
1056 );
1057 assert_eq!(
1058 req(&env, "GET", &format!("{HOME}work/a.ics"), &auth, &[], "")
1059 .await
1060 .text(),
1061 event("a", "A")
1062 );
1063
1064 // Overwrite: F refuses an existing destination.
1065 put(&env, &auth, &format!("{CAL}a2.ics"), &event("a2", "A2")).await;
1066 let r = req(
1067 &env,
1068 "MOVE",
1069 &format!("{CAL}a2.ics"),
1070 &auth,
1071 &[("destination", &format!("{CAL}b.ics")), ("overwrite", "F")],
1072 "",
1073 )
1074 .await;
1075 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
1076 let r = req(
1077 &env,
1078 "MOVE",
1079 &format!("{CAL}a2.ics"),
1080 &auth,
1081 &[("destination", &format!("{CAL}b.ics"))],
1082 "",
1083 )
1084 .await;
1085 assert_eq!(r.status, StatusCode::NO_CONTENT);
1086
1087 // The same UID under another name in the destination.
1088 put(&env, &auth, &format!("{CAL}dup.ics"), &event("a", "again")).await;
1089 let r = req(
1090 &env,
1091 "MOVE",
1092 &format!("{CAL}dup.ics"),
1093 &auth,
1094 &[("destination", &format!("{HOME}work/other.ics"))],
1095 "",
1096 )
1097 .await;
1098 assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
1099
1100 // Across kinds is refused.
1101 let r = req(
1102 &env,
1103 "MOVE",
1104 &format!("{CAL}b.ics"),
1105 &auth,
1106 &[("destination", &format!("{BOOK}b.vcf"))],
1107 "",
1108 )
1109 .await;
1110 assert_eq!(r.status, StatusCode::FORBIDDEN);
1111}
1112
1113#[tokio::test]
1114async fn hrefs_follow_the_requested_spelling() {
1115 let (env, auth) = setup().await;
1116 let body = propfind_body(&[(DAV, "displayname")]);
1117 let r = req(
1118 &env,
1119 "PROPFIND",
1120 "/pim/calendars/ALICE/",
1121 &auth,
1122 &[("depth", "1")],
1123 &body,
1124 )
1125 .await;
1126 let hrefs = hrefs_in(&r);
1127 assert!(
1128 hrefs.iter().all(|h| h.starts_with("/pim/calendars/ALICE/")),
1129 "{hrefs:?}"
1130 );
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Sharing, the system address book, rooms and principal search
1135// ---------------------------------------------------------------------------
1136
1137const BOB: &str = "bob";
1138const BOB_PW: &str = "bob12345678";
1139
1140/// alice with an event in her default calendar, and bob.
1141async fn two_users() -> (Env, Client, String, String) {
1142 let env = Env::new().await;
1143 let admin = env.admin().await;
1144 create_user(&admin, ALICE, PW, &[]).await;
1145 create_user(&admin, BOB, BOB_PW, &[]).await;
1146 let alice = basic(ALICE, PW);
1147 put(&env, &alice, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
1148 (env, admin, alice, basic(BOB, BOB_PW))
1149}
1150
1151/// The id of alice's default calendar, from the JSON API.
1152async fn calendar_id(alice: &Client) -> i64 {
1153 let r = alice.get("/api/pim/collections").await;
1154 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1155 r.json()
1156 .as_array()
1157 .unwrap()
1158 .iter()
1159 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
1160 .unwrap()["id"]
1161 .as_i64()
1162 .unwrap()
1163}
1164
1165fn privilege_names(p: &Element) -> Vec<String> {
1166 xml::elements(p)
1167 .flat_map(xml::elements)
1168 .map(|e| e.name.clone())
1169 .collect()
1170}
1171
1172#[tokio::test]
1173async fn lent_collections() {
1174 let (env, admin, alice_auth, bob) = two_users().await;
1175 let alice = login(&env, ALICE, PW).await;
1176 let id = calendar_id(&alice).await;
1177 let shares = format!("/api/pim/collections/{id}/shares");
1178
1179 let r = alice
1180 .post_json(&shares, &json!({"user": "nobody", "mode": "ro"}))
1181 .await;
1182 assert_eq!(r.status, StatusCode::NOT_FOUND);
1183 let r = alice
1184 .post_json(&shares, &json!({"user": ALICE, "mode": "ro"}))
1185 .await;
1186 assert_eq!(r.status, StatusCode::BAD_REQUEST);
1187 let r = alice
1188 .post_json(&shares, &json!({"user": "BOB", "mode": "ro"}))
1189 .await;
1190 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1191 let bob_client = login(&env, BOB, BOB_PW).await;
1192 assert_eq!(bob_client.get(&shares).await.status, StatusCode::NOT_FOUND);
1193 let listed = bob_client.get("/api/pim/collections").await.json();
1194 let lent = listed
1195 .as_array()
1196 .unwrap()
1197 .iter()
1198 .find(|c| c["id"] == id)
1199 .unwrap()
1200 .clone();
1201 assert_eq!(lent["mode"], "ro");
1202 assert_eq!(lent["owner"], ALICE);
1203 let shared = format!("/pim/calendars/bob/shared-{id}/");
1204 assert_eq!(lent["url"], shared.as_str());
1205
1206 // bob's home shows it, read-only, with alice as the owner.
1207 let body = propfind_body(&[
1208 (DAV, "displayname"),
1209 (DAV, "owner"),
1210 (DAV, "current-user-privilege-set"),
1211 ]);
1212 let r = req(
1213 &env,
1214 "PROPFIND",
1215 "/pim/calendars/bob/",
1216 &bob,
1217 &[("depth", "1")],
1218 &body,
1219 )
1220 .await;
1221 let ms = parse_multistatus(&r);
1222 assert_eq!(
1223 prop_text(&ms, &shared, DAV, "displayname").as_deref(),
1224 Some("Calendar (alice)")
1225 );
1226 assert_eq!(
1227 hrefs_of(&prop(&ms, &shared, DAV, "owner").unwrap()),
1228 ["/pim/principals/alice/"]
1229 );
1230 let privs = privilege_names(&prop(&ms, &shared, DAV, "current-user-privilege-set").unwrap());
1231 assert_eq!(privs, ["read", "read-current-user-privilege-set"]);
1232
1233 // Read works through every method, writes do not.
1234 let lunch = format!("{shared}lunch.ics");
1235 let r = req(&env, "GET", &lunch, &bob, &[], "").await;
1236 assert_eq!(r.status, StatusCode::OK);
1237 let r = req(&env, "REPORT", &shared, &bob, &[], &query("", "")).await;
1238 assert_eq!(hrefs_in(&r), [lunch.as_str()]);
1239 let sync = r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
1240 let r = req(&env, "REPORT", &shared, &bob, &[], sync).await;
1241 assert_eq!(hrefs_in(&r), [lunch.as_str()]);
1242 let r = req(
1243 &env,
1244 "PUT",
1245 &format!("{shared}new.ics"),
1246 &bob,
1247 &[],
1248 &event("new", "x"),
1249 )
1250 .await;
1251 assert_eq!(r.status, StatusCode::FORBIDDEN);
1252 assert!(error_condition(&r).is(DAV, "need-privileges"));
1253 let r = req(&env, "DELETE", &lunch, &bob, &[], "").await;
1254 assert_eq!(r.status, StatusCode::FORBIDDEN);
1255 let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Mine</d:displayname></d:prop></d:set></d:propertyupdate>"#;
1256 let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
1257 assert_eq!(r.status, StatusCode::FORBIDDEN);
1258
1259 // Read-write: bob adds an event, alice sees it. Moving it into his own
1260 // calendar is refused: an object never changes owner.
1261 let r = alice
1262 .post_json(&shares, &json!({"user": BOB, "mode": "rw"}))
1263 .await;
1264 assert_eq!(r.status, StatusCode::OK);
1265 put(
1266 &env,
1267 &bob,
1268 &format!("{shared}new.ics"),
1269 &event("new", "from bob"),
1270 )
1271 .await;
1272 let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
1273 assert_eq!(r.status, StatusCode::OK);
1274 let r = req(
1275 &env,
1276 "MOVE",
1277 &format!("{shared}new.ics"),
1278 &bob,
1279 &[("destination", "/pim/calendars/bob/default/new.ics")],
1280 "",
1281 )
1282 .await;
1283 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1284 let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
1285 assert_eq!(r.status, StatusCode::OK);
1286 let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
1287 assert_eq!(r.status, StatusCode::FORBIDDEN);
1288
1289 // bob deleting it only takes it out of his home.
1290 let r = req(&env, "DELETE", &shared, &bob, &[], "").await;
1291 assert_eq!(r.status, StatusCode::NO_CONTENT);
1292 assert_eq!(
1293 req(&env, "GET", &lunch, &bob, &[], "").await.status,
1294 StatusCode::NOT_FOUND
1295 );
1296 assert!(
1297 alice
1298 .get(&shares)
1299 .await
1300 .json()
1301 .as_array()
1302 .unwrap()
1303 .is_empty()
1304 );
1305 let r = req(
1306 &env,
1307 "GET",
1308 &format!("{CAL}lunch.ics"),
1309 &alice_auth,
1310 &[],
1311 "",
1312 )
1313 .await;
1314 assert_eq!(r.status, StatusCode::OK);
1315
1316 // Revoking, and deleting the borrower, end the loan.
1317 alice
1318 .post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
1319 .await;
1320 let r = alice
1321 .delete(&format!("{shares}/{}", user_id(&admin, BOB).await))
1322 .await;
1323 assert_eq!(r.status, StatusCode::OK);
1324 assert_eq!(
1325 req(&env, "GET", &lunch, &bob, &[], "").await.status,
1326 StatusCode::NOT_FOUND
1327 );
1328 alice
1329 .post_json(&shares, &json!({"user": BOB, "mode": "ro"}))
1330 .await;
1331 let r = admin
1332 .delete(&format!("/api/admin/users/{}", user_id(&admin, BOB).await))
1333 .await;
1334 assert_eq!(r.status, StatusCode::OK);
1335 assert!(
1336 alice
1337 .get(&shares)
1338 .await
1339 .json()
1340 .as_array()
1341 .unwrap()
1342 .is_empty()
1343 );
1344}
1345
1346const DIR: &str = "/pim/addressbooks/alice/system/";
1347
1348#[tokio::test]
1349async fn system_address_book() {
1350 let (env, admin, alice, _) = two_users().await;
1351 let body = propfind_body(&[(DAV, "getetag"), (CALSERVER, "getctag")]);
1352 let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "1")], &body).await;
1353 let ms = parse_multistatus(&r);
1354 // admin, alice and bob.
1355 assert_eq!(ms.len(), 4);
1356 let ctag = prop_text(&ms, DIR, CALSERVER, "getctag").unwrap();
1357 let card = ms.iter().find(|(h, _)| h != DIR).unwrap().0.clone();
1358 let r = req(&env, "GET", &card, &alice, &[], "").await;
1359 assert_eq!(r.status, StatusCode::OK);
1360 assert!(r.text().contains("EMAIL;TYPE=INTERNET:"), "{}", r.text());
1361
1362 let q = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
1363 <d:prop><card:address-data/></d:prop>
1364 <card:filter><card:prop-filter name="FN"><card:text-match>BOB</card:text-match></card:prop-filter></card:filter>
1365 </card:addressbook-query>"#;
1366 let r = req(&env, "REPORT", DIR, &alice, &[], q).await;
1367 assert_eq!(statuses(&r).len(), 1);
1368 assert!(r.text().contains("FN:bob"));
1369
1370 let sync = |token: &str| {
1371 format!(
1372 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
1373 )
1374 };
1375 let r = req(&env, "REPORT", DIR, &alice, &[], &sync("")).await;
1376 assert_eq!(statuses(&r).len(), 3);
1377 let token = sync_token_of(&r);
1378 let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
1379 assert!(statuses(&r).is_empty());
1380
1381 // A new account changes the CTag, and the old token no longer works.
1382 create_user(&admin, "carol", "carol12345", &[]).await;
1383 let r = req(&env, "REPORT", DIR, &alice, &[], &sync(&token)).await;
1384 assert_eq!(r.status, StatusCode::FORBIDDEN);
1385 assert!(error_condition(&r).is(DAV, "valid-sync-token"));
1386 let r = req(&env, "PROPFIND", DIR, &alice, &[("depth", "0")], &body).await;
1387 assert_ne!(
1388 prop_text(&parse_multistatus(&r), DIR, CALSERVER, "getctag").unwrap(),
1389 ctag
1390 );
1391
1392 let r = req(
1393 &env,
1394 "PUT",
1395 &format!("{DIR}x.vcf"),
1396 &alice,
1397 &[],
1398 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:x\r\nEND:VCARD\r\n",
1399 )
1400 .await;
1401 assert_eq!(r.status, StatusCode::FORBIDDEN);
1402 assert!(error_condition(&r).is(DAV, "need-privileges"));
1403 assert_eq!(
1404 req(&env, "DELETE", &card, &alice, &[], "").await.status,
1405 StatusCode::FORBIDDEN
1406 );
1407 assert_eq!(
1408 req(&env, "DELETE", DIR, &alice, &[], "").await.status,
1409 StatusCode::FORBIDDEN
1410 );
1411 let r = req(&env, "MKCOL", DIR, &alice, &[], "").await;
1412 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
1413}
1414
1415#[tokio::test]
1416async fn rooms_and_resources() {
1417 let (env, admin, alice, _) = two_users().await;
1418 let alice_client = login(&env, ALICE, PW).await;
1419 let room = json!({"name": "board", "display_name": "Board Room", "kind": "room"});
1420 assert_eq!(
1421 alice_client
1422 .post_json("/api/admin/rooms", &room)
1423 .await
1424 .status,
1425 StatusCode::FORBIDDEN
1426 );
1427 let r = admin.post_json("/api/admin/rooms", &room).await;
1428 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1429 let id = r.json()["id"].as_i64().unwrap();
1430 assert_eq!(r.json()["url"], "/pim/principals/board/");
1431 let taken = json!({"name": "ALICE", "kind": "resource"});
1432 assert_eq!(
1433 admin.post_json("/api/admin/rooms", &taken).await.status,
1434 StatusCode::CONFLICT
1435 );
1436 let r = admin
1437 .post_json(
1438 "/api/admin/users",
1439 &json!({"name": "Board", "password": "x1234567", "is_admin": false, "roots": []}),
1440 )
1441 .await;
1442 assert_eq!(r.status, StatusCode::CONFLICT);
1443 // Not an account: not listed, not editable, no sign-in.
1444 let users = admin.get("/api/admin/users").await.json();
1445 assert!(
1446 users
1447 .as_array()
1448 .unwrap()
1449 .iter()
1450 .all(|u| u["name"] != "board")
1451 );
1452 let r = admin
1453 .put_json(
1454 &format!("/api/admin/users/{id}"),
1455 &json!({"password": "x1234567"}),
1456 )
1457 .await;
1458 assert_eq!(r.status, StatusCode::NOT_FOUND);
1459 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
1460 assert_eq!(r.status, StatusCode::NOT_FOUND);
1461 let r = req(&env, "PROPFIND", "/pim/", &basic("board", ""), &[], "").await;
1462 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1463 let r = Client::new(env.app.clone())
1464 .post_json("/api/auth/login", &json!({"name": "board", "password": ""}))
1465 .await;
1466 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1467
1468 let p = "/pim/principals/board/";
1469 let body = propfind_body(&[
1470 (DAV, "displayname"),
1471 (CALDAV, "calendar-user-type"),
1472 (CALDAV, "calendar-user-address-set"),
1473 (CALDAV, "calendar-home-set"),
1474 ]);
1475 let r = req(&env, "PROPFIND", p, &alice, &[], &body).await;
1476 let ms = parse_multistatus(&r);
1477 assert_eq!(
1478 prop_text(&ms, p, DAV, "displayname").as_deref(),
1479 Some("Board Room")
1480 );
1481 assert_eq!(
1482 prop_text(&ms, p, CALDAV, "calendar-user-type").as_deref(),
1483 Some("ROOM")
1484 );
1485 let addresses = hrefs_of(&prop(&ms, p, CALDAV, "calendar-user-address-set").unwrap());
1486 assert!(addresses.contains(&"mailto:board@rooms.dovenest.invalid".to_string()));
1487 assert_eq!(
1488 hrefs_of(&prop(&ms, p, CALDAV, "calendar-home-set").unwrap()),
1489 ["/pim/calendars/board/"]
1490 );
1491
1492 // Everyone reads the bookings; only admins write them.
1493 let cal = "/pim/calendars/board/default/";
1494 let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
1495 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1496 let r = req(
1497 &env,
1498 "PUT",
1499 &format!("{cal}b.ics"),
1500 &alice,
1501 &[],
1502 &event("b", "x"),
1503 )
1504 .await;
1505 assert_eq!(r.status, StatusCode::FORBIDDEN);
1506 put(
1507 &env,
1508 &basic("admin", "admin1234"),
1509 &format!("{cal}b.ics"),
1510 &event("b", "x"),
1511 )
1512 .await;
1513 assert_eq!(
1514 req(&env, "GET", &format!("{cal}b.ics"), &alice, &[], "")
1515 .await
1516 .status,
1517 StatusCode::OK
1518 );
1519
1520 // In the system address book as a location.
1521 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;
1522 assert!(r.text().contains("FN:Board Room"), "{}", r.text());
1523
1524 let r = admin
1525 .put_json(
1526 &format!("/api/admin/rooms/{id}"),
1527 &json!({"display_name": "Boardroom"}),
1528 )
1529 .await;
1530 assert_eq!(r.json()["display_name"], "Boardroom");
1531 assert_eq!(
1532 admin
1533 .get("/api/admin/rooms")
1534 .await
1535 .json()
1536 .as_array()
1537 .unwrap()
1538 .len(),
1539 1
1540 );
1541 assert_eq!(
1542 admin.delete(&format!("/api/admin/rooms/{id}")).await.status,
1543 StatusCode::OK
1544 );
1545 assert_eq!(
1546 req(&env, "PROPFIND", p, &alice, &[], "").await.status,
1547 StatusCode::NOT_FOUND
1548 );
1549}
1550
1551#[tokio::test]
1552async fn principal_search() {
1553 let (env, admin, alice, _) = two_users().await;
1554 admin
1555 .post_json(
1556 "/api/admin/rooms",
1557 &json!({"name": "board", "display_name": "Board Room", "kind": "room"}),
1558 )
1559 .await;
1560 let principals = "/pim/principals/";
1561
1562 let r = req(&env, "PROPFIND", principals, &alice, &[("depth", "1")], "").await;
1563 // The collection, admin, alice, bob and the room.
1564 assert_eq!(parse_multistatus(&r).len(), 5);
1565
1566 let pps = r#"<d:principal-property-search xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" test="anyof">
1567 <d:property-search><d:prop><d:displayname/></d:prop><d:match>BO</d:match></d:property-search>
1568 <d:prop><d:displayname/><c:calendar-user-type/></d:prop>
1569 </d:principal-property-search>"#;
1570 let r = req(&env, "REPORT", principals, &alice, &[("depth", "0")], pps).await;
1571 assert_eq!(
1572 hrefs_in(&r),
1573 ["/pim/principals/board/", "/pim/principals/bob/"]
1574 );
1575 let ms = parse_multistatus(&r);
1576 assert_eq!(
1577 prop_text(&ms, "/pim/principals/board/", CALDAV, "calendar-user-type").as_deref(),
1578 Some("ROOM")
1579 );
1580
1581 let cs = r#"<cs:calendarserver-principal-search xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" context="location">
1582 <cs:search-token>bo</cs:search-token><d:prop><d:displayname/></d:prop>
1583 </cs:calendarserver-principal-search>"#;
1584 let r = req(&env, "REPORT", principals, &alice, &[], cs).await;
1585 assert_eq!(hrefs_in(&r), ["/pim/principals/board/"]);
1586
1587 let set = r#"<d:principal-search-property-set xmlns:d="DAV:"/>"#;
1588 let r = req(&env, "REPORT", principals, &alice, &[], set).await;
1589 assert_eq!(r.status, StatusCode::OK);
1590 assert!(r.text().contains("calendar-user-address-set"));
1591
1592 // Not a collection report.
1593 let r = req(&env, "REPORT", CAL, &alice, &[], pps).await;
1594 assert_eq!(r.status, StatusCode::FORBIDDEN);
1595}
1596
1597#[tokio::test]
1598async fn bad_filters_are_refused_by_name() {
1599 let (env, auth) = setup().await;
1600 let bad = query(
1601 r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
1602 "",
1603 );
1604 let r = req(&env, "REPORT", CAL, &auth, &[], &bad).await;
1605 assert_eq!(r.status, StatusCode::FORBIDDEN);
1606 assert!(error_condition(&r).is(CALDAV, "valid-filter"));
1607 let bad = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><card:filter test="sometimes"/></card:addressbook-query>"#;
1608 let r = req(&env, "REPORT", BOOK, &auth, &[], bad).await;
1609 assert!(error_condition(&r).is(CARDDAV, "valid-filter"));
1610}
1611
1612#[tokio::test]
1613async fn other_accounts_get_no_client_properties_or_loans_of_disabled_owners() {
1614 let (env, admin, alice_auth, bob) = two_users().await;
1615 let alice = login(&env, ALICE, PW).await;
1616 let id = calendar_id(&alice).await;
1617 let r = alice
1618 .post_json(
1619 &format!("/api/pim/collections/{id}/shares"),
1620 &json!({"user": BOB, "mode": "rw"}),
1621 )
1622 .await;
1623 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1624
1625 let principal = "/pim/principals/alice/";
1626 let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:x=\"urn:x\"><d:set><d:prop>\
1627 <x:note>private</x:note></d:prop></d:set></d:propertyupdate>";
1628 let r = req(&env, "PROPPATCH", principal, &alice_auth, &[], patch).await;
1629 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1630 let body = propfind_body(&[("urn:x", "note")]);
1631 let note = |auth: String| {
1632 let (env, body) = (&env, &body);
1633 async move {
1634 req(env, "PROPFIND", principal, &auth, &[("depth", "0")], body)
1635 .await
1636 .text()
1637 .contains("private")
1638 }
1639 };
1640 assert!(note(alice_auth.clone()).await);
1641 assert!(!note(bob.clone()).await);
1642
1643 let shared = format!("/pim/calendars/bob/shared-{id}/");
1644 let status = || req(&env, "PROPFIND", &shared, &bob, &[("depth", "0")], "");
1645 assert_eq!(status().await.status, StatusCode::MULTI_STATUS);
1646 let uid = user_id(&admin, ALICE).await;
1647 let r = admin
1648 .put_json(
1649 &format!("/api/admin/users/{uid}"),
1650 &json!({"active": false}),
1651 )
1652 .await;
1653 assert_eq!(r.status, StatusCode::OK);
1654 assert_eq!(status().await.status, StatusCode::NOT_FOUND);
1655}
1656
1657#[tokio::test]
1658async fn pruned_tombstones_invalidate_old_sync_tokens() {
1659 let (env, auth) = setup().await;
1660 let sync = |token: &str| {
1661 format!(
1662 r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:prop><d:getetag/></d:prop></d:sync-collection>"#
1663 )
1664 };
1665 let obj = |n: &str| format!("{CAL}{n}.ics");
1666 let todo = |n: &str| ics(&TODO.replace("UID:todo", &format!("UID:{n}")));
1667 for n in ["a", "b", "c"] {
1668 put(&env, &auth, &obj(n), &todo(n)).await;
1669 }
1670 let old = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync("")).await);
1671 for n in ["a", "b", "c"] {
1672 req(&env, "DELETE", &obj(n), &auth, &[], "").await;
1673 }
1674 let mid = sync_token_of(&req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await);
1675 put(&env, &auth, &obj("d"), &todo("d")).await;
1676 req(&env, "DELETE", &obj("d"), &auth, &[], "").await;
1677
1678 env.state.db.pim_prune(2).await.unwrap();
1679 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&old)).await;
1680 assert_eq!(r.status, StatusCode::FORBIDDEN);
1681 assert!(error_condition(&r).is(DAV, "valid-sync-token"));
1682 let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&mid)).await;
1683 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1684
1685 let parse = |t: &str| {
1686 let (id, seq) = t.rsplit_once(':').unwrap().1.rsplit_once('-').unwrap();
1687 (id.parse::<i64>().unwrap(), seq.parse::<i64>().unwrap())
1688 };
1689 let (id, old_seq) = parse(&old);
1690 let (_, mid_seq) = parse(&mid);
1691 let db = &env.state.db;
1692 assert_eq!(db.pim_changes(id, Some(old_seq)).await.unwrap(), None);
1693 assert!(db.pim_changes(id, Some(mid_seq)).await.unwrap().is_some());
1694}
1695
1696#[tokio::test]
1697async fn mkcol_checks_target_and_values() {
1698 let (env, auth) = setup().await;
1699 let r = req(&env, "MKCOL", &format!("{CAL}x.ics"), &auth, &[], "").await;
1700 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
1701 let body = |color: &str| {
1702 format!(
1703 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>"#
1704 )
1705 };
1706 let work = "/pim/calendars/alice/work/";
1707 let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("red")).await;
1708 assert_eq!(r.status, StatusCode::FORBIDDEN);
1709 let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("#FF8800AA")).await;
1710 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1711}
1712
1713async fn alice_pid(env: &Env) -> i64 {
1714 let user = env
1715 .state
1716 .db
1717 .find_user_by_name(ALICE)
1718 .await
1719 .unwrap()
1720 .unwrap();
1721 env.state.db.principal_of(user.id).await.unwrap()
1722}
1723
1724#[tokio::test]
1725async fn a_taken_slug_leaves_the_first_collection_alone() {
1726 use server::db::{PimCollection, PimKind};
1727 let (env, _) = setup().await;
1728 let db = &env.state.db;
1729 let pid = alice_pid(&env).await;
1730 let make = |name: &str| PimCollection {
1731 slug: "work".into(),
1732 displayname: Some(name.into()),
1733 components: "VEVENT".into(),
1734 ..Default::default()
1735 };
1736 let kind = PimKind::Calendar;
1737 assert!(
1738 db.pim_create_collection(pid, kind, &make("First"), &[])
1739 .await
1740 .unwrap()
1741 );
1742 assert!(
1743 !db.pim_create_collection(pid, kind, &make("Second"), &[])
1744 .await
1745 .unwrap()
1746 );
1747 let kept = db.pim_collection(pid, kind, "work").await.unwrap().unwrap();
1748 assert_eq!(kept.displayname.as_deref(), Some("First"));
1749}
1750
1751#[tokio::test]
1752async fn moving_an_object_onto_itself_changes_nothing() {
1753 use server::db::{PimCollection, PimKind, PimObject, PimOp, PimWrite, Precondition};
1754 let (env, _) = setup().await;
1755 let db = &env.state.db;
1756 let pid = alice_pid(&env).await;
1757 db.pim_ensure_defaults(pid).await.unwrap();
1758 let col: PimCollection = db
1759 .pim_collection(pid, PimKind::Calendar, "default")
1760 .await
1761 .unwrap()
1762 .unwrap();
1763 let obj = PimObject {
1764 name: "a.ics".into(),
1765 uid: "a".into(),
1766 component: "VEVENT".into(),
1767 etag: "\"e\"".into(),
1768 ..Default::default()
1769 };
1770 db.pim_apply(&[PimOp::Put {
1771 collection_id: col.id,
1772 obj,
1773 data: b"data".to_vec(),
1774 }])
1775 .await
1776 .unwrap();
1777 let before = db
1778 .pim_collection(pid, PimKind::Calendar, "default")
1779 .await
1780 .unwrap()
1781 .unwrap()
1782 .seq;
1783
1784 let none = Precondition {
1785 if_match: None,
1786 if_none_match: None,
1787 };
1788 let r = db
1789 .pim_move_object(col.id, "a.ics", col.id, "a.ics", true, &none)
1790 .await
1791 .unwrap();
1792 assert_eq!(r, PimWrite::Updated);
1793 let (_, data) = db.pim_object(col.id, "a.ics").await.unwrap().unwrap();
1794 assert_eq!(data, b"data");
1795 let changes = db.pim_changes(col.id, Some(before)).await.unwrap().unwrap();
1796 assert!(changes.is_empty(), "{changes:?}");
1797}
1798
1799#[tokio::test]
1800async fn a_deleted_user_gets_the_login_challenge() {
1801 let env = Env::new().await;
1802 let admin = env.admin().await;
1803 create_user(&admin, ALICE, PW, &[]).await;
1804 let auth = basic(ALICE, PW);
1805 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
1806 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
1807 let id = user_id(&admin, ALICE).await;
1808 let r = admin.delete(&format!("/api/admin/users/{id}")).await;
1809 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1810 let r = req(&env, "PROPFIND", HOME, &auth, &[("depth", "0")], "").await;
1811 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
1812 assert!(r.header("www-authenticate").is_some());
1813}
1814
1815#[tokio::test]
1816async fn mkcol_refuses_long_slugs_and_the_101st_collection() {
1817 use server::db::PimKind;
1818 let (env, auth) = setup().await;
1819 let long = format!("/pim/calendars/alice/{}/", "a".repeat(256));
1820 let r = req(&env, "MKCALENDAR", &long, &auth, &[], "").await;
1821 assert_eq!(r.status, StatusCode::FORBIDDEN);
1822 let ok = format!("/pim/calendars/alice/{}/", "a".repeat(255));
1823 let r = req(&env, "MKCALENDAR", &ok, &auth, &[], "").await;
1824 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1825
1826 // The cap counts the default calendar and the inbox as well.
1827 let mut n = 0;
1828 loop {
1829 let r = req(&env, "MKCALENDAR", &format!("{HOME}c{n}/"), &auth, &[], "").await;
1830 if r.status != StatusCode::CREATED {
1831 assert_eq!(r.status, StatusCode::FORBIDDEN);
1832 break;
1833 }
1834 n += 1;
1835 assert!(n < 100, "no limit");
1836 }
1837 let pid = alice_pid(&env).await;
1838 let all = env.state.db.pim_collections(pid, PimKind::Calendar).await;
1839 assert_eq!(all.unwrap().len(), 100);
1840 let r = req(
1841 &env,
1842 "MKCOL",
1843 "/pim/addressbooks/alice/other/",
1844 &auth,
1845 &[],
1846 "",
1847 )
1848 .await;
1849 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1850}
1851
1852#[tokio::test]
1853async fn multiget_answers_each_href_once_and_caps_the_count() {
1854 let (env, auth) = setup().await;
1855 put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
1856 let multiget = |hrefs: &[String]| {
1857 let hrefs: String = hrefs
1858 .iter()
1859 .map(|h| format!("<d:href>{h}</d:href>"))
1860 .collect();
1861 format!(
1862 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>"#
1863 )
1864 };
1865 let todo = format!("{CAL}todo.ics");
1866 let r = req(
1867 &env,
1868 "REPORT",
1869 CAL,
1870 &auth,
1871 &[],
1872 &multiget(&[todo.clone(), todo.clone()]),
1873 )
1874 .await;
1875 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1876 assert_eq!(statuses(&r).len(), 1, "{}", r.text());
1877
1878 let many: Vec<String> = (0..1001).map(|i| format!("{CAL}{i}.ics")).collect();
1879 let r = req(&env, "REPORT", CAL, &auth, &[], &multiget(&many)).await;
1880 let got = statuses(&r);
1881 assert_eq!(got.len(), 1001);
1882 assert_eq!(got.last().unwrap(), &(CAL.to_string(), Some(507)));
1883}
1884
1885#[tokio::test]
1886async fn a_put_racing_the_collection_delete_never_fails_with_500() {
1887 let (env, auth) = setup().await;
1888 let col = "/pim/calendars/alice/race/";
1889 for i in 0..20 {
1890 let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
1891 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1892 let todo = ics(&TODO.replace("UID:todo", &format!("UID:race{i}")));
1893 let path = format!("{col}{i}.ics");
1894 let (put, delete) = tokio::join!(
1895 req(&env, "PUT", &path, &auth, &[], &todo),
1896 req(&env, "DELETE", col, &auth, &[], ""),
1897 );
1898 assert_eq!(delete.status, StatusCode::NO_CONTENT);
1899 assert!(
1900 [StatusCode::CREATED, StatusCode::CONFLICT].contains(&put.status),
1901 "{}",
1902 put.status
1903 );
1904 }
1905}
1906
1907#[tokio::test]
1908async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500() {
1909 use tower::ServiceExt;
1910 let (env, auth) = setup().await;
1911 let col = "/pim/calendars/alice/race/";
1912 let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
1913 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1914 let patch = r#"<d:propertyupdate xmlns:d="DAV:" xmlns:x="urn:x"><d:set><d:prop>
1915 <x:note>dead</x:note></d:prop></d:set></d:propertyupdate>"#;
1916 let (send, arrive) = tokio::sync::oneshot::channel::<()>();
1917 let body = axum::body::Body::from_stream(futures_util::stream::once(async move {
1918 arrive.await.ok();
1919 Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(patch))
1920 }));
1921 let request = axum::http::Request::builder()
1922 .method("PROPPATCH")
1923 .uri(col)
1924 .header("host", "files.example.com")
1925 .header("authorization", &auth)
1926 .body(body)
1927 .unwrap();
1928 let pending = tokio::spawn(env.app.clone().oneshot(request));
1929 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1930 let r = req(&env, "DELETE", col, &auth, &[], "").await;
1931 assert_eq!(r.status, StatusCode::NO_CONTENT);
1932 send.send(()).unwrap();
1933 let r = pending.await.unwrap().unwrap();
1934 assert_eq!(r.status(), StatusCode::NOT_FOUND);
1935}
1936