api_pim_derived.rs
| 1 | //! What the server derives on its own: contact photos, the birthday |
| 2 | //! calendar, the cleanup after a deleted principal, and the admin view of |
| 3 | //! public feeds. |
| 4 | |
| 5 | mod common; |
| 6 | |
| 7 | use axum::http::{Method, StatusCode}; |
| 8 | use base64::Engine; |
| 9 | use common::*; |
| 10 | use pimdav::xml::{self, DAV}; |
| 11 | use serde_json::json; |
| 12 | use xmltree::Element; |
| 13 | |
| 14 | const PW: &str = "secret12345"; |
| 15 | const BOOK: &str = "/pim/addressbooks/alice/default/"; |
| 16 | |
| 17 | struct Pim { |
| 18 | env: Env, |
| 19 | admin: Client, |
| 20 | alice: Client, |
| 21 | } |
| 22 | |
| 23 | impl Pim { |
| 24 | async fn new(env: Env) -> Self { |
| 25 | let admin = env.admin().await; |
| 26 | for u in ["alice", "bob", "carol"] { |
| 27 | create_user(&admin, u, PW, &[]).await; |
| 28 | } |
| 29 | let alice = login(&env, "alice", PW).await; |
| 30 | Pim { env, admin, alice } |
| 31 | } |
| 32 | |
| 33 | async fn req(&self, user: &str, verb: &str, path: &str, depth: &str, body: &str) -> Resp { |
| 34 | let auth = basic(user, PW); |
| 35 | Client::new(self.env.app.clone()) |
| 36 | .raw( |
| 37 | Method::from_bytes(verb.as_bytes()).unwrap(), |
| 38 | path, |
| 39 | &[("authorization", &auth), ("depth", depth)], |
| 40 | body.as_bytes().to_vec(), |
| 41 | ) |
| 42 | .await |
| 43 | } |
| 44 | |
| 45 | async fn put(&self, user: &str, path: &str, body: &str) { |
| 46 | let r = self.req(user, "PUT", path, "0", body).await; |
| 47 | assert!( |
| 48 | r.status.is_success(), |
| 49 | "PUT {path}: {} {}", |
| 50 | r.status, |
| 51 | r.text() |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | /// The hrefs PROPFIND lists below a collection. |
| 56 | async fn members(&self, user: &str, collection: &str) -> Vec<String> { |
| 57 | let r = self.req(user, "PROPFIND", collection, "1", "").await; |
| 58 | assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); |
| 59 | let root = Element::parse(r.body.as_slice()).unwrap(); |
| 60 | xml::elements(&root) |
| 61 | .map(|resp| xml::text(xml::child(resp, DAV, "href").unwrap())) |
| 62 | .filter(|h| h != collection) |
| 63 | .collect() |
| 64 | } |
| 65 | |
| 66 | async fn sync_token(&self, user: &str, collection: &str) -> String { |
| 67 | let body = "<d:propfind xmlns:d=\"DAV:\"><d:prop><d:sync-token/></d:prop></d:propfind>"; |
| 68 | let r = self.req(user, "PROPFIND", collection, "0", body).await; |
| 69 | let root = Element::parse(r.body.as_slice()).unwrap(); |
| 70 | let resp = xml::elements(&root).next().unwrap(); |
| 71 | let stat = xml::child(resp, DAV, "propstat").unwrap(); |
| 72 | let prop = xml::child(stat, DAV, "prop").unwrap(); |
| 73 | xml::text(xml::child(prop, DAV, "sync-token").unwrap()) |
| 74 | } |
| 75 | |
| 76 | /// The id of alice's collection with this URL. |
| 77 | async fn id(&self, url: &str) -> i64 { |
| 78 | let list = self.alice.get("/api/pim/collections").await.json(); |
| 79 | list.as_array() |
| 80 | .unwrap() |
| 81 | .iter() |
| 82 | .find(|c| c["url"] == url) |
| 83 | .unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"] |
| 84 | .as_i64() |
| 85 | .unwrap() |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | fn unfold(s: &str) -> String { |
| 90 | s.replace("\r\n ", "") |
| 91 | } |
| 92 | |
| 93 | fn contact(uid: &str, extra: &str) -> String { |
| 94 | format!("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:Anna Berg\r\n{extra}END:VCARD\r\n") |
| 95 | } |
| 96 | |
| 97 | /// A contact whose PHOTO is a small PNG, inline as vCard 3 does it. |
| 98 | fn contact_with_photo(uid: &str) -> String { |
| 99 | let mut png = Vec::new(); |
| 100 | image::RgbImage::from_pixel(8, 8, image::Rgb([200, 30, 30])) |
| 101 | .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) |
| 102 | .unwrap(); |
| 103 | let b64 = base64::engine::general_purpose::STANDARD.encode(&png); |
| 104 | contact(uid, &format!("PHOTO;ENCODING=b;TYPE=PNG:{b64}\r\n")) |
| 105 | } |
| 106 | |
| 107 | #[tokio::test] |
| 108 | async fn contact_photos() { |
| 109 | let pim = Pim::new(Env::with_thumbs().await).await; |
| 110 | pim.put( |
| 111 | "alice", |
| 112 | &format!("{BOOK}anna.vcf"), |
| 113 | &contact_with_photo("anna"), |
| 114 | ) |
| 115 | .await; |
| 116 | pim.put( |
| 117 | "alice", |
| 118 | &format!("{BOOK}url.vcf"), |
| 119 | &contact("url", "PHOTO;VALUE=uri:http://127.0.0.1/a.png\r\n"), |
| 120 | ) |
| 121 | .await; |
| 122 | pim.put("alice", &format!("{BOOK}none.vcf"), &contact("none", "")) |
| 123 | .await; |
| 124 | let id = pim.id(BOOK).await; |
| 125 | let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo"); |
| 126 | |
| 127 | let r = pim.alice.get(&photo("anna.vcf")).await; |
| 128 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 129 | assert_eq!(r.header("content-type").as_deref(), Some("image/webp")); |
| 130 | assert_eq!(&r.body[..4], b"RIFF"); |
| 131 | let cached = walk(pim.env.cache.as_ref().unwrap().path()); |
| 132 | assert_eq!(cached, 1, "one thumbnail in the cache"); |
| 133 | let etag = r.header("etag").unwrap(); |
| 134 | let again = pim |
| 135 | .alice |
| 136 | .raw( |
| 137 | Method::GET, |
| 138 | &photo("anna.vcf"), |
| 139 | &[("if-none-match", &etag)], |
| 140 | Vec::new(), |
| 141 | ) |
| 142 | .await; |
| 143 | assert_eq!(again.status, StatusCode::NOT_MODIFIED); |
| 144 | |
| 145 | // No inline image, no object, or no access: 404. |
| 146 | for name in ["url.vcf", "none.vcf", "missing.vcf"] { |
| 147 | assert_eq!( |
| 148 | pim.alice.get(&photo(name)).await.status, |
| 149 | StatusCode::NOT_FOUND |
| 150 | ); |
| 151 | } |
| 152 | let bob = login(&pim.env, "bob", PW).await; |
| 153 | assert_eq!( |
| 154 | bob.get(&photo("anna.vcf")).await.status, |
| 155 | StatusCode::NOT_FOUND |
| 156 | ); |
| 157 | let r = pim |
| 158 | .alice |
| 159 | .post_json( |
| 160 | &format!("/api/pim/collections/{id}/shares"), |
| 161 | &json!({"user": "bob", "mode": "ro"}), |
| 162 | ) |
| 163 | .await; |
| 164 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 165 | assert_eq!(bob.get(&photo("anna.vcf")).await.status, StatusCode::OK); |
| 166 | |
| 167 | // A calendar holds no photos. |
| 168 | let cal = pim.id("/pim/calendars/alice/default/").await; |
| 169 | let r = pim |
| 170 | .alice |
| 171 | .get(&format!("/api/pim/collections/{cal}/objects/x.ics/photo")) |
| 172 | .await; |
| 173 | assert_eq!(r.status, StatusCode::NOT_FOUND); |
| 174 | } |
| 175 | |
| 176 | /// Files below `dir`. |
| 177 | fn walk(dir: &std::path::Path) -> usize { |
| 178 | std::fs::read_dir(dir) |
| 179 | .unwrap() |
| 180 | .map(|e| e.unwrap().path()) |
| 181 | .map(|p| if p.is_dir() { walk(&p) } else { 1 }) |
| 182 | .sum() |
| 183 | } |
| 184 | |
| 185 | #[tokio::test] |
| 186 | async fn photos_without_a_cache() { |
| 187 | let pim = Pim::new(Env::new().await).await; |
| 188 | pim.put( |
| 189 | "alice", |
| 190 | &format!("{BOOK}anna.vcf"), |
| 191 | &contact_with_photo("anna"), |
| 192 | ) |
| 193 | .await; |
| 194 | let broken = pim |
| 195 | .req( |
| 196 | "alice", |
| 197 | "PUT", |
| 198 | &format!("{BOOK}broken.vcf"), |
| 199 | "0", |
| 200 | &contact("broken", "PHOTO;ENCODING=b;TYPE=PNG:bm90IGFuIGltYWdl\r\n"), |
| 201 | ) |
| 202 | .await; |
| 203 | let etag = broken.header("etag").unwrap(); |
| 204 | let id = pim.id(BOOK).await; |
| 205 | let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo"); |
| 206 | |
| 207 | let r = pim.alice.get(&photo("anna.vcf")).await; |
| 208 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 209 | assert_eq!(&r.body[..4], b"RIFF"); |
| 210 | |
| 211 | // The image does not decode, so a 304 proves the ETag check comes first. |
| 212 | assert_eq!( |
| 213 | pim.alice.get(&photo("broken.vcf")).await.status, |
| 214 | StatusCode::NOT_FOUND |
| 215 | ); |
| 216 | let r = pim |
| 217 | .alice |
| 218 | .raw( |
| 219 | Method::GET, |
| 220 | &photo("broken.vcf"), |
| 221 | &[("if-none-match", &etag)], |
| 222 | Vec::new(), |
| 223 | ) |
| 224 | .await; |
| 225 | assert_eq!(r.status, StatusCode::NOT_MODIFIED); |
| 226 | } |
| 227 | |
| 228 | #[tokio::test] |
| 229 | async fn birthday_calendar() { |
| 230 | let pim = Pim::new(Env::new().await).await; |
| 231 | let birthdays = "/pim/calendars/alice/birthdays/"; |
| 232 | assert!( |
| 233 | pim.members("alice", "/pim/calendars/alice/") |
| 234 | .await |
| 235 | .contains(&birthdays.to_string()) |
| 236 | ); |
| 237 | assert!(pim.members("alice", birthdays).await.is_empty()); |
| 238 | |
| 239 | pim.put( |
| 240 | "alice", |
| 241 | &format!("{BOOK}anna.vcf"), |
| 242 | &contact("anna", "BDAY:1980-03-15\r\n"), |
| 243 | ) |
| 244 | .await; |
| 245 | // Only the own address books count, not the system one or lent ones. |
| 246 | let members = pim.members("alice", birthdays).await; |
| 247 | assert_eq!(members.len(), 1, "{members:?}"); |
| 248 | let r = pim.req("alice", "GET", &members[0], "0", "").await; |
| 249 | assert_eq!(r.status, StatusCode::OK); |
| 250 | let ics = unfold(&r.text()); |
| 251 | assert!(ics.contains("SUMMARY:🎂 Anna Berg (1980)"), "{ics}"); |
| 252 | assert!(ics.contains("RRULE:FREQ=YEARLY")); |
| 253 | assert!( |
| 254 | pim.members("bob", "/pim/calendars/bob/birthdays/") |
| 255 | .await |
| 256 | .is_empty() |
| 257 | ); |
| 258 | |
| 259 | // A changed birthday changes the token; the old one is no longer valid. |
| 260 | let before = pim.sync_token("alice", birthdays).await; |
| 261 | pim.put( |
| 262 | "alice", |
| 263 | &format!("{BOOK}anna.vcf"), |
| 264 | &contact("anna", "BDAY:1980-03-16\r\n"), |
| 265 | ) |
| 266 | .await; |
| 267 | let after = pim.sync_token("alice", birthdays).await; |
| 268 | assert_ne!(before, after); |
| 269 | let sync = |token: &str| { |
| 270 | format!( |
| 271 | "<d:sync-collection xmlns:d=\"DAV:\"><d:sync-token>{token}</d:sync-token>\ |
| 272 | <d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>" |
| 273 | ) |
| 274 | }; |
| 275 | let r = pim |
| 276 | .req("alice", "REPORT", birthdays, "1", &sync(&before)) |
| 277 | .await; |
| 278 | assert_eq!(r.status, StatusCode::FORBIDDEN); |
| 279 | assert!(r.text().contains("valid-sync-token")); |
| 280 | let r = pim |
| 281 | .req("alice", "REPORT", birthdays, "1", &sync(&after)) |
| 282 | .await; |
| 283 | assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); |
| 284 | |
| 285 | // Read-only. |
| 286 | let r = pim |
| 287 | .req("alice", "PUT", &format!("{birthdays}x.ics"), "0", "x") |
| 288 | .await; |
| 289 | assert_eq!(r.status, StatusCode::FORBIDDEN); |
| 290 | assert!(r.text().contains("need-privileges")); |
| 291 | let r = pim.req("alice", "DELETE", &members[0], "0", "").await; |
| 292 | assert_eq!(r.status, StatusCode::FORBIDDEN); |
| 293 | let r = pim.req("alice", "DELETE", birthdays, "0", "").await; |
| 294 | assert_eq!(r.status, StatusCode::FORBIDDEN); |
| 295 | |
| 296 | // Birthdays are transparent: no busy time. |
| 297 | let fb = "<c:free-busy-query xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\ |
| 298 | <c:time-range start=\"20260101T000000Z\" end=\"20270101T000000Z\"/></c:free-busy-query>"; |
| 299 | let r = pim.req("alice", "REPORT", birthdays, "1", fb).await; |
| 300 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 301 | assert!( |
| 302 | !r.text().lines().any(|l| l.starts_with("FREEBUSY")), |
| 303 | "{}", |
| 304 | r.text() |
| 305 | ); |
| 306 | } |
| 307 | |
| 308 | fn meeting(uid: &str, organizer: &str, attendees: &[&str], start: &str) -> String { |
| 309 | let attendees: String = attendees |
| 310 | .iter() |
| 311 | .map(|a| { |
| 312 | let cn = a.trim_start_matches("mailto:").split('@').next().unwrap(); |
| 313 | format!("ATTENDEE;CN=\"{cn}\";PARTSTAT=NEEDS-ACTION:{a}\r\n") |
| 314 | }) |
| 315 | .collect(); |
| 316 | format!( |
| 317 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\ |
| 318 | DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\ |
| 319 | ORGANIZER:{organizer}\r\nATTENDEE;PARTSTAT=ACCEPTED:{organizer}\r\n{attendees}\ |
| 320 | END:VEVENT\r\nEND:VCALENDAR\r\n" |
| 321 | ) |
| 322 | } |
| 323 | |
| 324 | fn addr(user: &str) -> String { |
| 325 | format!("mailto:{user}@dovenest.invalid") |
| 326 | } |
| 327 | |
| 328 | #[tokio::test] |
| 329 | async fn deleted_principals_are_forgotten() { |
| 330 | let pim = Pim::new(Env::new().await).await; |
| 331 | // A name no other test uses: Basic credentials are cached per process, |
| 332 | // and a recreated account must not collide with a parallel test's. |
| 333 | create_user(&pim.admin, "erin", PW, &[]).await; |
| 334 | let r = pim |
| 335 | .admin |
| 336 | .post_json( |
| 337 | "/api/admin/rooms", |
| 338 | &json!({"name": "atrium", "kind": "room"}), |
| 339 | ) |
| 340 | .await; |
| 341 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 342 | let room = r.json()["id"].as_i64().unwrap(); |
| 343 | let atrium = "mailto:atrium@rooms.dovenest.invalid"; |
| 344 | |
| 345 | let own = "/pim/calendars/alice/default/own.ics"; |
| 346 | pim.put( |
| 347 | "alice", |
| 348 | own, |
| 349 | &meeting( |
| 350 | "own", |
| 351 | &addr("alice"), |
| 352 | &[&addr("erin"), atrium], |
| 353 | "20260310T100000Z", |
| 354 | ), |
| 355 | ) |
| 356 | .await; |
| 357 | pim.put( |
| 358 | "erin", |
| 359 | "/pim/calendars/erin/default/theirs.ics", |
| 360 | &meeting( |
| 361 | "theirs", |
| 362 | &addr("erin"), |
| 363 | &[&addr("alice")], |
| 364 | "20260311T100000Z", |
| 365 | ), |
| 366 | ) |
| 367 | .await; |
| 368 | let alice_cal = "/pim/calendars/alice/default/"; |
| 369 | let copies = pim.members("alice", alice_cal).await; |
| 370 | assert_eq!(copies.len(), 2, "{copies:?}"); |
| 371 | let token = pim.sync_token("alice", alice_cal).await; |
| 372 | |
| 373 | let erin_id = user_id(&pim.admin, "erin").await; |
| 374 | let r = pim |
| 375 | .admin |
| 376 | .delete(&format!("/api/admin/users/{erin_id}")) |
| 377 | .await; |
| 378 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 379 | let r = pim.admin.delete(&format!("/api/admin/rooms/{room}")).await; |
| 380 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 381 | |
| 382 | assert_ne!(pim.sync_token("alice", alice_cal).await, token); |
| 383 | let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text()); |
| 384 | assert!(!org.contains(&addr("erin")), "{org}"); |
| 385 | assert!(!org.contains(atrium), "{org}"); |
| 386 | let tombs: Vec<&str> = org |
| 387 | .lines() |
| 388 | .filter(|l| l.contains("@deleted.dovenest.invalid")) |
| 389 | .collect(); |
| 390 | assert_eq!(tombs.len(), 2, "{org}"); |
| 391 | assert!( |
| 392 | tombs.iter().all(|l| l.contains("SCHEDULE-STATUS=3.7")), |
| 393 | "{org}" |
| 394 | ); |
| 395 | // The display name stays. |
| 396 | assert!( |
| 397 | tombs |
| 398 | .iter() |
| 399 | .any(|l| l.replace('"', "").contains("CN=erin;")), |
| 400 | "{org}" |
| 401 | ); |
| 402 | |
| 403 | let theirs = copies.iter().find(|h| !h.ends_with("own.ics")).unwrap(); |
| 404 | let copy = unfold(&pim.req("alice", "GET", theirs, "0", "").await.text()); |
| 405 | assert!(copy.contains("STATUS:CANCELLED"), "{copy}"); |
| 406 | assert!(copy.contains("ORGANIZER:mailto:erin-"), "{copy}"); |
| 407 | |
| 408 | // A new erin is not the old one: alice's next update reaches no one. |
| 409 | create_user(&pim.admin, "erin", PW, &[]).await; |
| 410 | pim.put( |
| 411 | "alice", |
| 412 | own, |
| 413 | &unfold(&pim.req("alice", "GET", own, "0", "").await.text()) |
| 414 | .replace("20260310T100000Z", "20260312T100000Z"), |
| 415 | ) |
| 416 | .await; |
| 417 | assert!( |
| 418 | pim.members("erin", "/pim/calendars/erin/default/") |
| 419 | .await |
| 420 | .is_empty() |
| 421 | ); |
| 422 | assert!( |
| 423 | pim.members("erin", "/pim/calendars/erin/inbox/") |
| 424 | .await |
| 425 | .is_empty() |
| 426 | ); |
| 427 | let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text()); |
| 428 | assert!(!org.contains(&addr("erin")), "{org}"); |
| 429 | } |
| 430 | |
| 431 | #[tokio::test] |
| 432 | async fn admin_sees_and_revokes_feeds() { |
| 433 | let pim = Pim::new(Env::new().await).await; |
| 434 | let id = pim.id("/pim/calendars/alice/default/").await; |
| 435 | let r = pim |
| 436 | .alice |
| 437 | .post_json( |
| 438 | &format!("/api/pim/collections/{id}/links"), |
| 439 | &json!({"busy_only": true}), |
| 440 | ) |
| 441 | .await; |
| 442 | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
| 443 | let path = r.json()["path"].as_str().unwrap().to_string(); |
| 444 | |
| 445 | let list = pim.admin.get("/api/admin/pim-links").await; |
| 446 | assert_eq!(list.status, StatusCode::OK); |
| 447 | let links = list.json(); |
| 448 | let link = &links.as_array().unwrap()[0]; |
| 449 | assert_eq!(link["owner_name"], "alice"); |
| 450 | assert_eq!(link["owner_active"], true); |
| 451 | assert_eq!(link["kind"], "calendar"); |
| 452 | assert_eq!(link["collection_id"], id); |
| 453 | assert_eq!(link["busy_only"], true); |
| 454 | assert_eq!(link["path"], path.as_str()); |
| 455 | assert_eq!( |
| 456 | pim.alice.get("/api/admin/pim-links").await.status, |
| 457 | StatusCode::FORBIDDEN |
| 458 | ); |
| 459 | |
| 460 | let anon = Client::new(pim.env.app.clone()); |
| 461 | assert_eq!(anon.get(&path).await.status, StatusCode::OK); |
| 462 | let link_id = link["id"].as_i64().unwrap(); |
| 463 | let r = pim |
| 464 | .admin |
| 465 | .delete(&format!("/api/admin/pim-links/{link_id}")) |
| 466 | .await; |
| 467 | assert_eq!(r.status, StatusCode::OK); |
| 468 | assert_eq!(anon.get(&path).await.status, StatusCode::NOT_FOUND); |
| 469 | let r = pim |
| 470 | .admin |
| 471 | .delete(&format!("/api/admin/pim-links/{link_id}")) |
| 472 | .await; |
| 473 | assert_eq!(r.status, StatusCode::NOT_FOUND); |
| 474 | } |
| 475 |