pim_io.rs
⎇
Raw
1//! Public feeds, import and export of calendars and address books.
2
3use crate::common::*;
4use axum::http::{Method, StatusCode};
5use serde_json::{Value, json};
6
7const PW: &str = "secret12345";
8const CAL: &str = "/pim/calendars/alice/default/";
9
10struct Io {
11 env: Env,
12 alice: Client,
13}
14
15impl Io {
16 async fn new() -> Self {
17 let env = Env::new().await;
18 let admin = env.admin().await;
19 create_user(&admin, "alice", PW, &[]).await;
20 create_user(&admin, "bob", PW, &[]).await;
21 let alice = login(&env, "alice", PW).await;
22 Io { env, alice }
23 }
24
25 async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
26 // Without Depth, PROPFIND lists only the collection itself.
27 req(
28 &self.env,
29 verb,
30 path,
31 &basic(user, PW),
32 &[("depth", "1")],
33 body,
34 )
35 .await
36 }
37
38 async fn anon(&self, path: &str, headers: &[(&str, &str)]) -> Resp {
39 Client::new(self.env.app.clone())
40 .raw(Method::GET, path, headers, Vec::new())
41 .await
42 }
43
44 async fn link(&self, id: i64, body: Value) -> String {
45 let r = self
46 .alice
47 .post_json(&format!("/api/pim/collections/{id}/links"), &body)
48 .await;
49 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
50 r.json()["path"].as_str().unwrap().to_string()
51 }
52
53 async fn import(&self, id: i64, body: &str) -> Value {
54 let r = self
55 .alice
56 .raw(
57 Method::POST,
58 &format!("/api/pim/collections/{id}/import"),
59 &[("content-type", "text/calendar")],
60 body.as_bytes().to_vec(),
61 )
62 .await;
63 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
64 r.json()
65 }
66}
67
68fn event(uid: &str, summary: &str, extra: &str) -> String {
69 format!(
70 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\nEND:VCALENDAR\r\n"
71 )
72}
73
74#[tokio::test]
75async fn feeds() {
76 let io = Io::new().await;
77 let cal = collection_id(&io.alice, CAL).await;
78 let book = collection_id(&io.alice, "/pim/addressbooks/alice/default/").await;
79 for uid in ["a", "b"] {
80 let r = io
81 .dav(
82 "alice",
83 "PUT",
84 &format!("{CAL}{uid}.ics"),
85 &event(
86 uid,
87 "Secret plan",
88 "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\n",
89 ),
90 )
91 .await;
92 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
93 }
94
95 let r = io
96 .dav(
97 "alice",
98 "PUT",
99 &format!("{CAL}p.ics"),
100 &event("p", "Doctor", "CLASS:PRIVATE\r\n"),
101 )
102 .await;
103 assert_eq!(r.status, StatusCode::CREATED);
104
105 let full = io.link(cal, json!({})).await;
106 assert!(
107 full.starts_with("/feed/") && full.ends_with(".ics"),
108 "{full}"
109 );
110 let r = io.anon(&full, &[]).await;
111 assert_eq!(r.status, StatusCode::OK);
112 assert!(
113 r.header("content-type")
114 .unwrap()
115 .starts_with("text/calendar")
116 );
117 let text = r.text();
118 assert!(
119 text.contains("UID:a\r\n") && text.contains("UID:b\r\n"),
120 "{text}"
121 );
122 assert!(text.contains("X-WR-CALNAME:"));
123 assert!(text.contains("Secret plan"));
124 // A public link shows private events as busy time only.
125 assert!(
126 !text.contains("Doctor") && text.contains("SUMMARY:Busy"),
127 "{text}"
128 );
129 let etag = r.header("etag").unwrap();
130 let r = io.anon(&full, &[("if-none-match", &etag)]).await;
131 assert_eq!(r.status, StatusCode::NOT_MODIFIED);
132 // The extension is optional.
133 let bare = full.trim_end_matches(".ics");
134 assert_eq!(io.anon(bare, &[]).await.status, StatusCode::OK);
135
136 // A change gives a new ETag.
137 io.dav("alice", "PUT", &format!("{CAL}c.ics"), &event("c", "x", ""))
138 .await;
139 let r = io.anon(&full, &[("if-none-match", &etag)]).await;
140 assert_eq!(r.status, StatusCode::OK);
141
142 let busy = io.link(cal, json!({ "busy_only": true })).await;
143 let text = io.anon(&busy, &[]).await.text();
144 assert!(text.contains("SUMMARY:Busy"), "{text}");
145 assert!(
146 !text.contains("Secret plan") && !text.contains("VALARM"),
147 "{text}"
148 );
149 assert!(!text.contains("UID:a\r\n"), "UIDs are hashed: {text}");
150 // The owner's export keeps everything.
151 let export = io
152 .alice
153 .get(&format!("/api/pim/collections/{cal}/export"))
154 .await
155 .text();
156 assert!(export.contains("Doctor"));
157
158 let locked = io.link(cal, json!({ "password": "feedpass123" })).await;
159 let r = io.anon(&locked, &[]).await;
160 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
161 assert!(r.header("www-authenticate").is_some());
162 let wrong = basic("", "nope-nope");
163 for _ in 0..3 {
164 let r = io.anon(&locked, &[("authorization", &wrong)]).await;
165 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
166 }
167 let right = basic("anyone", "feedpass123");
168 assert_eq!(
169 io.anon(&locked, &[("authorization", &right)]).await.status,
170 StatusCode::OK
171 );
172
173 // A link cannot be made expired, so this one runs out.
174 let soon = (chrono::Utc::now() + chrono::Duration::seconds(1)).to_rfc3339();
175 let expired = io.link(cal, json!({ "expires_at": soon })).await;
176 tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
177 assert_eq!(io.anon(&expired, &[]).await.status, StatusCode::GONE);
178
179 let cards = io.link(book, json!({})).await;
180 assert!(cards.ends_with(".vcf"));
181 let r = io.anon(&cards, &[]).await;
182 assert!(r.header("content-type").unwrap().starts_with("text/vcard"));
183 let r = io
184 .alice
185 .post_json(
186 &format!("/api/pim/collections/{book}/links"),
187 &json!({ "busy_only": true }),
188 )
189 .await;
190 assert_eq!(r.status, StatusCode::BAD_REQUEST);
191
192 // Only the owner sees and manages the links.
193 let bob = login(&io.env, "bob", PW).await;
194 assert_eq!(
195 bob.get(&format!("/api/pim/collections/{cal}/links"))
196 .await
197 .status,
198 StatusCode::NOT_FOUND
199 );
200 let list = io
201 .alice
202 .get(&format!("/api/pim/collections/{cal}/links"))
203 .await
204 .json();
205 assert_eq!(list.as_array().unwrap().len(), 4);
206 let first = list[0]["id"].as_i64().unwrap();
207 let r = io
208 .alice
209 .delete(&format!("/api/pim/collections/{cal}/links/{first}"))
210 .await;
211 assert_eq!(r.status, StatusCode::OK);
212 assert_eq!(io.anon(&full, &[]).await.status, StatusCode::NOT_FOUND);
213
214 // A deleted collection takes its links along.
215 let r = io
216 .dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
217 .await;
218 assert_eq!(r.status, StatusCode::CREATED);
219 let work = collection_id(&io.alice, "/pim/calendars/alice/work/").await;
220 let gone = io.link(work, json!({})).await;
221 assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::OK);
222 io.dav("alice", "DELETE", "/pim/calendars/alice/work/", "")
223 .await;
224 assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::NOT_FOUND);
225}
226
227#[tokio::test]
228async fn import_splits_and_updates() {
229 let io = Io::new().await;
230 let cal = collection_id(&io.alice, CAL).await;
231 let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nMETHOD:PUBLISH\r\n\
232 BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20260105T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
233 BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20260106T100000Z\r\nDTSTART:20260106T120000Z\r\nEND:VEVENT\r\n\
234 BEGIN:VEVENT\r\nDTSTART:20260107T100000Z\r\nSUMMARY:no uid\r\nEND:VEVENT\r\n\
235 BEGIN:VEVENT\r\nUID:nostart\r\nSUMMARY:no start\r\nEND:VEVENT\r\n\
236 BEGIN:VFREEBUSY\r\nUID:fb\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n";
237 let r = io.import(cal, file).await;
238 assert_eq!(r["created"], 2, "{r}");
239 assert_eq!(r["updated"], 0);
240 assert_eq!(r["skipped_total"], 2);
241 let reasons: Vec<&str> = r["skipped"]
242 .as_array()
243 .unwrap()
244 .iter()
245 .map(|s| s["reason"].as_str().unwrap())
246 .collect();
247 assert!(reasons.contains(&"supported-calendar-component"), "{r}");
248 assert!(reasons.contains(&"valid-calendar-data"), "{r}");
249
250 // The same file again updates, also the event that had no UID.
251 let r = io.import(cal, file).await;
252 assert_eq!(
253 (r["created"].as_i64(), r["updated"].as_i64()),
254 (Some(0), Some(2))
255 );
256
257 let r = io.dav("alice", "PROPFIND", CAL, "").await;
258 let listing = r.text();
259 assert_eq!(listing.matches(".ics</").count(), 2, "{listing}");
260 let export = io
261 .alice
262 .get(&format!("/api/pim/collections/{cal}/export"))
263 .await;
264 let text = export.text();
265 assert!(!text.contains("METHOD"));
266 assert!(text.contains("RECURRENCE-ID:20260106T100000Z"));
267 assert!(text.contains("DTSTAMP:"), "import adds DTSTAMP: {text}");
268}
269
270#[tokio::test]
271async fn an_import_of_meetings_long_over_sends_nothing() {
272 let io = Io::new().await;
273 let cal = collection_id(&io.alice, CAL).await;
274 let people =
275 "ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE:mailto:bob@dovenest.invalid\r\n";
276 let r = io.import(cal, &event("old", "Old", people)).await;
277 assert_eq!(r["created"], 1, "{r}");
278 for path in ["/pim/calendars/bob/default/", "/pim/calendars/bob/inbox/"] {
279 let r = io.dav("bob", "PROPFIND", path, "").await;
280 assert!(!r.text().contains(".ics</"), "{path}: {}", r.text());
281 }
282 // Still a scheduling object, so later changes schedule as usual.
283 let listing = io.dav("alice", "PROPFIND", CAL, "").await.text();
284 let href = listing
285 .split("<d:href>")
286 .filter_map(|s| s.split("</d:href>").next())
287 .find(|h| h.ends_with(".ics"))
288 .unwrap_or_else(|| panic!("{listing}"))
289 .to_string();
290 let r = io.dav("alice", "GET", &href, "").await;
291 assert!(r.header("schedule-tag").is_some(), "{}", r.text());
292
293 let until = format!("RRULE:FREQ=DAILY;UNTIL=29991231T000000Z\r\n{people}");
294 let r = io.import(cal, &event("new", "New", &until)).await;
295 assert_eq!(r["created"], 1, "{r}");
296 let r = io
297 .dav("bob", "PROPFIND", "/pim/calendars/bob/inbox/", "")
298 .await;
299 assert!(r.text().contains(".ics</"), "{}", r.text());
300}
301
302#[tokio::test]
303async fn import_schedules_like_a_put_and_keeps_uniqueness() {
304 let io = Io::new().await;
305 let cal = collection_id(&io.alice, CAL).await;
306 // A yearly series has not ended, so it schedules.
307 let meeting = event(
308 "meet",
309 "Meeting",
310 "RRULE:FREQ=YEARLY\r\nORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE:mailto:bob@dovenest.invalid\r\n",
311 );
312 let r = io.import(cal, &meeting).await;
313 assert_eq!(r["created"], 1, "{r}");
314 // Stored as a scheduling object, with a Schedule-Tag like after a PUT.
315 let listing = io.dav("alice", "PROPFIND", CAL, "").await.text();
316 let href = listing
317 .split("<d:href>")
318 .filter_map(|s| s.split("</d:href>").next())
319 .find(|h| h.ends_with(".ics"))
320 .unwrap_or_else(|| panic!("{listing}"))
321 .to_string();
322 let r = io.dav("alice", "GET", &href, "").await;
323 assert!(r.header("schedule-tag").is_some());
324 // bob got a copy and the REQUEST, as after a PUT.
325 for path in ["/pim/calendars/bob/default/", "/pim/calendars/bob/inbox/"] {
326 let r = io.dav("bob", "PROPFIND", path, "").await;
327 assert!(r.text().contains(".ics</"), "{path}: {}", r.text());
328 }
329 // One scheduling object per UID and owner (RFC 6638, 3.2.4.1).
330 io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
331 .await;
332 let work = collection_id(&io.alice, "/pim/calendars/alice/work/").await;
333 let r = io.import(work, &meeting).await;
334 assert_eq!(r["created"], 0);
335 assert_eq!(r["skipped"][0]["uid"], "meet");
336 assert_eq!(
337 r["skipped"][0]["reason"],
338 "unique-scheduling-object-resource"
339 );
340
341 // Imported again without bob, the meeting is cancelled for him.
342 let r = io.import(cal, &event("meet", "Meeting", "")).await;
343 assert_eq!(r["updated"], 1, "{r}");
344 let copy = io
345 .dav("bob", "PROPFIND", "/pim/calendars/bob/default/", "")
346 .await
347 .text();
348 let copy = copy
349 .split("<d:href>")
350 .filter_map(|s| s.split("</d:href>").next())
351 .find(|h| h.ends_with(".ics"))
352 .unwrap_or_else(|| panic!("{copy}"))
353 .to_string();
354 let r = io.dav("bob", "GET", &copy, "").await;
355 assert!(r.text().contains("STATUS:CANCELLED"), "{}", r.text());
356
357 // A read-only loan cannot be imported into.
358 let bob = login(&io.env, "bob", PW).await;
359 io.alice
360 .post_json(
361 &format!("/api/pim/collections/{cal}/shares"),
362 &json!({ "user": "bob", "mode": "ro" }),
363 )
364 .await;
365 let r = bob
366 .raw(
367 Method::POST,
368 &format!("/api/pim/collections/{cal}/import"),
369 &[("content-type", "text/calendar")],
370 event("x", "x", "").into_bytes(),
371 )
372 .await;
373 assert_eq!(r.status, StatusCode::FORBIDDEN);
374 // But exported.
375 let r = bob.get(&format!("/api/pim/collections/{cal}/export")).await;
376 assert_eq!(r.status, StatusCode::OK);
377}
378
379#[tokio::test]
380async fn export_round_trip() {
381 let io = Io::new().await;
382 let cal = collection_id(&io.alice, CAL).await;
383 io.import(cal, &event("r1", "One", "")).await;
384 io.import(cal, &event("r2", "Two", "")).await;
385
386 let url = format!("/api/pim/collections/{cal}/export");
387 let r = io.alice.get(&url).await;
388 assert_eq!(r.status, StatusCode::OK);
389 let disposition = r.header("content-disposition").unwrap();
390 assert!(disposition.starts_with("attachment;") && disposition.contains(".ics"));
391 let exported = r.text();
392 // Download only: there is no saving into a folder.
393 let r = io.alice.post_json(&url, &json!({})).await;
394 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
395
396 // The export imports back as the same objects.
397 io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/copy/", "")
398 .await;
399 let copy = collection_id(&io.alice, "/pim/calendars/alice/copy/").await;
400 let r = io.import(copy, &exported).await;
401 assert_eq!(r["created"], 2, "{r}");
402
403 // vCards, one without UID.
404 let book = collection_id(&io.alice, "/pim/addressbooks/alice/default/").await;
405 let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:One\r\nEND:VCARD\r\n\
406 BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nEND:VCARD\r\n";
407 let r = io.import(book, cards).await;
408 assert_eq!(r["created"], 2, "{r}");
409 let r = io.import(book, cards).await;
410 assert_eq!(r["updated"], 2, "{r}");
411
412 // The system address book is collection 0.
413 let export = format!(
414 "{}/0{}",
415 api_types::PIM_COLLECTIONS,
416 api_types::EXPORT_SUFFIX
417 );
418 let r = io.alice.get(&export).await;
419 assert_eq!(r.status, StatusCode::OK);
420 assert!(r.header("content-disposition").unwrap().contains(".vcf"));
421 assert!(r.text().contains("FN:alice"));
422}
423
424#[tokio::test]
425async fn import_as_a_new_collection() {
426 let io = Io::new().await;
427 let post = |query: &str, body: String| {
428 let (client, query) = (&io.alice, query.to_string());
429 async move {
430 let r = client
431 .raw(
432 Method::POST,
433 &format!("/api/pim/import?{query}"),
434 &[("content-type", "text/calendar")],
435 body.into_bytes(),
436 )
437 .await;
438 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
439 r.json()
440 }
441 };
442 let before = collections(&io.alice).await;
443 let vevent = |uid: &str, summary: &str| {
444 format!(
445 "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\n"
446 )
447 };
448
449 // The file names itself and its color.
450 let ics = format!(
451 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Ferien\r\nX-APPLE-CALENDAR-COLOR:#FF2968FF\r\n{}END:VCALENDAR\r\n",
452 vevent("f1", "Sommer")
453 );
454 let r = post("kind=calendar&file=holidays.ics&color=%2322c55e", ics).await;
455 assert_eq!(r["created"], 1, "{r}");
456 assert_eq!(r["collection"]["name"], "Ferien", "{r}");
457 assert_eq!(r["collection"]["color"], "#FF2968FF", "{r}");
458
459 // A typed name wins; without a color in the file the requested one counts.
460 let plain = format!(
461 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nCOLOR:teal\r\n{}END:VCALENDAR\r\n",
462 vevent("f2", "Winter")
463 );
464 let r = post("kind=calendar&name=Mine&file=x.ics&color=%2322c55e", plain).await;
465 assert_eq!(r["collection"]["name"], "Mine", "{r}");
466 assert_eq!(r["collection"]["color"], "#22c55e", "{r}");
467
468 // An address book takes the file name.
469 let vcf = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Ann\r\nN:;Ann;;;\r\nEND:VCARD\r\n";
470 let r = post("kind=addressbook&file=Team%20contacts.vcf", vcf.to_string()).await;
471 assert_eq!(r["collection"]["name"], "Team contacts", "{r}");
472 assert_eq!(r["collection"]["kind"], "addressbook", "{r}");
473 assert_eq!(collections(&io.alice).await, before + 3);
474
475 // Nothing importable: reported, and no empty collection is left behind.
476 let broken = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:no start\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
477 let r = post("kind=calendar&file=bad.ics", broken.to_string()).await;
478 assert_eq!(r["collection"], Value::Null, "{r}");
479 assert_eq!(r["skipped_total"], 1, "{r}");
480 assert_eq!(collections(&io.alice).await, before + 3);
481}
482
483async fn collections(c: &Client) -> usize {
484 c.get("/api/pim/collections")
485 .await
486 .json()
487 .as_array()
488 .unwrap()
489 .len()
490}
491
492#[tokio::test]
493async fn share_candidates_leave_out_owner_borrowers_and_disabled() {
494 let io = Io::new().await;
495 let admin = login(&io.env, "admin", "admin1234").await;
496 create_user(&admin, "carol", PW, &[]).await;
497 create_user(&admin, "dave", PW, &[]).await;
498 let dave = user_id(&admin, "dave").await;
499 let r = admin
500 .put_json(
501 &format!("/api/admin/users/{dave}"),
502 &json!({"active": false}),
503 )
504 .await;
505 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
506 let r = admin
507 .post_json(
508 "/api/admin/rooms",
509 &json!({"name": "atrium", "kind": "room"}),
510 )
511 .await;
512 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
513
514 let id = collection_id(&io.alice, CAL).await;
515 let r = io
516 .alice
517 .post_json(
518 &format!("/api/pim/collections/{id}/shares"),
519 &json!({"user": "bob", "mode": "ro"}),
520 )
521 .await;
522 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
523
524 let names = |v: Value| -> Vec<String> {
525 v.as_array()
526 .unwrap()
527 .iter()
528 .map(|c| c["name"].as_str().unwrap().to_string())
529 .collect()
530 };
531 let r = io
532 .alice
533 .get(&format!("/api/pim/collections/{id}/shares/candidates"))
534 .await;
535 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
536 // Not alice herself, not bob (a borrower), not dave (disabled), no room.
537 assert_eq!(names(r.json()), ["admin", "carol"]);
538
539 // Only the owner may ask.
540 let bob = login(&io.env, "bob", PW).await;
541 let r = bob
542 .get(&format!("/api/pim/collections/{id}/shares/candidates"))
543 .await;
544 assert_eq!(r.status, StatusCode::NOT_FOUND);
545}
546
547#[tokio::test]
548async fn a_skipped_import_into_a_new_user_leaves_only_the_default_calendar() {
549 use server::db::PimKind;
550 let io = Io::new().await;
551 let client = login(&io.env, "bob", PW).await;
552 let broken = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:no start\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
553 let r = client
554 .raw(
555 Method::POST,
556 "/api/pim/import?kind=calendar&file=bad.ics",
557 &[("content-type", "text/calendar")],
558 broken.as_bytes().to_vec(),
559 )
560 .await;
561 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
562 assert_eq!(r.json()["collection"], Value::Null);
563 assert_eq!(r.json()["skipped_total"], 1);
564
565 let db = &io.env.state.db;
566 let bob = db.find_user_by_name("bob").await.unwrap().unwrap();
567 let pid = db.principal_of(bob.id).await.unwrap();
568 let slugs: Vec<_> = db
569 .pim_collections(pid, PimKind::Calendar)
570 .await
571 .unwrap()
572 .into_iter()
573 .map(|c| c.slug)
574 .filter(|s| s != "inbox" && s != "outbox")
575 .collect();
576 assert_eq!(slugs, ["default"]);
577}
578
579#[tokio::test]
580async fn collection_names_and_descriptions_are_checked() {
581 let io = Io::new().await;
582 let create = |body: Value| {
583 let client = &io.alice;
584 async move { client.post_json("/api/pim/collections", &body).await }
585 };
586 let long = "x".repeat(257);
587 for name in [long.as_str(), "bell\u{7}"] {
588 let r = create(json!({"kind": "calendar", "name": name})).await;
589 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{name:?}");
590 }
591 let r =
592 create(json!({"kind": "calendar", "name": "Ok", "description": "x".repeat(1025)})).await;
593 assert_eq!(r.status, StatusCode::BAD_REQUEST);
594 let r = create(json!({"kind": "calendar", "name": "Ok", "description": "two\nlines"})).await;
595 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
596 let id = r.json()["id"].as_i64().unwrap();
597 let url = format!("/api/pim/collections/{id}");
598 for body in [
599 json!({"name": long}),
600 json!({"description": "x".repeat(1025)}),
601 ] {
602 let r = io.alice.put_json(&url, &body).await;
603 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{body}");
604 }
605
606 // A file's own name that cannot be stored gives way to the file name.
607 let ics = format!(
608 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:{long}\r\nBEGIN:VEVENT\r\nUID:n1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
609 );
610 let r = io
611 .alice
612 .raw(
613 Method::POST,
614 "/api/pim/import?kind=calendar&file=Trips.ics",
615 &[("content-type", "text/calendar")],
616 ics.into_bytes(),
617 )
618 .await;
619 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
620 assert_eq!(r.json()["collection"]["name"], "Trips");
621}
622
623#[tokio::test]
624async fn a_feed_password_matches_with_edge_spaces() {
625 let io = Io::new().await;
626 let cal = collection_id(&io.alice, CAL).await;
627 let locked = io.link(cal, json!({ "password": " feedpass123 " })).await;
628 for pw in [" feedpass123 ", "feedpass123"] {
629 let r = io.anon(&locked, &[("authorization", &basic("", pw))]).await;
630 assert_eq!(r.status, StatusCode::OK, "{pw:?}");
631 }
632}
633
634#[tokio::test]
635async fn feeds_of_a_disabled_owner_stop() {
636 let io = Io::new().await;
637 let admin = login(&io.env, "admin", "admin1234").await;
638 let feed = io
639 .link(collection_id(&io.alice, CAL).await, json!({}))
640 .await;
641 let alice = user_id(&admin, "alice").await;
642 for (active, status) in [(false, StatusCode::NOT_FOUND), (true, StatusCode::OK)] {
643 let r = admin
644 .put_json(
645 &format!("/api/admin/users/{alice}"),
646 &json!({ "active": active }),
647 )
648 .await;
649 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
650 assert_eq!(io.anon(&feed, &[]).await.status, status);
651 }
652}
653
654#[tokio::test]
655async fn a_loan_to_a_disabled_user_takes_a_new_mode() {
656 let io = Io::new().await;
657 let admin = login(&io.env, "admin", "admin1234").await;
658 create_user(&admin, "carol", PW, &[]).await;
659 let id = collection_id(&io.alice, CAL).await;
660 let shares = format!("/api/pim/collections/{id}/shares");
661 let r = io
662 .alice
663 .post_json(&shares, &json!({"user": "bob", "mode": "ro"}))
664 .await;
665 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
666 for name in ["bob", "carol"] {
667 let uid = user_id(&admin, name).await;
668 let r = admin
669 .put_json(
670 &format!("/api/admin/users/{uid}"),
671 &json!({"active": false}),
672 )
673 .await;
674 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
675 }
676
677 let r = io
678 .alice
679 .post_json(&shares, &json!({"user": "bob", "mode": "rw"}))
680 .await;
681 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
682 assert_eq!(r.json()["user_name"], "bob");
683 let list = io.alice.get(&shares).await.json();
684 assert_eq!(list[0]["mode"], "rw", "{list}");
685
686 // No new loan goes to a disabled account.
687 let r = io
688 .alice
689 .post_json(&shares, &json!({"user": "carol", "mode": "ro"}))
690 .await;
691 assert_eq!(r.status, StatusCode::NOT_FOUND);
692}
693
694#[tokio::test]
695async fn an_import_racing_the_collection_delete_never_fails_with_500() {
696 let io = Io::new().await;
697 let url = "/pim/calendars/alice/race/";
698 for i in 0..20 {
699 let r = io.dav("alice", "MKCALENDAR", url, "").await;
700 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
701 let path = format!(
702 "/api/pim/collections/{}/import",
703 collection_id(&io.alice, url).await
704 );
705 let (import, delete) = tokio::join!(
706 io.alice.raw(
707 Method::POST,
708 &path,
709 &[("content-type", "text/calendar")],
710 event(&format!("race{i}"), "x", "").into_bytes(),
711 ),
712 io.dav("alice", "DELETE", url, ""),
713 );
714 assert_eq!(delete.status, StatusCode::NO_CONTENT);
715 assert!(
716 [StatusCode::OK, StatusCode::NOT_FOUND].contains(&import.status),
717 "{}: {}",
718 import.status,
719 import.text()
720 );
721 }
722}
723
724#[tokio::test]
725async fn the_web_api_keeps_the_dav_limits() {
726 let io = Io::new().await;
727 let cal = collection_id(&io.alice, CAL).await;
728 // An object over the 10 MiB a PUT takes is skipped.
729 let big = event(
730 "big",
731 "Big",
732 &format!("DESCRIPTION:{}\r\n", "x".repeat(11 << 20)),
733 );
734 let r = io.import(cal, &(big + &event("small", "Small", ""))).await;
735 assert_eq!(r["created"], 1, "{r}");
736 assert_eq!(r["skipped"][0]["uid"], "big", "{r}");
737
738 // One large time zone copied into every event is refused up front.
739 let zone = format!(
740 "BEGIN:VTIMEZONE\r\nTZID:Big\r\n{}BEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n\
741 TZOFFSETFROM:+0000\r\nTZOFFSETTO:+0000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n",
742 format!("X-PAD:{}\r\n", "x".repeat(70)).repeat(15_000)
743 );
744 let events: String = (0..200)
745 .map(|i| {
746 format!(
747 "BEGIN:VEVENT\r\nUID:z{i}\r\nDTSTAMP:20260101T000000Z\r\n\
748 DTSTART;TZID=Big:20260101T100000\r\nEND:VEVENT\r\n"
749 )
750 })
751 .collect();
752 let file = format!(
753 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{zone}{events}END:VCALENDAR\r\n"
754 );
755 let r = io
756 .alice
757 .raw(
758 Method::POST,
759 &format!("/api/pim/collections/{cal}/import"),
760 &[("content-type", "text/calendar")],
761 file.into_bytes(),
762 )
763 .await;
764 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE, "{}", r.text());
765
766 // A zone no event names is not copied, and alarms are no objects.
767 let events: String = (0..150)
768 .map(|i| {
769 format!(
770 "BEGIN:VEVENT\r\nUID:a{i}\r\nDTSTAMP:20260101T000000Z\r\n\
771 DTSTART:20260101T100000Z\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\n\
772 TRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VEVENT\r\n"
773 )
774 })
775 .collect();
776 let file = format!(
777 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{zone}{events}END:VCALENDAR\r\n"
778 );
779 let r = io.import(cal, &file).await;
780 assert_eq!(r["created"], 150, "{r}");
781
782 // A suffixed slug never takes the lent form "shared-<id>".
783 for _ in 0..2 {
784 let r = io
785 .alice
786 .post_json(
787 "/api/pim/collections",
788 &json!({"kind": "calendar", "name": "Shared"}),
789 )
790 .await;
791 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
792 assert!(!r.text().contains("/shared-"), "{}", r.text());
793 }
794
795 // No more collections than MKCOL allows.
796 let create = async |name: String| {
797 io.alice
798 .post_json(
799 "/api/pim/collections",
800 &json!({"kind": "addressbook", "name": name}),
801 )
802 .await
803 .status
804 };
805 let mut made = 0;
806 while create(format!("Book {made}")).await == StatusCode::OK {
807 made += 1;
808 assert!(made <= 100);
809 }
810 assert_eq!(create("One more".into()).await, StatusCode::FORBIDDEN);
811}
812