api_pim_clients.rs
⎇
Raw
1//! What real clients need beyond the RFCs' core: discovery by GET and from
2//! the server root, client properties, vCard 3.0 for Apple, and addresses
3//! and logins for unusual account names.
4
5mod common;
6
7use axum::http::{Method, StatusCode};
8use common::*;
9use pimdav::xml::{self, CALDAV, CALSERVER, CARDDAV, DAV, Name};
10use serde_json::json;
11use xmltree::Element;
12
13const PW: &str = "secret12345";
14
15async fn req(
16 env: &Env,
17 verb: &str,
18 path: &str,
19 auth: &str,
20 extra: &[(&str, &str)],
21 body: &str,
22) -> Resp {
23 let mut headers = vec![("authorization", auth)];
24 headers.extend_from_slice(extra);
25 Client::new(env.app.clone())
26 .raw(
27 Method::from_bytes(verb.as_bytes()).unwrap(),
28 path,
29 &headers,
30 body.as_bytes().to_vec(),
31 )
32 .await
33}
34
35async fn setup(names: &[&str]) -> (Env, Client) {
36 let env = Env::new().await;
37 let admin = env.admin().await;
38 for n in names {
39 create_user(&admin, n, PW, &[]).await;
40 }
41 (env, admin)
42}
43
44/// The 200 properties of the single response, and its `<d:error>`.
45fn props(r: &Resp) -> (Vec<(u16, Element)>, Option<Name>) {
46 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
47 let root = Element::parse(r.body.as_slice()).unwrap();
48 let resp = xml::elements(&root).next().unwrap();
49 let error = xml::child(resp, DAV, "error").and_then(|e| xml::elements(e).next().map(Name::of));
50 let props = xml::elements(resp)
51 .filter(|e| Name::of(e).is(DAV, "propstat"))
52 .flat_map(|ps| {
53 let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
54 .split(' ')
55 .nth(1)
56 .unwrap()
57 .parse()
58 .unwrap();
59 xml::elements(xml::child(ps, DAV, "prop").unwrap())
60 .map(move |p| (code, p.clone()))
61 .collect::<Vec<_>>()
62 })
63 .collect();
64 (props, error)
65}
66
67fn find<'a>(props: &'a [(u16, Element)], ns: &str, local: &str) -> Option<&'a (u16, Element)> {
68 props.iter().find(|(_, p)| Name::of(p).is(ns, local))
69}
70
71#[tokio::test]
72async fn discovery_by_get_and_from_the_root() {
73 let (env, _) = setup(&["alice"]).await;
74 let auth = basic("alice", PW);
75 for path in [
76 "/pim/",
77 "/pim/principals/alice/",
78 "/pim/calendars/alice/",
79 "/pim/calendars/alice/default/",
80 "/pim/addressbooks/alice/system/",
81 ] {
82 let r = req(&env, "GET", path, &auth, &[], "").await;
83 assert_eq!(r.status, StatusCode::OK, "GET {path}");
84 let r = req(&env, "HEAD", path, &auth, &[], "").await;
85 assert_eq!(r.status, StatusCode::OK, "HEAD {path}");
86 assert!(r.body.is_empty());
87 }
88 let r = req(&env, "GET", "/pim/calendars/alice/nope/", &auth, &[], "").await;
89 assert_eq!(r.status, StatusCode::NOT_FOUND);
90
91 // Every response carries the DAV header, the challenge included.
92 let r = req(&env, "PROPFIND", "/pim/", &auth, &[], "").await;
93 assert!(r.header("dav").unwrap().contains("calendar-access"));
94 let r = req(&env, "PROPFIND", "/pim/", "", &[], "").await;
95 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
96 assert!(r.header("dav").is_some());
97 assert!(
98 r.header("www-authenticate")
99 .unwrap()
100 .contains("charset=\"UTF-8\"")
101 );
102
103 // A client given only the server address finds the principal.
104 let r = req(&env, "PROPFIND", "/", &auth, &[], "").await;
105 assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT);
106 assert_eq!(r.header("location").as_deref(), Some("/pim/"));
107 let r = req(&env, "OPTIONS", "/", "", &[], "").await;
108 assert_eq!(r.status, StatusCode::OK);
109 assert!(r.header("dav").unwrap().contains("addressbook"));
110 // The web app is still at the root.
111 let r = req(&env, "GET", "/", "", &[], "").await;
112 assert_eq!(r.status, StatusCode::OK);
113}
114
115#[tokio::test]
116async fn client_properties_are_stored() {
117 let (env, _) = setup(&["alice", "bob"]).await;
118 let alice = basic("alice", PW);
119 let home = "/pim/calendars/alice/";
120 let book_home = "/pim/addressbooks/alice/";
121
122 // macOS Calendar and Contacts store their settings on the homes.
123 let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
124 <c:default-alarm-vevent-date>BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n</c:default-alarm-vevent-date>\
125 </d:prop></d:set></d:propertyupdate>";
126 let (ps, _) = props(&req(&env, "PROPPATCH", home, &alice, &[], patch).await);
127 assert_eq!(ps[0].0, 200);
128 let me_card = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:cs=\"http://calendarserver.org/ns/\"><d:set><d:prop>\
129 <cs:me-card><d:href>/pim/addressbooks/alice/default/me.vcf</d:href></cs:me-card>\
130 </d:prop></d:set></d:propertyupdate>";
131 let (ps, _) = props(&req(&env, "PROPPATCH", book_home, &alice, &[], me_card).await);
132 assert_eq!(ps[0].0, 200);
133 let custom = "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:note xmlns:x=\"urn:x\">hi</x:note></d:prop></d:set></d:propertyupdate>";
134 let (ps, _) = props(
135 &req(
136 &env,
137 "PROPPATCH",
138 "/pim/principals/alice/",
139 &alice,
140 &[],
141 custom,
142 )
143 .await,
144 );
145 assert_eq!(ps[0].0, 200);
146
147 let find_body = |ns: &str, l: &str| {
148 format!("<d:propfind xmlns:d=\"DAV:\"><d:prop><{l} xmlns=\"{ns}\"/></d:prop></d:propfind>")
149 };
150 let r = req(
151 &env,
152 "PROPFIND",
153 home,
154 &alice,
155 &[("depth", "0")],
156 &find_body(CALDAV, "default-alarm-vevent-date"),
157 )
158 .await;
159 let (ps, _) = props(&r);
160 let (code, p) = find(&ps, CALDAV, "default-alarm-vevent-date").unwrap();
161 assert_eq!(*code, 200);
162 // XML parsing made the raw CRLF of the request LF (XML 1.0, 2.11).
163 assert_eq!(
164 p.get_text().unwrap(),
165 "BEGIN:VALARM\nTRIGGER:-PT15M\nEND:VALARM\n"
166 );
167 let r = req(
168 &env,
169 "PROPFIND",
170 book_home,
171 &alice,
172 &[("depth", "0")],
173 &find_body(CALSERVER, "me-card"),
174 )
175 .await;
176 let (ps, _) = props(&r);
177 let href = xml::child(&find(&ps, CALSERVER, "me-card").unwrap().1, DAV, "href").map(xml::text);
178 assert_eq!(
179 href.as_deref(),
180 Some("/pim/addressbooks/alice/default/me.vcf")
181 );
182 let r = req(
183 &env,
184 "PROPFIND",
185 "/pim/principals/alice/",
186 &alice,
187 &[("depth", "0")],
188 "",
189 )
190 .await;
191 let (ps, _) = props(&r);
192 assert_eq!(xml::text(&find(&ps, "urn:x", "note").unwrap().1), "hi");
193
194 // A computed property is refused by name, and nothing else is stored.
195 let mixed = "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:a xmlns:x=\"urn:x\">1</x:a>\
196 <d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>";
197 let r = req(&env, "PROPPATCH", home, &alice, &[], mixed).await;
198 let (ps, error) = props(&r);
199 let codes: Vec<u16> = ps.iter().map(|(c, _)| *c).collect();
200 assert_eq!(codes, [424, 403]);
201 assert!(error.unwrap().is(DAV, "cannot-modify-protected-property"));
202 let r = req(
203 &env,
204 "PROPFIND",
205 home,
206 &alice,
207 &[("depth", "0")],
208 &find_body("urn:x", "a"),
209 )
210 .await;
211 assert_eq!(find(&props(&r).0, "urn:x", "a").unwrap().0, 404);
212
213 // Unknown properties no longer fail a collection's PROPPATCH or MKCALENDAR.
214 let cal = "/pim/calendars/alice/default/";
215 let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:i=\"http://apple.com/ns/ical/\"><d:set><d:prop>\
216 <i:calendar-color>#ff0000</i:calendar-color><x:foo xmlns:x=\"urn:x\">bar</x:foo></d:prop></d:set></d:propertyupdate>";
217 let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], patch).await);
218 assert!(ps.iter().all(|(c, _)| *c == 200), "{ps:?}");
219 let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
220 let (ps, _) = props(&r);
221 assert_eq!(
222 xml::text(
223 &find(&ps, "http://apple.com/ns/ical/", "calendar-color")
224 .unwrap()
225 .1
226 ),
227 "#ff0000"
228 );
229 assert_eq!(xml::text(&find(&ps, "urn:x", "foo").unwrap().1), "bar");
230 let mk = "<c:mkcalendar xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
231 <d:displayname>Old iCal</d:displayname>\
232 <c:calendar-free-busy-set><d:href>/pim/calendars/alice/old/</d:href></c:calendar-free-busy-set>\
233 </d:prop></d:set></c:mkcalendar>";
234 let r = req(
235 &env,
236 "MKCALENDAR",
237 "/pim/calendars/alice/old/",
238 &alice,
239 &[],
240 mk,
241 )
242 .await;
243 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
244 let r = req(
245 &env,
246 "PROPFIND",
247 "/pim/calendars/alice/old/",
248 &alice,
249 &[("depth", "0")],
250 "",
251 )
252 .await;
253 assert!(find(&props(&r).0, CALDAV, "calendar-free-busy-set").is_some());
254
255 // Removing works, and an oversized value is refused.
256 let remove = "<d:propertyupdate xmlns:d=\"DAV:\"><d:remove><d:prop><x:foo xmlns:x=\"urn:x\"/></d:prop></d:remove></d:propertyupdate>";
257 let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], remove).await);
258 assert_eq!(ps[0].0, 200);
259 let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
260 assert!(find(&props(&r).0, "urn:x", "foo").is_none());
261 let big = format!(
262 "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:big xmlns:x=\"urn:x\">{}</x:big></d:prop></d:set></d:propertyupdate>",
263 "a".repeat(70_000)
264 );
265 let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], &big).await);
266 assert_eq!(ps[0].0, 507);
267
268 // A borrower reads the owner's properties and cannot change them.
269 let cid = alice_calendar_id(&env).await;
270 let alice_client = login(&env, "alice", PW).await;
271 let r = alice_client
272 .post_json(
273 &format!("/api/pim/collections/{cid}/shares"),
274 &json!({"user": "bob", "mode": "rw"}),
275 )
276 .await;
277 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
278 let bob = basic("bob", PW);
279 let lent = format!("/pim/calendars/bob/shared-{cid}/");
280 let r = req(&env, "PROPFIND", &lent, &bob, &[("depth", "0")], "").await;
281 assert_eq!(
282 xml::text(
283 &find(&props(&r).0, "http://apple.com/ns/ical/", "calendar-color")
284 .unwrap()
285 .1
286 ),
287 "#ff0000"
288 );
289 let (ps, _) = props(&req(&env, "PROPPATCH", &lent, &bob, &[], patch).await);
290 assert!(ps.iter().all(|(code, _)| *code == 403), "{ps:?}");
291 // And another account's home is not writable.
292 let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await;
293 assert_eq!(r.status, StatusCode::FORBIDDEN);
294}
295
296async fn alice_calendar_id(env: &Env) -> i64 {
297 let alice = login(env, "alice", PW).await;
298 let r = alice.get("/api/pim/collections").await;
299 r.json()
300 .as_array()
301 .unwrap()
302 .iter()
303 .find(|c| {
304 c["kind"] == "calendar"
305 && c["url"]
306 .as_str()
307 .is_some_and(|u| u.ends_with("/calendars/alice/default/"))
308 })
309 .unwrap()["id"]
310 .as_i64()
311 .unwrap()
312}
313
314#[tokio::test]
315async fn vcard_three_for_apple() {
316 let (env, _) = setup(&["alice"]).await;
317 let auth = basic("alice", PW);
318 let book = "/pim/addressbooks/alice/default/";
319
320 let r = req(&env, "PROPFIND", book, &auth, &[("depth", "0")], "").await;
321 let (ps, _) = props(&r);
322 let data = &find(&ps, CARDDAV, "supported-address-data").unwrap().1;
323 let versions: Vec<_> = xml::elements(data)
324 .filter_map(|e| e.attributes.get("version"))
325 .collect();
326 assert_eq!(versions, ["3.0"]);
327
328 // A vCard 4.0 group, as DAVx5 writes it, is stored as sent.
329 let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g1\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nEND:VCARD\r\n";
330 let path = format!("{book}g1.vcf");
331 let r = req(&env, "PUT", &path, &auth, &[], group).await;
332 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
333 let etag = r.header("etag").unwrap();
334
335 // Without Accept, 3.0 with the forms Apple reads; the ETag stays.
336 let r = req(&env, "GET", &path, &auth, &[], "").await;
337 let text = r.text();
338 assert!(
339 text.contains("VERSION:3.0") && text.contains("X-ADDRESSBOOKSERVER-KIND:group"),
340 "{text}"
341 );
342 assert!(
343 text.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:c1"),
344 "{text}"
345 );
346 assert_eq!(r.header("etag").as_deref(), Some(etag.as_str()));
347 let r = req(
348 &env,
349 "GET",
350 &path,
351 &auth,
352 &[("accept", "text/vcard; version=4.0")],
353 "",
354 )
355 .await;
356 assert_eq!(r.text(), group);
357
358 let multiget = format!(
359 r#"<card:addressbook-multiget xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:address-data/></d:prop><d:href>{path}</d:href></card:addressbook-multiget>"#
360 );
361 let r = req(&env, "REPORT", book, &auth, &[], &multiget).await;
362 assert!(
363 r.text().contains("X-ADDRESSBOOKSERVER-KIND:group"),
364 "{}",
365 r.text()
366 );
367 let v4 = multiget.replace(
368 "<card:address-data/>",
369 "<card:address-data version=\"4.0\"/>",
370 );
371 let r = req(&env, "REPORT", book, &auth, &[], &v4).await;
372 assert!(
373 r.text().contains("MEMBER:urn:uuid:c1") && !r.text().contains("X-ADDRESSBOOK"),
374 "{}",
375 r.text()
376 );
377}
378
379#[tokio::test]
380async fn addresses_and_logins_for_unusual_names() {
381 let (env, _) = setup(&["alice", "marc@example.com", "a..b"]).await;
382 let alice = basic("alice", PW);
383 let marc = basic("marc@example.com", PW);
384
385 let address = |env: &Env, auth: String, user: &'static str| {
386 let app = env.app.clone();
387 async move {
388 let r = Client::new(app)
389 .raw(
390 Method::from_bytes(b"PROPFIND").unwrap(),
391 &format!("/pim/principals/{user}/"),
392 &[("authorization", auth.as_str()), ("depth", "0")],
393 Vec::new(),
394 )
395 .await;
396 let (ps, _) = props(&r);
397 let set = &find(&ps, CALDAV, "calendar-user-address-set").unwrap().1;
398 xml::text(xml::elements(set).next().unwrap())
399 }
400 };
401 // One `@` and only characters valid in a local part.
402 let m = address(&env, marc.clone(), "marc@example.com").await;
403 assert_eq!(m, "mailto:marc%40example.com@dovenest.invalid");
404 let dots = address(&env, basic("a..b", PW), "a..b").await;
405 assert_eq!(dots, "mailto:a%2E%2Eb@dovenest.invalid");
406
407 // An invitation to that address reaches the account.
408 let ics = format!(
409 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:m1\r\nDTSTAMP:20260101T000000Z\r\n\
410 DTSTART:20261001T100000Z\r\nDTEND:20261001T110000Z\r\nSUMMARY:Meet\r\n\
411 ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@dovenest.invalid\r\n\
412 ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:{m}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
413 );
414 let r = req(
415 &env,
416 "PUT",
417 "/pim/calendars/alice/default/m1.ics",
418 &alice,
419 &[],
420 &ics,
421 )
422 .await;
423 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
424 let r = req(
425 &env,
426 "PROPFIND",
427 "/pim/calendars/marc@example.com/inbox/",
428 &marc,
429 &[("depth", "1")],
430 "",
431 )
432 .await;
433 assert_eq!(r.status, StatusCode::MULTI_STATUS);
434 let root = Element::parse(r.body.as_slice()).unwrap();
435 assert_eq!(xml::elements(&root).count(), 2, "{}", r.text());
436
437 // iOS sends `@` in the Basic user name as `%40`.
438 let r = req(
439 &env,
440 "PROPFIND",
441 "/pim/",
442 &basic("marc%40example.com", PW),
443 &[],
444 "",
445 )
446 .await;
447 assert_eq!(r.status, StatusCode::MULTI_STATUS);
448 let r = req(
449 &env,
450 "PROPFIND",
451 "/pim/",
452 &basic("marc%40example.com", "wrong"),
453 &[],
454 "",
455 )
456 .await;
457 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
458}
459