//! Implicit scheduling (RFC 6638) between the accounts and rooms of one //! server. mod common; use axum::http::{Method, StatusCode}; use common::*; 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()); }