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