api_pim_ui.rs
⎇
Raw
1//! The JSON endpoints behind the calendar and contacts web UI.
2
3mod common;
4
5use axum::http::{Method, StatusCode};
6use common::*;
7use serde_json::{Value, json};
8
9const PW: &str = "secret12345";
10
11struct Ui {
12 env: Env,
13 alice: Client,
14 bob: Client,
15}
16
17impl Ui {
18 async fn new() -> Self {
19 let env = Env::new().await;
20 let admin = env.admin().await;
21 create_user(&admin, "alice", PW, &[]).await;
22 create_user(&admin, "bob", PW, &[]).await;
23 let alice = login(&env, "alice", PW).await;
24 let bob = login(&env, "bob", PW).await;
25 Ui { env, alice, bob }
26 }
27
28 async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
29 Client::new(self.env.app.clone())
30 .raw(
31 Method::from_bytes(verb.as_bytes()).unwrap(),
32 path,
33 &[("authorization", &basic(user, PW)), ("depth", "1")],
34 body.as_bytes().to_vec(),
35 )
36 .await
37 }
38
39 async fn put(&self, user: &str, path: &str, body: &str) {
40 let r = self.dav(user, "PUT", path, body).await;
41 assert!(
42 r.status.is_success(),
43 "PUT {path}: {} {}",
44 r.status,
45 r.text()
46 );
47 }
48
49 async fn list(&self, who: &Client) -> Vec<Value> {
50 let r = who.get("/api/pim/collections").await;
51 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
52 r.json().as_array().unwrap().clone()
53 }
54
55 async fn id(&self, who: &Client, url: &str) -> i64 {
56 self.list(who)
57 .await
58 .iter()
59 .find(|c| c["url"] == url)
60 .unwrap_or_else(|| panic!("no {url}"))["id"]
61 .as_i64()
62 .unwrap()
63 }
64}
65
66fn ics(body: &str) -> String {
67 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n{body}END:VCALENDAR\r\n")
68}
69
70fn event(uid: &str, props: &str) -> String {
71 ics(&format!(
72 "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n{props}END:VEVENT\r\n"
73 ))
74}
75
76const ALICE: &str = "mailto:alice@filebrowser.invalid";
77const BOB: &str = "mailto:bob@filebrowser.invalid";
78
79#[tokio::test]
80async fn collections_can_be_created_changed_and_deleted() {
81 let ui = Ui::new().await;
82 let list = ui.list(&ui.alice).await;
83 // Default calendar and address book, plus the generated ones.
84 let generated: Vec<i64> = list
85 .iter()
86 .filter(|c| c["generated"] == true)
87 .map(|c| c["id"].as_i64().unwrap())
88 .collect();
89 assert_eq!(generated, [-1, 0], "{list:?}");
90 let default = list.iter().find(|c| c["is_default"] == true).unwrap();
91 assert_eq!(default["kind"], "calendar");
92
93 let r = ui
94 .alice
95 .post_json(
96 "/api/pim/collections",
97 &json!({"kind": "calendar", "name": "Work & Play", "color": "#ff8800", "components": ["VEVENT"]}),
98 )
99 .await;
100 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
101 let work = r.json();
102 assert_eq!(work["url"], "/pim/calendars/alice/work-play/");
103 assert_eq!(work["components"], json!(["VEVENT"]));
104 let id = work["id"].as_i64().unwrap();
105 // A second one with the same name gets its own URL.
106 let again = ui
107 .alice
108 .post_json(
109 "/api/pim/collections",
110 &json!({"kind": "calendar", "name": "Work & Play"}),
111 )
112 .await;
113 assert_eq!(again.json()["url"], "/pim/calendars/alice/work-play-2/");
114 // Reserved names and bad input.
115 let r = ui
116 .alice
117 .post_json(
118 "/api/pim/collections",
119 &json!({"kind": "addressbook", "name": "System"}),
120 )
121 .await;
122 assert_eq!(r.json()["url"], "/pim/addressbooks/alice/system-2/");
123 for bad in [
124 json!({"kind": "calendar", "name": " "}),
125 json!({"kind": "calendar", "name": "x", "color": "red"}),
126 json!({"kind": "calendar", "name": "x", "components": ["VCARD"]}),
127 ] {
128 let r = ui.alice.post_json("/api/pim/collections", &bad).await;
129 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad}");
130 }
131
132 let r = ui
133 .alice
134 .put_json(
135 &format!("/api/pim/collections/{id}"),
136 &json!({"name": "Work", "color": "", "description": "Job", "transparent": true}),
137 )
138 .await;
139 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
140 let changed = r.json();
141 assert_eq!(changed["name"], "Work");
142 assert_eq!(changed["color"], Value::Null);
143 assert_eq!(changed["description"], "Job");
144 assert_eq!(changed["transparent"], true);
145 // CalDAV sees the same.
146 let r = ui
147 .dav("alice", "PROPFIND", "/pim/calendars/alice/work-play/", "")
148 .await;
149 assert!(r.text().contains("Work</"), "{}", r.text());
150
151 // Bob cannot touch it, even when it is lent to him.
152 let r = ui
153 .alice
154 .post_json(
155 &format!("/api/pim/collections/{id}/shares"),
156 &json!({"user": "bob", "mode": "rw"}),
157 )
158 .await;
159 assert_eq!(r.status, StatusCode::OK);
160 let lent = ui
161 .list(&ui.bob)
162 .await
163 .into_iter()
164 .find(|c| c["id"] == id)
165 .unwrap();
166 assert_eq!(lent["mode"], "rw");
167 assert_eq!(lent["owner"], "alice");
168 assert_eq!(
169 ui.list(&ui.alice)
170 .await
171 .iter()
172 .find(|c| c["id"] == id)
173 .unwrap()["shares"],
174 1
175 );
176 let r = ui
177 .bob
178 .put_json(&format!("/api/pim/collections/{id}"), &json!({"name": "x"}))
179 .await;
180 assert_eq!(r.status, StatusCode::NOT_FOUND);
181 // Bob's delete only ends his loan.
182 assert_eq!(
183 ui.bob
184 .delete(&format!("/api/pim/collections/{id}"))
185 .await
186 .status,
187 StatusCode::OK
188 );
189 assert!(ui.list(&ui.bob).await.iter().all(|c| c["id"] != id));
190 assert!(ui.list(&ui.alice).await.iter().any(|c| c["id"] == id));
191
192 // Generated and default collections stay.
193 for gone in [-1, 0] {
194 let r = ui
195 .alice
196 .delete(&format!("/api/pim/collections/{gone}"))
197 .await;
198 assert_eq!(r.status, StatusCode::FORBIDDEN);
199 }
200 let default_id = default["id"].as_i64().unwrap();
201 let r = ui
202 .alice
203 .delete(&format!("/api/pim/collections/{default_id}"))
204 .await;
205 assert_eq!(r.status, StatusCode::CONFLICT);
206 assert_eq!(r.json()["code"], "err_default_calendar");
207 let r = ui.alice.delete(&format!("/api/pim/collections/{id}")).await;
208 assert_eq!(r.status, StatusCode::OK);
209 assert!(ui.list(&ui.alice).await.iter().all(|c| c["id"] != id));
210}
211
212#[tokio::test]
213async fn instances_expand_readable_calendars() {
214 let ui = Ui::new().await;
215 ui.put(
216 "alice",
217 "/pim/calendars/alice/default/weekly.ics",
218 &(event(
219 "weekly",
220 "DTSTART;TZID=Europe/Berlin:20260105T090000\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=3\r\nSUMMARY:Standup\r\nLOCATION:Room 1\r\n",
221 )
222 .replace(
223 "END:VCALENDAR",
224 "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;TZID=Europe/Berlin:20260112T090000\r\nDTSTART;TZID=Europe/Berlin:20260112T110000\r\nDURATION:PT1H\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nEND:VCALENDAR",
225 )),
226 )
227 .await;
228 ui.put(
229 "alice",
230 "/pim/calendars/alice/default/day.ics",
231 &event("day", "DTSTART;VALUE=DATE:20260110\r\nSUMMARY:Trip\r\n"),
232 )
233 .await;
234 ui.put(
235 "alice",
236 "/pim/addressbooks/alice/default/anna.vcf",
237 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:anna\r\nFN:Anna\r\nBDAY:1980-01-07\r\nEND:VCARD\r\n",
238 )
239 .await;
240
241 let get = |q: &str| {
242 let q = q.to_string();
243 let alice = ui.alice.clone();
244 async move { alice.get(&format!("/api/pim/instances?{q}")).await }
245 };
246 let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&tz=Europe/Berlin").await;
247 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
248 let all = r.json();
249 let list = all["instances"].as_array().unwrap();
250 let summaries: Vec<&str> = list
251 .iter()
252 .map(|i| i["summary"].as_str().unwrap())
253 .collect();
254 assert_eq!(
255 summaries,
256 ["Standup", "🎂 Anna (1980)", "Trip", "Moved", "Standup"],
257 "{all}"
258 );
259 assert_eq!(list[0]["start"], "2026-01-05T08:00:00Z");
260 assert_eq!(list[0]["location"], "Room 1");
261 assert_eq!(list[0]["recurrence_id"], "2026-01-05T08:00:00Z");
262 // All-day values start at midnight in the viewer's zone.
263 assert_eq!(list[2]["start"], "2026-01-09T23:00:00Z");
264 assert_eq!(list[2]["all_day"], true);
265 assert_eq!(list[2]["recurrence_id"], Value::Null);
266 assert_eq!(list[3]["recurrence_id"], "2026-01-12T08:00:00Z");
267 assert_eq!(list[1]["collection_id"], -1);
268 assert_eq!(all["truncated"], false);
269
270 // Only the chosen calendars.
271 let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&collections=-1").await;
272 assert_eq!(r.json()["instances"].as_array().unwrap().len(), 1);
273 // Bad ranges.
274 for q in [
275 "from=2026-01-01T00:00:00Z&to=2027-06-01T00:00:00Z",
276 "from=2026-02-01T00:00:00Z&to=2026-01-01T00:00:00Z",
277 "from=yesterday&to=2026-01-01T00:00:00Z",
278 ] {
279 assert_eq!(get(q).await.status, StatusCode::BAD_REQUEST, "{q}");
280 }
281
282 // A read-only loan shows to the borrower, not otherwise.
283 let q = "/api/pim/instances?from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z";
284 let count = |v: Value| {
285 v["instances"]
286 .as_array()
287 .unwrap()
288 .iter()
289 .filter(|i| i["uid"] == "weekly")
290 .count()
291 };
292 assert_eq!(count(ui.bob.get(q).await.json()), 0);
293 let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
294 ui.alice
295 .post_json(
296 &format!("/api/pim/collections/{cal}/shares"),
297 &json!({"user": "bob", "mode": "ro"}),
298 )
299 .await;
300 assert_eq!(count(ui.bob.get(q).await.json()), 3);
301}
302
303#[tokio::test]
304async fn object_detail_for_events_and_contacts() {
305 let ui = Ui::new().await;
306 ui.put(
307 "alice",
308 "/pim/calendars/alice/default/m.ics",
309 &event(
310 "m",
311 &format!(
312 "DTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=2\r\nSUMMARY:Plan\r\nDESCRIPTION:Line 1\\nLine 2\r\nCATEGORIES:Work,Q1\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT:{BOB}\r\n"
313 ),
314 ),
315 )
316 .await;
317 let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
318 let r = ui
319 .alice
320 .get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
321 .await;
322 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
323 let d = r.json();
324 assert_eq!(d["type"], "event");
325 assert_eq!(d["description"], "Line 1\nLine 2");
326 assert_eq!(d["categories"], json!(["Work", "Q1"]));
327 assert_eq!(d["rrule"], "FREQ=DAILY;COUNT=2");
328 assert_eq!(d["organizer"]["name"], "Alice");
329 assert_eq!(d["attendees"][1]["role"], "OPT-PARTICIPANT");
330 assert_eq!(d["attendees"][0]["is_owner"], true);
331 assert_eq!(d["can_edit"], true);
332 assert_eq!(d["can_reply"], false);
333 // Bob's copy: he may answer.
334 let bob = ui
335 .bob
336 .get("/api/pim/instances?from=2026-01-05T00:00:00Z&to=2026-01-06T00:00:00Z")
337 .await
338 .json();
339 let copy = &bob["instances"][0];
340 assert_eq!(copy["partstat"], "NEEDS-ACTION", "{bob}");
341 let d = ui
342 .bob
343 .get(&format!(
344 "/api/pim/collections/{}/objects/{}",
345 copy["collection_id"],
346 copy["name"].as_str().unwrap()
347 ))
348 .await
349 .json();
350 assert_eq!(d["can_reply"], true, "{d}");
351 // Someone else's object stays hidden.
352 let r = ui
353 .bob
354 .get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
355 .await;
356 assert_eq!(r.status, StatusCode::NOT_FOUND);
357
358 // A contact with a photo, and a group.
359 let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
360 ui.put(
361 "alice",
362 "/pim/addressbooks/alice/default/c1.vcf",
363 &format!(
364 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Carla Díaz\r\nN:Díaz;Carla;;;\r\nORG:Acme;Sales\r\nitem1.EMAIL;TYPE=INTERNET:carla@example.com\r\nitem1.X-ABLABEL:_$!<Other>!$_\r\nTEL;TYPE=CELL:+49 1\r\nADR;TYPE=WORK:;;Main St 1;Berlin;;10115;Germany\r\nBDAY:--03-15\r\nPHOTO;ENCODING=b;TYPE=PNG:{png}\r\nEND:VCARD\r\n"
365 ),
366 )
367 .await;
368 ui.put(
369 "alice",
370 "/pim/addressbooks/alice/default/g.vcf",
371 "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nMEMBER:urn:uuid:gone\r\nEND:VCARD\r\n",
372 )
373 .await;
374 let book = ui.id(&ui.alice, "/pim/addressbooks/alice/default/").await;
375 let d = ui
376 .alice
377 .get(&format!("/api/pim/collections/{book}/objects/c1.vcf"))
378 .await
379 .json();
380 assert_eq!(d["type"], "contact");
381 assert_eq!(d["org"], "Acme, Sales");
382 assert_eq!(
383 d["emails"][0],
384 json!({"label": "other", "value": "carla@example.com"})
385 );
386 assert_eq!(d["phones"][0]["label"], "cell");
387 assert_eq!(
388 d["addresses"][0]["value"],
389 "Main St 1\n10115 Berlin\nGermany"
390 );
391 assert_eq!(d["birthday"], "--03-15");
392 let photo = d["photo_url"].as_str().unwrap().to_string();
393 assert_eq!(ui.alice.get(&photo).await.status, StatusCode::OK);
394 let g = ui
395 .alice
396 .get(&format!("/api/pim/collections/{book}/objects/g.vcf"))
397 .await
398 .json();
399 assert_eq!(g["is_group"], true);
400 assert_eq!(g["members"], json!(["Carla Díaz", "gone"]));
401
402 // The generated birthday calendar has details too.
403 let list = ui
404 .alice
405 .get("/api/pim/instances?from=2026-03-01T00:00:00Z&to=2026-04-01T00:00:00Z")
406 .await
407 .json();
408 let bday = &list["instances"][0];
409 let d = ui
410 .alice
411 .get(&format!(
412 "/api/pim/collections/-1/objects/{}",
413 bday["name"].as_str().unwrap()
414 ))
415 .await
416 .json();
417 assert_eq!(d["summary"], "🎂 Carla Díaz");
418 assert_eq!(d["can_edit"], false);
419}
420
421#[tokio::test]
422async fn contacts_are_searched_across_books() {
423 let ui = Ui::new().await;
424 ui.put(
425 "alice",
426 "/pim/addressbooks/alice/default/z.vcf",
427 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:Zoë Weiß\r\nEMAIL:zoe@example.com\r\nEND:VCARD\r\n",
428 )
429 .await;
430 ui.put(
431 "alice",
432 "/pim/addressbooks/alice/default/n.vcf",
433 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nN:Nobody;Nora;;;\r\nTEL:+1 555\r\nEND:VCARD\r\n",
434 )
435 .await;
436 let all = ui.alice.get("/api/pim/contacts").await.json();
437 let names: Vec<&str> = all
438 .as_array()
439 .unwrap()
440 .iter()
441 .map(|c| c["full_name"].as_str().unwrap())
442 .collect();
443 // The directory lists the accounts too.
444 assert_eq!(names, ["admin", "alice", "bob", "Nora Nobody", "Zoë Weiß"]);
445 let hits = ui.alice.get("/api/pim/contacts?q=ZOË").await.json();
446 assert_eq!(hits.as_array().unwrap().len(), 1);
447 assert_eq!(hits[0]["email"], "zoe@example.com");
448 let hits = ui.alice.get("/api/pim/contacts?q=555").await.json();
449 assert_eq!(hits[0]["full_name"], "Nora Nobody");
450 let own = ui.alice.get("/api/pim/contacts?collections=0").await.json();
451 assert_eq!(own.as_array().unwrap().len(), 3);
452}
453
454#[tokio::test]
455async fn invitations_are_listed_and_answered() {
456 let ui = Ui::new().await;
457 // A month ahead, so the invitation stays open whenever the test runs.
458 let day = chrono::Utc::now().date_naive() + chrono::Days::new(30);
459 let (first, second) = (
460 day.format("%Y%m%d"),
461 (day + chrono::Days::new(1)).format("%Y%m%d"),
462 );
463 let meeting = event(
464 "meet",
465 &format!(
466 "DTSTART:{first}T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Sync\r\nORGANIZER;CN=Alice:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;RSVP=TRUE:{BOB}\r\n"
467 ),
468 );
469 ui.put("alice", "/pim/calendars/alice/default/meet.ics", &meeting)
470 .await;
471 let list = ui.bob.get("/api/pim/invitations").await.json();
472 let list = list.as_array().unwrap();
473 assert_eq!(list.len(), 1, "{list:?}");
474 assert_eq!(list[0]["summary"], "Sync");
475 assert_eq!(list[0]["recurring"], true);
476 assert_eq!(list[0]["recurrence_id"], Value::Null);
477 assert_eq!(
478 list[0]["start"],
479 format!("{}T09:00:00Z", day.format("%Y-%m-%d"))
480 );
481 assert!(
482 ui.alice
483 .get("/api/pim/invitations")
484 .await
485 .json()
486 .as_array()
487 .unwrap()
488 .is_empty()
489 );
490
491 // One instance first: an override in bob's copy, a REPLY for alice.
492 let reply = |partstat: &str, rid: Option<&str>| {
493 let mut body = json!({
494 "collection_id": list[0]["collection_id"],
495 "name": list[0]["name"],
496 "partstat": partstat,
497 });
498 if let Some(rid) = rid {
499 body["recurrence_id"] = json!(rid);
500 }
501 body
502 };
503 let r = ui
504 .bob
505 .post_json(
506 "/api/pim/invitations",
507 &reply(
508 "DECLINED",
509 Some(&format!(
510 "{}T09:00:00Z",
511 (day + chrono::Days::new(1)).format("%Y-%m-%d")
512 )),
513 ),
514 )
515 .await;
516 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
517 let org = ui
518 .dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "")
519 .await
520 .text()
521 .replace("\r\n ", "");
522 assert!(
523 org.contains(&format!("RECURRENCE-ID:{second}T090000Z")),
524 "{org}"
525 );
526 assert!(org.contains("PARTSTAT=DECLINED"), "{org}");
527 // The series is still open.
528 assert_eq!(
529 ui.bob
530 .get("/api/pim/invitations")
531 .await
532 .json()
533 .as_array()
534 .unwrap()
535 .len(),
536 1
537 );
538
539 let r = ui
540 .bob
541 .post_json("/api/pim/invitations", &reply("ACCEPTED", None))
542 .await;
543 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
544 assert!(
545 ui.bob
546 .get("/api/pim/invitations")
547 .await
548 .json()
549 .as_array()
550 .unwrap()
551 .is_empty()
552 );
553 let org = ui
554 .dav("alice", "GET", "/pim/calendars/alice/default/meet.ics", "")
555 .await
556 .text()
557 .replace("\r\n ", "");
558 let bob_line = org
559 .lines()
560 .find(|l| l.starts_with("ATTENDEE") && l.contains(BOB) && !l.contains("DECLINED"))
561 .unwrap_or_else(|| panic!("{org}"));
562 assert!(bob_line.contains("PARTSTAT=ACCEPTED"), "{org}");
563
564 // Bad answers.
565 let r = ui
566 .bob
567 .post_json("/api/pim/invitations", &reply("MAYBE", None))
568 .await;
569 assert_eq!(r.status, StatusCode::BAD_REQUEST);
570 let r = ui
571 .alice
572 .post_json("/api/pim/invitations", &reply("ACCEPTED", None))
573 .await;
574 assert_eq!(r.status, StatusCode::NOT_FOUND);
575}
576