pim_schedule.rs
⎇
Raw
1//! Implicit scheduling (RFC 6638) between the accounts and rooms of one
2//! server.
3
4use crate::common::*;
5use axum::http::{Method, StatusCode};
6use pimdav::xml::{self, CALDAV, DAV, Name};
7use serde_json::json;
8use xmltree::Element;
9
10const USERS: [&str; 3] = ["alice", "bob", "carol"];
11const PW: &str = "secret12345";
12
13struct Pim {
14 env: Env,
15 admin: Client,
16}
17
18impl Pim {
19 async fn new() -> Self {
20 let env = Env::new().await;
21 let admin = env.admin().await;
22 for u in USERS {
23 create_user(&admin, u, PW, &[]).await;
24 }
25 Pim { env, admin }
26 }
27
28 async fn req(
29 &self,
30 user: &str,
31 verb: &str,
32 path: &str,
33 extra: &[(&str, &str)],
34 body: &str,
35 ) -> Resp {
36 req(&self.env, verb, path, &basic(user, PW), extra, body).await
37 }
38
39 async fn members(&self, user: &str, collection: &str) -> Vec<String> {
40 members(&self.env, &basic(user, PW), collection).await
41 }
42
43 /// The only object of a user's default calendar.
44 async fn copy(&self, user: &str) -> (String, Resp) {
45 let members = self.members(user, &cal(user)).await;
46 assert_eq!(members.len(), 1, "{members:?}");
47 let href = members[0].clone();
48 let r = self.req(user, "GET", &href, &[], "").await;
49 assert_eq!(r.status, StatusCode::OK);
50 (href, r)
51 }
52
53 async fn inbox(&self, user: &str) -> Vec<String> {
54 let mut out = Vec::new();
55 for href in self
56 .members(user, &format!("/pim/calendars/{user}/inbox/"))
57 .await
58 {
59 out.push(unfold(&self.req(user, "GET", &href, &[], "").await.text()));
60 }
61 out
62 }
63}
64
65fn cal(user: &str) -> String {
66 format!("/pim/calendars/{user}/default/")
67}
68
69/// A parameter of the ATTENDEE property of `who`.
70fn attendee_param(ics: &str, who: &str, param: &str) -> Option<String> {
71 let suffix = format!(":{who}");
72 let unfolded = unfold(ics);
73 let line = unfolded
74 .lines()
75 .find(|l| l.starts_with("ATTENDEE") && l.ends_with(&suffix))?;
76 line.strip_suffix(&suffix)?
77 .split(';')
78 .find_map(|p| p.strip_prefix(&format!("{param}=")).map(str::to_string))
79}
80
81fn meeting(start: &str, attendees: &[&str]) -> String {
82 let attendees: String = attendees
83 .iter()
84 .map(|a| format!("ATTENDEE;RSVP=TRUE:{a}\r\n"))
85 .collect();
86 format!(
87 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:meet-1\r\n\
88 DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\
89 ORGANIZER:{}\r\nATTENDEE;PARTSTAT=ACCEPTED:{}\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n",
90 addr("alice"),
91 addr("alice")
92 )
93}
94
95const ALICE_EVENT: &str = "/pim/calendars/alice/default/meet.ics";
96
97async fn invite(pim: &Pim, attendees: &[&str]) -> Resp {
98 let r = pim
99 .req(
100 "alice",
101 "PUT",
102 ALICE_EVENT,
103 &[],
104 &meeting("20260301T100000Z", attendees),
105 )
106 .await;
107 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
108 r
109}
110
111#[tokio::test]
112async fn discovery_names_inbox_and_outbox() {
113 let pim = Pim::new().await;
114 let r = pim.req("alice", "OPTIONS", "/pim/", &[], "").await;
115 assert!(r.header("dav").unwrap().contains("calendar-auto-schedule"));
116 let body = "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>\
117 <c:schedule-inbox-URL/><c:schedule-outbox-URL/></d:prop></d:propfind>";
118 let r = pim
119 .req("alice", "PROPFIND", "/pim/principals/alice/", &[], body)
120 .await;
121 let text = r.text();
122 assert!(text.contains("/pim/calendars/alice/inbox/"), "{text}");
123 assert!(text.contains("/pim/calendars/alice/outbox/"), "{text}");
124}
125
126#[tokio::test]
127async fn invite_and_answer() {
128 let pim = Pim::new().await;
129 let r = invite(
130 &pim,
131 &[
132 &addr("bob"),
133 &addr("carol"),
134 "mailto:dave@example.com",
135 &addr("nobody"),
136 ],
137 )
138 .await;
139 // The server added SCHEDULE-STATUS, so the stored bytes differ.
140 assert!(r.header("etag").is_none());
141 assert!(r.header("schedule-tag").is_some());
142
143 let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
144 for (who, status) in [
145 (addr("bob"), "1.2"),
146 (addr("carol"), "1.2"),
147 ("mailto:dave@example.com".into(), "5.2"),
148 (addr("nobody"), "3.7"),
149 ] {
150 let got = attendee_param(&org, &who, "SCHEDULE-STATUS");
151 assert_eq!(got.as_deref(), Some(status), "{org}");
152 }
153
154 let (bob_href, got) = pim.copy("bob").await;
155 let bob_copy = unfold(&got.text());
156 let partstat = attendee_param(&bob_copy, &addr("bob"), "PARTSTAT");
157 assert_eq!(partstat.as_deref(), Some("NEEDS-ACTION"), "{bob_copy}");
158 assert!(
159 !bob_copy.contains("METHOD") && !bob_copy.contains("SCHEDULE-STATUS"),
160 "{bob_copy}"
161 );
162 let inbox = pim.inbox("bob").await;
163 assert_eq!(inbox.len(), 1);
164 assert!(inbox[0].contains("METHOD:REQUEST"));
165
166 // bob accepts, conditional on what he read.
167 let alice_tag = pim
168 .req("alice", "GET", ALICE_EVENT, &[], "")
169 .await
170 .header("schedule-tag");
171 let carol_tag = pim.copy("carol").await.1.header("schedule-tag");
172 let tag = got.header("schedule-tag").unwrap();
173 let accepted = bob_copy.replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
174 let r = pim
175 .req(
176 "bob",
177 "PUT",
178 &bob_href,
179 &[("if-schedule-tag-match", &tag)],
180 &accepted,
181 )
182 .await;
183 assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
184 let bob_copy = unfold(&pim.req("bob", "GET", &bob_href, &[], "").await.text());
185 assert!(
186 bob_copy.contains("ORGANIZER;SCHEDULE-STATUS=1.2"),
187 "{bob_copy}"
188 );
189
190 // alice sees the answer; her Schedule-Tag stays, so her pending edit
191 // would still go through.
192 let r = pim.req("alice", "GET", ALICE_EVENT, &[], "").await;
193 assert_eq!(r.header("schedule-tag"), alice_tag);
194 let org = r.text();
195 assert_eq!(
196 attendee_param(&org, &addr("bob"), "SCHEDULE-STATUS").as_deref(),
197 Some("2.0")
198 );
199 assert_eq!(
200 attendee_param(&org, &addr("bob"), "PARTSTAT").as_deref(),
201 Some("ACCEPTED")
202 );
203 let replies = pim.inbox("alice").await;
204 assert_eq!(replies.len(), 1);
205 assert!(replies[0].contains("METHOD:REPLY"));
206
207 // carol's copy learns it quietly: same Schedule-Tag, no inbox entry.
208 let (_, carol) = pim.copy("carol").await;
209 assert_eq!(carol.header("schedule-tag"), carol_tag);
210 let seen = attendee_param(&carol.text(), &addr("bob"), "PARTSTAT");
211 assert_eq!(seen.as_deref(), Some("ACCEPTED"), "{}", carol.text());
212 assert_eq!(pim.inbox("carol").await.len(), 1);
213
214 // Deleting her copy declines for carol.
215 let (carol_href, _) = pim.copy("carol").await;
216 assert_eq!(
217 pim.req("carol", "DELETE", &carol_href, &[], "")
218 .await
219 .status,
220 StatusCode::NO_CONTENT
221 );
222 let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text();
223 assert_eq!(
224 attendee_param(&org, &addr("carol"), "PARTSTAT").as_deref(),
225 Some("DECLINED")
226 );
227}
228
229#[tokio::test]
230async fn organizer_changes_reach_attendees() {
231 let pim = Pim::new().await;
232 invite(&pim, &[&addr("bob"), &addr("carol")]).await;
233 let (bob_href, got) = pim.copy("bob").await;
234 let accepted = unfold(&got.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
235 assert_eq!(
236 pim.req("bob", "PUT", &bob_href, &[], &accepted)
237 .await
238 .status,
239 StatusCode::NO_CONTENT
240 );
241 let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text();
242 assert_eq!(
243 attendee_param(&org, &addr("bob"), "PARTSTAT").as_deref(),
244 Some("ACCEPTED")
245 );
246 let bob_tag = pim.copy("bob").await.1.header("schedule-tag");
247
248 // Moving the meeting asks everyone again.
249 let moved = meeting("20260301T140000Z", &[&addr("bob"), &addr("carol")]);
250 assert_eq!(
251 pim.req("alice", "PUT", ALICE_EVENT, &[], &moved)
252 .await
253 .status,
254 StatusCode::NO_CONTENT
255 );
256 let (_, got) = pim.copy("bob").await;
257 let bob_copy = unfold(&got.text());
258 assert!(bob_copy.contains("DTSTART:20260301T140000Z"), "{bob_copy}");
259 let partstat = attendee_param(&bob_copy, &addr("bob"), "PARTSTAT");
260 assert_eq!(partstat.as_deref(), Some("NEEDS-ACTION"), "{bob_copy}");
261 assert_ne!(got.header("schedule-tag"), bob_tag);
262 assert_eq!(pim.inbox("bob").await.len(), 2);
263
264 // Dropping bob cancels his copy.
265 let without_bob = meeting("20260301T140000Z", &[&addr("carol")]);
266 pim.req("alice", "PUT", ALICE_EVENT, &[], &without_bob)
267 .await;
268 assert!(unfold(&pim.copy("bob").await.1.text()).contains("STATUS:CANCELLED"));
269 assert!(
270 pim.inbox("bob")
271 .await
272 .iter()
273 .any(|m| m.contains("METHOD:CANCEL"))
274 );
275
276 // Deleting the meeting cancels it for carol.
277 assert_eq!(
278 pim.req("alice", "DELETE", ALICE_EVENT, &[], "")
279 .await
280 .status,
281 StatusCode::NO_CONTENT
282 );
283 assert!(unfold(&pim.copy("carol").await.1.text()).contains("STATUS:CANCELLED"));
284}
285
286#[tokio::test]
287async fn attendees_have_limits() {
288 let pim = Pim::new().await;
289 invite(&pim, &[&addr("bob")]).await;
290 let (bob_href, got) = pim.copy("bob").await;
291 let bob_copy = unfold(&got.text());
292
293 let moved = bob_copy.replace("DTSTART:20260301T100000Z", "DTSTART:20260301T120000Z");
294 let r = pim.req("bob", "PUT", &bob_href, &[], &moved).await;
295 assert_eq!(r.status, StatusCode::FORBIDDEN);
296 assert!(error_condition(&r).is(CALDAV, "allowed-attendee-scheduling-object-change"));
297
298 let r = pim
299 .req(
300 "bob",
301 "PUT",
302 &bob_href,
303 &[("if-schedule-tag-match", "\"stale\"")],
304 &bob_copy,
305 )
306 .await;
307 assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
308 let into_inbox = pim
309 .req(
310 "bob",
311 "PUT",
312 "/pim/calendars/bob/inbox/x.ics",
313 &[],
314 &bob_copy,
315 )
316 .await;
317 assert_eq!(into_inbox.status, StatusCode::FORBIDDEN);
318
319 // A silent removal answers nothing.
320 let r = pim
321 .req("bob", "DELETE", &bob_href, &[("schedule-reply", "F")], "")
322 .await;
323 assert_eq!(r.status, StatusCode::NO_CONTENT);
324 let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
325 assert!(!org.contains("DECLINED"), "{org}");
326 assert!(pim.inbox("alice").await.is_empty());
327}
328
329#[tokio::test]
330async fn rooms_receive_bookings() {
331 let pim = Pim::new().await;
332 let r = pim
333 .admin
334 .post_json(
335 "/api/admin/rooms",
336 &json!({"name": "board", "display_name": "Board", "kind": "room"}),
337 )
338 .await;
339 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
340 invite(&pim, &["mailto:board@rooms.dovenest.invalid"]).await;
341 let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text();
342 let room = "mailto:board@rooms.dovenest.invalid";
343 assert_eq!(
344 attendee_param(&org, room, "SCHEDULE-STATUS").as_deref(),
345 Some("1.2"),
346 "{org}"
347 );
348 // Everyone reads a room's bookings.
349 let members = pim.members("bob", "/pim/calendars/board/default/").await;
350 assert_eq!(members.len(), 1, "{members:?}");
351}
352
353const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
354
355/// `days` from now at `hour` UTC, as an iCalendar date-time. Rooms only
356/// check instances from now on.
357fn future(days: i64, hour: u32) -> String {
358 (chrono::Utc::now() + chrono::TimeDelta::days(days))
359 .date_naive()
360 .and_hms_opt(hour, 0, 0)
361 .unwrap()
362 .format("%Y%m%dT%H%M%SZ")
363 .to_string()
364}
365
366fn booking(uid: &str, organizer: &str, start: &str, extra: &str, attendees: &[&str]) -> String {
367 let attendees: String = attendees
368 .iter()
369 .map(|a| format!("ATTENDEE:{a}\r\n"))
370 .collect();
371 format!(
372 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
373 DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\n{extra}\
374 ORGANIZER:{o}\r\nATTENDEE;PARTSTAT=ACCEPTED:{o}\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n",
375 o = addr(organizer),
376 )
377}
378
379impl Pim {
380 async fn room(&self) {
381 let r = self
382 .admin
383 .post_json(
384 "/api/admin/rooms",
385 &json!({"name": "board", "display_name": "Board", "kind": "room"}),
386 )
387 .await;
388 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
389 }
390
391 async fn put_ok(&self, user: &str, path: &str, body: &str) {
392 let r = self.req(user, "PUT", path, &[], body).await;
393 assert!(r.status.is_success(), "{}: {}", r.status, r.text());
394 }
395
396 async fn get(&self, user: &str, path: &str) -> String {
397 unfold(&self.req(user, "GET", path, &[], "").await.text())
398 }
399}
400
401#[tokio::test]
402async fn outbox_answers_free_busy() {
403 let pim = Pim::new().await;
404 let event = |uid: &str, start: &str| {
405 format!(
406 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
407 DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
408 )
409 };
410 pim.put_ok(
411 "bob",
412 &format!("{}busy.ics", cal("bob")),
413 &event("busy", "20260310T100000Z"),
414 )
415 .await;
416 // A calendar marked transparent adds no busy time.
417 let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
418 <c:schedule-calendar-transp><c:transparent/></c:schedule-calendar-transp>\
419 </d:prop></d:set></c:mkcalendar>";
420 let r = pim
421 .req("bob", "MKCALENDAR", "/pim/calendars/bob/side/", &[], mk)
422 .await;
423 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
424 pim.put_ok(
425 "bob",
426 "/pim/calendars/bob/side/x.ics",
427 &event("side", "20260310T120000Z"),
428 )
429 .await;
430 let body = "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>\
431 <c:schedule-calendar-transp/></d:prop></d:propfind>";
432 let r = pim
433 .req("bob", "PROPFIND", "/pim/calendars/bob/side/", &[], body)
434 .await;
435 assert!(r.text().contains("<c:transparent/>"), "{}", r.text());
436
437 let outbox = "/pim/calendars/alice/outbox/";
438 let r = pim.req("alice", "OPTIONS", outbox, &[], "").await;
439 assert!(r.header("allow").unwrap().contains("POST"));
440 let request = |organizer: &str| {
441 format!(
442 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
443 BEGIN:VFREEBUSY\r\nUID:fb\r\nDTSTAMP:20260101T000000Z\r\n\
444 DTSTART:20260310T000000Z\r\nDTEND:20260311T000000Z\r\nORGANIZER:{organizer}\r\n\
445 ATTENDEE:{}\r\nATTENDEE:{}\r\nATTENDEE:mailto:dave@example.com\r\n\
446 END:VFREEBUSY\r\nEND:VCALENDAR\r\n",
447 addr("bob"),
448 addr("nobody"),
449 )
450 };
451 let headers = [("content-type", "text/calendar")];
452 let r = pim
453 .req("alice", "POST", outbox, &headers, &request(&addr("alice")))
454 .await;
455 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
456 let root = Element::parse(r.body.as_slice()).unwrap();
457 let answers: Vec<(String, String, String)> = xml::elements(&root)
458 .map(|resp| {
459 let recipient = xml::child(resp, CALDAV, "recipient").unwrap();
460 let get = |local: &str| {
461 xml::child(resp, CALDAV, local)
462 .map(xml::text)
463 .unwrap_or_default()
464 };
465 (
466 xml::text(xml::child(recipient, DAV, "href").unwrap()),
467 get("request-status"),
468 unfold(&get("calendar-data")),
469 )
470 })
471 .collect();
472 assert_eq!(answers.len(), 3, "{answers:?}");
473 let (_, status, data) = &answers[0];
474 assert!(status.starts_with("2.0"), "{status}");
475 assert!(
476 data.contains("FREEBUSY;FBTYPE=BUSY:20260310T100000Z/20260310T110000Z"),
477 "{data}"
478 );
479 assert!(!data.contains("20260310T120000Z"), "{data}");
480 assert!(answers[1].1.starts_with("3.7"), "{answers:?}");
481 assert!(answers[2].1.starts_with("5.2"), "{answers:?}");
482
483 // Only for the own addresses.
484 let r = pim
485 .req("alice", "POST", outbox, &headers, &request(&addr("bob")))
486 .await;
487 assert_eq!(r.status, StatusCode::FORBIDDEN);
488 assert!(error_condition(&r).is(CALDAV, "organizer-allowed"));
489}
490
491#[tokio::test]
492async fn rooms_answer_from_their_bookings() {
493 let pim = Pim::new().await;
494 pim.room().await;
495 let slot = future(30, 10);
496 let alice_event = format!("{}a1.ics", cal("alice"));
497 pim.put_ok(
498 "alice",
499 &alice_event,
500 &booking("a1", "alice", &slot, "", &[ROOM]),
501 )
502 .await;
503 let org = pim.get("alice", &alice_event).await;
504 assert_eq!(
505 attendee_param(&org, ROOM, "PARTSTAT").as_deref(),
506 Some("ACCEPTED"),
507 "{org}"
508 );
509 assert!(
510 pim.inbox("alice")
511 .await
512 .iter()
513 .any(|m| m.contains("METHOD:REPLY"))
514 );
515
516 // A second meeting in the same slot is turned down.
517 let carol_event = format!("{}c1.ics", cal("carol"));
518 pim.put_ok(
519 "carol",
520 &carol_event,
521 &booking("c1", "carol", &slot, "", &[ROOM]),
522 )
523 .await;
524 let org = pim.get("carol", &carol_event).await;
525 assert_eq!(
526 attendee_param(&org, ROOM, "PARTSTAT").as_deref(),
527 Some("DECLINED"),
528 "{org}"
529 );
530
531 // Cancelling the first frees the slot for the next request.
532 let r = pim.req("alice", "DELETE", &alice_event, &[], "").await;
533 assert_eq!(r.status, StatusCode::NO_CONTENT);
534 let renamed = booking("c1", "carol", &slot, "SUMMARY:Again\r\n", &[ROOM]);
535 pim.put_ok("carol", &carol_event, &renamed).await;
536 let org = pim.get("carol", &carol_event).await;
537 assert_eq!(
538 attendee_param(&org, ROOM, "PARTSTAT").as_deref(),
539 Some("ACCEPTED"),
540 "{org}"
541 );
542
543 // A series declines only the instance that collides.
544 pim.put_ok(
545 "carol",
546 &format!("{}c2.ics", cal("carol")),
547 &booking("c2", "carol", &future(67, 9), "", &[ROOM]),
548 )
549 .await;
550 let bob_event = format!("{}b1.ics", cal("bob"));
551 let weekly = booking(
552 "b1",
553 "bob",
554 &future(60, 9),
555 "RRULE:FREQ=WEEKLY;COUNT=3\r\n",
556 &[ROOM],
557 );
558 pim.put_ok("bob", &bob_event, &weekly).await;
559 let org = pim.get("bob", &bob_event).await;
560 assert!(
561 org.contains(&format!("RECURRENCE-ID:{}", future(67, 9))),
562 "{org}"
563 );
564 let answers = |partstat: &str| {
565 org.lines()
566 .filter(|l| l.ends_with(ROOM) && l.contains(&format!("PARTSTAT={partstat}")))
567 .count()
568 };
569 assert_eq!((answers("ACCEPTED"), answers("DECLINED")), (1, 1), "{org}");
570}
571
572#[tokio::test]
573async fn rooms_check_series_to_their_horizon() {
574 let pim = Pim::new().await;
575 pim.room().await;
576 let org = |user: &'static str, uid: &'static str| {
577 let pim = &pim;
578 async move { pim.get(user, &format!("{}{uid}.ics", cal(user))).await }
579 };
580 let declined = |org: &str| {
581 org.lines()
582 .filter(|l| l.ends_with(ROOM) && l.contains("PARTSTAT=DECLINED"))
583 .count()
584 };
585 let weekly = |uid: &str, who: &str, start: &str, rule: &str| {
586 booking(
587 uid,
588 who,
589 start,
590 &format!("RRULE:FREQ=WEEKLY;{rule}\r\n"),
591 &[ROOM],
592 )
593 };
594 for (uid, start) in [
595 ("c1", future(800, 9)),
596 ("c2", future(800, 14)),
597 ("c3", future(3700, 9)),
598 ] {
599 pim.put_ok(
600 "carol",
601 &format!("{}{uid}.ics", cal("carol")),
602 &booking(uid, "carol", &start, "", &[ROOM]),
603 )
604 .await;
605 }
606
607 // A bounded series is checked to its end, past two years.
608 pim.put_ok(
609 "bob",
610 &format!("{}b1.ics", cal("bob")),
611 &weekly("b1", "bob", &future(786, 9), "COUNT=3"),
612 )
613 .await;
614 assert_eq!(declined(&org("bob", "b1").await), 1);
615 // A series without end is checked for two years only.
616 pim.put_ok(
617 "alice",
618 &format!("{}a1.ics", cal("alice")),
619 &weekly("a1", "alice", &future(786, 14), "INTERVAL=1"),
620 )
621 .await;
622 assert_eq!(declined(&org("alice", "a1").await), 0);
623 // Ten years is the limit even for a bounded series.
624 pim.put_ok(
625 "bob",
626 &format!("{}b2.ics", cal("bob")),
627 &weekly("b2", "bob", &future(3693, 9), "COUNT=2"),
628 )
629 .await;
630 assert_eq!(declined(&org("bob", "b2").await), 0);
631}
632
633#[tokio::test]
634async fn sharees_schedule_only_when_allowed() {
635 let pim = Pim::new().await;
636 let alice = login(&pim.env, "alice", PW).await;
637 let listed = alice.get("/api/pim/collections").await.json();
638 let id = listed
639 .as_array()
640 .unwrap()
641 .iter()
642 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
643 .unwrap()["id"]
644 .as_i64()
645 .unwrap();
646 let shares = format!("/api/pim/collections/{id}/shares");
647 let lend = |mode: &'static str| {
648 let alice = &alice;
649 let shares = &shares;
650 async move {
651 let r = alice
652 .post_json(shares, &json!({"user": "bob", "mode": mode}))
653 .await;
654 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
655 }
656 };
657 lend("rw").await;
658 let shared = format!("/pim/calendars/bob/shared-{id}/");
659 let invite = booking("s1", "alice", "20260401T100000Z", "", &[&addr("carol")]);
660
661 // Plain write access edits, but sends nothing as alice.
662 let r = pim
663 .req("bob", "PUT", &format!("{shared}s1.ics"), &[], &invite)
664 .await;
665 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
666 assert!(error_condition(&r).is(DAV, "need-privileges"));
667 assert!(r.text().contains("schedule-send-invite"), "{}", r.text());
668 let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\
669 DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
670 pim.put_ok("bob", &format!("{shared}p1.ics"), plain).await;
671
672 // With the schedule level, bob invites for alice, and says so.
673 lend("rw+schedule").await;
674 let body = "<d:propfind xmlns:d=\"DAV:\"><d:prop><d:current-user-privilege-set/></d:prop></d:propfind>";
675 let r = pim.req("bob", "PROPFIND", &shared, &[], body).await;
676 assert!(r.text().contains("schedule-send-invite"), "{}", r.text());
677 pim.put_ok("bob", &format!("{shared}s1.ics"), &invite).await;
678 let sent_by = format!("ORGANIZER;SENT-BY=\"{}\":{}", addr("bob"), addr("alice"));
679 let org = pim.get("alice", &format!("{}s1.ics", cal("alice"))).await;
680 assert!(org.contains(&sent_by), "{org}");
681 let (_, copy) = pim.copy("carol").await;
682 assert!(unfold(&copy.text()).contains(&sent_by), "{}", copy.text());
683
684 // An edit that sends nothing is stored as sent: alice's own edit keeps
685 // bob's SENT-BY, and the PUT keeps its ETag.
686 let s1 = format!("{}s1.ics", cal("alice"));
687 let edited = pim.req("alice", "GET", &s1, &[], "").await.text().replacen(
688 "UID:s1\r\n",
689 "UID:s1\r\nX-NOTE:quiet\r\n",
690 1,
691 );
692 let r = pim.req("alice", "PUT", &s1, &[], &edited).await;
693 assert!(r.status.is_success(), "{}", r.text());
694 assert!(r.header("etag").is_some(), "{edited}");
695 assert_eq!(pim.req("alice", "GET", &s1, &[], "").await.text(), edited);
696
697 // Answering for alice needs it too.
698 lend("rw").await;
699 pim.put_ok(
700 "carol",
701 &format!("{}c9.ics", cal("carol")),
702 &booking("c9", "carol", "20260402T100000Z", "", &[&addr("alice")]),
703 )
704 .await;
705 let members = pim.members("alice", &cal("alice")).await;
706 let href = members
707 .iter()
708 .find(|h| !h.ends_with("s1.ics") && !h.ends_with("p1.ics"))
709 .unwrap();
710 let name = href.rsplit('/').next().unwrap();
711 let got = pim.get("alice", href).await;
712 let accepted = got.replace(
713 &format!("PARTSTAT=NEEDS-ACTION:{}", addr("alice")),
714 &format!("PARTSTAT=ACCEPTED:{}", addr("alice")),
715 );
716 assert_ne!(got, accepted, "{got}");
717 let r = pim
718 .req("bob", "PUT", &format!("{shared}{name}"), &[], &accepted)
719 .await;
720 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
721 assert!(r.text().contains("schedule-send-reply"), "{}", r.text());
722 lend("rw+schedule").await;
723 pim.put_ok("bob", &format!("{shared}{name}"), &accepted)
724 .await;
725 let org = pim.get("carol", &format!("{}c9.ics", cal("carol"))).await;
726 assert_eq!(
727 attendee_param(&org, &addr("alice"), "PARTSTAT").as_deref(),
728 Some("ACCEPTED"),
729 "{org}"
730 );
731 let reply = pim.inbox("carol").await;
732 let reply = reply.iter().find(|m| m.contains("METHOD:REPLY")).unwrap();
733 assert!(
734 reply.contains(&format!("SENT-BY=\"{}\"", addr("bob"))),
735 "{reply}"
736 );
737}
738
739#[tokio::test]
740async fn one_resource_per_scheduled_uid() {
741 let pim = Pim::new().await;
742 invite(&pim, &[&addr("bob")]).await;
743 let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
744 let r = pim
745 .req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], mk)
746 .await;
747 assert_eq!(r.status, StatusCode::CREATED);
748 let r = pim
749 .req(
750 "alice",
751 "PUT",
752 "/pim/calendars/alice/work/again.ics",
753 &[],
754 &meeting("20260301T100000Z", &[&addr("bob")]),
755 )
756 .await;
757 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
758 assert!(error_condition(&r).is(CALDAV, "unique-scheduling-object-resource"));
759 assert!(r.text().contains(ALICE_EVENT), "{}", r.text());
760}
761
762#[tokio::test]
763async fn foreign_uids_are_not_overwritten() {
764 let pim = Pim::new().await;
765 pim.room().await;
766 // alice organizes meet-1, keeps a plain event and books the room.
767 invite(&pim, &[&addr("bob")]).await;
768 let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:plain-1\r\n\
769 DTSTAMP:20260101T000000Z\r\nDTSTART:20260310T100000Z\r\nSUMMARY:Mine\r\n\
770 END:VEVENT\r\nEND:VCALENDAR\r\n";
771 let alice_plain = "/pim/calendars/alice/default/plain.ics";
772 pim.put_ok("alice", alice_plain, plain).await;
773 let slot = future(3, 9);
774 pim.put_ok(
775 "alice",
776 "/pim/calendars/alice/default/book.ics",
777 &booking("book-1", "alice", &slot, "", &[ROOM]),
778 )
779 .await;
780 let room_copy = pim.members("bob", "/pim/calendars/board/default/").await;
781 assert_eq!(room_copy.len(), 1, "{room_copy:?}");
782 let before = [
783 pim.get("alice", ALICE_EVENT).await,
784 pim.get("alice", alice_plain).await,
785 pim.get("bob", &room_copy[0]).await,
786 ];
787 let alice_inbox = pim.inbox("alice").await.len();
788
789 // carol reuses those UIDs as organizer, inviting alice and the room.
790 let other = future(5, 14);
791 for (uid, to) in [
792 ("meet-1", addr("alice")),
793 ("plain-1", addr("alice")),
794 ("book-1", ROOM.to_string()),
795 ] {
796 let path = format!("/pim/calendars/carol/default/{uid}.ics");
797 pim.put_ok("carol", &path, &booking(uid, "carol", &other, "", &[&to]))
798 .await;
799 let sent = pim.get("carol", &path).await;
800 assert_eq!(
801 attendee_param(&sent, &to, "SCHEDULE-STATUS").as_deref(),
802 Some("3.8"),
803 "{sent}"
804 );
805 // Removing the attendee would cancel for them.
806 let r = pim.req("carol", "DELETE", &path, &[], "").await;
807 assert_eq!(r.status, StatusCode::NO_CONTENT);
808 }
809 // carol brings in a copy that names alice as organizer of plain-1 and
810 // answers it: alice's plain event takes no reply.
811 let fake = plain.replace(
812 "SUMMARY:Mine\r\n",
813 &format!(
814 "ORGANIZER:{}\r\nATTENDEE:{}\r\n",
815 addr("alice"),
816 addr("carol")
817 ),
818 );
819 let fake_path = "/pim/calendars/carol/default/fake.ics";
820 pim.put_ok("carol", fake_path, &fake).await;
821 let answered = fake.replace(
822 &format!("ATTENDEE:{}", addr("carol")),
823 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{}", addr("carol")),
824 );
825 pim.put_ok("carol", fake_path, &answered).await;
826
827 let after = [
828 pim.get("alice", ALICE_EVENT).await,
829 pim.get("alice", alice_plain).await,
830 pim.get("bob", &room_copy[0]).await,
831 ];
832 assert_eq!(before, after);
833 assert_eq!(pim.inbox("alice").await.len(), alice_inbox);
834
835 // The real organizer still reaches an attendee who deleted their copy.
836 let (bob_copy, _) = pim.copy("bob").await;
837 let r = pim
838 .req("bob", "DELETE", &bob_copy, &[("schedule-reply", "F")], "")
839 .await;
840 assert_eq!(r.status, StatusCode::NO_CONTENT);
841 let moved = pim
842 .get("alice", ALICE_EVENT)
843 .await
844 .replace("DTSTART:20260301T100000Z", "DTSTART:20260302T100000Z");
845 pim.put_ok("alice", ALICE_EVENT, &moved).await;
846 let (_, again) = pim.copy("bob").await;
847 assert!(
848 again.text().contains("20260302T100000Z"),
849 "{}",
850 again.text()
851 );
852}
853
854#[tokio::test]
855async fn move_stays_with_one_owner() {
856 let pim = Pim::new().await;
857 let alice = login(&pim.env, "alice", PW).await;
858 let listed = alice.get("/api/pim/collections").await.json();
859 let id = listed
860 .as_array()
861 .unwrap()
862 .iter()
863 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
864 .unwrap()["id"]
865 .as_i64()
866 .unwrap();
867 let r = alice
868 .post_json(
869 &format!("/api/pim/collections/{id}/shares"),
870 &json!({"user": "bob", "mode": "rw"}),
871 )
872 .await;
873 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
874 let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
875 let r = pim
876 .req("bob", "MKCALENDAR", "/pim/calendars/bob/work/", &[], mk)
877 .await;
878 assert_eq!(r.status, StatusCode::CREATED);
879 let own = "/pim/calendars/bob/default/m.ics";
880 pim.put_ok(
881 "bob",
882 own,
883 &booking("m1", "bob", "20260401T100000Z", "", &[&addr("carol")]),
884 )
885 .await;
886 let to = |path: &str| format!("http://localhost{path}");
887 // Into alice's lent calendar: refused, the object stays.
888 let lent = format!("/pim/calendars/bob/shared-{id}/m.ics");
889 let r = pim
890 .req("bob", "MOVE", own, &[("destination", &to(&lent))], "")
891 .await;
892 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
893 assert_eq!(pim.members("bob", &cal("bob")).await.len(), 1);
894 // Between bob's own calendars as before.
895 let work = "/pim/calendars/bob/work/m.ics";
896 let r = pim
897 .req("bob", "MOVE", own, &[("destination", &to(work))], "")
898 .await;
899 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
900}
901
902#[tokio::test]
903async fn deleting_a_calendar_cancels_its_meetings() {
904 let pim = Pim::new().await;
905 let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
906 let r = pim
907 .req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], mk)
908 .await;
909 assert_eq!(r.status, StatusCode::CREATED);
910 pim.put_ok(
911 "alice",
912 "/pim/calendars/alice/work/m.ics",
913 &booking("w1", "alice", "20260401T100000Z", "", &[&addr("bob")]),
914 )
915 .await;
916 let r = pim
917 .req("alice", "DELETE", "/pim/calendars/alice/work/", &[], "")
918 .await;
919 assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
920 let r = pim
921 .req(
922 "alice",
923 "PROPFIND",
924 "/pim/calendars/alice/work/",
925 &[("depth", "0")],
926 "",
927 )
928 .await;
929 assert_eq!(r.status, StatusCode::NOT_FOUND);
930 let (_, copy) = pim.copy("bob").await;
931 assert!(copy.text().contains("STATUS:CANCELLED"), "{}", copy.text());
932}
933
934#[tokio::test]
935async fn invitations_go_to_the_chosen_calendar() {
936 let pim = Pim::new().await;
937 let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"/>";
938 let r = pim
939 .req("bob", "MKCALENDAR", "/pim/calendars/bob/work/", &[], mk)
940 .await;
941 assert_eq!(r.status, StatusCode::CREATED);
942 let inbox = "/pim/calendars/bob/inbox/";
943 let set = |href: &str| {
944 format!(
945 "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
946 <c:schedule-default-calendar-URL><d:href>{href}</d:href></c:schedule-default-calendar-URL>\
947 </d:prop></d:set></d:propertyupdate>"
948 )
949 };
950 // Not one of bob's calendars: refused with the RFC 6638 precondition.
951 for bad in [
952 "/pim/calendars/alice/default/",
953 "/pim/calendars/bob/inbox/",
954 "/pim/calendars/bob/birthdays/",
955 ] {
956 let r = pim.req("bob", "PROPPATCH", inbox, &[], &set(bad)).await;
957 assert_eq!(r.status, StatusCode::MULTI_STATUS);
958 assert!(
959 r.text().contains("valid-schedule-default-calendar-URL"),
960 "{bad}: {}",
961 r.text()
962 );
963 }
964 let r = pim
965 .req(
966 "bob",
967 "PROPPATCH",
968 inbox,
969 &[],
970 &set("/pim/calendars/bob/work/"),
971 )
972 .await;
973 assert_eq!(r.status, StatusCode::MULTI_STATUS);
974 assert!(r.text().contains("200"), "{}", r.text());
975 let props = "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop>\
976 <c:schedule-default-calendar-URL/></d:prop></d:propfind>";
977 let r = pim
978 .req("bob", "PROPFIND", inbox, &[("depth", "0")], props)
979 .await;
980 assert!(
981 r.text().contains("/pim/calendars/bob/work/"),
982 "{}",
983 r.text()
984 );
985
986 invite(&pim, &[&addr("bob")]).await;
987 assert_eq!(
988 pim.members("bob", "/pim/calendars/bob/work/").await.len(),
989 1
990 );
991 assert!(pim.members("bob", &cal("bob")).await.is_empty());
992 // The chosen calendar is the one that must stay.
993 let r = pim
994 .req("bob", "DELETE", "/pim/calendars/bob/work/", &[], "")
995 .await;
996 assert_eq!(r.status, StatusCode::FORBIDDEN);
997 assert!(error_condition(&r).is(CALDAV, "default-calendar-needed"));
998 // Removing the property goes back to the oldest calendar.
999 let remove = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:remove><d:prop>\
1000 <c:schedule-default-calendar-URL/></d:prop></d:remove></d:propertyupdate>";
1001 let r = pim.req("bob", "PROPPATCH", inbox, &[], remove).await;
1002 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1003 let r = pim
1004 .req("bob", "PROPFIND", inbox, &[("depth", "0")], props)
1005 .await;
1006 assert!(
1007 r.text().contains("/pim/calendars/bob/default/"),
1008 "{}",
1009 r.text()
1010 );
1011}
1012
1013#[tokio::test]
1014async fn replies_need_a_listed_attendee() {
1015 let pim = Pim::new().await;
1016 invite(&pim, &[&addr("bob")]).await;
1017 // carol brings in her own copy of alice's meeting, then answers it.
1018 let copy = meeting("20260301T100000Z", &[&addr("carol")]);
1019 let path = format!("{}meet.ics", cal("carol"));
1020 pim.put_ok("carol", &path, &copy).await;
1021 let answered = copy.replace("ATTENDEE;RSVP=TRUE:", "ATTENDEE;PARTSTAT=ACCEPTED:");
1022 pim.put_ok("carol", &path, &answered).await;
1023 assert!(
1024 pim.get("carol", &path)
1025 .await
1026 .contains("ORGANIZER;SCHEDULE-STATUS=3.8")
1027 );
1028 assert!(pim.inbox("alice").await.is_empty());
1029 let org = pim.get("alice", ALICE_EVENT).await;
1030 assert!(!org.contains(&addr("carol")), "{org}");
1031}
1032
1033#[tokio::test]
1034async fn move_does_not_overwrite_a_meeting() {
1035 let pim = Pim::new().await;
1036 invite(&pim, &[&addr("bob")]).await;
1037 let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\
1038 DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
1039 let from = format!("{}p1.ics", cal("alice"));
1040 pim.put_ok("alice", &from, plain).await;
1041 let r = pim
1042 .req("alice", "MOVE", &from, &[("destination", ALICE_EVENT)], "")
1043 .await;
1044 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1045 assert!(pim.get("alice", ALICE_EVENT).await.contains("UID:meet-1"));
1046}
1047
1048#[tokio::test]
1049async fn imports_on_a_plain_loan_skip_meetings() {
1050 let pim = Pim::new().await;
1051 invite(&pim, &[&addr("bob")]).await;
1052 let alice = login(&pim.env, "alice", PW).await;
1053 let listed = alice.get("/api/pim/collections").await.json();
1054 let id = listed
1055 .as_array()
1056 .unwrap()
1057 .iter()
1058 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
1059 .unwrap()["id"]
1060 .as_i64()
1061 .unwrap();
1062 let r = alice
1063 .post_json(
1064 &format!("/api/pim/collections/{id}/shares"),
1065 &json!({"user": "carol", "mode": "rw"}),
1066 )
1067 .await;
1068 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1069
1070 let carol = login(&pim.env, "carol", PW).await;
1071 let moved = meeting("20260302T100000Z", &[]);
1072 let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\
1073 DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
1074 let r = carol
1075 .raw(
1076 Method::POST,
1077 &format!("/api/pim/collections/{id}/import"),
1078 &[],
1079 format!("{moved}{plain}").into_bytes(),
1080 )
1081 .await;
1082 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1083 let result = r.json();
1084 assert_eq!(
1085 (result["created"].as_i64(), result["updated"].as_i64()),
1086 (Some(1), Some(0))
1087 );
1088 assert_eq!(result["skipped"][0]["reason"], "need-privileges");
1089 let org = pim.get("alice", ALICE_EVENT).await;
1090 assert!(org.contains("DTSTART:20260301T100000Z"), "{org}");
1091}
1092
1093#[tokio::test]
1094async fn deleting_a_user_forgets_an_address_split_by_a_fold() {
1095 let pim = Pim::new().await;
1096 // CLIENT: no copy for alice, so only the address rewrite reaches it.
1097 let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:fold-1\r\n\
1098 DTSTAMP:20260101T000000Z\r\nDTSTART:20260301T100000Z\r\nDURATION:PT1H\r\n\
1099 ORGANIZER:mailto:bob@dovenest.invalid\r\n\
1100 ATTENDEE;SCHEDULE-AGENT=CLIENT:mailto:al\r\n ice@dovenest.invalid\r\n\
1101 END:VEVENT\r\nEND:VCALENDAR\r\n";
1102 let href = "/pim/calendars/bob/default/fold.ics";
1103 let r = pim.req("bob", "PUT", href, &[], ics).await;
1104 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1105 let id = user_id(&pim.admin, "alice").await;
1106 let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await;
1107 assert_eq!(r.status, StatusCode::OK);
1108 let copy = unfold(&pim.req("bob", "GET", href, &[], "").await.text());
1109 assert!(!copy.contains("alice@dovenest.invalid"), "{copy}");
1110 assert!(copy.contains("@deleted."), "{copy}");
1111}
1112
1113#[tokio::test]
1114async fn deleting_an_organizer_cancels_and_forgets_it() {
1115 let pim = Pim::new().await;
1116 invite(&pim, &[&addr("bob")]).await;
1117 let id = user_id(&pim.admin, "alice").await;
1118 let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await;
1119 assert_eq!(r.status, StatusCode::OK);
1120 let (_, copy) = pim.copy("bob").await;
1121 let copy = unfold(&copy.text());
1122 assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
1123 assert!(!copy.contains("alice@dovenest.invalid"), "{copy}");
1124 assert!(copy.contains("@deleted."), "{copy}");
1125 let inbox = pim.inbox("bob").await;
1126 assert!(!inbox.is_empty());
1127 assert!(
1128 inbox.iter().all(|m| !m.contains("alice@dovenest.invalid")),
1129 "{inbox:?}"
1130 );
1131}
1132
1133#[tokio::test]
1134async fn deleting_a_disabled_user_retracts_its_meetings() {
1135 let disable_and_delete = async |pim: &Pim, user: &str| {
1136 let id = user_id(&pim.admin, user).await;
1137 let r = pim
1138 .admin
1139 .put_json(
1140 &format!("/api/admin/users/{id}"),
1141 &json!({ "active": false }),
1142 )
1143 .await;
1144 assert_eq!(r.status, StatusCode::OK);
1145 let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await;
1146 assert_eq!(r.status, StatusCode::OK);
1147 };
1148 let pim = Pim::new().await;
1149 invite(&pim, &[&addr("bob")]).await;
1150 disable_and_delete(&pim, "bob").await;
1151 let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
1152 assert!(org.contains("PARTSTAT=DECLINED"), "{org}");
1153 assert!(
1154 pim.inbox("alice")
1155 .await
1156 .iter()
1157 .any(|m| m.contains("METHOD:REPLY")),
1158 "the decline reaches the organizer's inbox"
1159 );
1160
1161 let pim = Pim::new().await;
1162 invite(&pim, &[&addr("bob")]).await;
1163 disable_and_delete(&pim, "alice").await;
1164 assert!(
1165 pim.inbox("bob")
1166 .await
1167 .iter()
1168 .any(|m| m.contains("METHOD:CANCEL")),
1169 "the cancel reaches the attendee's inbox"
1170 );
1171}
1172
1173#[tokio::test]
1174async fn a_delete_that_matches_nothing_writes_nothing() {
1175 use server::db::{PimObject, PimOp};
1176 let pim = Pim::new().await;
1177 let db = &pim.env.state.db;
1178 let pid = db
1179 .principal_of(user_id(&pim.admin, "bob").await)
1180 .await
1181 .unwrap();
1182 db.pim_ensure_defaults(pid).await.unwrap();
1183 let cal = db
1184 .pim_collection(pid, server::db::PimKind::Calendar, "default")
1185 .await
1186 .unwrap()
1187 .unwrap();
1188 let ops = [PimOp::Put {
1189 collection_id: cal.id,
1190 obj: PimObject {
1191 name: "x.ics".into(),
1192 uid: "x".into(),
1193 component: "VEVENT".into(),
1194 etag: "\"e\"".into(),
1195 ..Default::default()
1196 },
1197 data: b"x".to_vec(),
1198 }];
1199 assert!(!db.delete_user(i64::MAX, &ops).await.unwrap());
1200 assert!(!db.delete_room(i64::MAX, &ops).await.unwrap());
1201 assert!(db.pim_object(cal.id, "x.ics").await.unwrap().is_none());
1202}
1203
1204#[tokio::test]
1205async fn find_uid_prefers_the_scheduling_copy() {
1206 use server::db::{PimCollection, PimKind, PimObject, PimOp};
1207 let pim = Pim::new().await;
1208 let db = &pim.env.state.db;
1209 let pid = db
1210 .principal_of(user_id(&pim.admin, "bob").await)
1211 .await
1212 .unwrap();
1213 db.pim_ensure_defaults(pid).await.unwrap();
1214 let work = PimCollection {
1215 slug: "work".into(),
1216 components: "VEVENT".into(),
1217 ..Default::default()
1218 };
1219 assert!(
1220 db.pim_create_collection(pid, PimKind::Calendar, &work, &[])
1221 .await
1222 .unwrap()
1223 );
1224 let default = db
1225 .pim_collection(pid, PimKind::Calendar, "default")
1226 .await
1227 .unwrap()
1228 .unwrap();
1229 let work = db
1230 .pim_collection(pid, PimKind::Calendar, "work")
1231 .await
1232 .unwrap()
1233 .unwrap();
1234 let put = |collection_id: i64, name: &str, schedule_tag: Option<&str>| PimOp::Put {
1235 collection_id,
1236 obj: PimObject {
1237 name: name.into(),
1238 uid: "meet-1".into(),
1239 component: "VEVENT".into(),
1240 etag: "\"e\"".into(),
1241 schedule_tag: schedule_tag.map(Into::into),
1242 ..Default::default()
1243 },
1244 data: b"x".to_vec(),
1245 };
1246 // The plain copy is older, so only the schedule tag can rank the other first.
1247 db.pim_apply(&[put(default.id, "plain.ics", None)])
1248 .await
1249 .unwrap();
1250 db.pim_apply(&[put(work.id, "meeting.ics", Some("\"s\""))])
1251 .await
1252 .unwrap();
1253
1254 let (id, obj, _) = db.pim_find_uid(pid, "meet-1").await.unwrap().unwrap();
1255 assert_eq!((id, obj.name.as_str()), (work.id, "meeting.ics"));
1256}
1257
1258#[tokio::test]
1259async fn free_busy_answers_each_principal_once_and_caps_the_attendees() {
1260 let pim = Pim::new().await;
1261 let mut attendees = vec![addr("bob"), "/pim/principals/bob/".to_string()];
1262 attendees.extend((0..100).map(|i| addr(&format!("nobody{i}"))));
1263 let statuses = free_busy_statuses(&pim, &attendees).await;
1264 assert_eq!(statuses.len(), 102);
1265 assert!(statuses[0].starts_with("2.0") && statuses[1].starts_with("2.0"));
1266 assert!(statuses[2].starts_with("3.7"), "{statuses:?}");
1267 assert!(statuses[101].starts_with("5.1"), "{statuses:?}");
1268}
1269
1270/// The REQUEST-STATUS per attendee of alice's free-busy request.
1271async fn free_busy_statuses(pim: &Pim, attendees: &[String]) -> Vec<String> {
1272 let lines: String = attendees
1273 .iter()
1274 .map(|a| format!("ATTENDEE:{a}\r\n"))
1275 .collect();
1276 let body = format!(
1277 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
1278 BEGIN:VFREEBUSY\r\nUID:fb\r\nDTSTAMP:20260101T000000Z\r\n\
1279 DTSTART:20260310T000000Z\r\nDTEND:20260311T000000Z\r\nORGANIZER:{}\r\n\
1280 {lines}END:VFREEBUSY\r\nEND:VCALENDAR\r\n",
1281 addr("alice")
1282 );
1283 let r = pim
1284 .req(
1285 "alice",
1286 "POST",
1287 "/pim/calendars/alice/outbox/",
1288 &[("content-type", "text/calendar")],
1289 &body,
1290 )
1291 .await;
1292 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1293 let root = Element::parse(r.body.as_slice()).unwrap();
1294 xml::elements(&root)
1295 .map(|resp| xml::text(xml::child(resp, CALDAV, "request-status").unwrap()))
1296 .collect()
1297}
1298
1299#[tokio::test]
1300async fn a_disabled_attendee_keeps_getting_updates_and_cancels() {
1301 let pim = Pim::new().await;
1302 invite(&pim, &[&addr("bob")]).await;
1303 let bob = user_id(&pim.admin, "bob").await;
1304 let set_active = async |active: bool| {
1305 let r = pim
1306 .admin
1307 .put_json(
1308 &format!("/api/admin/users/{bob}"),
1309 &json!({ "active": active }),
1310 )
1311 .await;
1312 assert_eq!(r.status, StatusCode::OK);
1313 };
1314 set_active(false).await;
1315 // Free-busy treats bob as unknown while he is disabled.
1316 let statuses = free_busy_statuses(&pim, &[addr("bob")]).await;
1317 assert!(statuses[0].starts_with("3.7"), "{statuses:?}");
1318 let moved = meeting("20260302T100000Z", &[&addr("bob")]);
1319 let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &moved).await;
1320 assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
1321 let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
1322 assert!(
1323 attendee_param(&org, &addr("bob"), "SCHEDULE-STATUS").is_some_and(|s| s.starts_with('1')),
1324 "{org}"
1325 );
1326 let r = pim.req("alice", "DELETE", ALICE_EVENT, &[], "").await;
1327 assert_eq!(r.status, StatusCode::NO_CONTENT);
1328
1329 set_active(true).await;
1330 let (_, copy) = pim.copy("bob").await;
1331 let copy = unfold(&copy.text());
1332 assert!(copy.contains("DTSTART:20260302T100000Z"), "{copy}");
1333 assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
1334}
1335
1336#[tokio::test]
1337async fn a_plain_event_with_the_same_uid_does_not_block_a_meeting() {
1338 let pim = Pim::new().await;
1339 let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:meet-1\r\n\
1340 DTSTAMP:20260101T000000Z\r\nDTSTART:20260301T100000Z\r\nDURATION:PT1H\r\n\
1341 END:VEVENT\r\nEND:VCALENDAR\r\n";
1342 pim.put_ok("alice", &format!("{}plain.ics", cal("alice")), plain)
1343 .await;
1344 let r = pim
1345 .req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], "")
1346 .await;
1347 assert_eq!(r.status, StatusCode::CREATED);
1348 let r = pim
1349 .req(
1350 "alice",
1351 "PUT",
1352 "/pim/calendars/alice/work/meet.ics",
1353 &[],
1354 &meeting("20260301T100000Z", &[&addr("bob")]),
1355 )
1356 .await;
1357 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
1358}
1359
1360#[tokio::test]
1361async fn a_quiet_import_keeps_the_attendees_answers() {
1362 let pim = Pim::new().await;
1363 let past = meeting("20240301T100000Z", &[&addr("bob")]);
1364 pim.put_ok("alice", ALICE_EVENT, &past).await;
1365 let (bob_href, _) = pim.copy("bob").await;
1366 let r = pim.req("bob", "DELETE", &bob_href, &[], "").await;
1367 assert_eq!(r.status, StatusCode::NO_CONTENT);
1368 let declined = |org: &str| attendee_param(org, &addr("bob"), "PARTSTAT");
1369 let org = pim.get("alice", ALICE_EVENT).await;
1370 assert_eq!(declined(&org).as_deref(), Some("DECLINED"), "{org}");
1371 let sent = pim.inbox("bob").await.len();
1372
1373 // An old export claims bob accepted. The meeting is over, so nothing is
1374 // sent, but bob's real answer stays.
1375 let alice = login(&pim.env, "alice", PW).await;
1376 let listed = alice.get("/api/pim/collections").await.json();
1377 let id = listed
1378 .as_array()
1379 .unwrap()
1380 .iter()
1381 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
1382 .unwrap()["id"]
1383 .as_i64()
1384 .unwrap();
1385 let export = past.replace("ATTENDEE;RSVP=TRUE:", "ATTENDEE;PARTSTAT=ACCEPTED:");
1386 let r = alice
1387 .raw(
1388 Method::POST,
1389 &format!("/api/pim/collections/{id}/import"),
1390 &[],
1391 export.into_bytes(),
1392 )
1393 .await;
1394 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1395 assert_eq!(r.json()["updated"], 1, "{}", r.text());
1396 let org = pim.get("alice", ALICE_EVENT).await;
1397 assert_eq!(declined(&org).as_deref(), Some("DECLINED"), "{org}");
1398 assert_eq!(pim.inbox("bob").await.len(), sent);
1399}
1400
1401#[tokio::test]
1402async fn a_plain_loan_may_bump_the_sequence() {
1403 let pim = Pim::new().await;
1404 invite(&pim, &[&addr("carol")]).await;
1405 let sent = pim.inbox("carol").await.len();
1406 let alice = login(&pim.env, "alice", PW).await;
1407 let listed = alice.get("/api/pim/collections").await.json();
1408 let id = listed
1409 .as_array()
1410 .unwrap()
1411 .iter()
1412 .find(|c| c["kind"] == "calendar" && c["mode"].is_null())
1413 .unwrap()["id"]
1414 .as_i64()
1415 .unwrap();
1416 let r = alice
1417 .post_json(
1418 &format!("/api/pim/collections/{id}/shares"),
1419 &json!({"user": "bob", "mode": "rw"}),
1420 )
1421 .await;
1422 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1423
1424 // bob's client adds an alarm and bumps SEQUENCE. That invites no one.
1425 let edit = meeting("20260301T100000Z", &[&addr("carol")]).replace(
1426 "END:VEVENT",
1427 "SEQUENCE:1\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\n\
1428 TRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT",
1429 );
1430 pim.put_ok(
1431 "bob",
1432 &format!("/pim/calendars/bob/shared-{id}/meet.ics"),
1433 &edit,
1434 )
1435 .await;
1436 assert!(pim.get("alice", ALICE_EVENT).await.contains("BEGIN:VALARM"));
1437 assert_eq!(pim.inbox("carol").await.len(), sent);
1438
1439 // alice's object keeps the SEQUENCE carol's copy has, so her answer
1440 // still counts.
1441 assert!(!pim.get("alice", ALICE_EVENT).await.contains("SEQUENCE:1"));
1442 let (href, copy) = pim.copy("carol").await;
1443 let accepted = unfold(&copy.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
1444 let r = pim.req("carol", "PUT", &href, &[], &accepted).await;
1445 assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
1446 let org = unfold(&pim.get("alice", ALICE_EVENT).await);
1447 assert!(org.contains("PARTSTAT=ACCEPTED"), "{org}");
1448}
1449
1450#[tokio::test]
1451async fn a_room_organizer_gets_its_inbox_with_the_first_reply() {
1452 let pim = Pim::new().await;
1453 pim.room().await;
1454 let admin = |verb: &str, path: &str, body: &str| {
1455 let auth = basic("admin", "admin1234");
1456 let (verb, path, body) = (verb.to_string(), path.to_string(), body.to_string());
1457 let app = pim.env.app.clone();
1458 async move {
1459 Client::new(app)
1460 .raw(
1461 Method::from_bytes(verb.as_bytes()).unwrap(),
1462 &path,
1463 &[("authorization", auth.as_str())],
1464 body.into_bytes(),
1465 )
1466 .await
1467 }
1468 };
1469 let event = format!(
1470 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:room-1\r\n\
1471 DTSTAMP:20260101T000000Z\r\nDTSTART:{}\r\nDURATION:PT1H\r\n\
1472 ORGANIZER:{ROOM}\r\nATTENDEE:{}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
1473 future(4, 9),
1474 addr("alice")
1475 );
1476 let r = admin("PUT", "/pim/calendars/board/default/r.ics", &event).await;
1477 assert!(r.status.is_success(), "{}: {}", r.status, r.text());
1478 let (href, copy) = pim.copy("alice").await;
1479 let accepted = unfold(&copy.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
1480 let r = pim.req("alice", "PUT", &href, &[], &accepted).await;
1481 assert!(r.status.is_success(), "{}: {}", r.status, r.text());
1482 let db = &pim.env.state.db;
1483 let room = db.pim_principal("board").await.unwrap().unwrap();
1484 let inbox = db
1485 .pim_collection(room.id, server::db::PimKind::Calendar, "inbox")
1486 .await
1487 .unwrap()
1488 .unwrap();
1489 assert_eq!(db.pim_objects(inbox.id).await.unwrap().len(), 1);
1490}
1491
1492#[tokio::test]
1493async fn a_put_may_not_change_the_uid_of_an_object() {
1494 let pim = Pim::new().await;
1495 invite(&pim, &[&addr("bob")]).await;
1496 let other = meeting("20260301T100000Z", &[&addr("bob")]).replace("UID:meet-1", "UID:meet-2");
1497 let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &other).await;
1498 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1499 assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
1500
1501 let card = |uid: &str| format!("BEGIN:VCARD\r\nVERSION:3.0\r\n{uid}FN:A\r\nEND:VCARD\r\n");
1502 let path = "/pim/addressbooks/alice/default/a.vcf";
1503 // A card may gain a UID, but not change it.
1504 pim.put_ok("alice", path, &card("")).await;
1505 pim.put_ok("alice", path, &card("UID:c-1\r\n")).await;
1506 let r = pim
1507 .req("alice", "PUT", path, &[], &card("UID:c-2\r\n"))
1508 .await;
1509 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1510 // Nor drop it, nor change a UID that equals the name.
1511 let r = pim.req("alice", "PUT", path, &[], &card("")).await;
1512 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1513 let named = "/pim/addressbooks/alice/default/b.vcf";
1514 pim.put_ok("alice", named, &card("UID:b.vcf\r\n")).await;
1515 let r = pim
1516 .req("alice", "PUT", named, &[], &card("UID:b-2\r\n"))
1517 .await;
1518 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
1519}
1520
1521#[tokio::test]
1522async fn uris_reach_copies_and_inboxes_unescaped() {
1523 let pim = Pim::new().await;
1524 // The attachment's bytes are UTF-8, which calcard reads as TEXT.
1525 let uris = "X-GOOGLE-CONFERENCE:https://meet/a,b\r\nURL:http://x/a,b;c\r\n\
1526 ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n";
1527 let body = meeting("20260301T100000Z", &[&addr("bob")]).replace(
1528 "SUMMARY:Planning\r\n",
1529 &format!("SUMMARY:Planning\r\n{uris}"),
1530 );
1531 let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &body).await;
1532 assert!(r.status.is_success(), "{}", r.text());
1533 let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
1534 let copy = unfold(&pim.copy("bob").await.1.text());
1535 let inbox = unfold(&pim.inbox("bob").await[0]);
1536 for text in [&org, &copy, &inbox] {
1537 assert!(
1538 text.contains("https://meet/a,b") && text.contains("http://x/a,b;c"),
1539 "{text}"
1540 );
1541 assert!(text.contains(";ENCODING=BASE64"), "{text}");
1542 assert!(text.contains(":SGVsbG8gd29ybGQKbGluZTI=\r\n"), "{text}");
1543 }
1544}
1545
1546#[tokio::test]
1547async fn two_addresses_of_one_attendee_get_one_delivery() {
1548 let pim = Pim::new().await;
1549 let body = meeting("20260301T100000Z", &[&addr("bob"), "/pim/principals/bob/"]);
1550 let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &body).await;
1551 assert!(r.status.is_success(), "{}", r.text());
1552 assert_eq!(pim.inbox("bob").await.len(), 1);
1553 let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
1554 assert_eq!(org.matches("SCHEDULE-STATUS=1.2").count(), 2, "{org}");
1555}
1556