From a8d295223bd4120aafeed8b8b4aaf424e9c40d66 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sat, 3 May 2025 19:01:37 +0200 Subject: [PATCH] CardDAV addressbook-query REPORT tests --- crates/dav/src/card/query.rs | 5 +- crates/dav/src/common/mod.rs | 7 + crates/dav/src/common/propfind.rs | 42 ++-- crates/dav/src/principal/matching.rs | 1 + tests/src/webdav/card_query.rs | 341 +++++++++++++++++++++++++++ tests/src/webdav/mod.rs | 13 +- 6 files changed, 386 insertions(+), 23 deletions(-) create mode 100644 tests/src/webdav/card_query.rs diff --git a/crates/dav/src/card/query.rs b/crates/dav/src/card/query.rs index 6bf52c50..3b351c16 100644 --- a/crates/dav/src/card/query.rs +++ b/crates/dav/src/card/query.rs @@ -210,8 +210,9 @@ pub(crate) fn serialize_vcard_with_props( if !props.is_empty() { let mut vcard = String::with_capacity(128); let _ = write!(&mut vcard, "BEGIN:VCARD\r\n"); - for item in props { - for entry in card.entries.iter() { + + for entry in card.entries.iter() { + for item in props { if entry.name == item.name && entry.group == item.group { let _ = entry.write_to(&mut vcard, !item.no_value); break; diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs index 7893d739..8d8fa358 100644 --- a/crates/dav/src/common/mod.rs +++ b/crates/dav/src/common/mod.rs @@ -40,6 +40,7 @@ pub mod uri; #[derive(Default, Debug)] pub(crate) struct DavQuery<'x> { + pub uri: &'x str, pub resource: DavQueryResource<'x>, pub propfind: PropFind, pub sync_type: SyncType, @@ -152,6 +153,7 @@ impl<'x> DavQuery<'x> { }, ret: headers.ret, depth_no_root: headers.depth_no_root, + uri: headers.uri, ..Default::default() } } @@ -169,6 +171,7 @@ impl<'x> DavQuery<'x> { propfind: multiget.properties, ret: headers.ret, depth_no_root: headers.depth_no_root, + uri: headers.uri, ..Default::default() } } @@ -188,6 +191,7 @@ impl<'x> DavQuery<'x> { limit: query.limit, ret: headers.ret, depth_no_root: headers.depth_no_root, + uri: headers.uri, ..Default::default() } } @@ -211,6 +215,7 @@ impl<'x> DavQuery<'x> { propfind: query.properties, ret: headers.ret, depth_no_root: headers.depth_no_root, + uri: headers.uri, ..Default::default() } } @@ -239,6 +244,7 @@ impl<'x> DavQuery<'x> { ret: headers.ret, depth_no_root: headers.depth_no_root, expand: false, + uri: headers.uri, } } @@ -266,6 +272,7 @@ impl<'x> DavQuery<'x> { ret: headers.ret, depth_no_root: headers.depth_no_root, expand: true, + uri: headers.uri, ..Default::default() } } diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index d0afbbe1..2dc09f49 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -352,6 +352,7 @@ impl PropFindRequestHandler for Server { let mut ctag = None; let mut paths; let mut query_filter = None; + let mut max_results = self.core.groupware.max_match_results; //let c = println!("handling DAV query {query:#?}"); @@ -405,10 +406,7 @@ impl PropFindRequestHandler for Server { // Filter by changelog match query.sync_type { SyncType::From(change_id) => { - let limit = std::cmp::min( - query.limit.unwrap_or(u32::MAX) as usize, - self.core.groupware.max_changes, - ); + max_results = self.core.groupware.max_changes; let container_changes = self .store() .changes(account_id, collection_container, Query::Since(change_id)) @@ -449,14 +447,10 @@ impl PropFindRequestHandler for Server { .flatten() { let changes = RoaringBitmap::from_iter( - changes - .changes - .iter() - .filter_map(|change| match change { - Change::Insert(id) | Change::Update(id) => Some(*id as u32), - _ => None, - }) - .take(limit), + changes.changes.iter().filter_map(|change| match change { + Change::Insert(id) | Change::Update(id) => Some(*id as u32), + _ => None, + }), ); if let Some(document_ids) = document_ids { *document_ids &= changes; @@ -665,13 +659,6 @@ impl PropFindRequestHandler for Server { DavQueryResource::None => unreachable!(), } - if query.depth == usize::MAX && paths.len() > self.core.groupware.max_match_results { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - BaseCondition::NumberOfMatchesWithinLimit, - ))); - } - let mut skip_not_found = query.expand; let properties = match &query.propfind { PropFind::PropName => { @@ -725,6 +712,7 @@ impl PropFindRequestHandler for Server { }; let view_as_id = access_token.primary_id(); + let mut limit = std::cmp::min(query.limit.unwrap_or(u32::MAX) as usize, max_results); for item in paths { let account_id = item.account_id; let document_id = item.document_id; @@ -1318,6 +1306,22 @@ impl PropFindRequestHandler for Server { prop_stat.push(PropStat::new_list(vec![])); } response.add_response(Response::new_propstat(item.name, prop_stat)); + + limit -= 1; + if limit == 0 { + break; + } + } + + if limit == 0 { + response.add_response( + Response::new_status([query.uri], StatusCode::INSUFFICIENT_STORAGE) + .with_error(BaseCondition::NumberOfMatchesWithinLimit) + .with_response_description(format!( + "The number of matches exceeds the limit of {}", + query.limit.unwrap_or(max_results as u32) + )), + ); } Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) diff --git a/crates/dav/src/principal/matching.rs b/crates/dav/src/principal/matching.rs index 615e4895..db97880e 100644 --- a/crates/dav/src/principal/matching.rs +++ b/crates/dav/src/principal/matching.rs @@ -68,6 +68,7 @@ impl PrincipalMatching for Server { depth: usize::MAX, ret: headers.ret, depth_no_root: headers.depth_no_root, + uri: headers.uri, ..Default::default() }, ) diff --git a/tests/src/webdav/card_query.rs b/tests/src/webdav/card_query.rs new file mode 100644 index 00000000..b7f16831 --- /dev/null +++ b/tests/src/webdav/card_query.rs @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::WebDavTest; +use dav_proto::schema::property::{CardDavProperty, DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &WebDavTest) { + println!("Running REPORT addressbook-query tests..."); + let client = test.client("john"); + + // Create test data + let default_path = format!("{}/john/default/", DavResourceName::Card.base_path()); + let mut hrefs = Vec::with_capacity(3); + for (i, vcard) in [VCARD1, VCARD2, VCARD3].iter().enumerate() { + let href = format!("{default_path}contact-{i}.vcf",); + client + .request("PUT", &href, *vcard) + .await + .with_status(hyper::StatusCode::CREATED); + hrefs.push(href); + } + let uri_sarah = hrefs[0].as_str(); + let uri_carlos = hrefs[1].as_str(); + let uri_acme = hrefs[2].as_str(); + + // Test 1: RFC6352 8.6.3 example 1 + let response = client + .request( + "REPORT", + &default_path, + r#" + + + + + + + + + + + + + + charlie + + + "#, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([uri_carlos]) + .into_propfind_response(None); + let props = response.properties(uri_carlos); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_not_empty(); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData( + Default::default(), + ))) + .with_values([r#"BEGIN:VCARD +VERSION:4.0 +FN:Carlos Rodriguez-Martinez +NICKNAME:Charlie +EMAIL;TYPE=WORK,pref:carlos.rodriguez@example-corp.com +EMAIL;TYPE=HOME:carlosrm@personalmail.example +UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a +END:VCARD +"# + .replace('\n', "\r\n") + .as_str()]); + + // Test 2: RFC6352 8.6.3 example 2 + let response = client + .request( + "REPORT", + &default_path, + r#" + + + + + + + + + + + + + john + + + rodriguez + + + "#, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([uri_carlos, uri_sarah]) + .into_propfind_response(None); + let props = response.properties(uri_carlos); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_not_empty(); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData( + Default::default(), + ))) + .with_values([r#"BEGIN:VCARD +FN:Carlos Rodriguez-Martinez +BDAY:--0623 +CATEGORIES:Marketing,Management,International +LANG;TYPE=WORK;PREF=1:es +LANG;TYPE=WORK;PREF=2:en +LANG;TYPE=WORK;PREF=3:pt +END:VCARD +"# + .replace('\n', "\r\n") + .as_str()]); + let props = response.properties(uri_sarah); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_not_empty(); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData( + Default::default(), + ))) + .with_values([r#"BEGIN:VCARD +FN:Sarah Johnson +BDAY:19850415 +CATEGORIES:Work,Research,VIP +LANG;TYPE=WORK;PREF=1:en +LANG;TYPE=WORK;PREF=2:fr +END:VCARD +"# + .replace('\n', "\r\n") + .as_str()]); + + // Test 3: Search within parameters + let response = client + .request( + "REPORT", + &default_path, + r#" + + + + + + + + + enterprise + + + +"#, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([uri_acme]) + .into_propfind_response(None); + let props = response.properties(uri_acme); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData( + Default::default(), + ))) + .with_values([VCARD3.replace('\n', "\r\n").as_str()]); + + // Test 4: Search using limit + client + .request( + "REPORT", + &default_path, + r#" + + + + + + + acme + + + global + + + + 2 + + "#, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 507 Insufficient Storage", + ) + .with_value( + "D:multistatus.D:response.D:error.D:number-of-matches-within-limits", + "", + ) + .with_value( + "D:multistatus.D:response.D:responsedescription", + "The number of matches exceeds the limit of 2", + ) + .with_href_count(3); + + client.delete_default_containers().await; + test.assert_is_empty().await; +} + +const VCARD1: &str = r#"BEGIN:VCARD +VERSION:4.0 +FN:Sarah Johnson +N:Johnson;Sarah;Marie;Dr.;Ph.D. +NICKNAME:Sadie +GENDER:F +BDAY:19850415 +ANNIVERSARY:20100610 +EMAIL;TYPE=work:sarah.johnson@example.com +EMAIL;TYPE=home,pref:sarahjpersonal@example.com +TEL;TYPE=cell,voice,pref:+1-555-123-4567 +TEL;TYPE=work,voice:+1-555-987-6543 +TEL;TYPE=home,voice:+1-555-456-7890 +ADR;TYPE=work;LABEL="123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA":;;123 Business Ave;New York;NY;10001;USA +ADR;TYPE=home,pref;LABEL="456 Residential St\nApt 7B\nBrooklyn, NY 11201\nUSA":;;456 Residential St;Brooklyn;NY;11201;USA +ORG:Acme Technologies Inc.;Research Department +TITLE:Senior Research Scientist +ROLE:Team Lead +CATEGORIES:Work,Research,VIP +URL;TYPE=work:https://www.example.com/staff/sjohnson +URL;TYPE=home:https://www.sarahjohnson.example.com +KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=sarah.johnson@example.com +NOTE:Sarah prefers video calls over phone calls. Available Mon-Thu 9-5 EST. +LANG;TYPE=work;PREF=1:en +LANG;TYPE=work;PREF=2:fr +TZ:-0500 +GEO:40.7128;-74.0060 +UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +REV:20220315T133000Z +END:VCARD +"#; + +const VCARD2: &str = r#"BEGIN:VCARD +VERSION:4.0 +FN:Carlos Rodriguez-Martinez +N:Rodriguez-Martinez;Carlos;Alberto;Mr.;Jr. +NICKNAME:Charlie +GENDER:M +BDAY:--0623 +ANNIVERSARY:20150809 +EMAIL;TYPE=work,pref:carlos.rodriguez@example-corp.com +EMAIL;TYPE=home:carlosrm@personalmail.example +TEL;TYPE=cell,voice,pref:+34-611-234-567 +TEL;TYPE=work,voice:+34-911-876-543 +TEL;TYPE=home,voice:+34-644-321-987 +TEL;TYPE=fax:+34-911-876-544 +ADR;TYPE=work;LABEL="Calle Empresarial 42\nPlanta 3\nMadrid, 28001\nSpain":;;Calle Empresarial 42;Madrid;;28001;Spain +ADR;TYPE=home,pref;LABEL="Avenida Residencial 15\nPiso 7, Puerta C\nMadrid, 28045\nSpain":;;Avenida Residencial 15;Madrid;;28045;Spain +ORG:Global Solutions S.L.;Marketing Division +TITLE:Digital Marketing Director +ROLE:Department Head +CATEGORIES:Marketing,Management,International +URL;TYPE=work:https://www.example-corp.com/team/carlos +URL;TYPE=home:https://www.carlosrodriguez.example +URL;TYPE=social:https://linkedin.com/in/carlosrodriguezm +KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=carlos.rodriguez@example-corp.com +NOTE:Carlos speaks English, Spanish, and Portuguese fluently. Prefers communication via email. Do not contact after 7PM CET. +LANG;TYPE=work;PREF=1:es +LANG;TYPE=work;PREF=2:en +LANG;TYPE=work;PREF=3:pt +TZ:+0100 +GEO:40.4168;-3.7038 +UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a +REV:20230712T092135Z +SOURCE:https://contacts.example.com/carlosrodriguez.vcf +KIND:individual +MEMBER:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af +RELATED;TYPE=friend:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +END:VCARD +"#; + +const VCARD3: &str = r#"BEGIN:VCARD +VERSION:4.0 +FN:Acme Business Solutions Ltd. +N:;;;; +KIND:ORG +ORG:Acme Business Solutions Ltd.;Technology Division +EMAIL;TYPE=WORK,pref:info@acme-solutions.example +EMAIL;TYPE=support:support@acme-solutions.example +EMAIL;TYPE=sales:sales@acme-solutions.example +TEL;TYPE=WORK,VOICE,pref:+44-20-1234-5678 +TEL;TYPE=FAX:+44-20-1234-5679 +TEL;TYPE=support:+44-800-987-6543 +ADR;TYPE=WORK;LABEL="10 Enterprise Way\nTech Park\nLondon, EC1A 1BB\nUnited + Kingdom":;;10 Enterprise Way\, Tech Park;London;;EC1A 1BB;United Kingdom +ADR;TYPE=branch;LABEL="25 Innovation Street\nManchester, M1 5QF\nUnited Kin + gdom":;;25 Innovation Street;Manchester;;M1 5QF;United Kingdom +URL;TYPE=WORK:https://www.acme-solutions.example +URL;TYPE=support:https://support.acme-solutions.example +CATEGORIES:Technology,B2B,Solutions,Services +NOTE:Business hours: Mon-Fri 9:00-17:30 GMT. Closed on UK bank holidays. VAT + Reg: GB123456789 +TZ:Z +GEO:51.5074\;-0.1278 +KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=info@acme-solu + tions.example +UID:urn:uuid:a9e95948-7b1c-46e8-bd85-c729a9e910f2 +REV:20230415T153000Z +LANG;TYPE=WORK;PREF=1:en +LANG;TYPE=WORK;PREF=2:de +LANG;TYPE=WORK;PREF=3:fr +SOURCE:https://directory.example.com/acme.vcf +RELATED;TYPE=CONTACT:urn:uuid:b9e93fdb-4d34-45fa-a1e2-47da0428c4a1 +RELATED;TYPE=CONTACT:urn:uuid:c8e74dfe-6b34-45fa-b1e2-47ea0428c4b2 +X-ABLabel:Company +PRODID:-//Example Corp.//Contact Manager 3.0//EN +END:VCARD +"#; diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 7697c05f..92676242 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -45,6 +45,7 @@ use tokio::sync::watch; use utils::config::Config; pub mod basic; +pub mod card_query; pub mod copy_move; pub mod lock; pub mod mkcol; @@ -375,8 +376,6 @@ pub async fn webdav_tests() { - ACLs: - ACL Method - AclPrincipalPropSet - - - Addressbook Query - Calendar Query - Freebusy Query @@ -391,6 +390,7 @@ pub async fn webdav_tests() { sync::test(&handle).await; lock::test(&handle).await; principals::test(&handle).await; + card_query::test(&handle).await; // Print elapsed time let elapsed = start_time.elapsed(); @@ -769,6 +769,15 @@ impl DavResponse { hrefs } + pub fn with_href_count(self, count: usize) -> Self { + let href_count = self.find_keys("D:multistatus.D:response.D:href").count(); + if href_count != count { + self.dump_response(); + panic!("Expected {} hrefs but got {}", count, href_count); + } + self + } + pub fn with_hrefs<'x>(self, hrefs: impl IntoIterator) -> Self { let expected_hrefs = hrefs.into_iter().collect::>(); let hrefs = self