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 no_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 id = pim.id(BOOK).await;
195 let r = pim
196 .alice
197 .get(&format!("/api/pim/collections/{id}/objects/anna.vcf/photo"))
198 .await;
199 assert_eq!(r.status, StatusCode::NOT_FOUND);
200}
201
202#[tokio::test]
203async fn birthday_calendar() {
204 let pim = Pim::new(Env::new().await).await;
205 let birthdays = "/pim/calendars/alice/birthdays/";
206 assert!(
207 pim.members("alice", "/pim/calendars/alice/")
208 .await
209 .contains(&birthdays.to_string())
210 );
211 assert!(pim.members("alice", birthdays).await.is_empty());
212
213 pim.put(
214 "alice",
215 &format!("{BOOK}anna.vcf"),
216 &contact("anna", "BDAY:1980-03-15\r\n"),
217 )
218 .await;
219 // Only the own address books count, not the system one or lent ones.
220 let members = pim.members("alice", birthdays).await;
221 assert_eq!(members.len(), 1, "{members:?}");
222 let r = pim.req("alice", "GET", &members[0], "0", "").await;
223 assert_eq!(r.status, StatusCode::OK);
224 let ics = unfold(&r.text());
225 assert!(ics.contains("SUMMARY:🎂 Anna Berg (1980)"), "{ics}");
226 assert!(ics.contains("RRULE:FREQ=YEARLY"));
227 assert!(
228 pim.members("bob", "/pim/calendars/bob/birthdays/")
229 .await
230 .is_empty()
231 );
232
233 // A changed birthday changes the token; the old one is no longer valid.
234 let before = pim.sync_token("alice", birthdays).await;
235 pim.put(
236 "alice",
237 &format!("{BOOK}anna.vcf"),
238 &contact("anna", "BDAY:1980-03-16\r\n"),
239 )
240 .await;
241 let after = pim.sync_token("alice", birthdays).await;
242 assert_ne!(before, after);
243 let sync = |token: &str| {
244 format!(
245 "<d:sync-collection xmlns:d=\"DAV:\"><d:sync-token>{token}</d:sync-token>\
246 <d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>"
247 )
248 };
249 let r = pim
250 .req("alice", "REPORT", birthdays, "1", &sync(&before))
251 .await;
252 assert_eq!(r.status, StatusCode::FORBIDDEN);
253 assert!(r.text().contains("valid-sync-token"));
254 let r = pim
255 .req("alice", "REPORT", birthdays, "1", &sync(&after))
256 .await;
257 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
258
259 // Read-only.
260 let r = pim
261 .req("alice", "PUT", &format!("{birthdays}x.ics"), "0", "x")
262 .await;
263 assert_eq!(r.status, StatusCode::FORBIDDEN);
264 assert!(r.text().contains("need-privileges"));
265 let r = pim.req("alice", "DELETE", &members[0], "0", "").await;
266 assert_eq!(r.status, StatusCode::FORBIDDEN);
267 let r = pim.req("alice", "DELETE", birthdays, "0", "").await;
268 assert_eq!(r.status, StatusCode::FORBIDDEN);
269
270 // Birthdays are transparent: no busy time.
271 let fb = "<c:free-busy-query xmlns:c=\"urn:ietf:params:xml:ns:caldav\">\
272 <c:time-range start=\"20260101T000000Z\" end=\"20270101T000000Z\"/></c:free-busy-query>";
273 let r = pim.req("alice", "REPORT", birthdays, "1", fb).await;
274 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
275 assert!(
276 !r.text().lines().any(|l| l.starts_with("FREEBUSY")),
277 "{}",
278 r.text()
279 );
280}
281
282fn meeting(uid: &str, organizer: &str, attendees: &[&str], start: &str) -> String {
283 let attendees: String = attendees
284 .iter()
285 .map(|a| {
286 let cn = a.trim_start_matches("mailto:").split('@').next().unwrap();
287 format!("ATTENDEE;CN=\"{cn}\";PARTSTAT=NEEDS-ACTION:{a}\r\n")
288 })
289 .collect();
290 format!(
291 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\n\
292 DTSTAMP:20260101T000000Z\r\nDTSTART:{start}\r\nDURATION:PT1H\r\nSUMMARY:Planning\r\n\
293 ORGANIZER:{organizer}\r\nATTENDEE;PARTSTAT=ACCEPTED:{organizer}\r\n{attendees}\
294 END:VEVENT\r\nEND:VCALENDAR\r\n"
295 )
296}
297
298fn addr(user: &str) -> String {
299 format!("mailto:{user}@filebrowser.invalid")
300}
301
302#[tokio::test]
303async fn deleted_principals_are_forgotten() {
304 let pim = Pim::new(Env::new().await).await;
305 // A name no other test uses: Basic credentials are cached per process,
306 // and a recreated account must not collide with a parallel test's.
307 create_user(&pim.admin, "erin", PW, &[]).await;
308 let r = pim
309 .admin
310 .post_json(
311 "/api/admin/rooms",
312 &json!({"name": "atrium", "kind": "room"}),
313 )
314 .await;
315 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
316 let room = r.json()["id"].as_i64().unwrap();
317 let atrium = "mailto:atrium@rooms.filebrowser.invalid";
318
319 let own = "/pim/calendars/alice/default/own.ics";
320 pim.put(
321 "alice",
322 own,
323 &meeting(
324 "own",
325 &addr("alice"),
326 &[&addr("erin"), atrium],
327 "20260310T100000Z",
328 ),
329 )
330 .await;
331 pim.put(
332 "erin",
333 "/pim/calendars/erin/default/theirs.ics",
334 &meeting(
335 "theirs",
336 &addr("erin"),
337 &[&addr("alice")],
338 "20260311T100000Z",
339 ),
340 )
341 .await;
342 let alice_cal = "/pim/calendars/alice/default/";
343 let copies = pim.members("alice", alice_cal).await;
344 assert_eq!(copies.len(), 2, "{copies:?}");
345 let token = pim.sync_token("alice", alice_cal).await;
346
347 let erin_id = user_id(&pim.admin, "erin").await;
348 let r = pim
349 .admin
350 .delete(&format!("/api/admin/users/{erin_id}"))
351 .await;
352 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
353 let r = pim.admin.delete(&format!("/api/admin/rooms/{room}")).await;
354 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
355
356 assert_ne!(pim.sync_token("alice", alice_cal).await, token);
357 let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
358 assert!(!org.contains(&addr("erin")), "{org}");
359 assert!(!org.contains(atrium), "{org}");
360 let tombs: Vec<&str> = org
361 .lines()
362 .filter(|l| l.contains("@deleted.filebrowser.invalid"))
363 .collect();
364 assert_eq!(tombs.len(), 2, "{org}");
365 assert!(
366 tombs.iter().all(|l| l.contains("SCHEDULE-STATUS=3.7")),
367 "{org}"
368 );
369 // The display name stays.
370 assert!(
371 tombs
372 .iter()
373 .any(|l| l.replace('"', "").contains("CN=erin;")),
374 "{org}"
375 );
376
377 let theirs = copies.iter().find(|h| !h.ends_with("own.ics")).unwrap();
378 let copy = unfold(&pim.req("alice", "GET", theirs, "0", "").await.text());
379 assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
380 assert!(copy.contains("ORGANIZER:mailto:erin-"), "{copy}");
381
382 // A new erin is not the old one: alice's next update reaches no one.
383 create_user(&pim.admin, "erin", PW, &[]).await;
384 pim.put(
385 "alice",
386 own,
387 &unfold(&pim.req("alice", "GET", own, "0", "").await.text())
388 .replace("20260310T100000Z", "20260312T100000Z"),
389 )
390 .await;
391 assert!(
392 pim.members("erin", "/pim/calendars/erin/default/")
393 .await
394 .is_empty()
395 );
396 assert!(
397 pim.members("erin", "/pim/calendars/erin/inbox/")
398 .await
399 .is_empty()
400 );
401 let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
402 assert!(!org.contains(&addr("erin")), "{org}");
403}
404
405#[tokio::test]
406async fn admin_sees_and_revokes_feeds() {
407 let pim = Pim::new(Env::new().await).await;
408 let id = pim.id("/pim/calendars/alice/default/").await;
409 let r = pim
410 .alice
411 .post_json(
412 &format!("/api/pim/collections/{id}/links"),
413 &json!({"busy_only": true}),
414 )
415 .await;
416 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
417 let path = r.json()["path"].as_str().unwrap().to_string();
418
419 let list = pim.admin.get("/api/admin/pim-links").await;
420 assert_eq!(list.status, StatusCode::OK);
421 let links = list.json();
422 let link = &links.as_array().unwrap()[0];
423 assert_eq!(link["owner_name"], "alice");
424 assert_eq!(link["owner_active"], true);
425 assert_eq!(link["kind"], "calendar");
426 assert_eq!(link["collection_id"], id);
427 assert_eq!(link["busy_only"], true);
428 assert_eq!(link["path"], path.as_str());
429 assert_eq!(
430 pim.alice.get("/api/admin/pim-links").await.status,
431 StatusCode::FORBIDDEN
432 );
433
434 let anon = Client::new(pim.env.app.clone());
435 assert_eq!(anon.get(&path).await.status, StatusCode::OK);
436 let link_id = link["id"].as_i64().unwrap();
437 let r = pim
438 .admin
439 .delete(&format!("/api/admin/pim-links/{link_id}"))
440 .await;
441 assert_eq!(r.status, StatusCode::OK);
442 assert_eq!(anon.get(&path).await.status, StatusCode::NOT_FOUND);
443 let r = pim
444 .admin
445 .delete(&format!("/api/admin/pim-links/{link_id}"))
446 .await;
447 assert_eq!(r.status, StatusCode::NOT_FOUND);
448}
449