//! Implicit scheduling (RFC 6638) between the accounts and rooms of one //! server. use crate::common::*; use axum::http::{Method, StatusCode}; use pimdav::xml::{self, CALDAV, DAV, Name}; use serde_json::json; use xmltree::Element; const USERS: [&str; 3] = ["alice", "bob", "carol"]; const PW: &str = "secret12345"; struct Pim { env: Env, admin: Client, } impl Pim { async fn new() -> Self { let env = Env::new().await; let admin = env.admin().await; for u in USERS { create_user(&admin, u, PW, &[]).await; } Pim { env, admin } } async fn req( &self, user: &str, verb: &str, path: &str, extra: &[(&str, &str)], body: &str, ) -> Resp { let auth = basic(user, PW); let mut headers = vec![("authorization", auth.as_str())]; headers.extend_from_slice(extra); Client::new(self.env.app.clone()) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), path, &headers, body.as_bytes().to_vec(), ) .await } /// The hrefs of the members of a collection. async fn members(&self, user: &str, collection: &str) -> Vec { let r = self .req(user, "PROPFIND", collection, &[("depth", "1")], "") .await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); xml::elements(&root) .map(|resp| xml::text(xml::child(resp, DAV, "href").unwrap())) .filter(|h| h != collection) .collect() } /// The only object of a user's default calendar. async fn copy(&self, user: &str) -> (String, Resp) { let members = self.members(user, &cal(user)).await; assert_eq!(members.len(), 1, "{members:?}"); let href = members[0].clone(); let r = self.req(user, "GET", &href, &[], "").await; assert_eq!(r.status, StatusCode::OK); (href, r) } async fn inbox(&self, user: &str) -> Vec { let mut out = Vec::new(); for href in self .members(user, &format!("/pim/calendars/{user}/inbox/")) .await { out.push(unfold(&self.req(user, "GET", &href, &[], "").await.text())); } out } } fn cal(user: &str) -> String { format!("/pim/calendars/{user}/default/") } fn addr(user: &str) -> String { format!("mailto:{user}@dovenest.invalid") } fn unfold(s: &str) -> String { s.replace("\r\n ", "") } /// A parameter of the ATTENDEE property of `who`. fn attendee_param(ics: &str, who: &str, param: &str) -> Option { let suffix = format!(":{who}"); let unfolded = unfold(ics); let line = unfolded .lines() .find(|l| l.starts_with("ATTENDEE") && l.ends_with(&suffix))?; line.strip_suffix(&suffix)? .split(';') .find_map(|p| p.strip_prefix(&format!("{param}=")).map(str::to_string)) } fn meeting(start: &str, attendees: &[&str]) -> String { let attendees: String = attendees .iter() .map(|a| format!("ATTENDEE;RSVP=TRUE:{a}\r\n")) .collect(); format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:meet-1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\ ORGANIZER:{}\r\nATTENDEE;PARTSTAT=ACCEPTED:{}\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n", addr("alice"), addr("alice") ) } const ALICE_EVENT: &str = "/pim/calendars/alice/default/meet.ics"; async fn invite(pim: &Pim, attendees: &[&str]) -> Resp { let r = pim .req( "alice", "PUT", ALICE_EVENT, &[], &meeting("20260301T100000Z", attendees), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); r } fn error_condition(r: &Resp) -> Name { let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text())); Name::of(xml::elements(&root).next().unwrap()) } #[tokio::test] async fn discovery_names_inbox_and_outbox() { let pim = Pim::new().await; let r = pim.req("alice", "OPTIONS", "/pim/", &[], "").await; assert!(r.header("dav").unwrap().contains("calendar-auto-schedule")); let body = "\ "; let r = pim .req("alice", "PROPFIND", "/pim/principals/alice/", &[], body) .await; let text = r.text(); assert!(text.contains("/pim/calendars/alice/inbox/"), "{text}"); assert!(text.contains("/pim/calendars/alice/outbox/"), "{text}"); } #[tokio::test] async fn invite_and_answer() { let pim = Pim::new().await; let r = invite( &pim, &[ &addr("bob"), &addr("carol"), "mailto:dave@example.com", &addr("nobody"), ], ) .await; // The server added SCHEDULE-STATUS, so the stored bytes differ. assert!(r.header("etag").is_none()); assert!(r.header("schedule-tag").is_some()); let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text()); for (who, status) in [ (addr("bob"), "1.2"), (addr("carol"), "1.2"), ("mailto:dave@example.com".into(), "5.2"), (addr("nobody"), "3.7"), ] { let got = attendee_param(&org, &who, "SCHEDULE-STATUS"); assert_eq!(got.as_deref(), Some(status), "{org}"); } let (bob_href, got) = pim.copy("bob").await; let bob_copy = unfold(&got.text()); let partstat = attendee_param(&bob_copy, &addr("bob"), "PARTSTAT"); assert_eq!(partstat.as_deref(), Some("NEEDS-ACTION"), "{bob_copy}"); assert!( !bob_copy.contains("METHOD") && !bob_copy.contains("SCHEDULE-STATUS"), "{bob_copy}" ); let inbox = pim.inbox("bob").await; assert_eq!(inbox.len(), 1); assert!(inbox[0].contains("METHOD:REQUEST")); // bob accepts, conditional on what he read. let alice_tag = pim .req("alice", "GET", ALICE_EVENT, &[], "") .await .header("schedule-tag"); let carol_tag = pim.copy("carol").await.1.header("schedule-tag"); let tag = got.header("schedule-tag").unwrap(); let accepted = bob_copy.replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED"); let r = pim .req( "bob", "PUT", &bob_href, &[("if-schedule-tag-match", &tag)], &accepted, ) .await; assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text()); let bob_copy = unfold(&pim.req("bob", "GET", &bob_href, &[], "").await.text()); assert!( bob_copy.contains("ORGANIZER;SCHEDULE-STATUS=1.2"), "{bob_copy}" ); // alice sees the answer; her Schedule-Tag stays, so her pending edit // would still go through. let r = pim.req("alice", "GET", ALICE_EVENT, &[], "").await; assert_eq!(r.header("schedule-tag"), alice_tag); let org = r.text(); assert_eq!( attendee_param(&org, &addr("bob"), "SCHEDULE-STATUS").as_deref(), Some("2.0") ); assert_eq!( attendee_param(&org, &addr("bob"), "PARTSTAT").as_deref(), Some("ACCEPTED") ); let replies = pim.inbox("alice").await; assert_eq!(replies.len(), 1); assert!(replies[0].contains("METHOD:REPLY")); // carol's copy learns it quietly: same Schedule-Tag, no inbox entry. let (_, carol) = pim.copy("carol").await; assert_eq!(carol.header("schedule-tag"), carol_tag); let seen = attendee_param(&carol.text(), &addr("bob"), "PARTSTAT"); assert_eq!(seen.as_deref(), Some("ACCEPTED"), "{}", carol.text()); assert_eq!(pim.inbox("carol").await.len(), 1); // Deleting her copy declines for carol. let (carol_href, _) = pim.copy("carol").await; assert_eq!( pim.req("carol", "DELETE", &carol_href, &[], "") .await .status, StatusCode::NO_CONTENT ); let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text(); assert_eq!( attendee_param(&org, &addr("carol"), "PARTSTAT").as_deref(), Some("DECLINED") ); } #[tokio::test] async fn organizer_changes_reach_attendees() { let pim = Pim::new().await; invite(&pim, &[&addr("bob"), &addr("carol")]).await; let (bob_href, got) = pim.copy("bob").await; let accepted = unfold(&got.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED"); assert_eq!( pim.req("bob", "PUT", &bob_href, &[], &accepted) .await .status, StatusCode::NO_CONTENT ); let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text(); assert_eq!( attendee_param(&org, &addr("bob"), "PARTSTAT").as_deref(), Some("ACCEPTED") ); let bob_tag = pim.copy("bob").await.1.header("schedule-tag"); // Moving the meeting asks everyone again. let moved = meeting("20260301T140000Z", &[&addr("bob"), &addr("carol")]); assert_eq!( pim.req("alice", "PUT", ALICE_EVENT, &[], &moved) .await .status, StatusCode::NO_CONTENT ); let (_, got) = pim.copy("bob").await; let bob_copy = unfold(&got.text()); assert!(bob_copy.contains("DTSTART:20260301T140000Z"), "{bob_copy}"); let partstat = attendee_param(&bob_copy, &addr("bob"), "PARTSTAT"); assert_eq!(partstat.as_deref(), Some("NEEDS-ACTION"), "{bob_copy}"); assert_ne!(got.header("schedule-tag"), bob_tag); assert_eq!(pim.inbox("bob").await.len(), 2); // Dropping bob cancels his copy. let without_bob = meeting("20260301T140000Z", &[&addr("carol")]); pim.req("alice", "PUT", ALICE_EVENT, &[], &without_bob) .await; assert!(unfold(&pim.copy("bob").await.1.text()).contains("STATUS:CANCELLED")); assert!( pim.inbox("bob") .await .iter() .any(|m| m.contains("METHOD:CANCEL")) ); // Deleting the meeting cancels it for carol. assert_eq!( pim.req("alice", "DELETE", ALICE_EVENT, &[], "") .await .status, StatusCode::NO_CONTENT ); assert!(unfold(&pim.copy("carol").await.1.text()).contains("STATUS:CANCELLED")); } #[tokio::test] async fn attendees_have_limits() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let (bob_href, got) = pim.copy("bob").await; let bob_copy = unfold(&got.text()); let moved = bob_copy.replace("DTSTART:20260301T100000Z", "DTSTART:20260301T120000Z"); let r = pim.req("bob", "PUT", &bob_href, &[], &moved).await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(CALDAV, "allowed-attendee-scheduling-object-change")); let r = pim .req( "bob", "PUT", &bob_href, &[("if-schedule-tag-match", "\"stale\"")], &bob_copy, ) .await; assert_eq!(r.status, StatusCode::PRECONDITION_FAILED); let into_inbox = pim .req( "bob", "PUT", "/pim/calendars/bob/inbox/x.ics", &[], &bob_copy, ) .await; assert_eq!(into_inbox.status, StatusCode::FORBIDDEN); // A silent removal answers nothing. let r = pim .req("bob", "DELETE", &bob_href, &[("schedule-reply", "F")], "") .await; assert_eq!(r.status, StatusCode::NO_CONTENT); let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text()); assert!(!org.contains("DECLINED"), "{org}"); assert!(pim.inbox("alice").await.is_empty()); } #[tokio::test] async fn rooms_receive_bookings() { let pim = Pim::new().await; let r = pim .admin .post_json( "/api/admin/rooms", &json!({"name": "board", "display_name": "Board", "kind": "room"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); invite(&pim, &["mailto:board@rooms.dovenest.invalid"]).await; let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text(); let room = "mailto:board@rooms.dovenest.invalid"; assert_eq!( attendee_param(&org, room, "SCHEDULE-STATUS").as_deref(), Some("1.2"), "{org}" ); // Everyone reads a room's bookings. let members = pim.members("bob", "/pim/calendars/board/default/").await; assert_eq!(members.len(), 1, "{members:?}"); } const ROOM: &str = "mailto:board@rooms.dovenest.invalid"; /// `days` from now at `hour` UTC, as an iCalendar date-time. Rooms only /// check instances from now on. fn future(days: i64, hour: u32) -> String { (chrono::Utc::now() + chrono::TimeDelta::days(days)) .date_naive() .and_hms_opt(hour, 0, 0) .unwrap() .format("%Y%m%dT%H%M%SZ") .to_string() } fn booking(uid: &str, organizer: &str, start: &str, extra: &str, attendees: &[&str]) -> String { let attendees: String = attendees .iter() .map(|a| format!("ATTENDEE:{a}\r\n")) .collect(); format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\n{extra}\ ORGANIZER:{o}\r\nATTENDEE;PARTSTAT=ACCEPTED:{o}\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n", o = addr(organizer), ) } impl Pim { async fn room(&self) { let r = self .admin .post_json( "/api/admin/rooms", &json!({"name": "board", "display_name": "Board", "kind": "room"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); } async fn put_ok(&self, user: &str, path: &str, body: &str) { let r = self.req(user, "PUT", path, &[], body).await; assert!(r.status.is_success(), "{}: {}", r.status, r.text()); } async fn get(&self, user: &str, path: &str) -> String { unfold(&self.req(user, "GET", path, &[], "").await.text()) } } #[tokio::test] async fn outbox_answers_free_busy() { let pim = Pim::new().await; let event = |uid: &str, start: &str| { format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" ) }; pim.put_ok( "bob", &format!("{}busy.ics", cal("bob")), &event("busy", "20260310T100000Z"), ) .await; // A calendar marked transparent adds no busy time. let mk = "\ \ "; let r = pim .req("bob", "MKCALENDAR", "/pim/calendars/bob/side/", &[], mk) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); pim.put_ok( "bob", "/pim/calendars/bob/side/x.ics", &event("side", "20260310T120000Z"), ) .await; let body = "\ "; let r = pim .req("bob", "PROPFIND", "/pim/calendars/bob/side/", &[], body) .await; assert!(r.text().contains(""), "{}", r.text()); let outbox = "/pim/calendars/alice/outbox/"; let r = pim.req("alice", "OPTIONS", outbox, &[], "").await; assert!(r.header("allow").unwrap().contains("POST")); let request = |organizer: &str| { format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\ BEGIN:VFREEBUSY\r\nUID:fb\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART:20260310T000000Z\r\nDTEND:20260311T000000Z\r\nORGANIZER:{organizer}\r\n\ ATTENDEE:{}\r\nATTENDEE:{}\r\nATTENDEE:mailto:dave@example.com\r\n\ END:VFREEBUSY\r\nEND:VCALENDAR\r\n", addr("bob"), addr("nobody"), ) }; let headers = [("content-type", "text/calendar")]; let r = pim .req("alice", "POST", outbox, &headers, &request(&addr("alice"))) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); let answers: Vec<(String, String, String)> = xml::elements(&root) .map(|resp| { let recipient = xml::child(resp, CALDAV, "recipient").unwrap(); let get = |local: &str| { xml::child(resp, CALDAV, local) .map(xml::text) .unwrap_or_default() }; ( xml::text(xml::child(recipient, DAV, "href").unwrap()), get("request-status"), unfold(&get("calendar-data")), ) }) .collect(); assert_eq!(answers.len(), 3, "{answers:?}"); let (_, status, data) = &answers[0]; assert!(status.starts_with("2.0"), "{status}"); assert!( data.contains("FREEBUSY;FBTYPE=BUSY:20260310T100000Z/20260310T110000Z"), "{data}" ); assert!(!data.contains("20260310T120000Z"), "{data}"); assert!(answers[1].1.starts_with("3.7"), "{answers:?}"); assert!(answers[2].1.starts_with("5.2"), "{answers:?}"); // Only for the own addresses. let r = pim .req("alice", "POST", outbox, &headers, &request(&addr("bob"))) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(CALDAV, "organizer-allowed")); } #[tokio::test] async fn rooms_answer_from_their_bookings() { let pim = Pim::new().await; pim.room().await; let slot = future(30, 10); let alice_event = format!("{}a1.ics", cal("alice")); pim.put_ok( "alice", &alice_event, &booking("a1", "alice", &slot, "", &[ROOM]), ) .await; let org = pim.get("alice", &alice_event).await; assert_eq!( attendee_param(&org, ROOM, "PARTSTAT").as_deref(), Some("ACCEPTED"), "{org}" ); assert!( pim.inbox("alice") .await .iter() .any(|m| m.contains("METHOD:REPLY")) ); // A second meeting in the same slot is turned down. let carol_event = format!("{}c1.ics", cal("carol")); pim.put_ok( "carol", &carol_event, &booking("c1", "carol", &slot, "", &[ROOM]), ) .await; let org = pim.get("carol", &carol_event).await; assert_eq!( attendee_param(&org, ROOM, "PARTSTAT").as_deref(), Some("DECLINED"), "{org}" ); // Cancelling the first frees the slot for the next request. let r = pim.req("alice", "DELETE", &alice_event, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); let renamed = booking("c1", "carol", &slot, "SUMMARY:Again\r\n", &[ROOM]); pim.put_ok("carol", &carol_event, &renamed).await; let org = pim.get("carol", &carol_event).await; assert_eq!( attendee_param(&org, ROOM, "PARTSTAT").as_deref(), Some("ACCEPTED"), "{org}" ); // A series declines only the instance that collides. pim.put_ok( "carol", &format!("{}c2.ics", cal("carol")), &booking("c2", "carol", &future(67, 9), "", &[ROOM]), ) .await; let bob_event = format!("{}b1.ics", cal("bob")); let weekly = booking( "b1", "bob", &future(60, 9), "RRULE:FREQ=WEEKLY;COUNT=3\r\n", &[ROOM], ); pim.put_ok("bob", &bob_event, &weekly).await; let org = pim.get("bob", &bob_event).await; assert!( org.contains(&format!("RECURRENCE-ID:{}", future(67, 9))), "{org}" ); let answers = |partstat: &str| { org.lines() .filter(|l| l.ends_with(ROOM) && l.contains(&format!("PARTSTAT={partstat}"))) .count() }; assert_eq!((answers("ACCEPTED"), answers("DECLINED")), (1, 1), "{org}"); } #[tokio::test] async fn rooms_check_series_to_their_horizon() { let pim = Pim::new().await; pim.room().await; let org = |user: &'static str, uid: &'static str| { let pim = &pim; async move { pim.get(user, &format!("{}{uid}.ics", cal(user))).await } }; let declined = |org: &str| { org.lines() .filter(|l| l.ends_with(ROOM) && l.contains("PARTSTAT=DECLINED")) .count() }; let weekly = |uid: &str, who: &str, start: &str, rule: &str| { booking( uid, who, start, &format!("RRULE:FREQ=WEEKLY;{rule}\r\n"), &[ROOM], ) }; for (uid, start) in [ ("c1", future(800, 9)), ("c2", future(800, 14)), ("c3", future(3700, 9)), ] { pim.put_ok( "carol", &format!("{}{uid}.ics", cal("carol")), &booking(uid, "carol", &start, "", &[ROOM]), ) .await; } // A bounded series is checked to its end, past two years. pim.put_ok( "bob", &format!("{}b1.ics", cal("bob")), &weekly("b1", "bob", &future(786, 9), "COUNT=3"), ) .await; assert_eq!(declined(&org("bob", "b1").await), 1); // A series without end is checked for two years only. pim.put_ok( "alice", &format!("{}a1.ics", cal("alice")), &weekly("a1", "alice", &future(786, 14), "INTERVAL=1"), ) .await; assert_eq!(declined(&org("alice", "a1").await), 0); // Ten years is the limit even for a bounded series. pim.put_ok( "bob", &format!("{}b2.ics", cal("bob")), &weekly("b2", "bob", &future(3693, 9), "COUNT=2"), ) .await; assert_eq!(declined(&org("bob", "b2").await), 0); } #[tokio::test] async fn sharees_schedule_only_when_allowed() { let pim = Pim::new().await; let alice = login(&pim.env, "alice", PW).await; let listed = alice.get("/api/pim/collections").await.json(); let id = listed .as_array() .unwrap() .iter() .find(|c| c["kind"] == "calendar" && c["mode"].is_null()) .unwrap()["id"] .as_i64() .unwrap(); let shares = format!("/api/pim/collections/{id}/shares"); let lend = |mode: &'static str| { let alice = &alice; let shares = &shares; async move { let r = alice .post_json(shares, &json!({"user": "bob", "mode": mode})) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); } }; lend("rw").await; let shared = format!("/pim/calendars/bob/shared-{id}/"); let invite = booking("s1", "alice", "20260401T100000Z", "", &[&addr("carol")]); // Plain write access edits, but sends nothing as alice. let r = pim .req("bob", "PUT", &format!("{shared}s1.ics"), &[], &invite) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert!(error_condition(&r).is(DAV, "need-privileges")); assert!(r.text().contains("schedule-send-invite"), "{}", r.text()); let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; pim.put_ok("bob", &format!("{shared}p1.ics"), plain).await; // With the schedule level, bob invites for alice, and says so. lend("rw+schedule").await; let body = ""; let r = pim.req("bob", "PROPFIND", &shared, &[], body).await; assert!(r.text().contains("schedule-send-invite"), "{}", r.text()); pim.put_ok("bob", &format!("{shared}s1.ics"), &invite).await; let sent_by = format!("ORGANIZER;SENT-BY=\"{}\":{}", addr("bob"), addr("alice")); let org = pim.get("alice", &format!("{}s1.ics", cal("alice"))).await; assert!(org.contains(&sent_by), "{org}"); let (_, copy) = pim.copy("carol").await; assert!(unfold(©.text()).contains(&sent_by), "{}", copy.text()); // An edit that sends nothing is stored as sent: alice's own edit keeps // bob's SENT-BY, and the PUT keeps its ETag. let s1 = format!("{}s1.ics", cal("alice")); let edited = pim.req("alice", "GET", &s1, &[], "").await.text().replacen( "UID:s1\r\n", "UID:s1\r\nX-NOTE:quiet\r\n", 1, ); let r = pim.req("alice", "PUT", &s1, &[], &edited).await; assert!(r.status.is_success(), "{}", r.text()); assert!(r.header("etag").is_some(), "{edited}"); assert_eq!(pim.req("alice", "GET", &s1, &[], "").await.text(), edited); // Answering for alice needs it too. lend("rw").await; pim.put_ok( "carol", &format!("{}c9.ics", cal("carol")), &booking("c9", "carol", "20260402T100000Z", "", &[&addr("alice")]), ) .await; let members = pim.members("alice", &cal("alice")).await; let href = members .iter() .find(|h| !h.ends_with("s1.ics") && !h.ends_with("p1.ics")) .unwrap(); let name = href.rsplit('/').next().unwrap(); let got = pim.get("alice", href).await; let accepted = got.replace( &format!("PARTSTAT=NEEDS-ACTION:{}", addr("alice")), &format!("PARTSTAT=ACCEPTED:{}", addr("alice")), ); assert_ne!(got, accepted, "{got}"); let r = pim .req("bob", "PUT", &format!("{shared}{name}"), &[], &accepted) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert!(r.text().contains("schedule-send-reply"), "{}", r.text()); lend("rw+schedule").await; pim.put_ok("bob", &format!("{shared}{name}"), &accepted) .await; let org = pim.get("carol", &format!("{}c9.ics", cal("carol"))).await; assert_eq!( attendee_param(&org, &addr("alice"), "PARTSTAT").as_deref(), Some("ACCEPTED"), "{org}" ); let reply = pim.inbox("carol").await; let reply = reply.iter().find(|m| m.contains("METHOD:REPLY")).unwrap(); assert!( reply.contains(&format!("SENT-BY=\"{}\"", addr("bob"))), "{reply}" ); } #[tokio::test] async fn one_resource_per_scheduled_uid() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let mk = ""; let r = pim .req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], mk) .await; assert_eq!(r.status, StatusCode::CREATED); let r = pim .req( "alice", "PUT", "/pim/calendars/alice/work/again.ics", &[], &meeting("20260301T100000Z", &[&addr("bob")]), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert!(error_condition(&r).is(CALDAV, "unique-scheduling-object-resource")); assert!(r.text().contains(ALICE_EVENT), "{}", r.text()); } #[tokio::test] async fn foreign_uids_are_not_overwritten() { let pim = Pim::new().await; pim.room().await; // alice organizes meet-1, keeps a plain event and books the room. invite(&pim, &[&addr("bob")]).await; let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:plain-1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:20260310T100000Z\r\nSUMMARY:Mine\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; let alice_plain = "/pim/calendars/alice/default/plain.ics"; pim.put_ok("alice", alice_plain, plain).await; let slot = future(3, 9); pim.put_ok( "alice", "/pim/calendars/alice/default/book.ics", &booking("book-1", "alice", &slot, "", &[ROOM]), ) .await; let room_copy = pim.members("bob", "/pim/calendars/board/default/").await; assert_eq!(room_copy.len(), 1, "{room_copy:?}"); let before = [ pim.get("alice", ALICE_EVENT).await, pim.get("alice", alice_plain).await, pim.get("bob", &room_copy[0]).await, ]; let alice_inbox = pim.inbox("alice").await.len(); // carol reuses those UIDs as organizer, inviting alice and the room. let other = future(5, 14); for (uid, to) in [ ("meet-1", addr("alice")), ("plain-1", addr("alice")), ("book-1", ROOM.to_string()), ] { let path = format!("/pim/calendars/carol/default/{uid}.ics"); pim.put_ok("carol", &path, &booking(uid, "carol", &other, "", &[&to])) .await; let sent = pim.get("carol", &path).await; assert_eq!( attendee_param(&sent, &to, "SCHEDULE-STATUS").as_deref(), Some("3.8"), "{sent}" ); // Removing the attendee would cancel for them. let r = pim.req("carol", "DELETE", &path, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); } // carol brings in a copy that names alice as organizer of plain-1 and // answers it: alice's plain event takes no reply. let fake = plain.replace( "SUMMARY:Mine\r\n", &format!( "ORGANIZER:{}\r\nATTENDEE:{}\r\n", addr("alice"), addr("carol") ), ); let fake_path = "/pim/calendars/carol/default/fake.ics"; pim.put_ok("carol", fake_path, &fake).await; let answered = fake.replace( &format!("ATTENDEE:{}", addr("carol")), &format!("ATTENDEE;PARTSTAT=ACCEPTED:{}", addr("carol")), ); pim.put_ok("carol", fake_path, &answered).await; let after = [ pim.get("alice", ALICE_EVENT).await, pim.get("alice", alice_plain).await, pim.get("bob", &room_copy[0]).await, ]; assert_eq!(before, after); assert_eq!(pim.inbox("alice").await.len(), alice_inbox); // The real organizer still reaches an attendee who deleted their copy. let (bob_copy, _) = pim.copy("bob").await; let r = pim .req("bob", "DELETE", &bob_copy, &[("schedule-reply", "F")], "") .await; assert_eq!(r.status, StatusCode::NO_CONTENT); let moved = pim .get("alice", ALICE_EVENT) .await .replace("DTSTART:20260301T100000Z", "DTSTART:20260302T100000Z"); pim.put_ok("alice", ALICE_EVENT, &moved).await; let (_, again) = pim.copy("bob").await; assert!( again.text().contains("20260302T100000Z"), "{}", again.text() ); } #[tokio::test] async fn move_stays_with_one_owner() { let pim = Pim::new().await; let alice = login(&pim.env, "alice", PW).await; let listed = alice.get("/api/pim/collections").await.json(); let id = listed .as_array() .unwrap() .iter() .find(|c| c["kind"] == "calendar" && c["mode"].is_null()) .unwrap()["id"] .as_i64() .unwrap(); let r = alice .post_json( &format!("/api/pim/collections/{id}/shares"), &json!({"user": "bob", "mode": "rw"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let mk = ""; let r = pim .req("bob", "MKCALENDAR", "/pim/calendars/bob/work/", &[], mk) .await; assert_eq!(r.status, StatusCode::CREATED); let own = "/pim/calendars/bob/default/m.ics"; pim.put_ok( "bob", own, &booking("m1", "bob", "20260401T100000Z", "", &[&addr("carol")]), ) .await; let to = |path: &str| format!("http://localhost{path}"); // Into alice's lent calendar: refused, the object stays. let lent = format!("/pim/calendars/bob/shared-{id}/m.ics"); let r = pim .req("bob", "MOVE", own, &[("destination", &to(&lent))], "") .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert_eq!(pim.members("bob", &cal("bob")).await.len(), 1); // Between bob's own calendars as before. let work = "/pim/calendars/bob/work/m.ics"; let r = pim .req("bob", "MOVE", own, &[("destination", &to(work))], "") .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); } #[tokio::test] async fn deleting_a_calendar_cancels_its_meetings() { let pim = Pim::new().await; let mk = ""; let r = pim .req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], mk) .await; assert_eq!(r.status, StatusCode::CREATED); pim.put_ok( "alice", "/pim/calendars/alice/work/m.ics", &booking("w1", "alice", "20260401T100000Z", "", &[&addr("bob")]), ) .await; let r = pim .req("alice", "DELETE", "/pim/calendars/alice/work/", &[], "") .await; assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text()); let r = pim .req( "alice", "PROPFIND", "/pim/calendars/alice/work/", &[("depth", "0")], "", ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); let (_, copy) = pim.copy("bob").await; assert!(copy.text().contains("STATUS:CANCELLED"), "{}", copy.text()); } #[tokio::test] async fn invitations_go_to_the_chosen_calendar() { let pim = Pim::new().await; let mk = ""; let r = pim .req("bob", "MKCALENDAR", "/pim/calendars/bob/work/", &[], mk) .await; assert_eq!(r.status, StatusCode::CREATED); let inbox = "/pim/calendars/bob/inbox/"; let set = |href: &str| { format!( "\ {href}\ " ) }; // Not one of bob's calendars: refused with the RFC 6638 precondition. for bad in [ "/pim/calendars/alice/default/", "/pim/calendars/bob/inbox/", "/pim/calendars/bob/birthdays/", ] { let r = pim.req("bob", "PROPPATCH", inbox, &[], &set(bad)).await; assert_eq!(r.status, StatusCode::MULTI_STATUS); assert!( r.text().contains("valid-schedule-default-calendar-URL"), "{bad}: {}", r.text() ); } let r = pim .req( "bob", "PROPPATCH", inbox, &[], &set("/pim/calendars/bob/work/"), ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS); assert!(r.text().contains("200"), "{}", r.text()); let props = "\ "; let r = pim .req("bob", "PROPFIND", inbox, &[("depth", "0")], props) .await; assert!( r.text().contains("/pim/calendars/bob/work/"), "{}", r.text() ); invite(&pim, &[&addr("bob")]).await; assert_eq!( pim.members("bob", "/pim/calendars/bob/work/").await.len(), 1 ); assert!(pim.members("bob", &cal("bob")).await.is_empty()); // The chosen calendar is the one that must stay. let r = pim .req("bob", "DELETE", "/pim/calendars/bob/work/", &[], "") .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(error_condition(&r).is(CALDAV, "default-calendar-needed")); // Removing the property goes back to the oldest calendar. let remove = "\ "; let r = pim.req("bob", "PROPPATCH", inbox, &[], remove).await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let r = pim .req("bob", "PROPFIND", inbox, &[("depth", "0")], props) .await; assert!( r.text().contains("/pim/calendars/bob/default/"), "{}", r.text() ); } #[tokio::test] async fn replies_need_a_listed_attendee() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; // carol brings in her own copy of alice's meeting, then answers it. let copy = meeting("20260301T100000Z", &[&addr("carol")]); let path = format!("{}meet.ics", cal("carol")); pim.put_ok("carol", &path, ©).await; let answered = copy.replace("ATTENDEE;RSVP=TRUE:", "ATTENDEE;PARTSTAT=ACCEPTED:"); pim.put_ok("carol", &path, &answered).await; assert!( pim.get("carol", &path) .await .contains("ORGANIZER;SCHEDULE-STATUS=3.8") ); assert!(pim.inbox("alice").await.is_empty()); let org = pim.get("alice", ALICE_EVENT).await; assert!(!org.contains(&addr("carol")), "{org}"); } #[tokio::test] async fn move_does_not_overwrite_a_meeting() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let from = format!("{}p1.ics", cal("alice")); pim.put_ok("alice", &from, plain).await; let r = pim .req("alice", "MOVE", &from, &[("destination", ALICE_EVENT)], "") .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert!(pim.get("alice", ALICE_EVENT).await.contains("UID:meet-1")); } #[tokio::test] async fn imports_on_a_plain_loan_skip_meetings() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let alice = login(&pim.env, "alice", PW).await; let listed = alice.get("/api/pim/collections").await.json(); let id = listed .as_array() .unwrap() .iter() .find(|c| c["kind"] == "calendar" && c["mode"].is_null()) .unwrap()["id"] .as_i64() .unwrap(); let r = alice .post_json( &format!("/api/pim/collections/{id}/shares"), &json!({"user": "carol", "mode": "rw"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let carol = login(&pim.env, "carol", PW).await; let moved = meeting("20260302T100000Z", &[]); let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:p1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:20260401T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let r = carol .raw( Method::POST, &format!("/api/pim/collections/{id}/import"), &[], format!("{moved}{plain}").into_bytes(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let result = r.json(); assert_eq!( (result["created"].as_i64(), result["updated"].as_i64()), (Some(1), Some(0)) ); assert_eq!(result["skipped"][0]["reason"], "need-privileges"); let org = pim.get("alice", ALICE_EVENT).await; assert!(org.contains("DTSTART:20260301T100000Z"), "{org}"); } #[tokio::test] async fn deleting_a_user_forgets_an_address_split_by_a_fold() { let pim = Pim::new().await; // CLIENT: no copy for alice, so only the address rewrite reaches it. let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:fold-1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:20260301T100000Z\r\nDURATION:PT1H\r\n\ ORGANIZER:mailto:bob@dovenest.invalid\r\n\ ATTENDEE;SCHEDULE-AGENT=CLIENT:mailto:al\r\n ice@dovenest.invalid\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; let href = "/pim/calendars/bob/default/fold.ics"; let r = pim.req("bob", "PUT", href, &[], ics).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let id = user_id(&pim.admin, "alice").await; let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await; assert_eq!(r.status, StatusCode::OK); let copy = unfold(&pim.req("bob", "GET", href, &[], "").await.text()); assert!(!copy.contains("alice@dovenest.invalid"), "{copy}"); assert!(copy.contains("@deleted."), "{copy}"); } #[tokio::test] async fn deleting_an_organizer_cancels_and_forgets_it() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let id = user_id(&pim.admin, "alice").await; let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await; assert_eq!(r.status, StatusCode::OK); let (_, copy) = pim.copy("bob").await; let copy = unfold(©.text()); assert!(copy.contains("STATUS:CANCELLED"), "{copy}"); assert!(!copy.contains("alice@dovenest.invalid"), "{copy}"); assert!(copy.contains("@deleted."), "{copy}"); let inbox = pim.inbox("bob").await; assert!(!inbox.is_empty()); assert!( inbox.iter().all(|m| !m.contains("alice@dovenest.invalid")), "{inbox:?}" ); } #[tokio::test] async fn deleting_a_disabled_user_retracts_its_meetings() { let disable_and_delete = async |pim: &Pim, user: &str| { let id = user_id(&pim.admin, user).await; let r = pim .admin .put_json( &format!("/api/admin/users/{id}"), &json!({ "active": false }), ) .await; assert_eq!(r.status, StatusCode::OK); let r = pim.admin.delete(&format!("/api/admin/users/{id}")).await; assert_eq!(r.status, StatusCode::OK); }; let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; disable_and_delete(&pim, "bob").await; let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text()); assert!(org.contains("PARTSTAT=DECLINED"), "{org}"); assert!( pim.inbox("alice") .await .iter() .any(|m| m.contains("METHOD:REPLY")), "the decline reaches the organizer's inbox" ); let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; disable_and_delete(&pim, "alice").await; assert!( pim.inbox("bob") .await .iter() .any(|m| m.contains("METHOD:CANCEL")), "the cancel reaches the attendee's inbox" ); } #[tokio::test] async fn a_delete_that_matches_nothing_writes_nothing() { use server::db::{PimObject, PimOp}; let pim = Pim::new().await; let db = &pim.env.state.db; let pid = db .principal_of(user_id(&pim.admin, "bob").await) .await .unwrap(); db.pim_ensure_defaults(pid).await.unwrap(); let cal = db .pim_collection(pid, server::db::PimKind::Calendar, "default") .await .unwrap() .unwrap(); let ops = [PimOp::Put { collection_id: cal.id, obj: PimObject { name: "x.ics".into(), uid: "x".into(), component: "VEVENT".into(), etag: "\"e\"".into(), ..Default::default() }, data: b"x".to_vec(), }]; assert!(!db.delete_user(i64::MAX, &ops).await.unwrap()); assert!(!db.delete_room(i64::MAX, &ops).await.unwrap()); assert!(db.pim_object(cal.id, "x.ics").await.unwrap().is_none()); } #[tokio::test] async fn find_uid_prefers_the_scheduling_copy() { use server::db::{PimCollection, PimKind, PimObject, PimOp}; let pim = Pim::new().await; let db = &pim.env.state.db; let pid = db .principal_of(user_id(&pim.admin, "bob").await) .await .unwrap(); db.pim_ensure_defaults(pid).await.unwrap(); let work = PimCollection { slug: "work".into(), components: "VEVENT".into(), ..Default::default() }; assert!( db.pim_create_collection(pid, PimKind::Calendar, &work, &[]) .await .unwrap() ); let default = db .pim_collection(pid, PimKind::Calendar, "default") .await .unwrap() .unwrap(); let work = db .pim_collection(pid, PimKind::Calendar, "work") .await .unwrap() .unwrap(); let put = |collection_id: i64, name: &str, schedule_tag: Option<&str>| PimOp::Put { collection_id, obj: PimObject { name: name.into(), uid: "meet-1".into(), component: "VEVENT".into(), etag: "\"e\"".into(), schedule_tag: schedule_tag.map(Into::into), ..Default::default() }, data: b"x".to_vec(), }; // The plain copy is older, so only the schedule tag can rank the other first. db.pim_apply(&[put(default.id, "plain.ics", None)]) .await .unwrap(); db.pim_apply(&[put(work.id, "meeting.ics", Some("\"s\""))]) .await .unwrap(); let (id, obj, _) = db.pim_find_uid(pid, "meet-1").await.unwrap().unwrap(); assert_eq!((id, obj.name.as_str()), (work.id, "meeting.ics")); } #[tokio::test] async fn free_busy_answers_each_principal_once_and_caps_the_attendees() { let pim = Pim::new().await; let mut attendees = vec![addr("bob"), "/pim/principals/bob/".to_string()]; attendees.extend((0..100).map(|i| addr(&format!("nobody{i}")))); let statuses = free_busy_statuses(&pim, &attendees).await; assert_eq!(statuses.len(), 102); assert!(statuses[0].starts_with("2.0") && statuses[1].starts_with("2.0")); assert!(statuses[2].starts_with("3.7"), "{statuses:?}"); assert!(statuses[101].starts_with("5.1"), "{statuses:?}"); } /// The REQUEST-STATUS per attendee of alice's free-busy request. async fn free_busy_statuses(pim: &Pim, attendees: &[String]) -> Vec { let lines: String = attendees .iter() .map(|a| format!("ATTENDEE:{a}\r\n")) .collect(); let body = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\ BEGIN:VFREEBUSY\r\nUID:fb\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART:20260310T000000Z\r\nDTEND:20260311T000000Z\r\nORGANIZER:{}\r\n\ {lines}END:VFREEBUSY\r\nEND:VCALENDAR\r\n", addr("alice") ); let r = pim .req( "alice", "POST", "/pim/calendars/alice/outbox/", &[("content-type", "text/calendar")], &body, ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); xml::elements(&root) .map(|resp| xml::text(xml::child(resp, CALDAV, "request-status").unwrap())) .collect() } #[tokio::test] async fn a_disabled_attendee_keeps_getting_updates_and_cancels() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let bob = user_id(&pim.admin, "bob").await; let set_active = async |active: bool| { let r = pim .admin .put_json( &format!("/api/admin/users/{bob}"), &json!({ "active": active }), ) .await; assert_eq!(r.status, StatusCode::OK); }; set_active(false).await; // Free-busy treats bob as unknown while he is disabled. let statuses = free_busy_statuses(&pim, &[addr("bob")]).await; assert!(statuses[0].starts_with("3.7"), "{statuses:?}"); let moved = meeting("20260302T100000Z", &[&addr("bob")]); let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &moved).await; assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text()); let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text()); assert!( attendee_param(&org, &addr("bob"), "SCHEDULE-STATUS").is_some_and(|s| s.starts_with('1')), "{org}" ); let r = pim.req("alice", "DELETE", ALICE_EVENT, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); set_active(true).await; let (_, copy) = pim.copy("bob").await; let copy = unfold(©.text()); assert!(copy.contains("DTSTART:20260302T100000Z"), "{copy}"); assert!(copy.contains("STATUS:CANCELLED"), "{copy}"); } #[tokio::test] async fn a_plain_event_with_the_same_uid_does_not_block_a_meeting() { let pim = Pim::new().await; let plain = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:meet-1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:20260301T100000Z\r\nDURATION:PT1H\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; pim.put_ok("alice", &format!("{}plain.ics", cal("alice")), plain) .await; let r = pim .req("alice", "MKCALENDAR", "/pim/calendars/alice/work/", &[], "") .await; assert_eq!(r.status, StatusCode::CREATED); let r = pim .req( "alice", "PUT", "/pim/calendars/alice/work/meet.ics", &[], &meeting("20260301T100000Z", &[&addr("bob")]), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); } #[tokio::test] async fn a_quiet_import_keeps_the_attendees_answers() { let pim = Pim::new().await; let past = meeting("20240301T100000Z", &[&addr("bob")]); pim.put_ok("alice", ALICE_EVENT, &past).await; let (bob_href, _) = pim.copy("bob").await; let r = pim.req("bob", "DELETE", &bob_href, &[], "").await; assert_eq!(r.status, StatusCode::NO_CONTENT); let declined = |org: &str| attendee_param(org, &addr("bob"), "PARTSTAT"); let org = pim.get("alice", ALICE_EVENT).await; assert_eq!(declined(&org).as_deref(), Some("DECLINED"), "{org}"); let sent = pim.inbox("bob").await.len(); // An old export claims bob accepted. The meeting is over, so nothing is // sent, but bob's real answer stays. let alice = login(&pim.env, "alice", PW).await; let listed = alice.get("/api/pim/collections").await.json(); let id = listed .as_array() .unwrap() .iter() .find(|c| c["kind"] == "calendar" && c["mode"].is_null()) .unwrap()["id"] .as_i64() .unwrap(); let export = past.replace("ATTENDEE;RSVP=TRUE:", "ATTENDEE;PARTSTAT=ACCEPTED:"); let r = alice .raw( Method::POST, &format!("/api/pim/collections/{id}/import"), &[], export.into_bytes(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.json()["updated"], 1, "{}", r.text()); let org = pim.get("alice", ALICE_EVENT).await; assert_eq!(declined(&org).as_deref(), Some("DECLINED"), "{org}"); assert_eq!(pim.inbox("bob").await.len(), sent); } #[tokio::test] async fn a_plain_loan_may_bump_the_sequence() { let pim = Pim::new().await; invite(&pim, &[&addr("carol")]).await; let sent = pim.inbox("carol").await.len(); let alice = login(&pim.env, "alice", PW).await; let listed = alice.get("/api/pim/collections").await.json(); let id = listed .as_array() .unwrap() .iter() .find(|c| c["kind"] == "calendar" && c["mode"].is_null()) .unwrap()["id"] .as_i64() .unwrap(); let r = alice .post_json( &format!("/api/pim/collections/{id}/shares"), &json!({"user": "bob", "mode": "rw"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // bob's client adds an alarm and bumps SEQUENCE. That invites no one. let edit = meeting("20260301T100000Z", &[&addr("carol")]).replace( "END:VEVENT", "SEQUENCE:1\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\n\ TRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT", ); pim.put_ok( "bob", &format!("/pim/calendars/bob/shared-{id}/meet.ics"), &edit, ) .await; assert!(pim.get("alice", ALICE_EVENT).await.contains("BEGIN:VALARM")); assert_eq!(pim.inbox("carol").await.len(), sent); // alice's object keeps the SEQUENCE carol's copy has, so her answer // still counts. assert!(!pim.get("alice", ALICE_EVENT).await.contains("SEQUENCE:1")); let (href, copy) = pim.copy("carol").await; let accepted = unfold(©.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED"); let r = pim.req("carol", "PUT", &href, &[], &accepted).await; assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text()); let org = unfold(&pim.get("alice", ALICE_EVENT).await); assert!(org.contains("PARTSTAT=ACCEPTED"), "{org}"); } #[tokio::test] async fn a_room_organizer_gets_its_inbox_with_the_first_reply() { let pim = Pim::new().await; pim.room().await; let admin = |verb: &str, path: &str, body: &str| { let auth = basic("admin", "admin1234"); let (verb, path, body) = (verb.to_string(), path.to_string(), body.to_string()); let app = pim.env.app.clone(); async move { Client::new(app) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), &path, &[("authorization", auth.as_str())], body.into_bytes(), ) .await } }; let event = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:room-1\r\n\ DTSTAMP:20260101T000000Z\r\nDTSTART:{}\r\nDURATION:PT1H\r\n\ ORGANIZER:{ROOM}\r\nATTENDEE:{}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", future(4, 9), addr("alice") ); let r = admin("PUT", "/pim/calendars/board/default/r.ics", &event).await; assert!(r.status.is_success(), "{}: {}", r.status, r.text()); let (href, copy) = pim.copy("alice").await; let accepted = unfold(©.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED"); let r = pim.req("alice", "PUT", &href, &[], &accepted).await; assert!(r.status.is_success(), "{}: {}", r.status, r.text()); let db = &pim.env.state.db; let room = db.pim_principal("board").await.unwrap().unwrap(); let inbox = db .pim_collection(room.id, server::db::PimKind::Calendar, "inbox") .await .unwrap() .unwrap(); assert_eq!(db.pim_objects(inbox.id).await.unwrap().len(), 1); } #[tokio::test] async fn a_put_may_not_change_the_uid_of_an_object() { let pim = Pim::new().await; invite(&pim, &[&addr("bob")]).await; let other = meeting("20260301T100000Z", &[&addr("bob")]).replace("UID:meet-1", "UID:meet-2"); let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &other).await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict")); let card = |uid: &str| format!("BEGIN:VCARD\r\nVERSION:3.0\r\n{uid}FN:A\r\nEND:VCARD\r\n"); let path = "/pim/addressbooks/alice/default/a.vcf"; // A card may gain a UID, but not change it. pim.put_ok("alice", path, &card("")).await; pim.put_ok("alice", path, &card("UID:c-1\r\n")).await; let r = pim .req("alice", "PUT", path, &[], &card("UID:c-2\r\n")) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); // Nor drop it, nor change a UID that equals the name. let r = pim.req("alice", "PUT", path, &[], &card("")).await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); let named = "/pim/addressbooks/alice/default/b.vcf"; pim.put_ok("alice", named, &card("UID:b.vcf\r\n")).await; let r = pim .req("alice", "PUT", named, &[], &card("UID:b-2\r\n")) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); } #[tokio::test] async fn uris_reach_copies_and_inboxes_unescaped() { let pim = Pim::new().await; // The attachment's bytes are UTF-8, which calcard reads as TEXT. let uris = "X-GOOGLE-CONFERENCE:https://meet/a,b\r\nURL:http://x/a,b;c\r\n\ ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n"; let body = meeting("20260301T100000Z", &[&addr("bob")]).replace( "SUMMARY:Planning\r\n", &format!("SUMMARY:Planning\r\n{uris}"), ); let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &body).await; assert!(r.status.is_success(), "{}", r.text()); let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text()); let copy = unfold(&pim.copy("bob").await.1.text()); let inbox = unfold(&pim.inbox("bob").await[0]); for text in [&org, ©, &inbox] { assert!( text.contains("https://meet/a,b") && text.contains("http://x/a,b;c"), "{text}" ); assert!(text.contains(";ENCODING=BASE64"), "{text}"); assert!(text.contains(":SGVsbG8gd29ybGQKbGluZTI=\r\n"), "{text}"); } } #[tokio::test] async fn two_addresses_of_one_attendee_get_one_delivery() { let pim = Pim::new().await; let body = meeting("20260301T100000Z", &[&addr("bob"), "/pim/principals/bob/"]); let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &body).await; assert!(r.status.is_success(), "{}", r.text()); assert_eq!(pim.inbox("bob").await.len(), 1); let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text()); assert_eq!(org.matches("SCHEDULE-STATUS=1.2").count(), 2, "{org}"); }