//! 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:VEVENT\r\nUID:nostart\r\nSUMMARY:no start\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"], 2); let reasons: Vec<&str> = r["skipped"] .as_array() .unwrap() .iter() .map(|s| s["reason"].as_str().unwrap()) .collect(); assert!(reasons.contains(&"supported-calendar-component"), "{r}"); assert!(reasons.contains(&"valid-calendar-data"), "{r}"); // 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 usize { c.get("/api/pim/collections") .await .json() .as_array() .unwrap() .len() } #[tokio::test] async fn share_candidates_leave_out_owner_borrowers_and_disabled() { let io = Io::new().await; let admin = login(&io.env, "admin", "admin1234").await; create_user(&admin, "carol", PW, &[]).await; create_user(&admin, "dave", PW, &[]).await; let dave = user_id(&admin, "dave").await; let r = admin .put_json( &format!("/api/admin/users/{dave}"), &json!({"active": false}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let r = admin .post_json( "/api/admin/rooms", &json!({"name": "atrium", "kind": "room"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let id = io.id(CAL).await; let r = io .alice .post_json( &format!("/api/pim/collections/{id}/shares"), &json!({"user": "bob", "mode": "ro"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let names = |v: Value| -> Vec { v.as_array() .unwrap() .iter() .map(|c| c["name"].as_str().unwrap().to_string()) .collect() }; let r = io .alice .get(&format!("/api/pim/collections/{id}/shares/candidates")) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // Not alice herself, not bob (a borrower), not dave (disabled), no room. assert_eq!(names(r.json()), ["admin", "carol"]); // Only the owner may ask. let bob = login(&io.env, "bob", PW).await; let r = bob .get(&format!("/api/pim/collections/{id}/shares/candidates")) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn a_skipped_import_into_a_new_user_leaves_only_the_default_calendar() { use server::db::PimKind; let io = Io::new().await; let client = login(&io.env, "bob", PW).await; let broken = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:no start\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let r = client .raw( Method::POST, "/api/pim/import?kind=calendar&file=bad.ics", &[("content-type", "text/calendar")], broken.as_bytes().to_vec(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.json()["collection"], Value::Null); assert_eq!(r.json()["skipped_total"], 1); let db = &io.env.state.db; let bob = db.find_user_by_name("bob").await.unwrap().unwrap(); let pid = db.principal_of(bob.id).await.unwrap(); let slugs: Vec<_> = db .pim_collections(pid, PimKind::Calendar) .await .unwrap() .into_iter() .map(|c| c.slug) .filter(|s| s != "inbox" && s != "outbox") .collect(); assert_eq!(slugs, ["default"]); } #[tokio::test] async fn collection_names_and_descriptions_are_checked() { let io = Io::new().await; let create = |body: Value| { let client = &io.alice; async move { client.post_json("/api/pim/collections", &body).await } }; let long = "x".repeat(257); for name in [long.as_str(), "bell\u{7}"] { let r = create(json!({"kind": "calendar", "name": name})).await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{name:?}"); } let r = create(json!({"kind": "calendar", "name": "Ok", "description": "x".repeat(1025)})).await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = create(json!({"kind": "calendar", "name": "Ok", "description": "two\nlines"})).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let id = r.json()["id"].as_i64().unwrap(); let url = format!("/api/pim/collections/{id}"); for body in [ json!({"name": long}), json!({"description": "x".repeat(1025)}), ] { let r = io.alice.put_json(&url, &body).await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{body}"); } // A file's own name that cannot be stored gives way to the file name. let ics = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:{long}\r\nBEGIN:VEVENT\r\nUID:n1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" ); let r = io .alice .raw( Method::POST, "/api/pim/import?kind=calendar&file=Trips.ics", &[("content-type", "text/calendar")], ics.into_bytes(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.json()["collection"]["name"], "Trips"); } #[tokio::test] async fn a_feed_password_matches_with_edge_spaces() { let io = Io::new().await; let cal = io.id(CAL).await; let locked = io.link(cal, json!({ "password": " feedpass123 " })).await; for pw in [" feedpass123 ", "feedpass123"] { let r = io.anon(&locked, &[("authorization", &basic("", pw))]).await; assert_eq!(r.status, StatusCode::OK, "{pw:?}"); } } #[tokio::test] async fn feeds_of_a_disabled_owner_stop() { let io = Io::new().await; let admin = login(&io.env, "admin", "admin1234").await; let feed = io.link(io.id(CAL).await, json!({})).await; let alice = user_id(&admin, "alice").await; for (active, status) in [(false, StatusCode::NOT_FOUND), (true, StatusCode::OK)] { let r = admin .put_json( &format!("/api/admin/users/{alice}"), &json!({ "active": active }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(io.anon(&feed, &[]).await.status, status); } } #[tokio::test] async fn a_loan_to_a_disabled_user_takes_a_new_mode() { let io = Io::new().await; let admin = login(&io.env, "admin", "admin1234").await; create_user(&admin, "carol", PW, &[]).await; let id = io.id(CAL).await; let shares = format!("/api/pim/collections/{id}/shares"); let r = io .alice .post_json(&shares, &json!({"user": "bob", "mode": "ro"})) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); for name in ["bob", "carol"] { let uid = user_id(&admin, name).await; let r = admin .put_json( &format!("/api/admin/users/{uid}"), &json!({"active": false}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); } let r = io .alice .post_json(&shares, &json!({"user": "bob", "mode": "rw"})) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.json()["user_name"], "bob"); let list = io.alice.get(&shares).await.json(); assert_eq!(list[0]["mode"], "rw", "{list}"); // No new loan goes to a disabled account. let r = io .alice .post_json(&shares, &json!({"user": "carol", "mode": "ro"})) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn an_import_racing_the_collection_delete_never_fails_with_500() { let io = Io::new().await; let url = "/pim/calendars/alice/race/"; for i in 0..20 { let r = io.dav("alice", "MKCALENDAR", url, "").await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let path = format!("/api/pim/collections/{}/import", io.id(url).await); let (import, delete) = tokio::join!( io.alice.raw( Method::POST, &path, &[("content-type", "text/calendar")], event(&format!("race{i}"), "x", "").into_bytes(), ), io.dav("alice", "DELETE", url, ""), ); assert_eq!(delete.status, StatusCode::NO_CONTENT); assert!( [StatusCode::OK, StatusCode::NOT_FOUND].contains(&import.status), "{}: {}", import.status, import.text() ); } }