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