//! 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}@filebrowser.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.filebrowser.invalid"]).await; let org = pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text(); let room = "mailto:board@rooms.filebrowser.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:?}"); }