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