api_pim_derived.rs
⎇
Raw
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
5mod common;
6
7use axum::http::{Method, StatusCode};
8use base64::Engine;
9use common::*;
10use pimdav::xml::{self, DAV};
11use serde_json::json;
12use xmltree::Element;
13
14const PW: &str = "secret12345";
15const BOOK: &str = "/pim/addressbooks/alice/default/";
16
17struct Pim {
18 env: Env,
19 admin: Client,
20 alice: Client,
21}
22
23impl 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
89fn unfold(s: &str) -> String {
90 s.replace("\r\n ", "")
91}
92
93fn 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.
98fn 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]
108async 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`.
177fn 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]
186async 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]
229async 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 // Without a change log a cut answer could not resume, so a limit is
286 // ignored: an initial sync lists every member, with no 507.
287 pim.put(
288 "alice",
289 &format!("{BOOK}ben.vcf"),
290 &contact("ben", "BDAY:1990-07-01\r\n"),
291 )
292 .await;
293 let limited = "<d:sync-collection xmlns:d=\"DAV:\"><d:sync-token/><d:sync-level>1</d:sync-level>\
294 <d:limit><d:nresults>1</d:nresults></d:limit><d:prop><d:getetag/></d:prop>\
295 </d:sync-collection>";
296 let r = pim.req("alice", "REPORT", birthdays, "1", limited).await;
297 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
298 let root = Element::parse(r.body.as_slice()).unwrap();
299 let hrefs: Vec<_> = xml::elements(&root)
300 .filter_map(|resp| xml::child(resp, DAV, "href").map(xml::text))
301 .collect();
302 assert_eq!(hrefs.len(), 2, "{}", r.text());
303 assert!(
304 hrefs
305 .iter()
306 .all(|h| h.starts_with(birthdays) && h != birthdays),
307 "{hrefs:?}"
308 );
309
310 // Read-only.
311 let r = pim
312 .req("alice", "PUT", &format!("{birthdays}x.ics"), "0", "x")
313 .await;
314 assert_eq!(r.status, StatusCode::FORBIDDEN);
315 assert!(r.text().contains("need-privileges"));
316 let r = pim.req("alice", "DELETE", &members[0], "0", "").await;
317 assert_eq!(r.status, StatusCode::FORBIDDEN);
318 let r = pim.req("alice", "DELETE", birthdays, "0", "").await;
319 assert_eq!(r.status, StatusCode::FORBIDDEN);
320
321 // Birthdays are transparent: no busy time.
322 let fb = "<c:free-busy-query xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
323 <c:time-range start=\"20260101T000000Z\" end=\"20270101T000000Z\"/></c:free-busy-query>";
324 let r = pim.req("alice", "REPORT", birthdays, "1", fb).await;
325 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
326 assert!(
327 !r.text().lines().any(|l| l.starts_with("FREEBUSY")),
328 "{}",
329 r.text()
330 );
331}
332
333fn meeting(uid: &str, organizer: &str, attendees: &[&str], start: &str) -> String {
334 let attendees: String = attendees
335 .iter()
336 .map(|a| {
337 let cn = a.trim_start_matches("mailto:").split('@').next().unwrap();
338 format!("ATTENDEE;CN=\"{cn}\";PARTSTAT=NEEDS-ACTION:{a}\r\n")
339 })
340 .collect();
341 format!(
342 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
343 DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\
344 ORGANIZER:{organizer}\r\nATTENDEE;PARTSTAT=ACCEPTED:{organizer}\r\n{attendees}\
345 END:VEVENT\r\nEND:VCALENDAR\r\n"
346 )
347}
348
349fn addr(user: &str) -> String {
350 format!("mailto:{user}@dovenest.invalid")
351}
352
353#[tokio::test]
354async fn deleted_principals_are_forgotten() {
355 let pim = Pim::new(Env::new().await).await;
356 // A name no other test uses: Basic credentials are cached per process,
357 // and a recreated account must not collide with a parallel test's.
358 create_user(&pim.admin, "erin", PW, &[]).await;
359 let r = pim
360 .admin
361 .post_json(
362 "/api/admin/rooms",
363 &json!({"name": "atrium", "kind": "room"}),
364 )
365 .await;
366 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
367 let room = r.json()["id"].as_i64().unwrap();
368 let atrium = "mailto:atrium@rooms.dovenest.invalid";
369
370 let own = "/pim/calendars/alice/default/own.ics";
371 pim.put(
372 "alice",
373 own,
374 &meeting(
375 "own",
376 &addr("alice"),
377 &[&addr("erin"), atrium],
378 "20260310T100000Z",
379 ),
380 )
381 .await;
382 pim.put(
383 "erin",
384 "/pim/calendars/erin/default/theirs.ics",
385 &meeting(
386 "theirs",
387 &addr("erin"),
388 &[&addr("alice")],
389 "20260311T100000Z",
390 ),
391 )
392 .await;
393 let alice_cal = "/pim/calendars/alice/default/";
394 let copies = pim.members("alice", alice_cal).await;
395 assert_eq!(copies.len(), 2, "{copies:?}");
396 let token = pim.sync_token("alice", alice_cal).await;
397
398 let erin_id = user_id(&pim.admin, "erin").await;
399 let r = pim
400 .admin
401 .delete(&format!("/api/admin/users/{erin_id}"))
402 .await;
403 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
404 let r = pim.admin.delete(&format!("/api/admin/rooms/{room}")).await;
405 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
406
407 assert_ne!(pim.sync_token("alice", alice_cal).await, token);
408 let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
409 assert!(!org.contains(&addr("erin")), "{org}");
410 assert!(!org.contains(atrium), "{org}");
411 let tombs: Vec<&str> = org
412 .lines()
413 .filter(|l| l.contains("@deleted.dovenest.invalid"))
414 .collect();
415 assert_eq!(tombs.len(), 2, "{org}");
416 assert!(
417 tombs.iter().all(|l| l.contains("SCHEDULE-STATUS=3.7")),
418 "{org}"
419 );
420 // The display name stays.
421 assert!(
422 tombs
423 .iter()
424 .any(|l| l.replace('"', "").contains("CN=erin;")),
425 "{org}"
426 );
427
428 let theirs = copies.iter().find(|h| !h.ends_with("own.ics")).unwrap();
429 let copy = unfold(&pim.req("alice", "GET", theirs, "0", "").await.text());
430 assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
431 assert!(copy.contains("ORGANIZER:mailto:erin-"), "{copy}");
432
433 // A new erin is not the old one: alice's next update reaches no one.
434 create_user(&pim.admin, "erin", PW, &[]).await;
435 pim.put(
436 "alice",
437 own,
438 &unfold(&pim.req("alice", "GET", own, "0", "").await.text())
439 .replace("20260310T100000Z", "20260312T100000Z"),
440 )
441 .await;
442 assert!(
443 pim.members("erin", "/pim/calendars/erin/default/")
444 .await
445 .is_empty()
446 );
447 assert!(
448 pim.members("erin", "/pim/calendars/erin/inbox/")
449 .await
450 .is_empty()
451 );
452 let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
453 assert!(!org.contains(&addr("erin")), "{org}");
454}
455
456#[tokio::test]
457async fn admin_sees_and_revokes_feeds() {
458 let pim = Pim::new(Env::new().await).await;
459 let id = pim.id("/pim/calendars/alice/default/").await;
460 let r = pim
461 .alice
462 .post_json(
463 &format!("/api/pim/collections/{id}/links"),
464 &json!({"busy_only": true}),
465 )
466 .await;
467 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
468 let path = r.json()["path"].as_str().unwrap().to_string();
469
470 let list = pim.admin.get("/api/admin/pim-links").await;
471 assert_eq!(list.status, StatusCode::OK);
472 let links = list.json();
473 let link = &links.as_array().unwrap()[0];
474 assert_eq!(link["owner_name"], "alice");
475 assert_eq!(link["owner_active"], true);
476 assert_eq!(link["kind"], "calendar");
477 assert_eq!(link["collection_id"], id);
478 assert_eq!(link["busy_only"], true);
479 assert_eq!(link["path"], path.as_str());
480 assert_eq!(
481 pim.alice.get("/api/admin/pim-links").await.status,
482 StatusCode::FORBIDDEN
483 );
484
485 let anon = Client::new(pim.env.app.clone());
486 assert_eq!(anon.get(&path).await.status, StatusCode::OK);
487 let link_id = link["id"].as_i64().unwrap();
488 let r = pim
489 .admin
490 .delete(&format!("/api/admin/pim-links/{link_id}"))
491 .await;
492 assert_eq!(r.status, StatusCode::OK);
493 assert_eq!(anon.get(&path).await.status, StatusCode::NOT_FOUND);
494 let r = pim
495 .admin
496 .delete(&format!("/api/admin/pim-links/{link_id}"))
497 .await;
498 assert_eq!(r.status, StatusCode::NOT_FOUND);
499}
500
501#[tokio::test]
502async fn own_shares_list_only_mine() {
503 let pim = Pim::new(Env::new().await).await;
504 let bob = login(&pim.env, "bob", PW).await;
505 let cal = pim.id("/pim/calendars/alice/default/").await;
506 let r = pim
507 .alice
508 .post_json(&format!("/api/pim/collections/{cal}/links"), &json!({}))
509 .await;
510 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
511 let r = pim
512 .alice
513 .post_json(
514 &format!("/api/pim/collections/{cal}/shares"),
515 &json!({"user": "bob", "mode": "rw"}),
516 )
517 .await;
518 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
519
520 let mine = pim.alice.get("/api/pim/shares").await;
521 assert_eq!(mine.status, StatusCode::OK);
522 let mine = mine.json();
523 assert_eq!(mine["links"].as_array().unwrap().len(), 1);
524 assert_eq!(mine["links"][0]["collection_id"], cal);
525 assert_eq!(mine["lends"].as_array().unwrap().len(), 1);
526 assert_eq!(mine["lends"][0]["collection_id"], cal);
527 assert_eq!(mine["lends"][0]["user_name"], "bob");
528 assert_eq!(mine["lends"][0]["mode"], "rw");
529
530 // The borrower sees neither alice's feed nor her loan as his own.
531 let theirs = bob.get("/api/pim/shares").await.json();
532 assert!(theirs["links"].as_array().unwrap().is_empty(), "{theirs}");
533 assert!(theirs["lends"].as_array().unwrap().is_empty(), "{theirs}");
534 let admin = pim.admin.get("/api/pim/shares").await.json();
535 assert!(admin["links"].as_array().unwrap().is_empty(), "{admin}");
536}
537