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