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