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 let r = pim
306 .admin
307 .post_json(
308 "/api/admin/rooms",
309 &json!({"name": "atrium", "kind": "room"}),
310 )
311 .await;
312 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
313 let room = r.json()["id"].as_i64().unwrap();
314 let atrium = "mailto:atrium@rooms.filebrowser.invalid";
315
316 let own = "/pim/calendars/alice/default/own.ics";
317 pim.put(
318 "alice",
319 own,
320 &meeting(
321 "own",
322 &addr("alice"),
323 &[&addr("bob"), atrium],
324 "20260310T100000Z",
325 ),
326 )
327 .await;
328 pim.put(
329 "bob",
330 "/pim/calendars/bob/default/theirs.ics",
331 &meeting(
332 "theirs",
333 &addr("bob"),
334 &[&addr("alice")],
335 "20260311T100000Z",
336 ),
337 )
338 .await;
339 let alice_cal = "/pim/calendars/alice/default/";
340 let copies = pim.members("alice", alice_cal).await;
341 assert_eq!(copies.len(), 2, "{copies:?}");
342 let token = pim.sync_token("alice", alice_cal).await;
343
344 let bob_id = user_id(&pim.admin, "bob").await;
345 let r = pim
346 .admin
347 .delete(&format!("/api/admin/users/{bob_id}"))
348 .await;
349 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
350 let r = pim.admin.delete(&format!("/api/admin/rooms/{room}")).await;
351 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
352
353 assert_ne!(pim.sync_token("alice", alice_cal).await, token);
354 let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
355 assert!(!org.contains(&addr("bob")), "{org}");
356 assert!(!org.contains(atrium), "{org}");
357 let tombs: Vec<&str> = org
358 .lines()
359 .filter(|l| l.contains("@deleted.filebrowser.invalid"))
360 .collect();
361 assert_eq!(tombs.len(), 2, "{org}");
362 assert!(
363 tombs.iter().all(|l| l.contains("SCHEDULE-STATUS=3.7")),
364 "{org}"
365 );
366 // The display name stays.
367 assert!(
368 tombs.iter().any(|l| l.replace('"', "").contains("CN=bob;")),
369 "{org}"
370 );
371
372 let theirs = copies.iter().find(|h| !h.ends_with("own.ics")).unwrap();
373 let copy = unfold(&pim.req("alice", "GET", theirs, "0", "").await.text());
374 assert!(copy.contains("STATUS:CANCELLED"), "{copy}");
375 assert!(copy.contains("ORGANIZER:mailto:bob-"), "{copy}");
376
377 // A new bob is not the old one: alice's next update reaches no one.
378 create_user(&pim.admin, "bob", PW, &[]).await;
379 pim.put(
380 "alice",
381 own,
382 &unfold(&pim.req("alice", "GET", own, "0", "").await.text())
383 .replace("20260310T100000Z", "20260312T100000Z"),
384 )
385 .await;
386 assert!(
387 pim.members("bob", "/pim/calendars/bob/default/")
388 .await
389 .is_empty()
390 );
391 assert!(
392 pim.members("bob", "/pim/calendars/bob/inbox/")
393 .await
394 .is_empty()
395 );
396 let org = unfold(&pim.req("alice", "GET", own, "0", "").await.text());
397 assert!(!org.contains(&addr("bob")), "{org}");
398}
399
400#[tokio::test]
401async fn admin_sees_and_revokes_feeds() {
402 let pim = Pim::new(Env::new().await).await;
403 let id = pim.id("/pim/calendars/alice/default/").await;
404 let r = pim
405 .alice
406 .post_json(
407 &format!("/api/pim/collections/{id}/links"),
408 &json!({"busy_only": true}),
409 )
410 .await;
411 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
412 let path = r.json()["path"].as_str().unwrap().to_string();
413
414 let list = pim.admin.get("/api/admin/pim-links").await;
415 assert_eq!(list.status, StatusCode::OK);
416 let links = list.json();
417 let link = &links.as_array().unwrap()[0];
418 assert_eq!(link["owner_name"], "alice");
419 assert_eq!(link["owner_active"], true);
420 assert_eq!(link["kind"], "calendar");
421 assert_eq!(link["collection_id"], id);
422 assert_eq!(link["busy_only"], true);
423 assert_eq!(link["path"], path.as_str());
424 assert_eq!(
425 pim.alice.get("/api/admin/pim-links").await.status,
426 StatusCode::FORBIDDEN
427 );
428
429 let anon = Client::new(pim.env.app.clone());
430 assert_eq!(anon.get(&path).await.status, StatusCode::OK);
431 let link_id = link["id"].as_i64().unwrap();
432 let r = pim
433 .admin
434 .delete(&format!("/api/admin/pim-links/{link_id}"))
435 .await;
436 assert_eq!(r.status, StatusCode::OK);
437 assert_eq!(anon.get(&path).await.status, StatusCode::NOT_FOUND);
438 let r = pim
439 .admin
440 .delete(&format!("/api/admin/pim-links/{link_id}"))
441 .await;
442 assert_eq!(r.status, StatusCode::NOT_FOUND);
443}
444