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