//! What the server derives on its own: contact photos, the birthday //! calendar, the cleanup after a deleted principal, and the admin view of //! public feeds. use crate::common::*; use axum::http::{Method, StatusCode}; use base64::Engine; use pimdav::xml::{self, DAV}; use serde_json::json; use xmltree::Element; const PW: &str = "secret12345"; const BOOK: &str = "/pim/addressbooks/alice/default/"; struct Pim { env: Env, admin: Client, alice: Client, } impl Pim { async fn new(env: Env) -> Self { let admin = env.admin().await; for u in ["alice", "bob", "carol"] { create_user(&admin, u, PW, &[]).await; } let alice = login(&env, "alice", PW).await; Pim { env, admin, alice } } async fn req(&self, user: &str, verb: &str, path: &str, depth: &str, body: &str) -> Resp { let auth = basic(user, PW); Client::new(self.env.app.clone()) .raw( Method::from_bytes(verb.as_bytes()).unwrap(), path, &[("authorization", &auth), ("depth", depth)], body.as_bytes().to_vec(), ) .await } async fn put(&self, user: &str, path: &str, body: &str) { let r = self.req(user, "PUT", path, "0", body).await; assert!( r.status.is_success(), "PUT {path}: {} {}", r.status, r.text() ); } /// The hrefs PROPFIND lists below a collection. async fn members(&self, user: &str, collection: &str) -> Vec { let r = self.req(user, "PROPFIND", collection, "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() } async fn sync_token(&self, user: &str, collection: &str) -> String { let body = ""; let r = self.req(user, "PROPFIND", collection, "0", body).await; let root = Element::parse(r.body.as_slice()).unwrap(); let resp = xml::elements(&root).next().unwrap(); let stat = xml::child(resp, DAV, "propstat").unwrap(); let prop = xml::child(stat, DAV, "prop").unwrap(); xml::text(xml::child(prop, DAV, "sync-token").unwrap()) } /// The id of alice's collection with this 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() } } fn unfold(s: &str) -> String { s.replace("\r\n ", "") } fn contact(uid: &str, extra: &str) -> String { format!("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:Anna Berg\r\n{extra}END:VCARD\r\n") } /// A contact whose PHOTO is a small PNG, inline as vCard 3 does it. fn contact_with_photo(uid: &str) -> String { let mut png = Vec::new(); image::RgbImage::from_pixel(8, 8, image::Rgb([200, 30, 30])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(&png); contact(uid, &format!("PHOTO;ENCODING=b;TYPE=PNG:{b64}\r\n")) } #[tokio::test] async fn contact_photos() { let pim = Pim::new(Env::with_thumbs().await).await; pim.put( "alice", &format!("{BOOK}anna.vcf"), &contact_with_photo("anna"), ) .await; pim.put( "alice", &format!("{BOOK}url.vcf"), &contact("url", "PHOTO;VALUE=uri:http://127.0.0.1/a.png\r\n"), ) .await; pim.put("alice", &format!("{BOOK}none.vcf"), &contact("none", "")) .await; let id = pim.id(BOOK).await; let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo"); let r = pim.alice.get(&photo("anna.vcf")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.header("content-type").as_deref(), Some("image/webp")); assert_eq!(&r.body[..4], b"RIFF"); let cached = walk(pim.env.cache.as_ref().unwrap().path()); assert_eq!(cached, 1, "one thumbnail in the cache"); let etag = r.header("etag").unwrap(); let again = pim .alice .raw( Method::GET, &photo("anna.vcf"), &[("if-none-match", &etag)], Vec::new(), ) .await; assert_eq!(again.status, StatusCode::NOT_MODIFIED); // No inline image, no object, or no access: 404. for name in ["url.vcf", "none.vcf", "missing.vcf"] { assert_eq!( pim.alice.get(&photo(name)).await.status, StatusCode::NOT_FOUND ); } let bob = login(&pim.env, "bob", PW).await; assert_eq!( bob.get(&photo("anna.vcf")).await.status, StatusCode::NOT_FOUND ); let r = pim .alice .post_json( &format!("/api/pim/collections/{id}/shares"), &json!({"user": "bob", "mode": "ro"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(bob.get(&photo("anna.vcf")).await.status, StatusCode::OK); // A calendar holds no photos. let cal = pim.id("/pim/calendars/alice/default/").await; let r = pim .alice .get(&format!("/api/pim/collections/{cal}/objects/x.ics/photo")) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); } /// Files below `dir`. fn walk(dir: &std::path::Path) -> usize { std::fs::read_dir(dir) .unwrap() .map(|e| e.unwrap().path()) .map(|p| if p.is_dir() { walk(&p) } else { 1 }) .sum() } #[tokio::test] async fn photos_without_a_cache() { let pim = Pim::new(Env::new().await).await; pim.put( "alice", &format!("{BOOK}anna.vcf"), &contact_with_photo("anna"), ) .await; let broken = pim .req( "alice", "PUT", &format!("{BOOK}broken.vcf"), "0", &contact("broken", "PHOTO;ENCODING=b;TYPE=PNG:bm90IGFuIGltYWdl\r\n"), ) .await; let etag = broken.header("etag").unwrap(); let id = pim.id(BOOK).await; let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo"); let r = pim.alice.get(&photo("anna.vcf")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(&r.body[..4], b"RIFF"); // The image does not decode, so a 304 proves the ETag check comes first. assert_eq!( pim.alice.get(&photo("broken.vcf")).await.status, StatusCode::NOT_FOUND ); let r = pim .alice .raw( Method::GET, &photo("broken.vcf"), &[("if-none-match", &etag)], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::NOT_MODIFIED); } #[tokio::test] async fn birthday_calendar() { let pim = Pim::new(Env::new().await).await; let birthdays = "/pim/calendars/alice/birthdays/"; assert!( pim.members("alice", "/pim/calendars/alice/") .await .contains(&birthdays.to_string()) ); assert!(pim.members("alice", birthdays).await.is_empty()); pim.put( "alice", &format!("{BOOK}anna.vcf"), &contact("anna", "BDAY:1980-03-15\r\n"), ) .await; // Only the own address books count, not the system one or lent ones. let members = pim.members("alice", birthdays).await; assert_eq!(members.len(), 1, "{members:?}"); let r = pim.req("alice", "GET", &members[0], "0", "").await; assert_eq!(r.status, StatusCode::OK); let ics = unfold(&r.text()); assert!(ics.contains("SUMMARY:🎂 Anna Berg (1980)"), "{ics}"); assert!(ics.contains("RRULE:FREQ=YEARLY")); assert!( pim.members("bob", "/pim/calendars/bob/birthdays/") .await .is_empty() ); // A changed birthday changes the token; the old one is no longer valid. let before = pim.sync_token("alice", birthdays).await; pim.put( "alice", &format!("{BOOK}anna.vcf"), &contact("anna", "BDAY:1980-03-16\r\n"), ) .await; let after = pim.sync_token("alice", birthdays).await; assert_ne!(before, after); let sync = |token: &str| { format!( "{token}\ 1" ) }; let r = pim .req("alice", "REPORT", birthdays, "1", &sync(&before)) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(r.text().contains("valid-sync-token")); let r = pim .req("alice", "REPORT", birthdays, "1", &sync(&after)) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); // Without a change log a cut answer could not resume, so a limit is // ignored: an initial sync lists every member, with no 507. pim.put( "alice", &format!("{BOOK}ben.vcf"), &contact("ben", "BDAY:1990-07-01\r\n"), ) .await; let limited = "1\ 1\ "; let r = pim.req("alice", "REPORT", birthdays, "1", limited).await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let root = Element::parse(r.body.as_slice()).unwrap(); let hrefs: Vec<_> = xml::elements(&root) .filter_map(|resp| xml::child(resp, DAV, "href").map(xml::text)) .collect(); assert_eq!(hrefs.len(), 2, "{}", r.text()); assert!( hrefs .iter() .all(|h| h.starts_with(birthdays) && h != birthdays), "{hrefs:?}" ); // Read-only. let r = pim .req("alice", "PUT", &format!("{birthdays}x.ics"), "0", "x") .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(r.text().contains("need-privileges")); let r = pim.req("alice", "DELETE", &members[0], "0", "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); let r = pim.req("alice", "DELETE", birthdays, "0", "").await; assert_eq!(r.status, StatusCode::FORBIDDEN); // Birthdays are transparent: no busy time. let fb = "\ "; let r = pim.req("alice", "REPORT", birthdays, "1", fb).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!( !r.text().lines().any(|l| l.starts_with("FREEBUSY")), "{}", r.text() ); } fn meeting(uid: &str, organizer: &str, attendees: &[&str], start: &str) -> String { let attendees: String = attendees .iter() .map(|a| { let cn = a.trim_start_matches("mailto:").split('@').next().unwrap(); format!("ATTENDEE;CN=\"{cn}\";PARTSTAT=NEEDS-ACTION:{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\nSUMMARY:Planning\r\n\ ORGANIZER:{organizer}\r\nATTENDEE;PARTSTAT=ACCEPTED:{organizer}\r\n{attendees}\ END:VEVENT\r\nEND:VCALENDAR\r\n" ) } fn addr(user: &str) -> String { format!("mailto:{user}@dovenest.invalid") } #[tokio::test] async fn deleted_principals_are_forgotten() { let pim = Pim::new(Env::new().await).await; // A name no other test uses: Basic credentials are cached per process, // and a recreated account must not collide with a parallel test's. create_user(&pim.admin, "erin", PW, &[]).await; let r = pim .admin .post_json( "/api/admin/rooms", &json!({"name": "atrium", "kind": "room"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let room = r.json()["id"].as_i64().unwrap(); let atrium = "mailto:atrium@rooms.dovenest.invalid"; let own = "/pim/calendars/alice/default/own.ics"; pim.put( "alice", own, &meeting( "own", &addr("alice"), &[&addr("erin"), atrium], "20260310T100000Z", ), ) .await; pim.put( "erin", "/pim/calendars/erin/default/theirs.ics", &meeting( "theirs", &addr("erin"), &[&addr("alice")], "20260311T100000Z", ), ) .await; let alice_cal = "/pim/calendars/alice/default/"; let copies = pim.members("alice", alice_cal).await; assert_eq!(copies.len(), 2, "{copies:?}"); let token = pim.sync_token("alice", alice_cal).await; let erin_id = user_id(&pim.admin, "erin").await; let r = pim .admin .delete(&format!("/api/admin/users/{erin_id}")) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let r = pim.admin.delete(&format!("/api/admin/rooms/{room}")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_ne!(pim.sync_token("alice", alice_cal).await, token); let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text()); assert!(!org.contains(&addr("erin")), "{org}"); assert!(!org.contains(atrium), "{org}"); let tombs: Vec<&str> = org .lines() .filter(|l| l.contains("@deleted.dovenest.invalid")) .collect(); assert_eq!(tombs.len(), 2, "{org}"); assert!( tombs.iter().all(|l| l.contains("SCHEDULE-STATUS=3.7")), "{org}" ); // The display name stays. assert!( tombs .iter() .any(|l| l.replace('"', "").contains("CN=erin;")), "{org}" ); let theirs = copies.iter().find(|h| !h.ends_with("own.ics")).unwrap(); let copy = unfold(&pim.req("alice", "GET", theirs, "0", "").await.text()); assert!(copy.contains("STATUS:CANCELLED"), "{copy}"); assert!(copy.contains("ORGANIZER:mailto:erin-"), "{copy}"); // A new erin is not the old one: alice's next update reaches no one. create_user(&pim.admin, "erin", PW, &[]).await; pim.put( "alice", own, &unfold(&pim.req("alice", "GET", own, "0", "").await.text()) .replace("20260310T100000Z", "20260312T100000Z"), ) .await; assert!( pim.members("erin", "/pim/calendars/erin/default/") .await .is_empty() ); assert!( pim.members("erin", "/pim/calendars/erin/inbox/") .await .is_empty() ); let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text()); assert!(!org.contains(&addr("erin")), "{org}"); } #[tokio::test] async fn admin_sees_and_revokes_feeds() { let pim = Pim::new(Env::new().await).await; let id = pim.id("/pim/calendars/alice/default/").await; let r = pim .alice .post_json( &format!("/api/pim/collections/{id}/links"), &json!({"busy_only": true}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let path = r.json()["path"].as_str().unwrap().to_string(); let list = pim.admin.get("/api/admin/pim-links").await; assert_eq!(list.status, StatusCode::OK); let links = list.json(); let link = &links.as_array().unwrap()[0]; assert_eq!(link["owner_name"], "alice"); assert_eq!(link["owner_active"], true); assert_eq!(link["kind"], "calendar"); assert_eq!(link["collection_id"], id); assert_eq!(link["busy_only"], true); assert_eq!(link["path"], path.as_str()); assert_eq!( pim.alice.get("/api/admin/pim-links").await.status, StatusCode::FORBIDDEN ); let anon = Client::new(pim.env.app.clone()); assert_eq!(anon.get(&path).await.status, StatusCode::OK); let link_id = link["id"].as_i64().unwrap(); let r = pim .admin .delete(&format!("/api/admin/pim-links/{link_id}")) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(anon.get(&path).await.status, StatusCode::NOT_FOUND); let r = pim .admin .delete(&format!("/api/admin/pim-links/{link_id}")) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn own_shares_list_only_mine() { let pim = Pim::new(Env::new().await).await; let bob = login(&pim.env, "bob", PW).await; let cal = pim.id("/pim/calendars/alice/default/").await; let r = pim .alice .post_json(&format!("/api/pim/collections/{cal}/links"), &json!({})) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let r = pim .alice .post_json( &format!("/api/pim/collections/{cal}/shares"), &json!({"user": "bob", "mode": "rw"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let mine = pim.alice.get("/api/pim/shares").await; assert_eq!(mine.status, StatusCode::OK); let mine = mine.json(); assert_eq!(mine["links"].as_array().unwrap().len(), 1); assert_eq!(mine["links"][0]["collection_id"], cal); assert_eq!(mine["lends"].as_array().unwrap().len(), 1); assert_eq!(mine["lends"][0]["collection_id"], cal); assert_eq!(mine["lends"][0]["user_name"], "bob"); assert_eq!(mine["lends"][0]["mode"], "rw"); // The borrower sees neither alice's feed nor her loan as his own. let theirs = bob.get("/api/pim/shares").await.json(); assert!(theirs["links"].as_array().unwrap().is_empty(), "{theirs}"); assert!(theirs["lends"].as_array().unwrap().is_empty(), "{theirs}"); let admin = pim.admin.get("/api/pim/shares").await.json(); assert!(admin["links"].as_array().unwrap().is_empty(), "{admin}"); }