From 9b002c2d3d1ebbb33c6ded3e4f2093b9556a7fdb Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sat, 3 May 2025 17:34:41 +0200 Subject: [PATCH] WebDAV REPORT expand-property, principal-* --- crates/dav/src/common/mod.rs | 23 +- crates/dav/src/common/propfind.rs | 2 +- crates/dav/src/principal/matching.rs | 77 +++--- crates/dav/src/principal/propfind.rs | 10 +- tests/src/webdav/lock.rs | 21 +- tests/src/webdav/mod.rs | 32 +-- tests/src/webdav/principals.rs | 356 +++++++++++++++++++++++++++ tests/src/webdav/prop.rs | 217 ++++++++++++++-- 8 files changed, 640 insertions(+), 98 deletions(-) create mode 100644 tests/src/webdav/principals.rs diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs index b4151da7..7893d739 100644 --- a/crates/dav/src/common/mod.rs +++ b/crates/dav/src/common/mod.rs @@ -247,21 +247,18 @@ impl<'x> DavQuery<'x> { expand: ExpandProperty, headers: RequestHeaders<'x>, ) -> Self { + let mut props = Vec::with_capacity(expand.properties.len()); + for item in expand.properties { + if !matches!(item.property, DavProperty::DeadProperty(_)) + && !props.contains(&item.property) + { + props.push(item.property); + } + } + Self { resource: DavQueryResource::Uri(resource), - propfind: PropFind::Prop( - expand - .properties - .into_iter() - .filter_map(|item| { - if !matches!(item.property, DavProperty::DeadProperty(_)) { - Some(item.property) - } else { - None - } - }) - .collect(), - ), + propfind: PropFind::Prop(props), depth: match headers.depth { Depth::Zero => 0, _ => 1, diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index b48a74cb..d0afbbe1 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -491,7 +491,7 @@ impl PropFindRequestHandler for Server { } else { containers.contains(item.document_id) } - }) + }) && (!query.depth_no_root || item.name != resource) }) .map(|item| { PropFindItem::new(resources.format_resource(item), account_id, item) diff --git a/crates/dav/src/principal/matching.rs b/crates/dav/src/principal/matching.rs index 169db2bd..615e4895 100644 --- a/crates/dav/src/principal/matching.rs +++ b/crates/dav/src/principal/matching.rs @@ -20,7 +20,11 @@ use store::roaring::RoaringBitmap; use crate::{ DavError, - common::{DavQuery, DavQueryResource, propfind::PropFindRequestHandler, uri::DavUriResource}, + common::{ + DavQuery, DavQueryResource, + propfind::PropFindRequestHandler, + uri::{DavUriResource, UriResource}, + }, }; use super::propfind::PrincipalPropFind; @@ -41,45 +45,54 @@ impl PrincipalMatching for Server { headers: RequestHeaders<'_>, mut request: PrincipalMatch, ) -> crate::Result { - let resource = self - .validate_uri(access_token, headers.uri) - .await - .and_then(|uri| uri.into_owned_uri())?; + let resource = self.validate_uri(access_token, headers.uri).await?; match resource.collection { Collection::AddressBook | Collection::Calendar | Collection::FileNode => { - self.handle_dav_query( - access_token, - DavQuery { - resource: DavQueryResource::Uri(resource), - propfind: PropFind::Prop(request.properties), - depth: usize::MAX, - ret: headers.ret, - depth_no_root: headers.depth_no_root, - ..Default::default() - }, - ) - .await - } - Collection::Principal => { - let mut response = MultiStatus::new(Vec::with_capacity(16)); if request.properties.is_empty() { request .properties - .push(DavProperty::WebDav(WebDavProperty::DisplayName)); + .push(DavProperty::WebDav(WebDavProperty::Owner)); + } + if let Some(account_id) = resource.account_id { + return self + .handle_dav_query( + access_token, + DavQuery { + resource: DavQueryResource::Uri(UriResource { + collection: resource.collection, + account_id, + resource: resource.resource, + }), + propfind: PropFind::Prop(request.properties), + depth: usize::MAX, + ret: headers.ret, + depth_no_root: headers.depth_no_root, + ..Default::default() + }, + ) + .await; } - let request = PropFind::Prop(request.properties); - self.prepare_principal_propfind_response( - access_token, - Collection::Principal, - RoaringBitmap::from_iter(access_token.all_ids()).into_iter(), - &request, - &mut response, - ) - .await?; - Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) } - _ => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), + Collection::Principal => {} + _ => return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)), } + + let mut response = MultiStatus::new(Vec::with_capacity(16)); + if request.properties.is_empty() { + request + .properties + .push(DavProperty::WebDav(WebDavProperty::DisplayName)); + } + let request = PropFind::Prop(request.properties); + self.prepare_principal_propfind_response( + access_token, + resource.collection, + RoaringBitmap::from_iter(access_token.all_ids()).into_iter(), + &request, + &mut response, + ) + .await?; + Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) } } diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs index 942d84cf..f960a19b 100644 --- a/crates/dav/src/principal/propfind.rs +++ b/crates/dav/src/principal/propfind.rs @@ -234,13 +234,9 @@ impl PrincipalPropFind for Server { } }, DavProperty::Principal(principal_property) => match principal_property { - PrincipalProperty::AlternateURISet => { - fields.push(DavPropertyValue::empty(property.clone())); - } - PrincipalProperty::GroupMemberSet => { - fields.push(DavPropertyValue::empty(property.clone())); - } - PrincipalProperty::GroupMembership => { + PrincipalProperty::AlternateURISet + | PrincipalProperty::GroupMemberSet + | PrincipalProperty::GroupMembership => { fields.push(DavPropertyValue::empty(property.clone())); } PrincipalProperty::PrincipalURL => { diff --git a/tests/src/webdav/lock.rs b/tests/src/webdav/lock.rs index da113c3e..1c4ec594 100644 --- a/tests/src/webdav/lock.rs +++ b/tests/src/webdav/lock.rs @@ -81,8 +81,15 @@ pub async fn test(test: &WebDavTest) { .await .with_status(StatusCode::CREATED); - // Test 5: Creating a file under a locked resource without a lock token should fail + // Test 5: Creating a lock under an infinity locked resource should fail let file_path = format!("{path}/file.txt"); + client + .lock_create(&file_path, "super-owner", true, "0", "Second-123") + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + + // Test 6: Creating a file under a locked resource without a lock token should fail let contents = resource_type.generate(); client .request("PUT", &file_path, &contents) @@ -90,7 +97,7 @@ pub async fn test(test: &WebDavTest) { .with_status(StatusCode::LOCKED) .with_value("D:error.D:lock-token-submitted.D:href", &path); - // Test 6: Creating a file under a locked resource with a lock token should succeed + // Test 7: Creating a file under a locked resource with a lock token should succeed client .request_with_headers( "PUT", @@ -101,7 +108,7 @@ pub async fn test(test: &WebDavTest) { .await .with_status(StatusCode::CREATED); - // Test 7: Locks should be included in propfind responses + // Test 8: Locks should be included in propfind responses let response = client .propfind(&path, [DavProperty::WebDav(WebDavProperty::LockDiscovery)]) .await; @@ -120,7 +127,7 @@ pub async fn test(test: &WebDavTest) { ]); } - // Test 8: Delete with and without a lock token + // Test 9: Delete with and without a lock token client .request("DELETE", &path, "") .await @@ -136,7 +143,7 @@ pub async fn test(test: &WebDavTest) { .await .with_status(StatusCode::NO_CONTENT); - // Test 9: Unlock with and without a lock token + // Test 10: Unlock with and without a lock token client .unlock(&path, "urn:stalwart:davlock:1234") .await @@ -147,7 +154,7 @@ pub async fn test(test: &WebDavTest) { .await .with_status(StatusCode::NO_CONTENT); - // Test 10: Locking with a large dead property should fail + // Test 11: Locking with a large dead property should fail let path = format!("{base_path}/invalid-lock"); client .lock_create( @@ -163,7 +170,7 @@ pub async fn test(test: &WebDavTest) { .await .with_status(StatusCode::PAYLOAD_TOO_LARGE); - // Test 11: Too many locks should fail + // Test 12: Too many locks should fail for i in 0..test.server.core.groupware.max_locks_per_user { client .lock_create( diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 13db8285..7697c05f 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -49,6 +49,7 @@ pub mod copy_move; pub mod lock; pub mod mkcol; pub mod multiget; +pub mod principals; pub mod prop; pub mod put_get; pub mod sync; @@ -218,6 +219,19 @@ disabled-events = ["network.*"] "#; +pub const TEST_DAV_USERS: &[(&str, &str, &str, &str)] = &[ + ("admin", "secret1", "Superuser", "admin@example,com"), + ("john", "secret2", "John Doe", "jdoe@example.com"), + ( + "jane", + "secret3", + "Jane Doe-Smith", + "jane.smith@example.com", + ), + ("bill", "secret4", "Bill Foobar", "bill@example,com"), + ("mike", "secret5", "Mike Noquota", "mike@example,com"), +]; + #[allow(dead_code)] pub struct WebDavTest { server: Server, @@ -318,21 +332,15 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest // Create test accounts let mut clients = AHashMap::new(); - for (account, secret, name, email) in [ - ("admin", "secret1", "Superuser", "admin@example,com"), - ("john", "secret2", "John Doe", "jdoe@example.com"), - ("jane", "secret3", "Jane Smith", "jane.smith@example.com"), - ("bill", "secret4", "Bill Foobar", "bill@example,com"), - ("mike", "secret5", "Mike Noquota", "mike@example,com"), - ] { + for (account, secret, name, email) in TEST_DAV_USERS { let account_id = store .create_test_user(account, secret, name, &[email]) .await; clients.insert( - account, + *account, DummyWebDavClient::new(account_id, account, secret, email), ); - if account == "mike" { + if *account == "mike" { store.set_test_quota(account, 1024).await; } } @@ -364,11 +372,6 @@ pub async fn webdav_tests() { /* TODO: - - Principals: - - PrincipalMatch - - PrincipalPropertySearch - - PrincipalSearchPropertySet - - Expand property - ACLs: - ACL Method - AclPrincipalPropSet @@ -387,6 +390,7 @@ pub async fn webdav_tests() { multiget::test(&handle).await; sync::test(&handle).await; lock::test(&handle).await; + principals::test(&handle).await; // Print elapsed time let elapsed = start_time.elapsed(); diff --git a/tests/src/webdav/principals.rs b/tests/src/webdav/principals.rs new file mode 100644 index 00000000..3a31bbdc --- /dev/null +++ b/tests/src/webdav/principals.rs @@ -0,0 +1,356 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::WebDavTest; +use crate::webdav::{TEST_DAV_USERS, prop::ALL_DAV_PROPERTIES}; +use dav_proto::schema::property::{DavProperty, PrincipalProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &WebDavTest) { + println!("Running principals tests..."); + let client = test.client("jane"); + let principal_path = format!("D:href:{}/", DavResourceName::Principal.base_path()); + let jane_principal_path = format!("D:href:{}/jane/", DavResourceName::Principal.base_path()); + + // Test 1: PROPFIND on /dav/pal should return all principals + let response = client + .propfind( + DavResourceName::Principal.collection_path(), + ALL_DAV_PROPERTIES, + ) + .await; + for (account, _, name, _) in TEST_DAV_USERS { + let props = response.properties(&format!( + "{}/{}/", + DavResourceName::Principal.base_path(), + account + )); + let path_pal = format!( + "D:href:{}/{}/", + DavResourceName::Principal.base_path(), + account + ); + let path_card = format!("D:href:{}/{}/", DavResourceName::Card.base_path(), account); + let path_cal = format!("D:href:{}/{}/", DavResourceName::Cal.base_path(), account); + props + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values([*name]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([jane_principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::PrincipalURL)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([path_cal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([path_card.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + .with_values([principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values([ + "D:supported-report.D:report.D:principal-property-search", + "D:supported-report.D:report.D:principal-search-property-set", + "D:supported-report.D:report.D:principal-match", + ]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:principal", "D:collection"]) + .with_status(StatusCode::OK); + } + + // Test 2: PROPFIND on /dav/[resource] should return user and shared resources + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + let supported_reports = match resource_type { + DavResourceName::File => [ + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:principal-match", + ] + .as_slice(), + DavResourceName::Cal => [ + "D:supported-report.D:report.A:free-busy-query", + "D:supported-report.D:report.A:calendar-query", + "D:supported-report.D:report.D:expand-property", + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:principal-match", + "D:supported-report.D:report.A:calendar-multiget", + ] + .as_slice(), + DavResourceName::Card => [ + "D:supported-report.D:report.B:addressbook-query", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:expand-property", + "D:supported-report.D:report.B:addressbook-multiget", + "D:supported-report.D:report.D:principal-match", + "D:supported-report.D:report.D:sync-collection", + ] + .as_slice(), + _ => unreachable!(), + }; + let privilege_set = if resource_type == DavResourceName::Cal { + [ + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-acl", + "D:privilege.A:read-free-busy", + "D:privilege.D:read-acl", + "D:privilege.D:write-properties", + "D:privilege.D:write", + "D:privilege.D:write-content", + "D:privilege.D:unlock", + "D:privilege.D:all", + "D:privilege.D:read", + "D:privilege.D:bind", + "D:privilege.D:unbind", + ] + .as_slice() + } else { + [ + "D:privilege.D:all", + "D:privilege.D:read", + "D:privilege.D:write", + "D:privilege.D:write-properties", + "D:privilege.D:write-content", + "D:privilege.D:unlock", + "D:privilege.D:read-acl", + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-acl", + "D:privilege.D:bind", + "D:privilege.D:unbind", + ] + .as_slice() + }; + + let response = client + .propfind(resource_type.collection_path(), ALL_DAV_PROPERTIES) + .await; + let props = response.properties(resource_type.collection_path()); + props + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values(supported_reports.iter().copied()) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection"]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([jane_principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([format!("D:href:{}/jane/", DavResourceName::Cal.base_path()).as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([format!("D:href:{}/jane/", DavResourceName::Card.base_path()).as_str()]) + .with_status(StatusCode::OK); + + for (account, _, name, _) in TEST_DAV_USERS + .iter() + .filter(|(account, _, _, _)| ["jane", "support"].contains(account)) + { + let path_card = format!("D:href:{}/{}/", DavResourceName::Card.base_path(), account); + let path_cal = format!("D:href:{}/{}/", DavResourceName::Cal.base_path(), account); + let path_pal = format!( + "D:href:{}/{}/", + DavResourceName::Principal.base_path(), + account + ); + let props = response.properties(&format!("{}/{account}/", resource_type.base_path())); + + props + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values([*name]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(["D:collection"]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([jane_principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values(privilege_set.iter().copied()) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values(supported_reports.iter().copied()) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::PrincipalURL)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + .with_values([principal_path.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([path_pal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet)) + .with_values([path_cal.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::Principal( + PrincipalProperty::AddressbookHomeSet, + )) + .with_values([path_card.as_str()]) + .with_status(StatusCode::OK); + props + .get(DavProperty::WebDav(WebDavProperty::SyncToken)) + .with_status(StatusCode::OK) + .is_not_empty(); + props + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .with_status(StatusCode::OK) + .is_not_empty(); + props + .get(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes)) + .with_status(StatusCode::OK) + .is_not_empty(); + } + + // Test 3: principal-match-query on resources + let response = client + .request( + "REPORT", + resource_type.collection_path(), + PRINCIPAL_MATCH_QUERY, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response.with_hrefs([ + format!("{}/jane/", resource_type.base_path()).as_str(), + format!("{}/support/", resource_type.base_path()).as_str(), + ]); + } + + // Test 4: principal-match-query on principals + let response = client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_MATCH_QUERY, + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response.with_hrefs([ + format!("{}/jane/", DavResourceName::Principal.base_path()).as_str(), + format!("{}/support/", DavResourceName::Principal.base_path()).as_str(), + ]); + + // Test 5: principal-search-property-set REPORT + let response = client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_SEARCH_PROPERTY_SET_QUERY, + ) + .await + .with_status(StatusCode::OK); + response + .with_value( + "D:principal-search-property-set.D:principal-search-property.D:prop.D:displayname", + "", + ) + .with_value( + "D:principal-search-property-set.D:principal-search-property.D:description", + "Account or Group name", + ); + + // Test 6: principal-property-search REPORT + let response = client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_PROPERTY_SEARCH_QUERY.replace("$NAME", "doe"), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response.with_hrefs([ + format!("{}/jane/", DavResourceName::Principal.base_path()).as_str(), + format!("{}/john/", DavResourceName::Principal.base_path()).as_str(), + ]); + response + .properties(&format!("{}/jane/", DavResourceName::Principal.base_path())) + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values([TEST_DAV_USERS + .iter() + .find(|(account, _, _, _)| *account == "jane") + .unwrap() + .2]) + .with_status(StatusCode::OK); + client + .request( + "REPORT", + DavResourceName::Principal.collection_path(), + PRINCIPAL_PROPERTY_SEARCH_QUERY.replace("$NAME", "support"), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + .with_hrefs([format!("{}/support/", DavResourceName::Principal.base_path()).as_str()]); + + client.delete_default_containers().await; + test.assert_is_empty().await; +} + +const PRINCIPAL_MATCH_QUERY: &str = r#" + + + + + +"#; + +const PRINCIPAL_SEARCH_PROPERTY_SET_QUERY: &str = + r#""#; + +const PRINCIPAL_PROPERTY_SEARCH_QUERY: &str = r#" + + + + + + $NAME + + + + +"#; diff --git a/tests/src/webdav/prop.rs b/tests/src/webdav/prop.rs index f90f1ec9..f6c14d04 100644 --- a/tests/src/webdav/prop.rs +++ b/tests/src/webdav/prop.rs @@ -85,20 +85,23 @@ pub async fn test(test: &WebDavTest) { } // Test 5: PROPFIND Depth 1 on user base path - let response = client + client .request_with_headers("PROPFIND", &user_base_path, [("depth", "1")], "") - .await; - if resource_type != DavResourceName::File { - response.with_status(StatusCode::MULTI_STATUS).with_hrefs([ - format!("{user_base_path}/").as_str(), - format!("{user_base_path}/default/").as_str(), - &test_base_path, - ]); - } else { - response - .with_status(StatusCode::MULTI_STATUS) - .with_hrefs([format!("{user_base_path}/").as_str(), &test_base_path]); - } + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs( + [ + format!("{user_base_path}/default/").as_str(), + format!("{user_base_path}/").as_str(), + &test_base_path, + ] + .into_iter() + .skip(if resource_type == DavResourceName::File { + 1 + } else { + 0 + }), + ); // Test 6: PROPFIND Depth 1 on created collection client @@ -117,7 +120,60 @@ pub async fn test(test: &WebDavTest) { StatusCode::MULTI_STATUS }); - // Test 8: Retrieve all static properties + // Test 8 PROPFIND with depth-no-root + client + .request_with_headers( + "PROPFIND", + &user_base_path, + [("depth", "1"), ("prefer", "depth-noroot")], + "", + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs( + [ + format!("{user_base_path}/default/").as_str(), + &test_base_path, + ] + .into_iter() + .skip(if resource_type == DavResourceName::File { + 1 + } else { + 0 + }), + ); + client + .request_with_headers( + "PROPFIND", + &test_base_path, + [("depth", "1"), ("prefer", "depth-noroot")], + "", + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([test_path.as_str()]); + + // Test 8 PROPFIND with prefer return=minimal + let response = client + .propfind_with_headers(&test_base_path, ALL_DAV_PROPERTIES, []) + .await; + response + .properties(&test_base_path) + .is_defined(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_defined(DavProperty::Principal(PrincipalProperty::GroupMembership)); + let response = client + .propfind_with_headers( + &test_base_path, + ALL_DAV_PROPERTIES, + [("prefer", "return=minimal")], + ) + .await; + response + .properties(&test_base_path) + .is_defined(DavProperty::WebDav(WebDavProperty::GetETag)) + .is_undefined(DavProperty::Principal(PrincipalProperty::GroupMembership)); + + // Test 9: Retrieve all static properties for (path, etag, is_file) in [ (&test_base_path, &etag_folder, false), (&test_path, &etag_file, true), @@ -370,11 +426,34 @@ pub async fn test(test: &WebDavTest) { } } + // Test 10: expand-property report + for path in [&test_base_path, &test_path] { + let response = client + .request("REPORT", path, EXPAND_REPORT_QUERY) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + let properties = response.properties(path); + for prop in [ + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::Owner), + ] { + properties.get(prop).with_some_values([ + format!( + "D:response.D:href:{}/jane/", + DavResourceName::Principal.base_path(), + ) + .as_str(), + "D:response.D:propstat.D:prop.D:displayname:Jane Doe-Smith", + ]); + } + } + for (path, etag, is_file) in [ (&test_base_path, &etag_folder, false), (&test_path, &etag_file, true), ] { - // Test 9: PROPPATCH should fail when a precondition fails + // Test 11: PROPPATCH should fail when a precondition fails client .proppatch( path, @@ -407,7 +486,7 @@ pub async fn test(test: &WebDavTest) { .with_status(StatusCode::OK) .without_values([etag.as_str()]); - // Test 10: PROPPATCH set on DAV properties + // Test 12: PROPPATCH set on DAV properties client .patch_and_check( path, @@ -431,7 +510,7 @@ pub async fn test(test: &WebDavTest) { ) .await; - // Test 11: PROPPATCH remove on DAV properties + // Test 13: PROPPATCH remove on DAV properties let mut props = vec![ ( DavProperty::DeadProperty(DeadElementTag::new( @@ -450,7 +529,7 @@ pub async fn test(test: &WebDavTest) { match resource_type { DavResourceName::File if is_file => { - // Test 12: Change a file's content-type + // Test 14: Change a file's content-type client .patch_and_check( path, @@ -462,7 +541,7 @@ pub async fn test(test: &WebDavTest) { .await; } DavResourceName::Cal if !is_file => { - // Test 13: Change a calendar's properties + // Test 15: Change a calendar's properties client .patch_and_check( path, @@ -498,7 +577,7 @@ pub async fn test(test: &WebDavTest) { .await; } DavResourceName::Card if !is_file => { - // Test 14: Change an addressbook's properties + // Test 16: Change an addressbook's properties client .patch_and_check( path, @@ -521,7 +600,7 @@ pub async fn test(test: &WebDavTest) { _ => (), } - // Test 15: PROPPATCH should fail on large properties + // Test 17: PROPPATCH should fail on large properties let mut chunky_props = vec![ DavProperty::WebDav(WebDavProperty::DisplayName), DavProperty::DeadProperty(DeadElementTag::new( @@ -572,7 +651,7 @@ pub async fn test(test: &WebDavTest) { .with_description("Property value is too long"); } - // Test 16: PROPPATCH should fail on invalid calendar property values + // Test 18: PROPPATCH should fail on invalid calendar property values if !is_file && resource_type == DavResourceName::Cal { let response = client .proppatch( @@ -603,7 +682,16 @@ pub async fn test(test: &WebDavTest) { .with_description("Invalid calendar timezone"); } } + + client + .request("DELETE", &test_base_path, "") + .await + .with_status(StatusCode::NO_CONTENT); } + + client.delete_default_containers().await; + client.delete_default_containers_by_account("support").await; + test.assert_is_empty().await; } #[derive(Debug)] @@ -637,6 +725,16 @@ impl DavMultiStatus { }), } } + + pub fn with_hrefs<'x>(&self, expect_hrefs: impl IntoIterator) -> &Self { + let expect_hrefs: AHashSet<_> = expect_hrefs.into_iter().collect(); + let hrefs: AHashSet<_> = self.hrefs.keys().map(|s| s.as_str()).collect(); + if hrefs != expect_hrefs { + self.response.dump_response(); + panic!("Expected hrefs {expect_hrefs:?}, but got {hrefs:?}",); + } + self + } } pub struct DavPropertyResult<'x> { @@ -671,6 +769,33 @@ impl DavPropertyResult<'_> { ) }) } + + pub fn is_defined(&self, name: impl AsRef) -> &Self { + if self + .properties + .0 + .iter() + .any(|prop| prop.values.contains_key(name.as_ref())) + { + self + } else { + self.response.dump_response(); + panic!("Expected property {} to be defined", name.as_ref()); + } + } + + pub fn is_undefined(&self, name: impl AsRef) -> &Self { + if self + .properties + .0 + .iter() + .any(|prop| prop.values.contains_key(name.as_ref())) + { + self.response.dump_response(); + panic!("Expected property {} to be undefined", name.as_ref()); + } + self + } } impl<'x> DavQueryResult<'x> { @@ -689,6 +814,23 @@ impl<'x> DavQueryResult<'x> { self } + pub fn with_some_values(&self, expected_values: impl IntoIterator) -> &Self { + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + for expected_value in expected_values { + if !values.contains(expected_value) { + self.response.dump_response(); + panic!("Expected at least one of {expected_value:?} values, but got {values:?}",); + } + } + + self + } + pub fn without_values(&self, expected_values: impl IntoIterator) -> &Self { let expected_values = AHashSet::from_iter(expected_values); let values = self @@ -895,6 +1037,19 @@ impl DummyWebDavClient { } pub async fn propfind(&self, path: &str, properties: I) -> DavMultiStatus + where + I: IntoIterator, + T: AsRef, + { + self.propfind_with_headers(path, properties, []).await + } + + pub async fn propfind_with_headers( + &self, + path: &str, + properties: I, + headers: impl IntoIterator, + ) -> DavMultiStatus where I: IntoIterator, T: AsRef, @@ -913,7 +1068,7 @@ impl DummyWebDavClient { request.push_str(""); - self.request("PROPFIND", path, &request) + self.request_with_headers("PROPFIND", path, headers, &request) .await .with_status(StatusCode::MULTI_STATUS) .into_propfind_response(None) @@ -975,7 +1130,21 @@ impl Default for DavItem { } } -const ALL_DAV_PROPERTIES: &[DavProperty] = &[ +const EXPAND_REPORT_QUERY: &str = r#" + + + + + + + + + +"#; + +pub const ALL_DAV_PROPERTIES: &[DavProperty] = &[ DavProperty::WebDav(WebDavProperty::CreationDate), DavProperty::WebDav(WebDavProperty::DisplayName), DavProperty::WebDav(WebDavProperty::GetContentLanguage),