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