diff --git a/crates/dav-proto/src/requests/mod.rs b/crates/dav-proto/src/requests/mod.rs index 79531ff6..906f768a 100644 --- a/crates/dav-proto/src/requests/mod.rs +++ b/crates/dav-proto/src/requests/mod.rs @@ -127,6 +127,10 @@ impl ArchivedDeadProperty { } impl DeadElementTag { + pub fn new(name: String, attrs: Option) -> Self { + DeadElementTag { name, attrs } + } + pub fn size(&self) -> usize { self.name.len() + self.attrs.as_ref().map_or(0, |attrs| attrs.len()) } diff --git a/crates/dav-proto/src/responses/property.rs b/crates/dav-proto/src/responses/property.rs index c527d676..947be158 100644 --- a/crates/dav-proto/src/responses/property.rs +++ b/crates/dav-proto/src/responses/property.rs @@ -228,6 +228,12 @@ impl DavProperty { } } +impl AsRef for DavProperty { + fn as_ref(&self) -> &str { + self.tag_name().0 + } +} + impl Display for ReportSet { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("")?; diff --git a/crates/dav-proto/src/schema/property.rs b/crates/dav-proto/src/schema/property.rs index 0735e366..722f4a44 100644 --- a/crates/dav-proto/src/schema/property.rs +++ b/crates/dav-proto/src/schema/property.rs @@ -316,7 +316,7 @@ impl Rfc1123DateTime { } impl DavProperty { - pub const ALL_PROPS: [DavProperty; 17] = [ + pub const ALL_PROPS: [DavProperty; 11] = [ DavProperty::WebDav(WebDavProperty::CreationDate), DavProperty::WebDav(WebDavProperty::DisplayName), DavProperty::WebDav(WebDavProperty::GetETag), @@ -325,15 +325,9 @@ impl DavProperty { DavProperty::WebDav(WebDavProperty::LockDiscovery), DavProperty::WebDav(WebDavProperty::SupportedLock), DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), - DavProperty::WebDav(WebDavProperty::SyncToken), - DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), - DavProperty::WebDav(WebDavProperty::AclRestrictions), - DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), - DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), DavProperty::WebDav(WebDavProperty::GetContentLanguage), DavProperty::WebDav(WebDavProperty::GetContentLength), DavProperty::WebDav(WebDavProperty::GetContentType), - DavProperty::WebDav(WebDavProperty::SupportedReportSet), ]; pub fn is_all_prop(&self) -> bool { @@ -347,15 +341,9 @@ impl DavProperty { | DavProperty::WebDav(WebDavProperty::LockDiscovery) | DavProperty::WebDav(WebDavProperty::SupportedLock) | DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal) - | DavProperty::WebDav(WebDavProperty::SyncToken) - | DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet) - | DavProperty::WebDav(WebDavProperty::AclRestrictions) - | DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet) - | DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet) | DavProperty::WebDav(WebDavProperty::GetContentLanguage) | DavProperty::WebDav(WebDavProperty::GetContentLength) | DavProperty::WebDav(WebDavProperty::GetContentType) - | DavProperty::WebDav(WebDavProperty::SupportedReportSet) | DavProperty::DeadProperty(_) ) } diff --git a/crates/dav/src/calendar/mkcol.rs b/crates/dav/src/calendar/mkcol.rs index 18e41bce..738faffc 100644 --- a/crates/dav/src/calendar/mkcol.rs +++ b/crates/dav/src/calendar/mkcol.rs @@ -22,6 +22,7 @@ use trc::AddContext; use crate::{ DavError, DavMethod, PropStatBuilder, common::{ + ExtractETag, lock::{LockRequestHandler, ResourceState}, uri::DavUriResource, }, @@ -130,17 +131,20 @@ impl CalendarMkColRequestHandler for Server { calendar .insert(access_token, account_id, document_id, &mut batch) .caused_by(trc::location!())?; + let etag = batch.etag(); self.commit_batch(batch).await.caused_by(trc::location!())?; if let Some(prop_stat) = return_prop_stat { - Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body( - MkColResponse::new(prop_stat.build()) - .with_namespace(Namespace::CalDav) - .with_mkcalendar(is_mkcalendar) - .to_string(), - )) + Ok(HttpResponse::new(StatusCode::CREATED) + .with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::CalDav) + .with_mkcalendar(is_mkcalendar) + .to_string(), + ) + .with_etag_opt(etag)) } else { - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } } } diff --git a/crates/dav/src/calendar/proppatch.rs b/crates/dav/src/calendar/proppatch.rs index 5b204506..0a3adba9 100644 --- a/crates/dav/src/calendar/proppatch.rs +++ b/crates/dav/src/calendar/proppatch.rs @@ -326,6 +326,7 @@ impl CalendarPropPatchRequestHandler for Server { } (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { calendar.created = dt; + items.insert_ok(property.property); } ( DavProperty::WebDav(WebDavProperty::ResourceType), @@ -408,6 +409,7 @@ impl CalendarPropPatchRequestHandler for Server { } (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { event.created = dt; + items.insert_ok(property.property); } (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) if self.core.groupware.dead_property_size.is_some() => diff --git a/crates/dav/src/card/mkcol.rs b/crates/dav/src/card/mkcol.rs index 8705a16e..aeba96aa 100644 --- a/crates/dav/src/card/mkcol.rs +++ b/crates/dav/src/card/mkcol.rs @@ -8,6 +8,7 @@ use super::proppatch::CardPropPatchRequestHandler; use crate::{ DavError, DavMethod, PropStatBuilder, common::{ + ExtractETag, lock::{LockRequestHandler, ResourceState}, uri::DavUriResource, }, @@ -110,16 +111,19 @@ impl CardMkColRequestHandler for Server { .caused_by(trc::location!())?; book.insert(access_token, account_id, document_id, &mut batch) .caused_by(trc::location!())?; + let etag = batch.etag(); self.commit_batch(batch).await.caused_by(trc::location!())?; if let Some(prop_stat) = return_prop_stat { - Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body( - MkColResponse::new(prop_stat.build()) - .with_namespace(Namespace::CardDav) - .to_string(), - )) + Ok(HttpResponse::new(StatusCode::CREATED) + .with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::CardDav) + .to_string(), + ) + .with_etag_opt(etag)) } else { - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } } } diff --git a/crates/dav/src/card/proppatch.rs b/crates/dav/src/card/proppatch.rs index 43cbb74c..7c245ca5 100644 --- a/crates/dav/src/card/proppatch.rs +++ b/crates/dav/src/card/proppatch.rs @@ -273,6 +273,7 @@ impl CardPropPatchRequestHandler for Server { } (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { address_book.created = dt; + items.insert_ok(property.property); } ( DavProperty::WebDav(WebDavProperty::ResourceType), @@ -354,6 +355,7 @@ impl CardPropPatchRequestHandler for Server { } (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { card.created = dt; + items.insert_ok(property.property); } (DavProperty::DeadProperty(dead), DavValue::DeadProperty(values)) if self.core.groupware.dead_property_size.is_some() => diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 4c0c4f50..b48a74cb 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -129,6 +129,7 @@ impl PropFindRequestHandler for Server { Depth::Zero => false, Depth::Infinity => { if resource.account_id.is_none() + || resource.resource.is_none() || matches!(resource.collection, Collection::FileNode) { return Err(DavErrorCondition::new( @@ -1009,7 +1010,7 @@ impl PropFindRequestHandler for Server { )) .with_supported_privilege(SupportedPrivilege::new( Privilege::Unbind, - "Add resources to a collection", + "Remove resources from a collection", )) .with_supported_privilege(SupportedPrivilege::new( Privilege::Unlock, @@ -1193,8 +1194,10 @@ impl PropFindRequestHandler for Server { if let ArchivedTimezone::IANA(tz) = &calendar.inner.preferences(account_id).time_zone { - fields - .push(DavPropertyValue::new(property.clone(), tz.to_string())); + fields.push(DavPropertyValue::new( + property.clone(), + Tz::from_id(tz.to_native()).unwrap_or(Tz::UTC).to_string(), + )); } else { fields_not_found.push(DavPropertyValue::empty(property.clone())); } @@ -1238,13 +1241,13 @@ impl PropFindRequestHandler for Server { (CalDavProperty::MinDateTime, ArchivedResource::Calendar(_)) => { fields.push(DavPropertyValue::new( property.clone(), - DavValue::Timestamp(i64::MIN), + DavValue::String("0001-01-01T00:00:00Z".to_string()), )); } (CalDavProperty::MaxDateTime, ArchivedResource::Calendar(_)) => { fields.push(DavPropertyValue::new( property.clone(), - DavValue::Timestamp(32531605200), + DavValue::String("9999-12-31T23:59:59Z".to_string()), )); } (CalDavProperty::MaxInstances, ArchivedResource::Calendar(_)) => { diff --git a/crates/dav/src/file/mkcol.rs b/crates/dav/src/file/mkcol.rs index e9c38ae5..01acaace 100644 --- a/crates/dav/src/file/mkcol.rs +++ b/crates/dav/src/file/mkcol.rs @@ -19,6 +19,7 @@ use trc::AddContext; use crate::{ DavMethod, PropStatBuilder, common::{ + ExtractETag, acl::DavAclHandler, lock::{LockRequestHandler, ResourceState}, uri::DavUriResource, @@ -125,16 +126,19 @@ impl FileMkColRequestHandler for Server { .create_document(document_id) .custom(ObjectIndexBuilder::<(), _>::new().with_changes(node)) .caused_by(trc::location!())?; + let etag = batch.etag(); self.commit_batch(batch).await.caused_by(trc::location!())?; if let Some(prop_stat) = return_prop_stat { - Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body( - MkColResponse::new(prop_stat.build()) - .with_namespace(Namespace::Dav) - .to_string(), - )) + Ok(HttpResponse::new(StatusCode::CREATED) + .with_xml_body( + MkColResponse::new(prop_stat.build()) + .with_namespace(Namespace::Dav) + .to_string(), + ) + .with_etag_opt(etag)) } else { - Ok(HttpResponse::new(StatusCode::CREATED)) + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } } } diff --git a/crates/dav/src/file/proppatch.rs b/crates/dav/src/file/proppatch.rs index 2541be05..bc48f05a 100644 --- a/crates/dav/src/file/proppatch.rs +++ b/crates/dav/src/file/proppatch.rs @@ -186,6 +186,7 @@ impl FilePropPatchRequestHandler for Server { } (DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => { file.created = dt; + items.insert_ok(property.property); } (DavProperty::WebDav(WebDavProperty::GetContentType), DavValue::String(name)) if file.file.is_some() => diff --git a/tests/src/webdav/copy_move.rs b/tests/src/webdav/copy_move.rs index da297614..885b9984 100644 --- a/tests/src/webdav/copy_move.rs +++ b/tests/src/webdav/copy_move.rs @@ -13,6 +13,7 @@ use hyper::StatusCode; pub async fn test(test: &WebDavTest) { let client = test.client("jane"); + let mike_noquota = test.client("mike"); for resource_type in [ DavResourceName::File, @@ -649,10 +650,72 @@ pub async fn test(test: &WebDavTest) { .request("DELETE", &test_base_path, "") .await .with_status(StatusCode::NO_CONTENT); + + // Test 19: Quota enforcement (on CalDAV/CardDAV items are linked, not copied therefore there is no quota increase) + if resource_type == DavResourceName::File { + let path = format!("{}/mike/quota-test/", resource_type.base_path()); + let content = resource_type.generate(); + mike_noquota + .mkcol("MKCOL", &path, [], []) + .await + .with_status(StatusCode::CREATED); + mike_noquota + .request_with_headers("PUT", &format!("{path}file"), [], &content) + .await + .with_status(StatusCode::CREATED); + let mut num_success = 0; + let mut did_fail = false; + + for i in 0..100 { + let response = mike_noquota + .request_with_headers( + "COPY", + &path, + [( + "destination", + format!("{}/mike/quota-test{i}", resource_type.base_path()).as_str(), + )], + &content, + ) + .await; + match response.status { + StatusCode::CREATED => { + num_success += 1; + } + StatusCode::PRECONDITION_FAILED => { + did_fail = true; + break; + } + _ => panic!("Unexpected status code: {:?}", response.status), + } + } + if !did_fail { + panic!("Quota test failed: {} files created", num_success); + } + if num_success == 0 { + panic!("Quota test failed: no files created"); + } + + mike_noquota + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); + for i in 0..num_success { + mike_noquota + .request( + "DELETE", + &format!("{}/mike/quota-test{i}", resource_type.base_path()), + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + } + } } client.delete_default_containers().await; client.delete_default_containers_by_account("support").await; + mike_noquota.delete_default_containers().await; test.assert_is_empty().await; } diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs index a2ba5539..dd238708 100644 --- a/tests/src/webdav/mkcol.rs +++ b/tests/src/webdav/mkcol.rs @@ -8,7 +8,7 @@ use hyper::StatusCode; use crate::webdav::{TEST_FILE_1, TEST_ICAL_1, TEST_VCARD_1, TEST_VTIMEZONE_1}; -use super::WebDavTest; +use super::{DavResponse, DummyWebDavClient, WebDavTest}; pub async fn test(test: &WebDavTest) { println!("Running MKCOL tests..."); @@ -102,7 +102,7 @@ pub async fn test(test: &WebDavTest) { } // Create using extended MKCOL - for (path, properties, resource_types) in [ + for (path, expected_properties, resource_types) in [ ( "/dav/file/john/my-named-files/", [("D:displayname", "Named Files")].as_slice(), @@ -131,38 +131,34 @@ pub async fn test(test: &WebDavTest) { ["D:collection", "A:calendar"].as_slice(), ), ] { - let mut response = client + let response = client .mkcol( "MKCOL", path, resource_types.iter().copied(), - properties.iter().copied(), + expected_properties.iter().copied(), ) .await .with_status(StatusCode::CREATED) - .match_many("D:mkcol-response.D:propstat.D:status", ["HTTP/1.1 200 OK"]); - for (property, _) in properties { - response = response.match_one( - &format!("D:mkcol-response.D:propstat.D:prop.{property}"), - "", - ); + .into_propfind_response("D:mkcol-response".into()); + let properties = response.properties(""); + for (property, _) in expected_properties { + properties + .get(property) + .with_status(StatusCode::OK) + .with_values([""]); } // Check the properties of the created collection - let mut response = client - .propfind(path, properties.iter().map(|x| x.0)) - .await - .with_status(StatusCode::MULTI_STATUS) - .match_one("D:multistatus.D:response.D:href", path) - .match_one( - "D:multistatus.D:response.D:propstat.D:status", - "HTTP/1.1 200 OK", - ); - for (property, value) in properties { - response = response.match_one( - &format!("D:multistatus.D:response.D:propstat.D:prop.{property}"), - value, - ); + let response = client + .propfind(path, expected_properties.iter().map(|x| x.0)) + .await; + let properties = response.properties(path); + for (property, value) in expected_properties { + properties + .get(property) + .with_status(StatusCode::OK) + .with_values([*value]); } } @@ -200,3 +196,44 @@ pub async fn test(test: &WebDavTest) { client.delete_default_containers().await; test.assert_is_empty().await; } + +impl DummyWebDavClient { + pub async fn mkcol( + &self, + method: &str, + path: &str, + resource_types: impl IntoIterator, + properties: impl IntoIterator, + ) -> DavResponse { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + let mut has_resource_type = false; + for (idx, resource_type) in resource_types.into_iter().enumerate() { + if idx == 0 { + request.push_str(""); + } + request.push_str(&format!("<{resource_type}/>")); + has_resource_type = true; + } + + if has_resource_type { + request.push_str(""); + } + + for (key, value) in properties { + request.push_str(&format!("<{key}>{value}")); + } + request.push_str(""); + + if method == "MKCALENDAR" { + request = request.replace("D:mkcol", "A:mkcalendar"); + } + + self.request(method, path, &request).await + } +} diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index b135f988..de5702f1 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -10,7 +10,7 @@ use crate::{ }; use ::managesieve::core::ManageSieveSessionManager; use ::store::Stores; -use ahash::AHashMap; +use ahash::{AHashMap, AHashSet}; use base64::{Engine, engine::general_purpose::STANDARD}; use common::{ Caches, Core, Data, DavResource, DavResources, Inner, Server, @@ -21,7 +21,10 @@ use common::{ core::BuildServer, manager::boot::build_ipc, }; -use dav_proto::Depth; +use dav_proto::{ + Depth, + schema::property::{DavProperty, WebDavProperty}, +}; use groupware::{DavResourceName, hierarchy::DavHierarchy}; use http::HttpSessionManager; use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION}; @@ -44,6 +47,7 @@ use utils::config::Config; pub mod basic; pub mod copy_move; pub mod mkcol; +pub mod prop; pub mod put_get; const SERVER: &str = r#" @@ -316,7 +320,7 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest ("john", "secret2", "John Doe", "jdoe@example.com"), ("jane", "secret3", "Jane Smith", "jane.smith@example.com"), ("bill", "secret4", "Bill Foobar", "bill@example,com"), - ("mike", "secret5", "Mile Noquota", "mike@example,com"), + ("mike", "secret5", "Mike Noquota", "mike@example,com"), ] { let account_id = store .create_test_user(account, secret, name, &[email]) @@ -326,7 +330,7 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest DummyWebDavClient::new(account_id, account, secret, email), ); if account == "mike" { - store.set_test_quota(account, 10).await; + store.set_test_quota(account, 1024).await; } } store @@ -358,6 +362,7 @@ pub async fn webdav_tests() { put_get::test(&handle).await; mkcol::test(&handle).await; copy_move::test(&handle).await; + prop::test(&handle).await; // Print elapsed time let elapsed = start_time.elapsed(); @@ -400,6 +405,7 @@ pub struct DummyWebDavClient { credentials: String, } +#[derive(Debug)] pub struct DavResponse { headers: AHashMap, status: StatusCode, @@ -488,66 +494,6 @@ impl DummyWebDavClient { } } - pub async fn mkcol( - &self, - method: &str, - path: &str, - resource_types: impl IntoIterator, - properties: impl IntoIterator, - ) -> DavResponse { - let mut request = concat!( - "", - "", - "" - ) - .to_string(); - - let mut has_resource_type = false; - for (idx, resource_type) in resource_types.into_iter().enumerate() { - if idx == 0 { - request.push_str(""); - } - request.push_str(&format!("<{resource_type}/>")); - has_resource_type = true; - } - - if has_resource_type { - request.push_str(""); - } - - for (key, value) in properties { - request.push_str(&format!("<{key}>{value}")); - } - request.push_str(""); - - if method == "MKCALENDAR" { - request = request.replace("D:mkcol", "A:mkcalendar"); - } - - self.request(method, path, &request).await - } - - pub async fn propfind( - &self, - path: &str, - properties: impl IntoIterator, - ) -> DavResponse { - let mut request = concat!( - "", - "", - "" - ) - .to_string(); - - for property in properties { - request.push_str(&format!("<{property}/>")); - } - - request.push_str(""); - - self.request("PROPFIND", path, &request).await - } - pub async fn sync_collection( &self, path: &str, @@ -581,6 +527,19 @@ impl DummyWebDavClient { .with_status(StatusCode::MULTI_STATUS) } + pub async fn available_quota(&self, path: &str) -> u64 { + self.propfind( + path, + [DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)], + ) + .await + .properties(path) + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .value() + .parse() + .unwrap() + } + pub async fn create_hierarchy( &self, base_path: &str, @@ -782,6 +741,26 @@ impl DavResponse { hrefs } + pub fn with_hrefs<'x>(self, hrefs: impl IntoIterator) -> Self { + let expected_hrefs = hrefs.into_iter().collect::>(); + let hrefs = self + .find_keys("D:multistatus.D:response.D:href") + .collect::>(); + if expected_hrefs != hrefs { + self.dump_response(); + + println!("\nMissing: {:?}", expected_hrefs.difference(&hrefs)); + println!("\nExtra: {:?}", hrefs.difference(&expected_hrefs)); + + panic!( + "Hierarchy mismatch: expected {} items, received {} items", + expected_hrefs.len(), + hrefs.len() + ); + } + self + } + fn dump_response(&self) { eprintln!("-------------------------------------"); eprintln!("Status: {}", self.status); @@ -868,19 +847,34 @@ fn flatten_xml(xml: &str) -> Vec<(String, String)> { Event::Start(ref e) => { let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); path.push(name); + let base_path = path.join("."); for attr in e.attributes() { let attr = attr.unwrap(); let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); let value = attr.unescape_value().unwrap(); let value_str = value.trim().to_string(); - result.push((format!("{}.[{}]", path.join("."), key), value_str)); + result.push((format!("{}.[{}]", base_path, key), value_str)); } text_content = None; } Event::Empty(ref e) => { let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); - result.push((format!("{}.{}", path.join("."), name), "".to_string())); + let base_path = format!("{}.{}", path.join("."), name); + let mut has_attrs = false; + + for attr in e.attributes() { + let attr = attr.unwrap(); + let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); + let value = attr.unescape_value().unwrap(); + let value_str = value.trim().to_string(); + has_attrs = true; + result.push((format!("{}.[{}]", base_path, key), value_str)); + } + + if !has_attrs { + result.push((base_path, "".to_string())); + } } Event::Text(e) => { let text = e.unescape().unwrap(); diff --git a/tests/src/webdav/prop.rs b/tests/src/webdav/prop.rs new file mode 100644 index 00000000..f90f1ec9 --- /dev/null +++ b/tests/src/webdav/prop.rs @@ -0,0 +1,1033 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{DavResponse, DummyWebDavClient, WebDavTest}; +use crate::webdav::{GenerateTestDavResource, TEST_ICAL_2, TEST_VTIMEZONE_1}; +use ahash::{AHashMap, AHashSet}; +use dav_proto::schema::{ + property::{CalDavProperty, CardDavProperty, DavProperty, PrincipalProperty, WebDavProperty}, + request::DeadElementTag, +}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &WebDavTest) { + let client = test.client("jane"); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running PROPFIND/PROPPATCH tests ({})...", + resource_type.base_path() + ); + let user_base_path = format!("{}/jane", resource_type.base_path()); + let group_base_path = format!("{}/support", resource_type.base_path()); + + // Create a new test container and file + let test_base_path = format!("{user_base_path}/PropFind_Folder/"); + let etag_folder = client + .mkcol("MKCOL", &test_base_path, [], []) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + let test_contents = resource_type.generate(); + let test_path = format!("{test_base_path}test_file"); + let etag_file = client + .request_with_headers( + "PUT", + &test_path, + [("content-type", "text/x-other")], + test_contents.as_str(), + ) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + + // Test 1: PROPFIND Depth 0 on root + client + .request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "0")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([resource_type.collection_path()]); + + // Test 2: PROPFIND Depth 0 on user base path + client + .request_with_headers("PROPFIND", &user_base_path, [("depth", "0")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([format!("{user_base_path}/").as_str()]); + + // Test 3: PROPFIND Depth 1 on root + client + .request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "1")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([ + resource_type.collection_path(), + format!("{user_base_path}/").as_str(), + format!("{group_base_path}/").as_str(), + ]); + + // Test 4: Infinity depth is not allowed + for path in [resource_type.base_path(), user_base_path.as_str()] { + client + .request_with_headers("PROPFIND", path, [("depth", "infinity")], "") + .await + .with_status(StatusCode::FORBIDDEN); + } + + // Test 5: PROPFIND Depth 1 on user base path + let response = 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]); + } + + // Test 6: PROPFIND Depth 1 on created collection + client + .request_with_headers("PROPFIND", &test_base_path, [("depth", "1")], "") + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([test_base_path.as_str(), test_path.as_str()]); + + // Test 7: Infinity depth is not allowed on file containers + client + .request_with_headers("PROPFIND", &test_base_path, [("depth", "infinity")], "") + .await + .with_status(if resource_type == DavResourceName::File { + StatusCode::FORBIDDEN + } else { + StatusCode::MULTI_STATUS + }); + + // Test 8: Retrieve all static properties + for (path, etag, is_file) in [ + (&test_base_path, &etag_folder, false), + (&test_path, &etag_file, true), + ] { + let response = client.propfind(path, ALL_DAV_PROPERTIES).await; + let properties = response.properties(path); + properties + .get(DavProperty::WebDav(WebDavProperty::CreationDate)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::GetLastModified)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::SyncToken)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_values([etag.as_str()]); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedLock)) + .with_values([ + "D:lockentry.D:lockscope.D:exclusive", + "D:lockentry.D:locktype.D:write", + "D:lockentry.D:lockscope.D:shared", + "D:lockentry.D:locktype.D:write", + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)) + .with_values([ + format!("D:href:{}/jane/", DavResourceName::Principal.base_path()).as_str(), + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::Owner)) + .with_values([ + format!("D:href:{}/jane/", DavResourceName::Principal.base_path()).as_str(), + ]); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::AclRestrictions)) + .with_values(["D:grant-only", "D:no-invert"]); + properties + .get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)) + .with_values([ + format!("D:href:{}", DavResourceName::Principal.collection_path()).as_str(), + ]); + + if is_file { + // File specific properties + properties + .get(DavProperty::WebDav(WebDavProperty::GetContentType)) + .with_values([match resource_type { + DavResourceName::File => "text/x-other", + DavResourceName::Cal => "text/calendar", + DavResourceName::Card => "text/vcard", + _ => unreachable!(), + }]); + properties + .get(DavProperty::WebDav(WebDavProperty::GetContentLength)) + .with_values([test_contents.len().to_string().as_str()]); + } else { + // Collection specific properties + properties + .get(DavProperty::WebDav(WebDavProperty::GetCTag)) + .is_not_empty(); + properties + .get(DavProperty::WebDav(WebDavProperty::ResourceType)) + .with_values(match resource_type { + DavResourceName::File => ["D:collection"].as_slice().iter().copied(), + DavResourceName::Cal => { + ["D:collection", "A:calendar"].as_slice().iter().copied() + } + DavResourceName::Card => { + ["D:collection", "B:addressbook"].as_slice().iter().copied() + } + _ => unreachable!(), + }); + let used_bytes: u64 = properties + .get(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes)) + .value() + .parse() + .unwrap(); + let available_bytes: u64 = properties + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .value() + .parse() + .unwrap(); + assert!(used_bytes > 0); + assert!(available_bytes > 0); + properties + .get(DavProperty::WebDav(WebDavProperty::SupportedReportSet)) + .with_values(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() + .iter() + .copied(), + DavResourceName::Cal => [ + "D:supported-report.D:report.A:calendar-query", + "D:supported-report.D:report.D:sync-collection", + "D:supported-report.D:report.D:acl-principal-prop-set", + "D:supported-report.D:report.D:expand-property", + "D:supported-report.D:report.A:free-busy-query", + "D:supported-report.D:report.A:calendar-multiget", + "D:supported-report.D:report.D:principal-match", + ] + .as_slice() + .iter() + .copied(), + DavResourceName::Card => [ + "D:supported-report.D:report.B:addressbook-multiget", + "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.B:addressbook-query", + "D:supported-report.D:report.D:expand-property", + ] + .as_slice() + .iter() + .copied(), + _ => unreachable!(), + }); + + if resource_type == DavResourceName::Cal { + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "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", + "D:privilege.A:read-free-busy", + ]); + properties + .get(DavProperty::CalDav( + CalDavProperty::SupportedCalendarComponentSet, + )) + .with_values([ + "A:supported-calendar-component-set.A:comp.[name]:VAVAILABILITY", + "A:supported-calendar-component-set.A:comp.[name]:AVAILABLE", + "A:supported-calendar-component-set.A:comp.[name]:VRESOURCE", + "A:supported-calendar-component-set.A:comp.[name]:VTODO", + "A:supported-calendar-component-set.A:comp.[name]:DAYLIGHT", + "A:supported-calendar-component-set.A:comp.[name]:STANDARD", + "A:supported-calendar-component-set.A:comp.[name]:VLOCATION", + "A:supported-calendar-component-set.A:comp.[name]:VTIMEZONE", + "A:supported-calendar-component-set.A:comp.[name]:VFREEBUSY", + "A:supported-calendar-component-set.A:comp.[name]:VEVENT", + "A:supported-calendar-component-set.A:comp.[name]:VJOURNAL", + "A:supported-calendar-component-set.A:comp.[name]:PARTICIPANT", + "A:supported-calendar-component-set.A:comp.[name]:VALARM", + ]); + properties + .get(DavProperty::CalDav(CalDavProperty::SupportedCalendarData)) + .with_values([ + concat!( + "A:supported-calendar-data.A:calendar-data-type.", + "[content-type]:text/calendar" + ), + "A:supported-calendar-data.A:calendar-data-type.[version]:2.0", + "A:supported-calendar-data.A:calendar-data-type.[version]:1.0", + ]); + properties + .get(DavProperty::CalDav(CalDavProperty::SupportedCollationSet)) + .with_values([ + "A:supported-collation:i;unicode-casemap", + "A:supported-collation:i;ascii-casemap", + ]); + properties + .get(DavProperty::CalDav(CalDavProperty::MinDateTime)) + .with_values(["0001-01-01T00:00:00Z"]); + properties + .get(DavProperty::CalDav(CalDavProperty::MaxDateTime)) + .with_values(["9999-12-31T23:59:59Z"]); + for (key, value) in [ + ( + DavProperty::CalDav(CalDavProperty::MaxResourceSize), + test.server.core.groupware.max_ical_size, + ), + ( + DavProperty::CalDav(CalDavProperty::MaxInstances), + test.server.core.groupware.max_ical_instances, + ), + ( + DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance), + test.server.core.groupware.max_ical_attendees_per_instance, + ), + ] { + properties + .get(key) + .with_values([value.to_string().as_str()]); + } + } else { + if resource_type == DavResourceName::Card { + properties + .get(DavProperty::CardDav(CardDavProperty::SupportedAddressData)) + .with_values([ + concat!( + "B:supported-address-data.B:address-data-type.", + "[content-type]:text/vcard" + ), + "B:supported-address-data.B:address-data-type.[version]:3.0", + "B:supported-address-data.B:address-data-type.[version]:4.0", + "B:supported-address-data.B:address-data-type.[version]:2.1", + ]); + properties + .get(DavProperty::CardDav(CardDavProperty::SupportedCollationSet)) + .with_values([ + "B:supported-collation:i;unicode-casemap", + "B:supported-collation:i;ascii-casemap", + ]); + properties + .get(DavProperty::CardDav(CardDavProperty::MaxResourceSize)) + .with_values([test + .server + .core + .groupware + .max_vcard_size + .to_string() + .as_str()]); + } + + properties + .get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)) + .with_values([ + "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", + ]); + } + } + } + + 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 + client + .proppatch( + path, + [( + DavProperty::WebDav(WebDavProperty::DisplayName), + "Magnific name", + )], + [], + [("if", format!("(Not [{etag}])").as_str())], + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED); + client + .proppatch( + path, + [( + DavProperty::WebDav(WebDavProperty::DisplayName), + "Magnific name - second try", + )], + [], + [("if", format!("([{etag}])").as_str())], + ) + .await + .with_status(StatusCode::MULTI_STATUS); + client + .propfind(path, [DavProperty::WebDav(WebDavProperty::GetETag)]) + .await + .properties(path) + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_status(StatusCode::OK) + .without_values([etag.as_str()]); + + // Test 10: PROPPATCH set on DAV properties + client + .patch_and_check( + path, + [ + ( + DavProperty::WebDav(WebDavProperty::DisplayName), + "New display name", + ), + ( + DavProperty::WebDav(WebDavProperty::CreationDate), + "2000-01-01T00:00:00Z", + ), + ( + DavProperty::DeadProperty(DeadElementTag::new( + "C:my-dead-element".to_string(), + None, + )), + "this is a dead but exciting element", + ), + ], + ) + .await; + + // Test 11: PROPPATCH remove on DAV properties + let mut props = vec![ + ( + DavProperty::DeadProperty(DeadElementTag::new( + "C:my-dead-element".to_string(), + None, + )), + "", + ), + (DavProperty::WebDav(WebDavProperty::DisplayName), ""), + ]; + if !is_file && resource_type == DavResourceName::Cal { + // DisplayName can be removed from calendar collections + props.pop(); + } + client.patch_and_check(path, props).await; + + match resource_type { + DavResourceName::File if is_file => { + // Test 12: Change a file's content-type + client + .patch_and_check( + path, + [( + DavProperty::WebDav(WebDavProperty::GetContentType), + "text/x-yadda-yadda", + )], + ) + .await; + } + DavResourceName::Cal if !is_file => { + // Test 13: Change a calendar's properties + client + .patch_and_check( + path, + [ + ( + DavProperty::CalDav(CalDavProperty::CalendarDescription), + "New calendar description", + ), + ( + DavProperty::CalDav(CalDavProperty::TimezoneId), + "Europe/Ljubljana", + ), + ], + ) + .await; + client + .patch_and_check( + path, + [ + (DavProperty::CalDav(CalDavProperty::CalendarDescription), ""), + (DavProperty::CalDav(CalDavProperty::TimezoneId), ""), + ], + ) + .await; + client + .patch_and_check( + path, + [( + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + TEST_VTIMEZONE_1.replace('\n', "\r\n").as_str(), + )], + ) + .await; + } + DavResourceName::Card if !is_file => { + // Test 14: Change an addressbook's properties + client + .patch_and_check( + path, + [( + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + "New calendar description", + )], + ) + .await; + client + .patch_and_check( + path, + [( + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + "", + )], + ) + .await; + } + _ => (), + } + + // Test 15: PROPPATCH should fail on large properties + let mut chunky_props = vec![ + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::DeadProperty(DeadElementTag::new( + "C:my-chunky-dead-element".to_string(), + None, + )), + ]; + if !is_file { + if resource_type == DavResourceName::Cal { + chunky_props.push(DavProperty::CalDav(CalDavProperty::CalendarDescription)); + } else if resource_type == DavResourceName::Card { + chunky_props.push(DavProperty::CardDav( + CardDavProperty::AddressbookDescription, + )); + } + } + let chunky_live_contents = (0..=(test.server.core.groupware.live_property_size + 1)) + .map(|_| "a") + .collect::(); + let chunky_dead_contents = + (0..=(test.server.core.groupware.dead_property_size.unwrap() + 1)) + .map(|_| "a") + .collect::(); + let response = client + .proppatch( + path, + chunky_props.iter().map(|prop| { + ( + prop.clone(), + if matches!(prop, DavProperty::DeadProperty(_)) { + &chunky_dead_contents + } else { + &chunky_live_contents + } + .as_str(), + ) + }), + [], + [], + ) + .await + .into_propfind_response(None); + let props = response.properties(path); + for prop in chunky_props { + props + .get(prop) + .with_status(StatusCode::INSUFFICIENT_STORAGE) + .with_description("Property value is too long"); + } + + // Test 16: PROPPATCH should fail on invalid calendar property values + if !is_file && resource_type == DavResourceName::Cal { + let response = client + .proppatch( + path, + [ + ( + DavProperty::CalDav(CalDavProperty::TimezoneId), + "unknown/zone", + ), + ( + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + TEST_ICAL_2, + ), + ], + [], + [], + ) + .await + .into_propfind_response(None); + let props = response.properties(path); + props + .get(DavProperty::CalDav(CalDavProperty::TimezoneId)) + .with_status(StatusCode::PRECONDITION_FAILED) + .with_description("Invalid timezone ID"); + props + .get(DavProperty::CalDav(CalDavProperty::CalendarTimezone)) + .with_status(StatusCode::PRECONDITION_FAILED) + .with_description("Invalid calendar timezone"); + } + } + } +} + +#[derive(Debug)] +pub struct DavMultiStatus { + pub response: DavResponse, + pub hrefs: AHashMap, +} + +#[derive(Debug, serde::Serialize)] +pub struct DavItem { + #[serde(serialize_with = "serialize_status_code")] + pub status: StatusCode, + pub values: AHashMap>, + pub error: Vec, + pub description: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct DavProperties(Vec); + +impl DavMultiStatus { + pub fn properties(&self, href: &str) -> DavPropertyResult<'_> { + DavPropertyResult { + response: &self.response, + properties: self.hrefs.get(href).unwrap_or_else(|| { + self.response.dump_response(); + panic!( + "No properties found for href: {href} in {}", + serde_json::to_string_pretty(&self.hrefs).unwrap() + ) + }), + } + } +} + +pub struct DavPropertyResult<'x> { + pub response: &'x DavResponse, + pub properties: &'x DavProperties, +} + +pub struct DavQueryResult<'x> { + pub response: &'x DavResponse, + pub prop: &'x DavItem, + pub values: &'x [String], +} + +impl DavPropertyResult<'_> { + pub fn get(&self, name: impl AsRef) -> DavQueryResult<'_> { + let name = name.as_ref(); + self.properties + .0 + .iter() + .find_map(|prop| { + prop.values.get(name).map(|values| DavQueryResult { + response: self.response, + prop, + values, + }) + }) + .unwrap_or_else(|| { + self.response.dump_response(); + panic!( + "No property found for name: {name} in {}", + serde_json::to_string_pretty(&self.properties.0).unwrap() + ) + }) + } +} + +impl<'x> DavQueryResult<'x> { + pub fn with_values(&self, expected_values: impl IntoIterator) -> &Self { + let expected_values = AHashSet::from_iter(expected_values); + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + if values != expected_values { + self.response.dump_response(); + panic!("Expected {expected_values:?} 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 + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + if !expected_values.is_disjoint(&values) { + self.response.dump_response(); + panic!("Expected no {expected_values:?} values, but got {values:?}",); + } + self + } + + pub fn is_not_empty(&self) -> &Self { + if self.values.is_empty() || self.values.iter().all(|s| s.is_empty()) { + self.response.dump_response(); + panic!("Expected non-empty values, but got {:?}", self.values); + } + self + } + + pub fn value(&self) -> &str { + if let Some(value) = self.values.iter().find(|s| !s.is_empty()) { + value + } else { + self.response.dump_response(); + panic!("Expected a value, but got {:?}", self.values); + } + } + + pub fn with_status(&self, status: StatusCode) -> &Self { + if self.prop.status != status { + self.response.dump_response(); + panic!("Expected status {status}, but got {}", self.prop.status); + } + self + } + + pub fn with_description(&self, description: &str) -> &Self { + if self.prop.description.as_deref() != Some(description) { + self.response.dump_response(); + panic!( + "Expected description {description}, but got {:?}", + self.prop.description + ); + } + self + } + pub fn with_error(&self, error: &str) -> &Self { + if !self.prop.error.contains(&error.to_string()) { + self.response.dump_response(); + panic!("Expected error {error}, but got {:?}", self.prop.error); + } + self + } +} + +impl DavResponse { + pub fn into_propfind_response(mut self, prop_prefix: Option<&str>) -> DavMultiStatus { + if let Some(prop_prefix) = prop_prefix { + for (key, _) in self.xml.iter_mut() { + if let Some(suffix) = key.strip_prefix(prop_prefix) { + *key = format!("D:multistatus.D:response{suffix}"); + } + } + self.xml.push(( + "D:multistatus.D:response.D:href".to_string(), + "".to_string(), + )); + } + + let mut result = DavMultiStatus { + response: self, + hrefs: AHashMap::new(), + }; + let mut href = None; + let mut props = Vec::new(); + let mut prop = DavItem::default(); + + for (key, value) in &result.response.xml { + match key.as_str() { + "D:multistatus.D:response.D:href" => { + if let Some(href) = href.take() { + if !prop.is_empty() { + props.push(std::mem::take(&mut prop)); + } + result + .hrefs + .insert(href, DavProperties(std::mem::take(&mut props))); + } + href = Some(value.to_string()); + } + "D:multistatus.D:response.D:propstat.D:status" => { + prop.status = value + .split_ascii_whitespace() + .nth(1) + .unwrap_or_default() + .parse() + .unwrap(); + } + "D:multistatus.D:response.D:propstat.D:responsedescription" => { + prop.description = Some(value.to_string()); + } + + _ => { + if let Some(prop_name) = + key.strip_prefix("D:multistatus.D:response.D:propstat.D:prop.") + { + if prop.status != StatusCode::PROXY_AUTHENTICATION_REQUIRED { + props.push(std::mem::take(&mut prop)); + } + + let (prop_name, prop_value) = + if let Some((prop_name, prop_sub_name)) = prop_name.split_once('.') { + if value.is_empty() { + (prop_name, prop_sub_name.to_string()) + } else { + (prop_name, format!("{}:{}", prop_sub_name, value)) + } + } else { + (prop_name, value.to_string()) + }; + prop.values + .entry(prop_name.to_string()) + .or_default() + .push(prop_value); + } + } + } + } + + if let Some(href) = href.take() { + if !prop.is_empty() { + props.push(prop); + } + result.hrefs.insert(href, DavProperties(props)); + } + + result + } +} + +impl DummyWebDavClient { + pub async fn patch_and_check( + &self, + path: &str, + properties: impl IntoIterator, + ) where + T: AsRef + Clone, + { + let mut expect_set = Vec::new(); + let mut expect_remove = Vec::new(); + + for (key, value) in properties { + if !value.is_empty() { + expect_set.push((key, value)); + } else { + expect_remove.push(key); + } + } + + let response = self + .proppatch( + path, + expect_set.iter().cloned(), + expect_remove.iter().cloned(), + [], + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + let patch_prop = response.properties(path); + for (key, _) in &expect_set { + patch_prop.get(key.as_ref()).with_status(StatusCode::OK); + } + for key in &expect_remove { + patch_prop + .get(key.as_ref()) + .with_status(StatusCode::NO_CONTENT); + } + + let response = self + .propfind( + path, + expect_set + .iter() + .map(|(k, _)| k) + .chain(expect_remove.iter()), + ) + .await; + let prop = response.properties(path); + + for (key, value) in expect_set { + prop.get(key.as_ref()) + .with_values([value]) + .with_status(StatusCode::OK); + } + + for key in expect_remove { + prop.get(key.as_ref()).with_status(StatusCode::NOT_FOUND); + } + } + + pub async fn propfind(&self, path: &str, properties: I) -> DavMultiStatus + where + I: IntoIterator, + T: AsRef, + { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in properties { + request.push_str(&format!("<{}/>", property.as_ref())); + } + + request.push_str(""); + + self.request("PROPFIND", path, &request) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn proppatch( + &self, + path: &str, + set: impl IntoIterator, + clear: impl IntoIterator, + headers: impl IntoIterator, + ) -> DavResponse + where + T: AsRef, + { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in clear { + request.push_str(&format!("<{}/>", property.as_ref())); + } + + request.push_str(""); + + for (key, value) in set { + let key = key.as_ref(); + request.push_str(&format!("<{key}>{value}")); + } + + request.push_str(""); + + self.request_with_headers("PROPPATCH", path, headers, &request) + .await + } +} + +impl DavItem { + pub fn is_empty(&self) -> bool { + self.values.is_empty() + && self.status == StatusCode::PROXY_AUTHENTICATION_REQUIRED + && self.error.is_empty() + && self.description.is_none() + } +} + +impl Default for DavItem { + fn default() -> Self { + DavItem { + status: StatusCode::PROXY_AUTHENTICATION_REQUIRED, + values: AHashMap::new(), + error: Vec::new(), + description: None, + } + } +} + +const ALL_DAV_PROPERTIES: &[DavProperty] = &[ + DavProperty::WebDav(WebDavProperty::CreationDate), + DavProperty::WebDav(WebDavProperty::DisplayName), + DavProperty::WebDav(WebDavProperty::GetContentLanguage), + DavProperty::WebDav(WebDavProperty::GetContentLength), + DavProperty::WebDav(WebDavProperty::GetContentType), + DavProperty::WebDav(WebDavProperty::GetETag), + DavProperty::WebDav(WebDavProperty::GetLastModified), + DavProperty::WebDav(WebDavProperty::ResourceType), + DavProperty::WebDav(WebDavProperty::LockDiscovery), + DavProperty::WebDav(WebDavProperty::SupportedLock), + DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal), + DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes), + DavProperty::WebDav(WebDavProperty::QuotaUsedBytes), + DavProperty::WebDav(WebDavProperty::SupportedReportSet), + DavProperty::WebDav(WebDavProperty::SyncToken), + DavProperty::WebDav(WebDavProperty::Owner), + DavProperty::WebDav(WebDavProperty::Group), + DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet), + DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet), + DavProperty::WebDav(WebDavProperty::Acl), + DavProperty::WebDav(WebDavProperty::AclRestrictions), + DavProperty::WebDav(WebDavProperty::InheritedAclSet), + DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet), + DavProperty::WebDav(WebDavProperty::GetCTag), + DavProperty::CardDav(CardDavProperty::AddressbookDescription), + DavProperty::CardDav(CardDavProperty::SupportedAddressData), + DavProperty::CardDav(CardDavProperty::SupportedCollationSet), + DavProperty::CardDav(CardDavProperty::MaxResourceSize), + DavProperty::CalDav(CalDavProperty::CalendarDescription), + DavProperty::CalDav(CalDavProperty::CalendarTimezone), + DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet), + DavProperty::CalDav(CalDavProperty::SupportedCalendarData), + DavProperty::CalDav(CalDavProperty::SupportedCollationSet), + DavProperty::CalDav(CalDavProperty::MaxResourceSize), + DavProperty::CalDav(CalDavProperty::MinDateTime), + DavProperty::CalDav(CalDavProperty::MaxDateTime), + DavProperty::CalDav(CalDavProperty::MaxInstances), + DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance), + DavProperty::CalDav(CalDavProperty::TimezoneServiceSet), + DavProperty::CalDav(CalDavProperty::TimezoneId), + DavProperty::Principal(PrincipalProperty::AlternateURISet), + DavProperty::Principal(PrincipalProperty::PrincipalURL), + DavProperty::Principal(PrincipalProperty::GroupMemberSet), + DavProperty::Principal(PrincipalProperty::GroupMembership), + DavProperty::Principal(PrincipalProperty::CalendarHomeSet), + DavProperty::Principal(PrincipalProperty::AddressbookHomeSet), + DavProperty::Principal(PrincipalProperty::PrincipalAddress), +]; + +fn serialize_status_code(status_code: &StatusCode, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&status_code.to_string()) +} diff --git a/tests/src/webdav/put_get.rs b/tests/src/webdav/put_get.rs index aaeb44c6..2e110efe 100644 --- a/tests/src/webdav/put_get.rs +++ b/tests/src/webdav/put_get.rs @@ -151,24 +151,48 @@ pub async fn test(test: &WebDavTest) { // PUT requests cannot exceed quota let mike_noquota = test.client("mike"); - for (path, ct, content) in [ - ("/dav/file/mike/file1.txt", "text/plain", TEST_FILE_1), - ( - "/dav/card/mike/default/card1.vcf", - "text/vcard; charset=utf-8", - TEST_VCARD_1, - ), - ( - "/dav/cal/mike/default/event1.ics", - "text/calendar; charset=utf-8", - TEST_ICAL_1, - ), + for resource_type in [ + DavResourceName::File, + DavResourceName::Card, + DavResourceName::Cal, ] { + let path = format!("{}/mike/quota-test/", resource_type.base_path()); mike_noquota - .request_with_headers("PUT", path, [("content-type", ct)], content) + .mkcol("MKCOL", &path, [], []) .await - .with_status(StatusCode::PRECONDITION_FAILED) - .with_failed_precondition("D:quota-not-exceeded", ""); + .with_status(StatusCode::CREATED); + let mut num_success = 0; + let mut did_fail = false; + + for i in 0..100 { + let content = resource_type.generate(); + let available = mike_noquota.available_quota(&path).await; + + let response = mike_noquota + .request_with_headers("PUT", &format!("{path}file{i}"), [], &content) + .await; + if available > content.len() as u64 { + num_success += 1; + response.with_status(StatusCode::CREATED); + } else { + response + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition("D:quota-not-exceeded", ""); + did_fail = true; + break; + } + } + if !did_fail { + panic!("Quota test failed: {} files created", num_success); + } + if num_success == 0 { + panic!("Quota test failed: no files created"); + } + + mike_noquota + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); } // PUT precondition enforcement