diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 672169a9..57c7ed41 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -45,7 +45,9 @@ impl GroupwareConfig { live_property_size: config .property("dav.limits.size.live-property") .unwrap_or(250), - max_lock_timeout: config.property("dav.limits.timeout.max-lock").unwrap_or(60), + max_lock_timeout: config + .property("dav.limits.timeout.max-lock") + .unwrap_or(3600), max_locks_per_user: config .property("dav.limits.max-locks-per-user") .unwrap_or(10), diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs index fc686e04..154ac8f6 100644 --- a/crates/dav/src/common/lock.rs +++ b/crates/dav/src/common/lock.rs @@ -11,6 +11,7 @@ use dav_proto::schema::request::{DavPropertyValue, DeadProperty}; use dav_proto::schema::response::{BaseCondition, List, PropResponse}; use dav_proto::{Condition, Depth, Timeout}; use dav_proto::{RequestHeaders, schema::request::LockInfo}; +use groupware::hierarchy::DavHierarchy; use http_proto::HttpResponse; use hyper::StatusCode; use jmap_proto::types::collection::Collection; @@ -260,6 +261,13 @@ impl LockRequestHandler for Server { lock_item.expires = expires; if let LockRequest::Lock(lock_info) = lock_info { + // Validate lock_info + if lock_info.owner.as_ref().is_some_and(|o| { + o.size() > self.core.groupware.dead_property_size.unwrap_or(512) + }) { + return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE)); + } + lock_item.lock_id = store::rand::random::() ^ expires; lock_item.owner = access_token.primary_id; lock_item.depth_infinity = matches!(headers.depth, Depth::Infinity); @@ -270,15 +278,19 @@ impl LockRequestHandler for Server { let base_path = base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default()); let active_lock = lock_item.to_active_lock(format!("{base_path}/{resource_path}")); - HttpResponse::new(StatusCode::CREATED) - .with_lock_token(&active_lock.lock_token.as_ref().unwrap().0) - .with_xml_body( - PropResponse::new(vec![DavPropertyValue::new( - WebDavProperty::LockDiscovery, - vec![active_lock], - )]) - .to_string(), - ) + HttpResponse::new(if if_lock_token == 0 { + StatusCode::CREATED + } else { + StatusCode::OK + }) + .with_lock_token(&active_lock.lock_token.as_ref().unwrap().0) + .with_xml_body( + PropResponse::new(vec![DavPropertyValue::new( + WebDavProperty::LockDiscovery, + vec![active_lock], + )]) + .to_string(), + ) } else { let lock_id = headers .lock_token @@ -542,10 +554,14 @@ impl LockRequestHandler for Server { // Fetch sync token if needs_sync_token && resource_state.sync_token.is_none() { let change_id = self - .store() - .get_last_change_id(resource_state.account_id, resource_state.collection) + .fetch_dav_resources( + access_token, + resource_state.account_id, + resource_state.collection.main_collection(), + ) .await - .caused_by(trc::location!())?; + .caused_by(trc::location!())? + .modseq; resource_state.sync_token = Some(Urn::Sync(change_id.unwrap_or_default()).to_string()); } @@ -679,7 +695,7 @@ impl<'x> LockCaches<'x> { pub fn is_cached(&self, resource_state: &ResourceState<'_>) -> Option { self.caches.iter().position(|cache| { resource_state.account_id == cache.account_id - && resource_state.collection == cache.collection + && resource_state.collection.main_collection() == cache.collection.main_collection() }) } @@ -844,13 +860,13 @@ impl ArchivedLockItem { impl OwnedUri<'_> { pub fn lock_key(&self) -> Vec { - build_lock_key(self.account_id, self.collection) + build_lock_key(self.account_id, self.collection.main_collection()) } } impl ResourceState<'_> { pub fn lock_key(&self) -> Vec { - build_lock_key(self.account_id, self.collection) + build_lock_key(self.account_id, self.collection.main_collection()) } } diff --git a/tests/src/webdav/basic.rs b/tests/src/webdav/basic.rs index f3b3338b..5884226b 100644 --- a/tests/src/webdav/basic.rs +++ b/tests/src/webdav/basic.rs @@ -30,14 +30,14 @@ pub async fn test(test: &WebDavTest) { client .request("PROPFIND", "/.well-known/carddav", "") .await - .match_many( + .with_values( "D:multistatus.D:response.D:href", ["/dav/card/", "/dav/card/john/"], ); test.client("jane") .request("PROPFIND", "/.well-known/caldav", "") .await - .match_many( + .with_values( "D:multistatus.D:response.D:href", ["/dav/cal/", "/dav/cal/jane/", "/dav/cal/support/"], ); diff --git a/tests/src/webdav/lock.rs b/tests/src/webdav/lock.rs new file mode 100644 index 00000000..da113c3e --- /dev/null +++ b/tests/src/webdav/lock.rs @@ -0,0 +1,257 @@ +/* + * 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; +use dav_proto::schema::property::{DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &WebDavTest) { + let client = test.client("john"); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running LOCK/UNLOCK tests ({})...", + resource_type.base_path() + ); + let base_path = format!("{}/john", resource_type.base_path()); + + // Test 1: Creating a collection under an unmapped resource without providing a lock token should fail + let path = format!("{base_path}/do-not-write"); + let response = client + .lock_create(&path, "super-owner", true, "infinity", "Second-123") + .await + .with_status(StatusCode::CREATED); + let lock_token = response + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:owner.D:href", + "super-owner", + ) + .with_value("D:prop.D:lockdiscovery.D:activelock.D:depth", "infinity") + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:timeout", + "Second-123", + ) + .lock_token() + .to_string(); + + // Test 2: Refreshing a lock token with an invalid a lock token should fail + client + .lock_refresh(&path, "urn:stalwart:davlock:1234", "infinity", "Second-456") + .await + .with_status(StatusCode::PRECONDITION_FAILED); + + // Test 3: Refreshing a lock token with valid a lock token should succeed + client + .lock_refresh(&path, &lock_token, "infinity", "Second-456") + .await + .with_status(StatusCode::OK) + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:owner.D:href", + "super-owner", + ) + .with_value( + "D:prop.D:lockdiscovery.D:activelock.D:timeout", + "Second-456", + ); + + // Test 3: Creating a collection under an unmapped resource with a lock token should fail + client + .request_with_headers("MKCOL", &path, [], "") + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + + // Test 4: Creating a collection under a mapped resource with a lock token should succeed + client + .request_with_headers( + "MKCOL", + &path, + [("if", format!("(<{lock_token}>)").as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + + // Test 5: Creating a file under a locked resource without a lock token should fail + let file_path = format!("{path}/file.txt"); + let contents = resource_type.generate(); + client + .request("PUT", &file_path, &contents) + .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 with a lock token should succeed + client + .request_with_headers( + "PUT", + &file_path, + [("if", format!("(<{lock_token}>)").as_str())], + &contents, + ) + .await + .with_status(StatusCode::CREATED); + + // Test 7: Locks should be included in propfind responses + let response = client + .propfind(&path, [DavProperty::WebDav(WebDavProperty::LockDiscovery)]) + .await; + for href in [path.clone() + "/", file_path] { + let props = response.properties(&href); + props + .get(DavProperty::WebDav(WebDavProperty::LockDiscovery)) + .with_values([ + "D:activelock.D:owner.D:href:super-owner", + "D:activelock.D:timeout:Second-456", + "D:activelock.D:depth:infinity", + format!("D:activelock.D:locktoken.D:href:{lock_token}").as_str(), + format!("D:activelock.D:lockroot.D:href:{path}").as_str(), + "D:activelock.D:locktype.D:write", + "D:activelock.D:lockscope.D:exclusive", + ]); + } + + // Test 8: Delete with and without a lock token + client + .request("DELETE", &path, "") + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &path); + client + .request_with_headers( + "DELETE", + &path, + [("if", format!("(<{lock_token}>)").as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 9: Unlock with and without a lock token + client + .unlock(&path, "urn:stalwart:davlock:1234") + .await + .with_status(StatusCode::CONFLICT) + .with_value("D:error.D:lock-token-matches-request-uri", ""); + client + .unlock(&path, &lock_token) + .await + .with_status(StatusCode::NO_CONTENT); + + // Test 10: Locking with a large dead property should fail + let path = format!("{base_path}/invalid-lock"); + client + .lock_create( + &path, + (0..=test.server.core.groupware.dead_property_size.unwrap() + 1) + .map(|_| "a") + .collect::() + .as_str(), + true, + "infinity", + "Second-123", + ) + .await + .with_status(StatusCode::PAYLOAD_TOO_LARGE); + + // Test 11: Too many locks should fail + for i in 0..test.server.core.groupware.max_locks_per_user { + client + .lock_create( + &format!("{base_path}/invalid-lock-{i}"), + "super-owner", + true, + "infinity", + "Second-123", + ) + .await + .with_status(StatusCode::CREATED); + } + client + .lock_create( + &format!("{base_path}/invalid-lock-greedy"), + "super-owner", + true, + "infinity", + "Second-123", + ) + .await + .with_status(StatusCode::TOO_MANY_REQUESTS); + } + + client.delete_default_containers().await; + test.assert_is_empty().await; +} + +const LOCK_REQUEST: &str = r#" + + + + + $OWNER + + "#; + +impl DummyWebDavClient { + pub async fn lock_create( + &self, + path: &str, + owner: &str, + is_exclusive: bool, + depth: &str, + timeout: &str, + ) -> DavResponse { + let lock_request = LOCK_REQUEST + .replace("$TYPE", if is_exclusive { "exclusive" } else { "shared" }) + .replace("$OWNER", owner); + self.request_with_headers( + "LOCK", + path, + [("depth", depth), ("timeout", timeout)], + &lock_request, + ) + .await + } + + pub async fn lock_refresh( + &self, + path: &str, + lock_token: &str, + depth: &str, + timeout: &str, + ) -> DavResponse { + let condition = format!("(<{lock_token}>)"); + self.request_with_headers( + "LOCK", + path, + [ + ("if", condition.as_str()), + ("depth", depth), + ("timeout", timeout), + ], + "", + ) + .await + } + + pub async fn unlock(&self, path: &str, lock_token: &str) -> DavResponse { + let condition = format!("<{lock_token}>"); + self.request_with_headers("UNLOCK", path, [("lock-token", condition.as_str())], "") + .await + } +} + +impl DavResponse { + pub fn lock_token(&self) -> &str { + self.value("D:prop.D:lockdiscovery.D:activelock.D:locktoken.D:href") + } +} diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs index dd238708..9250bed8 100644 --- a/tests/src/webdav/mkcol.rs +++ b/tests/src/webdav/mkcol.rs @@ -94,11 +94,11 @@ pub async fn test(test: &WebDavTest) { .mkcol("MKCOL", path, ["D:collection", resource_type], []) .await .with_status(StatusCode::FORBIDDEN) - .match_one( + .with_value( "D:mkcol-response.D:propstat.D:error.D:valid-resourcetype", "", ) - .match_one("D:mkcol-response.D:propstat.D:prop.D:resourcetype", ""); + .with_value("D:mkcol-response.D:propstat.D:prop.D:resourcetype", ""); } // Create using extended MKCOL @@ -172,8 +172,8 @@ pub async fn test(test: &WebDavTest) { ) .await .with_status(StatusCode::CREATED) - .match_one("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "") - .match_many( + .with_value("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "") + .with_values( "A:mkcalendar-response.D:propstat.D:status", ["HTTP/1.1 200 OK"], ); diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index de5702f1..13db8285 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -46,9 +46,12 @@ use utils::config::Config; pub mod basic; pub mod copy_move; +pub mod lock; pub mod mkcol; +pub mod multiget; pub mod prop; pub mod put_get; +pub mod sync; const SERVER: &str = r#" [server] @@ -358,11 +361,32 @@ pub async fn webdav_tests() { ) .await; + /* + TODO: + + - Principals: + - PrincipalMatch + - PrincipalPropertySearch + - PrincipalSearchPropertySet + - Expand property + - ACLs: + - ACL Method + - AclPrincipalPropSet + + - Addressbook Query + - 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; // Print elapsed time let elapsed = start_time.elapsed(); @@ -784,8 +808,15 @@ impl DavResponse { .map(|(_, value)| value.as_str()) } + pub fn value(&self, name: &str) -> &str { + self.find_keys(name).next().unwrap_or_else(|| { + self.dump_response(); + panic!("Key {name} not found.") + }) + } + // Poor man's XPath - pub fn match_one(self, query: &str, expect: impl AsRef) -> Self { + pub fn with_value(self, query: &str, expect: impl AsRef) -> Self { let expect = expect.as_ref(); if let Some(value) = self.find_keys(query).next() { if value != expect { @@ -799,7 +830,7 @@ impl DavResponse { self } - pub fn match_many(self, query: &str, expect: I) -> Self + pub fn with_values(self, query: &str, expect: I) -> Self where I: IntoIterator, T: AsRef, diff --git a/tests/src/webdav/multiget.rs b/tests/src/webdav/multiget.rs new file mode 100644 index 00000000..124dd6c6 --- /dev/null +++ b/tests/src/webdav/multiget.rs @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::WebDavTest; +use crate::webdav::GenerateTestDavResource; +use dav_proto::schema::property::{CalDavProperty, CardDavProperty, DavProperty, WebDavProperty}; +use groupware::DavResourceName; +use hyper::StatusCode; + +const MULTIGET_CALENDAR: &str = r#" + + + + + + $PATH_1 + $PATH_2 + +"#; +const MULTIGET_ADDRESSBOOK: &str = r#" + + + + + + $PATH_1 + $PATH_2 + +"#; + +pub async fn test(test: &WebDavTest) { + let client = test.client("john"); + + for resource_type in [DavResourceName::Cal, DavResourceName::Card] { + println!( + "Running REPORT multiget tests ({})...", + resource_type.base_path() + ); + + let mut paths = Vec::new(); + for name in ["file1", "file2"] { + let contents = resource_type.generate(); + let path = format!("{}/john/default/{}", resource_type.base_path(), name); + let etag = client + .request("PUT", &path, contents.as_str()) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + paths.push((path, etag, contents)); + } + + if resource_type == DavResourceName::Cal { + let path = format!("{}/john", resource_type.base_path()); + let body = MULTIGET_CALENDAR + .replace("$PATH_1", &paths[0].0) + .replace("$PATH_2", &paths[1].0); + let response = client + .request("REPORT", &path, &body) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + for (path, etag, contents) in paths { + let props = response.properties(&path); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_values([etag.as_str()]); + props + .get(DavProperty::CalDav(CalDavProperty::CalendarData( + Default::default(), + ))) + .with_values([contents.as_str()]); + } + } else { + let path = format!("{}/john", resource_type.base_path()); + let body = MULTIGET_ADDRESSBOOK + .replace("$PATH_1", &paths[0].0) + .replace("$PATH_2", &paths[1].0); + let response = client + .request("REPORT", &path, &body) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + for (path, etag, contents) in paths { + let props = response.properties(&path); + props + .get(DavProperty::WebDav(WebDavProperty::GetETag)) + .with_values([etag.as_str()]); + props + .get(DavProperty::CardDav(CardDavProperty::AddressData( + Default::default(), + ))) + .with_values([contents.as_str()]); + } + } + } + + client.delete_default_containers().await; + test.assert_is_empty().await; +} diff --git a/tests/src/webdav/sync.rs b/tests/src/webdav/sync.rs new file mode 100644 index 00000000..54c7d38f --- /dev/null +++ b/tests/src/webdav/sync.rs @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::WebDavTest; +use crate::webdav::GenerateTestDavResource; +use dav_proto::Depth; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &WebDavTest) { + let client = test.client("john"); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running REPORT sync-collection tests ({})...", + resource_type.base_path() + ); + let user_base_path = format!("{}/john", resource_type.base_path()); + + // Test 1: Initial sync + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_eq!( + response.hrefs().len(), + if resource_type == DavResourceName::File { + 1 + } else { + 2 + }, + "{:?}", + response.hrefs() + ); + let sync_token_1 = response.sync_token().to_string(); + + // Test 2: No changes since last sync + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::Infinity, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), Vec::::new()); + + // Test 3: Create a collection and make sure it is synced + let new_collection = format!("{}/new-collection/", user_base_path); + client + .mkcol("MKCOL", &new_collection, [], []) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::Infinity, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), vec![new_collection.clone()]); + let sync_token_2 = response.sync_token().to_string(); + + // Test 4: Create a file and make sure it is synced + let new_file = format!("{new_collection}new-file"); + let contents = resource_type.generate(); + client + .request("PUT", &new_file, &contents) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection( + &user_base_path, + &sync_token_1, + Depth::Infinity, + ["D:getetag"], + ) + .await; + assert_eq!( + response.hrefs(), + vec![new_collection.clone(), new_file.clone()] + ); + let sync_token_3 = response.sync_token().to_string(); + let response = client + .sync_collection( + &user_base_path, + &sync_token_2, + Depth::Infinity, + ["D:getetag"], + ) + .await; + assert_eq!(response.hrefs(), vec![new_file.clone()]); + + // Test 5: sync-token with Depth 1 + let response = client + .sync_collection(&user_base_path, &sync_token_1, Depth::One, ["D:getetag"]) + .await; + assert_eq!(response.hrefs(), vec![new_collection.clone()]); + + // Test 6: sync-token with Depth 0 + let response = client + .sync_collection(&new_collection, &sync_token_1, Depth::Zero, ["D:getetag"]) + .await; + assert_eq!(response.hrefs(), vec![new_collection.clone()]); + + // Test 7: Outdated sync-token in If header should fail + let new_file2 = format!("{new_collection}new-file2"); + let contents = resource_type.generate(); + let condition = format!("(<{sync_token_2}>)"); + client + .request_with_headers( + "PUT", + &new_file2, + [("if", condition.as_str())], + contents.as_str(), + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + + // Test 8: Correct sync-token in If header should work + let condition = format!("(<{sync_token_3}>)"); + client + .request_with_headers( + "PUT", + &new_file2, + [("if", condition.as_str())], + contents.as_str(), + ) + .await + .with_status(StatusCode::CREATED) + .with_empty_body(); + + client + .request("DELETE", &new_collection, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + client.delete_default_containers().await; + test.assert_is_empty().await; +}