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