//! The JSON endpoints behind the calendar and contacts web UI. mod common; use axum::http::{Method, StatusCode}; use common::*; use serde_json::{Value, json}; const PW: &str = "secret12345"; struct Ui { env: Env, alice: Client, bob: Client, } impl Ui { 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; let bob = login(&env, "bob", PW).await; Ui { env, alice, bob } } async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp { Client::new(self.env.app.clone()) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), path, &[("authorization", &basic(user, PW)), ("depth", "1")], body.as_bytes().to_vec(), ) .await } async fn put(&self, user: &str, path: &str, body: &str) { let r = self.dav(user, "PUT", path, body).await; assert!( r.status.is_success(), "PUT {path}: {} {}", r.status, r.text() ); } async fn list(&self, who: &Client) -> Vec { let r = who.get("/api/pim/collections").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); r.json().as_array().unwrap().clone() } async fn id(&self, who: &Client, url: &str) -> i64 { self.list(who) .await .iter() .find(|c| c["url"] == url) .unwrap_or_else(|| panic!("no {url}"))["id"] .as_i64() .unwrap() } } fn ics(body: &str) -> String { format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n{body}END:VCALENDAR\r\n") } fn event(uid: &str, props: &str) -> String { ics(&format!( "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n{props}END:VEVENT\r\n" )) } const ALICE: &str = "mailto:alice@dovenest.invalid"; const BOB: &str = "mailto:bob@dovenest.invalid"; #[tokio::test] async fn collections_can_be_created_changed_and_deleted() { let ui = Ui::new().await; let list = ui.list(&ui.alice).await; // Default calendar and address book, plus the generated ones. let generated: Vec = list .iter() .filter(|c| c["generated"] == true) .map(|c| c["id"].as_i64().unwrap()) .collect(); assert_eq!(generated, [-1, 0], "{list:?}"); let default = list.iter().find(|c| c["is_default"] == true).unwrap(); assert_eq!(default["kind"], "calendar"); let r = ui .alice .post_json( "/api/pim/collections", &json!({"kind": "calendar", "name": "Work & Play", "color": "#ff8800", "components": ["VEVENT"]}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let work = r.json(); assert_eq!(work["url"], "/pim/calendars/alice/work-play/"); assert_eq!(work["components"], json!(["VEVENT"])); let id = work["id"].as_i64().unwrap(); // A second one with the same name gets its own URL. let again = ui .alice .post_json( "/api/pim/collections", &json!({"kind": "calendar", "name": "Work & Play"}), ) .await; assert_eq!(again.json()["url"], "/pim/calendars/alice/work-play-2/"); // Reserved names and bad input. let r = ui .alice .post_json( "/api/pim/collections", &json!({"kind": "addressbook", "name": "System"}), ) .await; assert_eq!(r.json()["url"], "/pim/addressbooks/alice/system-2/"); for bad in [ json!({"kind": "calendar", "name": " "}), json!({"kind": "calendar", "name": "x", "color": "red"}), json!({"kind": "calendar", "name": "x", "components": ["VCARD"]}), ] { let r = ui.alice.post_json("/api/pim/collections", &bad).await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad}"); } let r = ui .alice .put_json( &format!("/api/pim/collections/{id}"), &json!({"name": "Work", "color": "", "description": "Job", "transparent": true}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let changed = r.json(); assert_eq!(changed["name"], "Work"); assert_eq!(changed["color"], Value::Null); assert_eq!(changed["description"], "Job"); assert_eq!(changed["transparent"], true); // CalDAV sees the same. let r = ui .dav("alice", "PROPFIND", "/pim/calendars/alice/work-play/", "") .await; assert!(r.text().contains("Work = list .iter() .map(|i| i["summary"].as_str().unwrap()) .collect(); assert_eq!( summaries, ["Standup", "🎂 Anna (1980)", "Trip", "Moved", "Standup"], "{all}" ); assert_eq!(list[0]["start"], "2026-01-05T08:00:00Z"); assert_eq!(list[0]["location"], "Room 1"); assert_eq!(list[0]["recurrence_id"], "2026-01-05T08:00:00Z"); // All-day values start at midnight in the viewer's zone. assert_eq!(list[2]["start"], "2026-01-09T23:00:00Z"); assert_eq!(list[2]["all_day"], true); assert_eq!(list[2]["recurrence_id"], Value::Null); assert_eq!(list[3]["recurrence_id"], "2026-01-12T08:00:00Z"); assert_eq!(list[1]["collection_id"], -1); assert_eq!(all["truncated"], false); // Only the chosen calendars. let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&collections=-1").await; assert_eq!(r.json()["instances"].as_array().unwrap().len(), 1); // Bad ranges. for q in [ "from=2026-01-01T00:00:00Z&to=2027-06-01T00:00:00Z", "from=2026-02-01T00:00:00Z&to=2026-01-01T00:00:00Z", "from=yesterday&to=2026-01-01T00:00:00Z", ] { assert_eq!(get(q).await.status, StatusCode::BAD_REQUEST, "{q}"); } // A read-only loan shows to the borrower, not otherwise. let q = "/api/pim/instances?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z"; let count = |v: Value| { v["instances"] .as_array() .unwrap() .iter() .filter(|i| i["uid"] == "weekly") .count() }; assert_eq!(count(ui.bob.get(q).await.json()), 0); let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await; ui.alice .post_json( &format!("/api/pim/collections/{cal}/shares"), &json!({"user": "bob", "mode": "ro"}), ) .await; assert_eq!(count(ui.bob.get(q).await.json()), 3); } #[tokio::test] async fn object_detail_for_events_and_contacts() { let ui = Ui::new().await; ui.put( "alice", "/pim/calendars/alice/default/m.ics", &event( "m", &format!( "DTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=2\r\nSUMMARY:Plan\r\nDESCRIPTION:Line 1\\nLine 2\r\nCATEGORIES:Work,Q1\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT:{BOB}\r\n" ), ), ) .await; let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await; let r = ui .alice .get(&format!("/api/pim/collections/{cal}/objects/m.ics")) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let d = r.json(); assert_eq!(d["type"], "event"); assert_eq!(d["description"], "Line 1\nLine 2"); assert_eq!(d["categories"], json!(["Work", "Q1"])); assert_eq!(d["rrule"], "FREQ=DAILY;COUNT=2"); assert_eq!(d["organizer"]["name"], "Alice"); assert_eq!(d["attendees"][1]["role"], "OPT-PARTICIPANT"); assert_eq!(d["attendees"][0]["is_owner"], true); assert_eq!(d["can_edit"], true); assert_eq!(d["can_reply"], false); // Bob's copy: he may answer. let bob = ui .bob .get("/api/pim/instances?from=2026-01-05T00:00:00Z&to=2026-01-06T00:00:00Z") .await .json(); let copy = &bob["instances"][0]; assert_eq!(copy["partstat"], "NEEDS-ACTION", "{bob}"); let d = ui .bob .get(&format!( "/api/pim/collections/{}/objects/{}", copy["collection_id"], copy["name"].as_str().unwrap() )) .await .json(); assert_eq!(d["can_reply"], true, "{d}"); // Someone else's object stays hidden. let r = ui .bob .get(&format!("/api/pim/collections/{cal}/objects/m.ics")) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // A contact with a photo, and a group. let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; ui.put( "alice", "/pim/addressbooks/alice/default/c1.vcf", &format!( "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Carla Díaz\r\nN:Díaz;Carla;;;\r\nORG:Acme;Sales\r\nitem1.EMAIL;TYPE=INTERNET:carla@example.com\r\nitem1.X-ABLABEL:_$!!$_\r\nTEL;TYPE=CELL:+49 1\r\nADR;TYPE=WORK:;;Main St 1;Berlin;;10115;Germany\r\nBDAY:--03-15\r\nPHOTO;ENCODING=b;TYPE=PNG:{png}\r\nEND:VCARD\r\n" ), ) .await; ui.put( "alice", "/pim/addressbooks/alice/default/g.vcf", "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nMEMBER:urn:uuid:gone\r\nEND:VCARD\r\n", ) .await; let book = ui.id(&ui.alice, "/pim/addressbooks/alice/default/").await; let d = ui .alice .get(&format!("/api/pim/collections/{book}/objects/c1.vcf")) .await .json(); assert_eq!(d["type"], "contact"); assert_eq!(d["org"], "Acme, Sales"); assert_eq!( d["emails"][0], json!({"label": "other", "value": "carla@example.com"}) ); assert_eq!(d["phones"][0]["label"], "cell"); assert_eq!( d["addresses"][0]["value"], "Main St 1\n10115 Berlin\nGermany" ); assert_eq!(d["birthday"], "--03-15"); let photo = d["photo_url"].as_str().unwrap().to_string(); assert_eq!(ui.alice.get(&photo).await.status, StatusCode::OK); let g = ui .alice .get(&format!("/api/pim/collections/{book}/objects/g.vcf")) .await .json(); assert_eq!(g["is_group"], true); assert_eq!(g["members"], json!(["Carla Díaz", "gone"])); // The generated birthday calendar has details too. let list = ui .alice .get("/api/pim/instances?from=2026-03-01T00:00:00Z&to=2026-04-01T00:00:00Z") .await .json(); let bday = &list["instances"][0]; let d = ui .alice .get(&format!( "/api/pim/collections/-1/objects/{}", bday["name"].as_str().unwrap() )) .await .json(); assert_eq!(d["summary"], "🎂 Carla Díaz"); assert_eq!(d["can_edit"], false); } #[tokio::test] async fn contacts_are_searched_across_books() { let ui = Ui::new().await; ui.put( "alice", "/pim/addressbooks/alice/default/z.vcf", "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:Zoë Weiß\r\nEMAIL:zoe@example.com\r\nEND:VCARD\r\n", ) .await; ui.put( "alice", "/pim/addressbooks/alice/default/n.vcf", "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nN:Nobody;Nora;;;\r\nTEL:+1 555\r\nEND:VCARD\r\n", ) .await; let all = ui.alice.get("/api/pim/contacts").await.json(); let names: Vec<&str> = all .as_array() .unwrap() .iter() .map(|c| c["full_name"].as_str().unwrap()) .collect(); // The directory lists the accounts too. assert_eq!(names, ["admin", "alice", "bob", "Nora Nobody", "Zoë Weiß"]); let hits = ui.alice.get("/api/pim/contacts?q=ZOË").await.json(); assert_eq!(hits.as_array().unwrap().len(), 1); assert_eq!(hits[0]["email"], "zoe@example.com"); let hits = ui.alice.get("/api/pim/contacts?q=555").await.json(); assert_eq!(hits[0]["full_name"], "Nora Nobody"); let own = ui.alice.get("/api/pim/contacts?collections=0").await.json(); assert_eq!(own.as_array().unwrap().len(), 3); } #[tokio::test] async fn invitations_are_listed_and_answered() { let ui = Ui::new().await; // A month ahead, so the invitation stays open whenever the test runs. let day = chrono::Utc::now().date_naive() + chrono::Days::new(30); let (first, second) = ( day.format("%Y%m%d"), (day + chrono::Days::new(1)).format("%Y%m%d"), ); let meeting = event( "meet", &format!( "DTSTART:{first}T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Sync\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;RSVP=TRUE:{BOB}\r\n" ), ); ui.put("alice", "/pim/calendars/alice/default/meet.ics", &meeting) .await; let list = ui.bob.get("/api/pim/invitations").await.json(); let list = list.as_array().unwrap(); assert_eq!(list.len(), 1, "{list:?}"); assert_eq!(list[0]["summary"], "Sync"); assert_eq!(list[0]["recurring"], true); assert_eq!(list[0]["recurrence_id"], Value::Null); assert_eq!( list[0]["start"], format!("{}T09:00:00Z", day.format("%Y-%m-%d")) ); assert!( ui.alice .get("/api/pim/invitations") .await .json() .as_array() .unwrap() .is_empty() ); // One instance first: an override in bob's copy, a REPLY for alice. let reply = |partstat: &str, rid: Option<&str>| { let mut body = json!({ "collection_id": list[0]["collection_id"], "name": list[0]["name"], "partstat": partstat, }); if let Some(rid) = rid { body["recurrence_id"] = json!(rid); } body }; let r = ui .bob .post_json( "/api/pim/invitations", &reply( "DECLINED", Some(&format!( "{}T09:00:00Z", (day + chrono::Days::new(1)).format("%Y-%m-%d") )), ), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let org = ui .dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "") .await .text() .replace("\r\n ", ""); assert!( org.contains(&format!("RECURRENCE-ID:{second}T090000Z")), "{org}" ); assert!(org.contains("PARTSTAT=DECLINED"), "{org}"); // The series is still open. assert_eq!( ui.bob .get("/api/pim/invitations") .await .json() .as_array() .unwrap() .len(), 1 ); let r = ui .bob .post_json("/api/pim/invitations", &reply("ACCEPTED", None)) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!( ui.bob .get("/api/pim/invitations") .await .json() .as_array() .unwrap() .is_empty() ); let org = ui .dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "") .await .text() .replace("\r\n ", ""); let bob_line = org .lines() .find(|l| l.starts_with("ATTENDEE") && l.contains(BOB) && !l.contains("DECLINED")) .unwrap_or_else(|| panic!("{org}")); assert!(bob_line.contains("PARTSTAT=ACCEPTED"), "{org}"); // Bad answers. let r = ui .bob .post_json("/api/pim/invitations", &reply("MAYBE", None)) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = ui .alice .post_json("/api/pim/invitations", &reply("ACCEPTED", None)) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); }