//! Public feeds, import and export of calendars and address books. mod common; use axum::http::{Method, StatusCode}; use common::*; use serde_json::{Value, json}; const PW: &str = "secret12345"; const CAL: &str = "/pim/calendars/alice/default/"; struct Io { env: Env, alice: Client, } impl Io { async fn new() -> Self { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "alice", PW, &[]).await; create_user(&admin, "bob", PW, &[]).await; let alice = login(&env, "alice", PW).await; Io { env, alice } } async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp { let auth = basic(user, PW); // Without Depth, PROPFIND lists only the collection itself. Client::new(self.env.app.clone()) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), path, &[("authorization", &auth), ("depth", "1")], body.as_bytes().to_vec(), ) .await } async fn anon(&self, path: &str, headers: &[(&str, &str)]) -> Resp { Client::new(self.env.app.clone()) .raw(Method::GET, path, headers, Vec::new()) .await } /// The id of alice's collection with this CalDAV/CardDAV URL. async fn id(&self, url: &str) -> i64 { let list = self.alice.get("/api/pim/collections").await.json(); list.as_array() .unwrap() .iter() .find(|c| c["url"] == url) .unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"] .as_i64() .unwrap() } async fn link(&self, id: i64, body: Value) -> String { let r = self .alice .post_json(&format!("/api/pim/collections/{id}/links"), &body) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); r.json()["path"].as_str().unwrap().to_string() } async fn import(&self, id: i64, body: &str) -> Value { let r = self .alice .raw( Method::POST, &format!("/api/pim/collections/{id}/import"), &[("content-type", "text/calendar")], body.as_bytes().to_vec(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); r.json() } } fn event(uid: &str, summary: &str, extra: &str) -> String { format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\nEND:VCALENDAR\r\n" ) } #[tokio::test] async fn feeds() { let io = Io::new().await; let cal = io.id(CAL).await; let book = io.id("/pim/addressbooks/alice/default/").await; for uid in ["a", "b"] { let r = io .dav( "alice", "PUT", &format!("{CAL}{uid}.ics"), &event( uid, "Secret plan", "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\n", ), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); } let r = io .dav( "alice", "PUT", &format!("{CAL}p.ics"), &event("p", "Doctor", "CLASS:PRIVATE\r\n"), ) .await; assert_eq!(r.status, StatusCode::CREATED); let full = io.link(cal, json!({})).await; assert!( full.starts_with("/feed/") && full.ends_with(".ics"), "{full}" ); let r = io.anon(&full, &[]).await; assert_eq!(r.status, StatusCode::OK); assert!( r.header("content-type") .unwrap() .starts_with("text/calendar") ); let text = r.text(); assert!( text.contains("UID:a\r\n") && text.contains("UID:b\r\n"), "{text}" ); assert!(text.contains("X-WR-CALNAME:")); assert!(text.contains("Secret plan")); // A public link shows private events as busy time only. assert!( !text.contains("Doctor") && text.contains("SUMMARY:Busy"), "{text}" ); let etag = r.header("etag").unwrap(); let r = io.anon(&full, &[("if-none-match", &etag)]).await; assert_eq!(r.status, StatusCode::NOT_MODIFIED); // The extension is optional. let bare = full.trim_end_matches(".ics"); assert_eq!(io.anon(bare, &[]).await.status, StatusCode::OK); // A change gives a new ETag. io.dav("alice", "PUT", &format!("{CAL}c.ics"), &event("c", "x", "")) .await; let r = io.anon(&full, &[("if-none-match", &etag)]).await; assert_eq!(r.status, StatusCode::OK); let busy = io.link(cal, json!({ "busy_only": true })).await; let text = io.anon(&busy, &[]).await.text(); assert!(text.contains("SUMMARY:Busy"), "{text}"); assert!( !text.contains("Secret plan") && !text.contains("VALARM"), "{text}" ); assert!(!text.contains("UID:a\r\n"), "UIDs are hashed: {text}"); // The owner's export keeps everything. let export = io .alice .get(&format!("/api/pim/collections/{cal}/export")) .await .text(); assert!(export.contains("Doctor")); let locked = io.link(cal, json!({ "password": "feedpass123" })).await; let r = io.anon(&locked, &[]).await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert!(r.header("www-authenticate").is_some()); let wrong = basic("", "nope-nope"); for _ in 0..3 { let r = io.anon(&locked, &[("authorization", &wrong)]).await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); } let right = basic("anyone", "feedpass123"); assert_eq!( io.anon(&locked, &[("authorization", &right)]).await.status, StatusCode::OK ); let expired = io .link(cal, json!({ "expires_at": "2000-01-01T00:00:00Z" })) .await; assert_eq!(io.anon(&expired, &[]).await.status, StatusCode::GONE); let cards = io.link(book, json!({})).await; assert!(cards.ends_with(".vcf")); let r = io.anon(&cards, &[]).await; assert!(r.header("content-type").unwrap().starts_with("text/vcard")); let r = io .alice .post_json( &format!("/api/pim/collections/{book}/links"), &json!({ "busy_only": true }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Only the owner sees and manages the links. let bob = login(&io.env, "bob", PW).await; assert_eq!( bob.get(&format!("/api/pim/collections/{cal}/links")) .await .status, StatusCode::NOT_FOUND ); let list = io .alice .get(&format!("/api/pim/collections/{cal}/links")) .await .json(); assert_eq!(list.as_array().unwrap().len(), 4); let first = list[0]["id"].as_i64().unwrap(); let r = io .alice .delete(&format!("/api/pim/collections/{cal}/links/{first}")) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(io.anon(&full, &[]).await.status, StatusCode::NOT_FOUND); // A deleted collection takes its links along. let r = io .dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "") .await; assert_eq!(r.status, StatusCode::CREATED); let work = io.id("/pim/calendars/alice/work/").await; let gone = io.link(work, json!({})).await; assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::OK); io.dav("alice", "DELETE", "/pim/calendars/alice/work/", "") .await; assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn import_splits_and_updates() { let io = Io::new().await; let cal = io.id(CAL).await; let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nMETHOD:PUBLISH\r\n\ BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20260105T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20260106T100000Z\r\nDTSTART:20260106T120000Z\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nDTSTART:20260107T100000Z\r\nSUMMARY:no uid\r\nEND:VEVENT\r\n\ BEGIN:VFREEBUSY\r\nUID:fb\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n"; let r = io.import(cal, file).await; assert_eq!(r["created"], 2, "{r}"); assert_eq!(r["updated"], 0); assert_eq!(r["skipped_total"], 1); assert_eq!(r["skipped"][0]["reason"], "supported-calendar-component"); // The same file again updates, also the event that had no UID. let r = io.import(cal, file).await; assert_eq!( (r["created"].as_i64(), r["updated"].as_i64()), (Some(0), Some(2)) ); let r = io.dav("alice", "PROPFIND", CAL, "").await; let listing = r.text(); assert_eq!(listing.matches(".ics") .filter_map(|s| s.split("").next()) .find(|h| h.ends_with(".ics")) .unwrap_or_else(|| panic!("{listing}")) .to_string(); let r = io.dav("alice", "GET", &href, "").await; assert!(r.header("schedule-tag").is_some()); // bob got neither a copy nor a message. for path in ["/pim/calendars/bob/default/", "/pim/calendars/bob/inbox/"] { let r = io.dav("bob", "PROPFIND", path, "").await; assert!(!r.text().contains(".ics