From cb030369989f0bea00f878612d2d6b1ec57ca95a Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sun, 4 May 2025 12:19:36 +0200 Subject: [PATCH] WebDAV ACL tests --- crates/common/src/auth/access_token.rs | 13 + crates/common/src/config/groupware.rs | 8 +- crates/common/src/sharing/acl.rs | 45 ++- crates/common/src/sharing/document.rs | 24 +- crates/dav/src/calendar/proppatch.rs | 4 +- crates/dav/src/card/proppatch.rs | 4 +- crates/dav/src/common/acl.rs | 94 +++--- crates/dav/src/common/propfind.rs | 80 +++-- crates/dav/src/file/copy_move.rs | 2 +- crates/dav/src/file/delete.rs | 2 +- crates/dav/src/file/update.rs | 2 +- tests/src/webdav/acl.rs | 398 ++++++++++++++++++++++ tests/src/webdav/card_query.rs | 186 +++++------ tests/src/webdav/mod.rs | 437 ++++++++++++------------- 14 files changed, 889 insertions(+), 410 deletions(-) create mode 100644 tests/src/webdav/acl.rs diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 9571bb28..63ba9aea 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -434,6 +434,19 @@ impl AccessToken { .chain(self.access_to.iter().map(|(id, _)| *id)) } + pub fn all_ids_by_collection(&self, collection: Collection) -> impl Iterator { + [self.primary_id] + .into_iter() + .chain(self.member_of.iter().copied()) + .chain(self.access_to.iter().filter_map(move |(id, cols)| { + if cols.contains(collection) { + Some(*id) + } else { + None + } + })) + } + pub fn is_member(&self, account_id: u32) -> bool { self.primary_id == account_id || self.member_of.contains(&account_id) diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 57c7ed41..2718b489 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -14,8 +14,7 @@ pub struct GroupwareConfig { pub live_property_size: usize, pub max_lock_timeout: u64, pub max_locks_per_user: usize, - pub max_changes: usize, - pub max_match_results: usize, + pub max_results: usize, // Calendar settings pub max_ical_size: usize, @@ -51,10 +50,7 @@ impl GroupwareConfig { max_locks_per_user: config .property("dav.limits.max-locks-per-user") .unwrap_or(10), - max_changes: config.property("dav.limits.max-changes").unwrap_or(1000), - max_match_results: config - .property("dav.limits.max-match-results") - .unwrap_or(1000), + max_results: config.property("dav.limits.max-results").unwrap_or(2000), max_vcard_size: config .property("dav.limits.size.vcard") .unwrap_or(512 * 1024), diff --git a/crates/common/src/sharing/acl.rs b/crates/common/src/sharing/acl.rs index d9c22bea..28fd8503 100644 --- a/crates/common/src/sharing/acl.rs +++ b/crates/common/src/sharing/acl.rs @@ -16,7 +16,7 @@ use jmap_proto::{ types::{ acl::Acl, property::Property, - value::{AclGrant, MaybePatchValue, Value}, + value::{AclGrant, ArchivedAclGrant, MaybePatchValue, Value}, }, }; use utils::map::bitmap::Bitmap; @@ -164,6 +164,49 @@ impl Server { self.increment_token_revision(changed_principals).await; } + pub async fn refresh_archived_acls( + &self, + acl_changes: &[AclGrant], + acl_current: &[ArchivedAclGrant], + ) { + let mut changed_principals = ChangedPrincipals::new(); + for current_item in acl_current.iter() { + let mut invalidate = true; + for change_item in acl_changes { + if change_item.account_id == current_item.account_id { + invalidate = change_item.grants != current_item.grants; + break; + } + } + if invalidate { + changed_principals.add_change( + current_item.account_id.to_native(), + Type::Individual, + PrincipalField::EnabledPermissions, + ); + } + } + + for change_item in acl_changes { + let mut invalidate = true; + for current_item in acl_current.iter() { + if change_item.account_id == current_item.account_id { + invalidate = change_item.grants != current_item.grants; + break; + } + } + if invalidate { + changed_principals.add_change( + change_item.account_id, + Type::Individual, + PrincipalField::EnabledPermissions, + ); + } + } + + self.increment_token_revision(changed_principals).await; + } + pub async fn map_acl_set(&self, acl_set: Vec) -> Result, SetError> { let mut acls = Vec::with_capacity(acl_set.len() / 2); for item in acl_set.chunks_exact(2) { diff --git a/crates/common/src/sharing/document.rs b/crates/common/src/sharing/document.rs index 49bed421..726f4e8e 100644 --- a/crates/common/src/sharing/document.rs +++ b/crates/common/src/sharing/document.rs @@ -28,9 +28,7 @@ impl Server { .chain(access_token.member_of.clone().iter()) { for acl_item in self - .core - .storage - .data + .store() .acl_query(AclQuery::SharedWith { grant_account_id, to_account_id, @@ -92,4 +90,24 @@ impl Server { } Ok(false) } + + pub async fn document_acl( + &self, + grant_account_id: u32, + to_account_id: u32, + to_collection: impl Into, + to_document_id: u32, + ) -> trc::Result> { + self.core + .storage + .data + .get_value::(ValueKey { + account_id: to_account_id, + collection: to_collection.into(), + document_id: to_document_id, + class: ValueClass::Acl(grant_account_id), + }) + .await + .map(|v| v.map(Bitmap::::from).unwrap_or_default()) + } } diff --git a/crates/dav/src/calendar/proppatch.rs b/crates/dav/src/calendar/proppatch.rs index 0a3adba9..f0da8ce3 100644 --- a/crates/dav/src/calendar/proppatch.rs +++ b/crates/dav/src/calendar/proppatch.rs @@ -97,9 +97,9 @@ impl CalendarPropPatchRequestHandler for Server { // Verify ACL if !access_token.is_member(account_id) { let (acl, document_id) = if resource.is_container() { - (Acl::Read, resource.document_id) + (Acl::Modify, resource.document_id) } else { - (Acl::ReadItems, resource.parent_id.unwrap()) + (Acl::ModifyItems, resource.parent_id.unwrap()) }; if !self diff --git a/crates/dav/src/card/proppatch.rs b/crates/dav/src/card/proppatch.rs index 7c245ca5..31b74a14 100644 --- a/crates/dav/src/card/proppatch.rs +++ b/crates/dav/src/card/proppatch.rs @@ -94,9 +94,9 @@ impl CardPropPatchRequestHandler for Server { // Verify ACL if !access_token.is_member(account_id) { let (acl, document_id) = if resource.is_container() { - (Acl::Read, resource.document_id) + (Acl::Modify, resource.document_id) } else { - (Acl::ReadItems, resource.parent_id.unwrap()) + (Acl::ModifyItems, resource.parent_id.unwrap()) }; if !self diff --git a/crates/dav/src/common/acl.rs b/crates/dav/src/common/acl.rs index e5120108..f42d86c0 100644 --- a/crates/dav/src/common/acl.rs +++ b/crates/dav/src/common/acl.rs @@ -4,6 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::{ + DavError, DavErrorCondition, DavResourceName, common::uri::DavUriResource, + principal::propfind::PrincipalPropFind, +}; use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; use dav_proto::{ RequestHeaders, @@ -30,11 +34,6 @@ use store::{ahash::AHashSet, roaring::RoaringBitmap, write::BatchBuilder}; use trc::AddContext; use utils::map::bitmap::Bitmap; -use crate::{ - DavError, DavErrorCondition, DavResourceName, common::uri::DavUriResource, - principal::propfind::PrincipalPropFind, -}; - use super::ArchivedResource; pub(crate) trait DavAclHandler: Sync + Send { @@ -144,8 +143,10 @@ impl DavAclHandler for Server { .await?; if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) { - let mut batch = BatchBuilder::new(); + // Refresh ACLs + self.refresh_archived_acls(&grants, acls).await; + let mut batch = BatchBuilder::new(); match container { ArchivedResource::Calendar(calendar) => { let mut new_calendar = calendar @@ -331,7 +332,6 @@ impl DavAclHandler for Server { Privilege::WriteContent => { acls.insert(Acl::Modify); acls.insert(Acl::ModifyItems); - acls.insert(Acl::RemoveItems); } Privilege::WriteProperties => { acls.insert(Acl::Modify); @@ -482,26 +482,6 @@ impl DavAclHandler for Server { { for grant in grants.iter() { let grant_account_id = u32::from(grant.account_id); - let mut privileges = Vec::with_capacity(4); - let acl = Bitmap::::from(&grant.grants); - if acl.contains(Acl::Read) || acl.contains(Acl::ReadItems) { - privileges.push(Privilege::Read); - } - if acl.contains(Acl::Modify) - || acl.contains(Acl::Delete) - || acl.contains(Acl::ModifyItems) - || acl.contains(Acl::RemoveItems) - { - privileges.push(Privilege::Write); - } - if acl.contains(Acl::Administer) { - privileges.push(Privilege::ReadAcl); - privileges.push(Privilege::WriteAcl); - } - if acl.contains(Acl::ReadFreeBusy) { - privileges.push(Privilege::ReadFreeBusy); - } - let principal = if let Some(expand) = expand { self.expand_principal(access_token, grant_account_id, expand) .await? @@ -530,7 +510,12 @@ impl DavAclHandler for Server { ))) }; - aces.push(Ace::new(principal, GrantDeny::grant(privileges))); + aces.push(Ace::new( + principal, + GrantDeny::grant(current_user_privilege_set(Bitmap::::from( + &grant.grants, + ))), + )); } } @@ -557,28 +542,37 @@ impl Privileges for AccessToken { if self.is_member(account_id) { Privilege::all(is_calendar) } else { - let mut acls = AHashSet::with_capacity(16); - for grant in grants.effective_acl(self) { - match grant { - Acl::Read | Acl::ReadItems => { - acls.insert(Privilege::Read); - acls.insert(Privilege::ReadCurrentUserPrivilegeSet); - } - Acl::Modify | Acl::Delete | Acl::ModifyItems | Acl::RemoveItems => { - acls.insert(Privilege::Write); - } - Acl::Administer => { - acls.insert(Privilege::ReadAcl); - acls.insert(Privilege::WriteAcl); - } - Acl::ReadFreeBusy => { - acls.insert(Privilege::ReadFreeBusy); - } - _ => {} - } - } - - acls.into_iter().collect() + current_user_privilege_set(grants.effective_acl(self)) } } } + +pub(crate) fn current_user_privilege_set(acl_bitmap: Bitmap) -> Vec { + let mut acls = AHashSet::with_capacity(16); + for grant in acl_bitmap { + match grant { + Acl::Read | Acl::ReadItems => { + acls.insert(Privilege::Read); + acls.insert(Privilege::ReadCurrentUserPrivilegeSet); + } + Acl::Modify => { + acls.insert(Privilege::WriteProperties); + } + Acl::ModifyItems => { + acls.insert(Privilege::WriteContent); + } + Acl::Delete | Acl::RemoveItems => { + acls.insert(Privilege::Write); + } + Acl::Administer => { + acls.insert(Privilege::ReadAcl); + acls.insert(Privilege::WriteAcl); + } + Acl::ReadFreeBusy => { + acls.insert(Privilege::ReadFreeBusy); + } + _ => {} + } + } + acls.into_iter().collect() +} diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 2dc09f49..742fcdba 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -4,6 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use super::{ + ArchivedResource, DavCollection, DavQuery, DavQueryFilter, ETag, SyncType, + acl::{DavAclHandler, Privileges}, + lock::{LockData, build_lock_key}, + uri::{UriResource, Urn}, +}; use crate::{ DavError, DavErrorCondition, calendar::{ @@ -14,7 +20,7 @@ use crate::{ CARD_CONTAINER_PROPS, CARD_ITEM_PROPS, query::{serialize_vcard_with_props, vcard_query}, }, - common::{DavQueryResource, uri::DavUriResource}, + common::{DavQueryResource, acl::current_user_privilege_set, uri::DavUriResource}, file::{FILE_CONTAINER_PROPS, FILE_ITEM_PROPS}, principal::{CurrentUserPrincipal, propfind::PrincipalPropFind}, }; @@ -57,13 +63,6 @@ use store::{ }; use trc::AddContext; -use super::{ - ArchivedResource, DavCollection, DavQuery, DavQueryFilter, ETag, SyncType, - acl::{DavAclHandler, Privileges}, - lock::{LockData, build_lock_key}, - uri::{UriResource, Urn}, -}; - pub(crate) trait PropFindRequestHandler: Sync + Send { fn handle_propfind_request( &self, @@ -307,7 +306,9 @@ impl PropFindRequestHandler for Server { if return_children { let ids = if !matches!(resource.collection, Collection::Principal) { - RoaringBitmap::from_iter(access_token.all_ids()) + RoaringBitmap::from_iter( + access_token.all_ids_by_collection(resource.collection), + ) } else { // Return all principals let principals = self @@ -352,7 +353,6 @@ 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:#?}"); @@ -406,7 +406,6 @@ impl PropFindRequestHandler for Server { // Filter by changelog match query.sync_type { SyncType::From(change_id) => { - max_results = self.core.groupware.max_changes; let container_changes = self .store() .changes(account_id, collection_container, Query::Since(change_id)) @@ -532,12 +531,10 @@ impl PropFindRequestHandler for Server { }; if paths.is_empty() && query.sync_type.is_none() { - if let Some(resource) = resource.resource { - response.add_response(Response::new_status( - [resources.format_item(resource)], - StatusCode::NOT_FOUND, - )); - } + response.add_response( + Response::new_status([query.uri], StatusCode::NOT_FOUND) + .with_response_description("No resources found"), + ); return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) .with_xml_body(response.to_string())); @@ -712,7 +709,10 @@ 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); + let mut limit = std::cmp::min( + query.limit.unwrap_or(u32::MAX) as usize, + self.core.groupware.max_results, + ); for item in paths { let account_id = item.account_id; let document_id = item.document_id; @@ -1027,15 +1027,34 @@ impl PropFindRequestHandler for Server { )); } WebDavProperty::CurrentUserPrivilegeSet => { - if let Some(acls) = archive.acls() { - fields.push(DavPropertyValue::new( - property.clone(), - access_token.current_privilege_set( + let privileges = if access_token.is_member(account_id) { + Privilege::all(matches!( + collection, + Collection::Calendar | Collection::CalendarEvent + )) + } else if let Some(acls) = archive.acls() { + access_token.current_privilege_set( + account_id, + acls, + collection_container == Collection::Calendar, + ) + } else if let Some(parent_id) = item.parent_id { + current_user_privilege_set( + self.document_acl( + access_token.primary_id(), account_id, - acls, - collection_container == Collection::Calendar, - ), - )); + collection_container, + parent_id, + ) + .await + .caused_by(trc::location!())?, + ) + } else { + vec![] + }; + + if !privileges.is_empty() { + fields.push(DavPropertyValue::new(property.clone(), privileges)); } else if !skip_not_found { fields_not_found.push(DavPropertyValue::empty(property.clone())); } @@ -1319,9 +1338,16 @@ impl PropFindRequestHandler for Server { .with_error(BaseCondition::NumberOfMatchesWithinLimit) .with_response_description(format!( "The number of matches exceeds the limit of {}", - query.limit.unwrap_or(max_results as u32) + query + .limit + .unwrap_or(self.core.groupware.max_results as u32) )), ); + } else if response.response.0.is_empty() && query.sync_type.is_none() { + response.add_response( + Response::new_status([query.uri], StatusCode::NOT_FOUND) + .with_response_description("No resources found"), + ); } Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string())) diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 9989bb5b..a4093540 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.rs @@ -65,7 +65,7 @@ impl FileCopyMoveRequestHandler for Server { from_account_id, Collection::FileNode, if is_move { - [Acl::Read, Acl::Modify].as_slice().iter().copied() + [Acl::Read, Acl::Delete].as_slice().iter().copied() } else { [Acl::Read].as_slice().iter().copied() }, diff --git a/crates/dav/src/file/delete.rs b/crates/dav/src/file/delete.rs index cf3a34f2..d6ac2fbb 100644 --- a/crates/dav/src/file/delete.rs +++ b/crates/dav/src/file/delete.rs @@ -74,7 +74,7 @@ impl FileDeleteRequestHandler for Server { .await .caused_by(trc::location!())?; if permissions.len() != sorted_ids.len() as u64 - || sorted_ids.iter().all(|id| permissions.contains(*id)) + || !sorted_ids.iter().all(|id| permissions.contains(*id)) { return Err(DavError::Code(StatusCode::FORBIDDEN)); } diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs index 5cf7d720..b307264c 100644 --- a/crates/dav/src/file/update.rs +++ b/crates/dav/src/file/update.rs @@ -138,7 +138,7 @@ impl FileUpdateRequestHandler for Server { // Verify that the node is a file if let Some(file) = node.inner.file.as_ref() { if BlobHash::generate(&bytes).as_slice() == file.blob_hash.0.as_slice() { - return Ok(HttpResponse::new(StatusCode::OK)); + return Ok(HttpResponse::new(StatusCode::NO_CONTENT)); } } else { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); diff --git a/tests/src/webdav/acl.rs b/tests/src/webdav/acl.rs new file mode 100644 index 00000000..7ee0d25f --- /dev/null +++ b/tests/src/webdav/acl.rs @@ -0,0 +1,398 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use dav_proto::schema::property::{DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +use crate::webdav::GenerateTestDavResource; + +use super::{DavResponse, DummyWebDavClient, WebDavTest}; + +pub async fn test(test: &WebDavTest) { + let owner_client = test.client("bill"); + let sharee_client = test.client("john"); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!("Running ACL tests ({})...", resource_type.base_path()); + let is_file = resource_type == DavResourceName::File; + let sharee_principal = format!("{}/john/", DavResourceName::Principal.base_path()); + let sharee_base_path = format!("{}/john/", resource_type.base_path()); + let owner_principal = format!("{}/bill/", DavResourceName::Principal.base_path()); + let owner_base_path = format!("{}/bill/", resource_type.base_path()); + + // Create a resource for the owner + let owner_folder = format!("{owner_base_path}test-shared/"); + let owner_folder_private = format!("{owner_base_path}test-private/"); + let owner_file = format!("{owner_folder}test-file"); + let owner_file_content = resource_type.generate(); + let owner_file_private = format!("{owner_folder_private}test-file-private"); + let owner_file_content_private = resource_type.generate(); + for (folder, file, content) in [ + (&owner_folder, &owner_file, &owner_file_content), + ( + &owner_folder_private, + &owner_file_private, + &owner_file_content_private, + ), + ] { + owner_client + .request("MKCOL", folder, "") + .await + .with_status(StatusCode::CREATED); + owner_client + .request("PUT", file, content) + .await + .with_status(StatusCode::CREATED); + } + + // Create a resource for the sharee + let sharee_folder = format!("{sharee_base_path}test-folder/"); + let sharee_file = format!("{sharee_folder}test-file"); + let sharee_file_content = resource_type.generate(); + sharee_client + .request("MKCOL", &sharee_folder, "") + .await + .with_status(StatusCode::CREATED); + sharee_client + .request("PUT", &sharee_file, &sharee_file_content) + .await + .with_status(StatusCode::CREATED); + + // Test 1: Sharee should only see their own resources + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str()]); + + // Test 2: Share a resource and make sure the root folder is visible + owner_client + .acl(&owner_folder, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + if is_file { + owner_client + .acl(&owner_file, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + } + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str(), owner_base_path.as_str()]); + + // Test 3: Verify that only the shared resource is visible + sharee_client + .propfind_with_headers( + &owner_base_path, + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([owner_folder.as_str()]); + + // Test 4: Verify that the sharee can access the shared resource + sharee_client + .propfind( + &owner_folder, + [DavProperty::WebDav(WebDavProperty::GetETag)], + ) + .await + .with_hrefs([owner_folder.as_str(), owner_file.as_str()]); + sharee_client + .request("GET", &owner_file, "") + .await + .with_status(StatusCode::OK) + .with_body(&owner_file_content); + + // Test 5: Read ACL as owner + let response = owner_client + .propfind(&owner_folder, [DavProperty::WebDav(WebDavProperty::Acl)]) + .await; + response + .properties(&owner_folder) + .get(DavProperty::WebDav(WebDavProperty::Acl)) + .with_values([ + format!("D:ace.D:principal.D:href:{sharee_principal}").as_str(), + "D:ace.D:grant.D:privilege.D:read", + "D:ace.D:grant.D:privilege.D:read-current-user-privilege-set", + ]); + + // Test 6: acl-principal-prop-set REPORT + let response = owner_client + .request("REPORT", &owner_folder, ACL_PRINCIPAL_QUERY) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + response + .properties(&sharee_principal) + .get(DavProperty::WebDav(WebDavProperty::DisplayName)) + .with_values(["John Doe"]); + + // Test 7: Verify current-user-privilege-set and owner + let response = sharee_client + .propfind( + &owner_folder, + [ + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Owner), + ], + ) + .await; + for href in [owner_folder.as_str(), owner_file.as_str()] { + let props = response.properties(href); + props + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:read", + "D:privilege.D:read-current-user-privilege-set", + ]); + props + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([format!("D:href:{owner_principal}").as_str()]); + } + + // Test 8: Write operations should fail + for (path, dest, dest_copy) in [ + ( + &owner_folder, + &sharee_folder, + Some(format!("{sharee_base_path}copied/")), + ), + (&owner_file, &sharee_file, None), + ] { + sharee_client + .proppatch( + path, + [(DavProperty::WebDav(WebDavProperty::DisplayName), "test")], + [], + [], + ) + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request("DELETE", path, "") + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request_with_headers("MOVE", path, [("destination", dest.as_str())], "") + .await + .with_status(StatusCode::FORBIDDEN); + if let Some(dest_copy) = dest_copy { + sharee_client + .request_with_headers("COPY", path, [("destination", dest_copy.as_str())], "") + .await + .with_status(StatusCode::CREATED); + } + } + sharee_client + .request("PUT", &owner_file, resource_type.generate()) + .await + .with_status(StatusCode::FORBIDDEN); + + // Test 9: Grant write access to the sharee + owner_client + .acl( + &owner_folder, + sharee_principal.as_str(), + ["read", "write-content", "write-properties"], + ) + .await + .with_status(StatusCode::OK); + if is_file { + owner_client + .acl( + &owner_file, + sharee_principal.as_str(), + ["read", "write-content", "write-properties"], + ) + .await + .with_status(StatusCode::OK); + } + let response = owner_client + .propfind(&owner_folder, [DavProperty::WebDav(WebDavProperty::Acl)]) + .await; + response + .properties(&owner_folder) + .get(DavProperty::WebDav(WebDavProperty::Acl)) + .with_values([ + format!("D:ace.D:principal.D:href:{sharee_principal}").as_str(), + "D:ace.D:grant.D:privilege.D:read", + "D:ace.D:grant.D:privilege.D:read-current-user-privilege-set", + "D:ace.D:grant.D:privilege.D:write-content", + "D:ace.D:grant.D:privilege.D:write-properties", + ]); + let response = sharee_client + .propfind( + &owner_folder, + [DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)], + ) + .await; + for href in [owner_folder.as_str(), owner_file.as_str()] { + response + .properties(href) + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "D:privilege.D:read", + "D:privilege.D:read-current-user-privilege-set", + "D:privilege.D:write-content", + "D:privilege.D:write-properties", + ]); + } + + // Test 10: Delete operations should fail + for (path, dest) in [(&owner_folder, &sharee_folder), (&owner_file, &sharee_file)] { + sharee_client + .proppatch( + path, + [(DavProperty::WebDav(WebDavProperty::DisplayName), "test")], + [], + [], + ) + .await + .with_status(StatusCode::MULTI_STATUS); + sharee_client + .request("DELETE", path, "") + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request_with_headers("MOVE", path, [("destination", dest.as_str())], "") + .await + .with_status(StatusCode::FORBIDDEN); + } + sharee_client + .request("PUT", &owner_file, &owner_file_content) + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 11: Grant delete access to the sharee and verify + owner_client + .acl(&owner_folder, sharee_principal.as_str(), ["read", "write"]) + .await + .with_status(StatusCode::OK); + if is_file { + owner_client + .acl(&owner_file, sharee_principal.as_str(), ["read", "write"]) + .await + .with_status(StatusCode::OK); + } + sharee_client + .request_with_headers( + "MOVE", + &owner_file, + [("destination", sharee_file.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &owner_folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 12: Share and unshare a resource + owner_client + .acl(&owner_folder_private, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str(), owner_base_path.as_str()]); + sharee_client + .propfind_with_headers( + &owner_base_path, + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([owner_folder_private.as_str()]); + owner_client + .acl(&owner_folder_private, sharee_principal.as_str(), []) + .await + .with_status(StatusCode::OK); + sharee_client + .propfind_with_headers( + resource_type.collection_path(), + [DavProperty::WebDav(WebDavProperty::GetETag)], + [("prefer", "depth-noroot")], + ) + .await + .with_hrefs([sharee_base_path.as_str()]); + + // Delete resources + owner_client + .request("DELETE", &owner_folder_private, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &sharee_folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client + .request("DELETE", &format!("{sharee_base_path}copied/"), "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + sharee_client.delete_default_containers().await; + owner_client.delete_default_containers().await; + test.assert_is_empty().await; +} + +impl DummyWebDavClient { + pub async fn acl<'x>( + &self, + query: &str, + principal_href: &str, + grant: impl IntoIterator, + ) -> DavResponse { + let body = ACL_QUERY.replace("$HREF", principal_href).replace( + "$GRANT", + &grant.into_iter().fold(String::new(), |mut output, g| { + use std::fmt::Write; + let _ = write!(output, ""); + output + }), + ); + self.request("ACL", query, &body).await + } +} + +const ACL_QUERY: &str = r#" + + + + $HREF + + + $GRANT + + + "#; + +const ACL_PRINCIPAL_QUERY: &str = r#" + + + + + "#; diff --git a/tests/src/webdav/card_query.rs b/tests/src/webdav/card_query.rs index b7f16831..76454755 100644 --- a/tests/src/webdav/card_query.rs +++ b/tests/src/webdav/card_query.rs @@ -30,31 +30,7 @@ pub async fn test(test: &WebDavTest) { // Test 1: RFC6352 8.6.3 example 1 let response = client - .request( - "REPORT", - &default_path, - r#" - - - - - - - - - - - - - - charlie - - - "#, - ) + .request("REPORT", &default_path, QUERY1) .await .with_status(StatusCode::MULTI_STATUS) .with_hrefs([uri_carlos]) @@ -81,35 +57,7 @@ END:VCARD // Test 2: RFC6352 8.6.3 example 2 let response = client - .request( - "REPORT", - &default_path, - r#" - - - - - - - - - - - - - john - - - rodriguez - - - "#, - ) + .request("REPORT", &default_path, QUERY2) .await .with_status(StatusCode::MULTI_STATUS) .with_hrefs([uri_carlos, uri_sarah]) @@ -154,24 +102,7 @@ END:VCARD // Test 3: Search within parameters let response = client - .request( - "REPORT", - &default_path, - r#" - - - - - - - - - enterprise - - - -"#, - ) + .request("REPORT", &default_path, QUERY3) .await .with_status(StatusCode::MULTI_STATUS) .with_hrefs([uri_acme]) @@ -185,10 +116,91 @@ END:VCARD // Test 4: Search using limit client - .request( - "REPORT", - &default_path, - r#" + .request("REPORT", &default_path, QUERY4) + .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 QUERY1: &str = r#" + + + + + + + + + + + + + + charlie + + + "#; + +const QUERY2: &str = r#" + + + + + + + + + + + + + john + + + rodriguez + + + "#; + +const QUERY3: &str = r#" + + + + + + + + + enterprise + + + +"#; + +const QUERY4: &str = r#" @@ -209,27 +221,7 @@ END:VCARD 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 diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 92676242..72b990fb 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -44,6 +44,7 @@ use store::rand::{Rng, distr::Alphanumeric, rng}; use tokio::sync::watch; use utils::config::Config; +pub mod acl; pub mod basic; pub mod card_query; pub mod copy_move; @@ -55,183 +56,51 @@ pub mod prop; pub mod put_get; pub mod sync; -const SERVER: &str = r#" -[server] -hostname = "webdav.example.org" -http.url = "'https://127.0.0.1:8899'" +#[tokio::test] +pub async fn webdav_tests() { + // Prepare settings + let start_time = Instant::now(); + let delete = true; + let handle = init_webdav_tests( + &std::env::var("STORE") + .expect("Missing store type. Try running `STORE= cargo test`"), + delete, + ) + .await; -[server.listener.webdav] -bind = ["127.0.0.1:8899"] -protocol = "http" -max-connections = 81920 -tls.implicit = true + /* + TODO: -[server.socket] -reuse-addr = true + - Calendar Query + - Freebusy Query -[server.tls] -enable = true -implicit = false -certificate = "default" + */ -[session.ehlo] -reject-non-fqdn = false + basic::test(&handle).await; + put_get::test(&handle).await; + mkcol::test(&handle).await; + copy_move::test(&handle).await; + prop::test(&handle).await; + multiget::test(&handle).await; + sync::test(&handle).await; + lock::test(&handle).await; + principals::test(&handle).await; + acl::test(&handle).await; + card_query::test(&handle).await; -[session.rcpt] -relay = [ { if = "!is_empty(authenticated_as)", then = true }, - { else = false } ] -directory = "'{STORE}'" + // Print elapsed time + let elapsed = start_time.elapsed(); + println!( + "Elapsed: {}.{:03}s", + elapsed.as_secs(), + elapsed.subsec_millis() + ); -[session.rcpt.errors] -total = 5 -wait = "1ms" - -[queue] -path = "{TMP}" -hash = 64 - -[report] -path = "{TMP}" -hash = 64 - -[resolver] -type = "system" - -[queue.outbound] -next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, - { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" }, - { else = false } ] - -[session.data.add-headers] -delivered-to = false - -[session.extensions] -future-release = [ { if = "!is_empty(authenticated_as)", then = "99999999d"}, - { else = false } ] - -[store."sqlite"] -type = "sqlite" -path = "{TMP}/sqlite.db" - -[store."rocksdb"] -type = "rocksdb" -path = "{TMP}/rocks.db" - -[store."foundationdb"] -type = "foundationdb" - -[store."postgresql"] -type = "postgresql" -host = "localhost" -port = 5432 -database = "stalwart" -user = "postgres" -password = "mysecretpassword" - -[store."psql-replica"] -type = "sql-read-replica" -primary = "postgresql" -replicas = "postgresql" - -[store."mysql"] -type = "mysql" -host = "localhost" -port = 3307 -database = "stalwart" -user = "root" -password = "password" - -[store."elastic"] -type = "elasticsearch" -url = "https://localhost:9200" -user = "elastic" -password = "RtQ-Lu6+o4rxx=XJplVJ" -disable = true - -[store."elastic".tls] -allow-invalid-certs = true - -[certificate.default] -cert = "%{file:{CERT}}%" -private-key = "%{file:{PK}}%" - -[storage] -data = "{STORE}" -fts = "{STORE}" -blob = "{STORE}" -lookup = "{STORE}" -directory = "{STORE}" - -[jmap.protocol] -set.max-objects = 100000 - -[jmap.protocol.request] -max-concurrent = 8 - -[jmap.protocol.upload] -max-size = 5000000 -max-concurrent = 4 -ttl = "1m" - -[jmap.protocol.upload.quota] -files = 3 -size = 50000 - -[jmap.rate-limit] -account = "1000/1m" -authentication = "100/2s" -anonymous = "100/1m" - -[store."auth"] -type = "sqlite" -path = "{TMP}/auth.db" - -[store."auth".query] -name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true" -members = "SELECT member_of FROM group_members WHERE name = ?" -recipients = "SELECT name FROM emails WHERE address = ?" -emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC" -verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type = 'primary' ORDER BY address LIMIT 5" -expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50" -domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || ? LIMIT 1" - -[directory."{STORE}"] -type = "internal" -store = "{STORE}" - -[oauth] -key = "parerga_und_paralipomena" - -[oauth.auth] -max-attempts = 1 - -[oauth.expiry] -user-code = "1s" -token = "1s" -refresh-token = "3s" -refresh-token-renew = "2s" - -[tracer.console] -type = "console" -level = "{LEVEL}" -multiline = false -ansi = true -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"), -]; + // Remove test data + if delete { + handle.temp_dir.delete(); + } +} #[allow(dead_code)] pub struct WebDavTest { @@ -358,54 +227,6 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest } } -#[tokio::test] -pub async fn webdav_tests() { - // Prepare settings - let start_time = Instant::now(); - let delete = true; - let handle = init_webdav_tests( - &std::env::var("STORE") - .expect("Missing store type. Try running `STORE= cargo test`"), - delete, - ) - .await; - - /* - TODO: - - - ACLs: - - ACL Method - - AclPrincipalPropSet - - Calendar Query - - Freebusy Query - - */ - - basic::test(&handle).await; - put_get::test(&handle).await; - mkcol::test(&handle).await; - copy_move::test(&handle).await; - prop::test(&handle).await; - multiget::test(&handle).await; - 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(); - println!( - "Elapsed: {}.{:03}s", - elapsed.as_secs(), - elapsed.subsec_millis() - ); - - // Remove test data - if delete { - handle.temp_dir.delete(); - } -} - impl WebDavTest { pub fn client(&self, name: &'static str) -> &DummyWebDavClient { self.clients.get(name).unwrap() @@ -1164,3 +985,181 @@ fn generate_random_name(length: usize) -> String { .map(|_| rng.sample(Alphanumeric) as char) .collect() } + +const SERVER: &str = r#" +[server] +hostname = "webdav.example.org" +http.url = "'https://127.0.0.1:8899'" + +[server.listener.webdav] +bind = ["127.0.0.1:8899"] +protocol = "http" +max-connections = 81920 +tls.implicit = true + +[server.socket] +reuse-addr = true + +[server.tls] +enable = true +implicit = false +certificate = "default" + +[session.ehlo] +reject-non-fqdn = false + +[session.rcpt] +relay = [ { if = "!is_empty(authenticated_as)", then = true }, + { else = false } ] +directory = "'{STORE}'" + +[session.rcpt.errors] +total = 5 +wait = "1ms" + +[queue] +path = "{TMP}" +hash = 64 + +[report] +path = "{TMP}" +hash = 64 + +[resolver] +type = "system" + +[queue.outbound] +next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, + { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" }, + { else = false } ] + +[session.data.add-headers] +delivered-to = false + +[session.extensions] +future-release = [ { if = "!is_empty(authenticated_as)", then = "99999999d"}, + { else = false } ] + +[store."sqlite"] +type = "sqlite" +path = "{TMP}/sqlite.db" + +[store."rocksdb"] +type = "rocksdb" +path = "{TMP}/rocks.db" + +[store."foundationdb"] +type = "foundationdb" + +[store."postgresql"] +type = "postgresql" +host = "localhost" +port = 5432 +database = "stalwart" +user = "postgres" +password = "mysecretpassword" + +[store."psql-replica"] +type = "sql-read-replica" +primary = "postgresql" +replicas = "postgresql" + +[store."mysql"] +type = "mysql" +host = "localhost" +port = 3307 +database = "stalwart" +user = "root" +password = "password" + +[store."elastic"] +type = "elasticsearch" +url = "https://localhost:9200" +user = "elastic" +password = "RtQ-Lu6+o4rxx=XJplVJ" +disable = true + +[store."elastic".tls] +allow-invalid-certs = true + +[certificate.default] +cert = "%{file:{CERT}}%" +private-key = "%{file:{PK}}%" + +[storage] +data = "{STORE}" +fts = "{STORE}" +blob = "{STORE}" +lookup = "{STORE}" +directory = "{STORE}" + +[jmap.protocol] +set.max-objects = 100000 + +[jmap.protocol.request] +max-concurrent = 8 + +[jmap.protocol.upload] +max-size = 5000000 +max-concurrent = 4 +ttl = "1m" + +[jmap.protocol.upload.quota] +files = 3 +size = 50000 + +[jmap.rate-limit] +account = "1000/1m" +authentication = "100/2s" +anonymous = "100/1m" + +[store."auth"] +type = "sqlite" +path = "{TMP}/auth.db" + +[store."auth".query] +name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true" +members = "SELECT member_of FROM group_members WHERE name = ?" +recipients = "SELECT name FROM emails WHERE address = ?" +emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC" +verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type = 'primary' ORDER BY address LIMIT 5" +expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50" +domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || ? LIMIT 1" + +[directory."{STORE}"] +type = "internal" +store = "{STORE}" + +[oauth] +key = "parerga_und_paralipomena" + +[oauth.auth] +max-attempts = 1 + +[oauth.expiry] +user-code = "1s" +token = "1s" +refresh-token = "3s" +refresh-token-renew = "2s" + +[tracer.console] +type = "console" +level = "{LEVEL}" +multiline = false +ansi = true +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"), +];