WebDAV COPY/MOVE tests

This commit is contained in:
mdecimus
2025-05-01 16:35:07 +02:00
parent 91944162d9
commit 5416e35d4d
28 changed files with 1600 additions and 363 deletions

View File

@@ -26,6 +26,8 @@ jmap = { path = "../crates/jmap", features = ["test_mode", "enterprise"] }
jmap_proto = { path = "../crates/jmap-proto" }
imap = { path = "../crates/imap", features = ["test_mode"] }
imap_proto = { path = "../crates/imap-proto" }
dav = { path = "../crates/dav", features = ["test_mode"] }
dav-proto = { path = "../crates/dav-proto", features = ["test_mode"] }
groupware = { path = "../crates/groupware", features = ["test_mode"] }
http = { path = "../crates/http", features = ["test_mode", "enterprise"] }
http_proto = { path = "../crates/http-proto" }

View File

@@ -39,10 +39,6 @@ pub async fn test(test: &WebDavTest) {
.await
.match_many(
"D:multistatus.D:response.D:href",
[
"/dav/cal/",
"/dav/cal/jane/",
"/dav/cal/support%40example%2Ecom/",
],
["/dav/cal/", "/dav/cal/jane/", "/dav/cal/support/"],
);
}

View File

@@ -0,0 +1,752 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{DavResponse, WebDavTest};
use crate::webdav::GenerateTestDavResource;
use ahash::AHashSet;
use dav_proto::Depth;
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 COPY/MOVE 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());
let default_test_depth = if resource_type == DavResourceName::File {
2
} else {
0
};
// Obtain sync token
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()
);
// Create nested files and folders
let (hierarchy_root, mut hierarchy) = client
.create_hierarchy(&user_base_path, default_test_depth, 2, 3)
.await;
let prev_sync_token = response.sync_token();
let response = client
.sync_collection(
&user_base_path,
prev_sync_token,
Depth::Infinity,
["D:getetag"],
)
.await;
let sync_token = response.sync_token();
let changed_hrefs = response.hrefs();
assert_ne!(sync_token, prev_sync_token);
assert_eq!(
changed_hrefs,
hierarchy.iter().map(|x| x.0.as_str()).collect::<Vec<_>>(),
"lengths {} & {}",
changed_hrefs.len(),
hierarchy.len()
);
client.validate_values(&hierarchy).await;
// Copying and moving to the same or root containers is invalid
for method in ["COPY", "MOVE"] {
for destination in [
"/dav",
"/dav/cal",
"/dav/card",
"/dav/file",
"/dav/pal",
hierarchy_root.as_str(),
] {
client
.request_with_headers(
method,
&hierarchy_root,
[("destination", destination)],
"",
)
.await
.with_status(StatusCode::BAD_GATEWAY);
}
}
// Test 1: Rename container
let new_hierarchy_root = format!("{user_base_path}/Test_Folder/");
client
.request_with_headers(
"MOVE",
&hierarchy_root,
[("destination", new_hierarchy_root.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
replace_prefix(&mut hierarchy, &hierarchy_root, &new_hierarchy_root);
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
let hierarchy_root = new_hierarchy_root;
// Test 2: Copy container
let new_hierarchy_root = format!("{user_base_path}/Test_Folder_Copy/");
client
.request_with_headers(
"COPY",
&hierarchy_root,
[("destination", new_hierarchy_root.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
let mut copied_hierarchy = hierarchy.clone();
replace_prefix(&mut copied_hierarchy, &hierarchy_root, &new_hierarchy_root);
copied_hierarchy.extend_from_slice(&hierarchy);
assert_result(&response, &copied_hierarchy);
client.validate_values(&copied_hierarchy).await;
// Test 3: Delete original container
client
.request("DELETE", &new_hierarchy_root, "")
.await
.with_status(StatusCode::NO_CONTENT);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 4: Create a shallow container and overwrite the previous one using MOVE
let (new_hierarchy_root, mut hierarchy) =
client.create_hierarchy(&user_base_path, 0, 0, 3).await;
client
.request_with_headers(
"MOVE",
&new_hierarchy_root,
[("destination", hierarchy_root.as_str())],
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
replace_prefix(&mut hierarchy, &new_hierarchy_root, &hierarchy_root);
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 5: Create a deep container and overwrite the previous one using COPY
let (new_hierarchy_root, new_hierarchy) = client
.create_hierarchy(&user_base_path, default_test_depth, 1, 2)
.await;
client
.request_with_headers(
"COPY",
&new_hierarchy_root,
[("destination", hierarchy_root.as_str())],
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
let mut orig_hierarchy = new_hierarchy.clone();
replace_prefix(&mut orig_hierarchy, &new_hierarchy_root, &hierarchy_root);
let mut full_hierarchy = new_hierarchy.clone();
full_hierarchy.extend_from_slice(&orig_hierarchy);
assert_result(&response, &full_hierarchy);
client.validate_values(&full_hierarchy).await;
// Test 6: Copy and move containers to a shared account
let shared_hierarchy_root_1 = format!("{group_base_path}/Test_Shared_Folder_1/");
let shared_hierarchy_root_2 = format!("{group_base_path}/Test_Shared_Folder_2/");
client
.request_with_headers(
"MOVE",
&new_hierarchy_root,
[("destination", shared_hierarchy_root_1.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
client
.request_with_headers(
"COPY",
&hierarchy_root,
[("destination", shared_hierarchy_root_2.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &orig_hierarchy);
client.validate_values(&orig_hierarchy).await;
let response = client
.sync_collection(&group_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
replace_prefix(
&mut full_hierarchy,
&new_hierarchy_root,
&shared_hierarchy_root_1,
);
replace_prefix(
&mut full_hierarchy,
&hierarchy_root,
&shared_hierarchy_root_2,
);
assert_result(&response, &full_hierarchy);
client.validate_values(&full_hierarchy).await;
// Delete all containers
for shared_container in [
shared_hierarchy_root_1,
shared_hierarchy_root_2,
hierarchy_root,
] {
client
.request("DELETE", &shared_container, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
// Create test containers
let mut hierarchy = vec![];
for folder_name in ["folder1", "folder2", "folder3"] {
let folder_path = format!("{user_base_path}/{folder_name}/");
client
.mkcol("MKCOL", &folder_path, [], [])
.await
.with_status(StatusCode::CREATED);
for file_name in ["file1", "file2", "file3"] {
let file_path = format!("{folder_path}{file_name}");
let file_contents = resource_type.generate();
client
.request("PUT", &file_path, &file_contents)
.await
.with_status(StatusCode::CREATED);
hierarchy.push((file_path, file_contents));
}
hierarchy.push((folder_path, "".to_string()));
}
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 7: Copying or moving files to the root container is not allowed
let folder1_file1 = format!("{user_base_path}/folder1/file1");
if resource_type != DavResourceName::File {
for method in ["COPY", "MOVE"] {
client
.request_with_headers(
method,
&folder1_file1,
[("destination", user_base_path.as_str())],
"",
)
.await
.with_status(StatusCode::BAD_GATEWAY);
client
.request_with_headers(
method,
&folder1_file1,
[("destination", format!("{user_base_path}/folder2").as_str())],
"",
)
.await
.with_status(StatusCode::BAD_GATEWAY);
}
}
// Test 8: Copying or moving to the same location is not allowed
for method in ["COPY", "MOVE"] {
client
.request_with_headers(
method,
&folder1_file1,
[("destination", folder1_file1.as_str())],
"",
)
.await
.with_status(StatusCode::BAD_GATEWAY);
}
// Test 9: Rename file
let folder1_file1_new = format!("{user_base_path}/folder1/file1_new");
client
.request_with_headers(
"MOVE",
&folder1_file1,
[("destination", folder1_file1_new.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
rename(&mut hierarchy, &folder1_file1, &folder1_file1_new);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 10: Move a file under a different container
let folder2_file1_from_folder1 = format!("{user_base_path}/folder2/file1_from_folder1");
client
.request_with_headers(
"MOVE",
&folder1_file1_new,
[("destination", folder2_file1_from_folder1.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
rename(
&mut hierarchy,
&folder1_file1_new,
&folder2_file1_from_folder1,
);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 11: Move and overwrite a file under a different container
let folder1_file2 = format!("{user_base_path}/folder1/file2");
client
.request_with_headers(
"MOVE",
&folder2_file1_from_folder1,
[("destination", folder1_file2.as_str())],
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
delete(&mut hierarchy, &folder1_file2);
rename(&mut hierarchy, &folder2_file1_from_folder1, &folder1_file2);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 12: Copy a file under a different container
let file3_path = format!("{user_base_path}/folder1/file3");
let folder3_file3_from_folder1 = format!("{user_base_path}/folder3/file3_from_folder1");
client
.request_with_headers(
"COPY",
&file3_path,
[("destination", folder3_file3_from_folder1.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
copy(&mut hierarchy, &file3_path, &folder3_file3_from_folder1);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 12: Copy and overwrite a file under a different container
let folder2_file2 = format!("{user_base_path}/folder2/file2");
client
.request_with_headers(
"COPY",
&folder3_file3_from_folder1,
[("destination", folder2_file2.as_str())],
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
delete(&mut hierarchy, &folder2_file2);
copy(&mut hierarchy, &folder3_file3_from_folder1, &folder2_file2);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 13: Copy and move files to a shared container
let shared_hierarchy_root = format!("{group_base_path}/Test_Child_Folder/");
let folder3_file1 = format!("{user_base_path}/folder3/file1");
let shared_file_1 = format!("{shared_hierarchy_root}shared_file_1");
let shared_file_2 = format!("{shared_hierarchy_root}shared_file_2");
client
.mkcol("MKCOL", &shared_hierarchy_root, [], [])
.await
.with_status(StatusCode::CREATED);
client
.request_with_headers(
"MOVE",
&folder3_file1,
[("destination", shared_file_1.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
client
.request_with_headers(
"COPY",
&folder1_file2,
[("destination", shared_file_2.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
let shared_hierarchy = vec![
(shared_hierarchy_root.clone(), "".to_string()),
(
shared_file_1,
get_contents(&hierarchy, &folder3_file1).unwrap(),
),
(
shared_file_2,
get_contents(&hierarchy, &folder1_file2).unwrap(),
),
];
delete(&mut hierarchy, &folder3_file1);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
let response = client
.sync_collection(&group_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &shared_hierarchy);
client.validate_values(&shared_hierarchy).await;
client
.request("DELETE", &shared_hierarchy_root, "")
.await
.with_status(StatusCode::NO_CONTENT);
if resource_type == DavResourceName::File {
// Test 14: Move a container under a different container
let folder2 = format!("{user_base_path}/folder2/");
let folder3 = format!("{user_base_path}/folder3/");
let folder2_folder3 = format!("{user_base_path}/folder2/folder3/");
client
.request_with_headers(
"MOVE",
&folder3,
[("destination", folder2_folder3.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
replace_prefix(&mut hierarchy, &folder3, &folder2_folder3);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
// Test 15: Moving or copying a parent under a child is not allowed
for method in ["MOVE", "COPY"] {
client
.request_with_headers(
method,
&folder2_folder3,
[("destination", folder2.as_str())],
"",
)
.await
.with_status(StatusCode::BAD_GATEWAY);
}
// Test 16: Copy a container under a different container
let folder1 = format!("{user_base_path}/folder1/");
let folder2_folder1 = format!("{user_base_path}/folder2/folder1/");
client
.request_with_headers(
"COPY",
&folder1,
[("destination", folder2_folder1.as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
let response = client
.sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"])
.await;
copy_prefix(&mut hierarchy, &folder1, &folder2_folder1);
assert_result(&response, &hierarchy);
client.validate_values(&hierarchy).await;
} else {
// Test 17: UID collision
let folder1 = format!("{user_base_path}/folder1/");
let folder2 = format!("{user_base_path}/folder2/");
let file_contents = resource_type.generate();
for folder_path in [&folder1, &folder2] {
let file_path = format!("{folder_path}uid_test");
client
.request("PUT", &file_path, file_contents.as_str())
.await
.with_status(StatusCode::CREATED);
}
let uid_file_src = format!("{folder1}uid_test");
let uid_file_dest = format!("{folder2}uid_test_dup");
for method in ["COPY", "MOVE"] {
client
.request_with_headers(
method,
&uid_file_src,
[("destination", uid_file_dest.as_str())],
"",
)
.await
.with_status(StatusCode::PRECONDITION_FAILED)
.with_failed_precondition(
if resource_type == DavResourceName::Cal {
"A:no-uid-conflict.D:href"
} else {
"B:no-uid-conflict.D:href"
},
&format!("{folder2}uid_test"),
);
}
}
// Delete all containers and create a new one
client
.request("DELETE", &format!("{user_base_path}/folder3/"), "")
.await
.with_status(if resource_type == DavResourceName::File {
StatusCode::NOT_FOUND
} else {
StatusCode::NO_CONTENT
});
for folder in ["folder1", "folder2"] {
let folder_path = format!("{user_base_path}/{folder}/");
client
.request("DELETE", &folder_path, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
// Create a new test container and file
let test_base_path = format!("{user_base_path}/My_Test_Folder/");
client
.mkcol("MKCOL", &test_base_path, [], [])
.await
.with_status(StatusCode::CREATED);
let test_contents_1 = resource_type.generate();
let test_contents_2 = resource_type.generate();
let test_file1_path = format!("{test_base_path}test_file_1");
let test_file2_path = format!("{test_base_path}test_file_2");
let test_etag_1 = client
.request("PUT", &test_file1_path, test_contents_1.as_str())
.await
.with_status(StatusCode::CREATED)
.etag()
.to_string();
let test_etag_2 = client
.request("PUT", &test_file2_path, test_contents_2.as_str())
.await
.with_status(StatusCode::CREATED)
.etag()
.to_string();
// Test 18: Failed DAV preconditions
for method in ["COPY", "MOVE"] {
client
.request_with_headers(
method,
&test_file1_path,
[
("destination", test_file2_path.as_str()),
("overwrite", "F"),
],
"",
)
.await
.with_status(StatusCode::PRECONDITION_FAILED)
.with_empty_body();
client
.request_with_headers(
method,
&test_file1_path,
[
("destination", test_file2_path.as_str()),
("if-none-match", "*"),
],
"",
)
.await
.with_status(StatusCode::PRECONDITION_FAILED)
.with_empty_body();
let iff = format!(
"<{test_file1_path}> (Not [{test_etag_1}]) <{test_file2_path}> (Not [{test_etag_2}])",
);
client
.request_with_headers(
method,
&test_file1_path,
[
("destination", test_file2_path.as_str()),
("if", iff.as_str()),
],
"",
)
.await
.with_status(StatusCode::PRECONDITION_FAILED)
.with_empty_body();
}
// Test 18: Successful DAV preconditions
let iff =
format!("<{test_file1_path}> ([{test_etag_1}]) <{test_file2_path}> ([{test_etag_2}])",);
client
.request_with_headers(
"MOVE",
&test_file1_path,
[
("destination", test_file2_path.as_str()),
("if", iff.as_str()),
],
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
// Delete the test container
client
.request("DELETE", &test_base_path, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
client.delete_default_containers().await;
client.delete_default_containers_by_account("support").await;
test.assert_is_empty().await;
}
fn assert_result(response: &DavResponse, hierarchy: &[(String, String)]) {
assert!(!hierarchy.is_empty());
let response = response
.hrefs()
.into_iter()
.filter(|h| {
!h.ends_with("/jane/") && !h.ends_with("/support/") && !h.ends_with("/default/")
})
.collect::<AHashSet<_>>();
let hierarchy = hierarchy
.iter()
.map(|x| x.0.as_str())
.collect::<AHashSet<_>>();
if hierarchy != response {
println!("\nMissing: {:?}", hierarchy.difference(&response));
println!("\nExtra: {:?}", response.difference(&hierarchy));
panic!(
"Hierarchy mismatch: expected {} items, received {} items",
hierarchy.len(),
response.len()
);
}
}
fn replace_prefix(items: &mut [(String, String)], old_prefix: &str, new_prefix: &str) {
let mut did_replace = false;
for (href, _) in items.iter_mut() {
if let Some(value) = href.strip_prefix(old_prefix) {
*href = format!("{new_prefix}{value}");
did_replace = true;
}
}
if !did_replace {
panic!("Prefix not found: {}", old_prefix);
}
}
fn rename(items: &mut [(String, String)], old_name: &str, new_name: &str) {
for (href, _) in items.iter_mut() {
if href == old_name {
*href = new_name.to_string();
return;
}
}
panic!("Item not found: {}", old_name);
}
fn delete(items: &mut Vec<(String, String)>, name: &str) {
let mut did_delete = false;
items.retain(|(href, _)| {
did_delete = did_delete || href == name;
href != name
});
if !did_delete {
panic!("Item not found: {}", name);
}
}
fn copy(items: &mut Vec<(String, String)>, old_name: &str, new_name: &str) {
for (href, contents) in items.iter_mut() {
if href == old_name {
let value = (new_name.to_string(), contents.to_string());
items.push(value);
return;
}
}
panic!("Item not found: {}", old_name);
}
fn copy_prefix(items: &mut Vec<(String, String)>, old_prefix: &str, new_prefix: &str) {
let mut new_items = vec![];
for (href, contents) in items.iter() {
if let Some(value) = href.strip_prefix(old_prefix) {
new_items.push((format!("{new_prefix}{value}"), contents.to_string()));
}
}
if !new_items.is_empty() {
items.extend(new_items);
} else {
panic!("Prefix not found: {}", old_prefix);
}
}
fn get_contents(items: &[(String, String)], name: &str) -> Option<String> {
for (href, contents) in items.iter() {
if href == name {
return Some(contents.to_string());
}
}
None
}

View File

@@ -131,27 +131,27 @@ pub async fn test(test: &WebDavTest) {
["D:collection", "A:calendar"].as_slice(),
),
] {
let response = client
let mut response = client
.mkcol(
"MKCOL",
path,
resource_types.iter().copied(),
properties.iter().copied(),
)
.await;
response
.await
.with_status(StatusCode::CREATED)
.match_many("D:mkcol-response.D:propstat.D:status", ["HTTP/1.1 200 OK"]);
for (property, _) in properties {
response.match_one(
response = response.match_one(
&format!("D:mkcol-response.D:propstat.D:prop.{property}"),
"",
);
}
// Check the properties of the created collection
let response = client.propfind(path, properties.iter().map(|x| x.0)).await;
response
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(
@@ -159,7 +159,7 @@ pub async fn test(test: &WebDavTest) {
"HTTP/1.1 200 OK",
);
for (property, value) in properties {
response.match_one(
response = response.match_one(
&format!("D:multistatus.D:response.D:propstat.D:prop.{property}"),
value,
);

View File

@@ -21,7 +21,8 @@ use common::{
core::BuildServer,
manager::boot::build_ipc,
};
use groupware::hierarchy::DavHierarchy;
use dav_proto::Depth;
use groupware::{DavResourceName, hierarchy::DavHierarchy};
use http::HttpSessionManager;
use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION};
use imap::core::ImapSessionManager;
@@ -36,10 +37,12 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use store::rand::{Rng, distr::Alphanumeric, rng};
use tokio::sync::watch;
use utils::config::Config;
pub mod basic;
pub mod copy_move;
pub mod mkcol;
pub mod put_get;
@@ -327,13 +330,9 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest
}
}
store
.create_test_group(
"support@example.com",
"Support Group",
&["support@example.com"],
)
.create_test_group("support", "Support Group", &["support@example.com"])
.await;
store.add_to_group("jane", "support@example.com").await;
store.add_to_group("jane", "support").await;
WebDavTest {
server: inner.build_server(),
@@ -355,9 +354,10 @@ pub async fn webdav_tests() {
)
.await;
//basic::test(&handle).await;
//put_get::test(&handle).await;
basic::test(&handle).await;
put_get::test(&handle).await;
mkcol::test(&handle).await;
copy_move::test(&handle).await;
// Print elapsed time
let elapsed = start_time.elapsed();
@@ -498,15 +498,22 @@ impl DummyWebDavClient {
let mut request = concat!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
"<D:mkcol xmlns:D=\"DAV:\" xmlns:A=\"urn:ietf:params:xml:ns:caldav\" xmlns:B=\"urn:ietf:params:xml:ns:carddav\">",
"<D:set><D:prop><D:resourcetype>"
"<D:set><D:prop>"
)
.to_string();
for resource_type in resource_types {
let mut has_resource_type = false;
for (idx, resource_type) in resource_types.into_iter().enumerate() {
if idx == 0 {
request.push_str("<D:resourcetype>");
}
request.push_str(&format!("<{resource_type}/>"));
has_resource_type = true;
}
request.push_str("</D:resourcetype>");
if has_resource_type {
request.push_str("</D:resourcetype>");
}
for (key, value) in properties {
request.push_str(&format!("<{key}>{value}</{key}>"));
@@ -541,9 +548,151 @@ impl DummyWebDavClient {
self.request("PROPFIND", path, &request).await
}
pub async fn sync_collection(
&self,
path: &str,
sync_token: &str,
depth: Depth,
properties: impl IntoIterator<Item = &str>,
) -> DavResponse {
let mut request = concat!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
"<D:sync-collection xmlns:D=\"DAV:\" xmlns:A=\"urn:ietf:params:xml:ns:caldav\" xmlns:B=\"urn:ietf:params:xml:ns:carddav\">",
"<D:prop>"
)
.to_string();
for property in properties {
request.push_str(&format!("<{property}/>"));
}
request.push_str("</D:prop><D:sync-token>");
request.push_str(sync_token);
request.push_str("</D:sync-token><D:sync-level>");
request.push_str(match depth {
Depth::One => "1",
Depth::Infinity => "infinite",
_ => "0",
});
request.push_str("</D:sync-level></D:sync-collection>");
self.request("REPORT", path, &request)
.await
.with_status(StatusCode::MULTI_STATUS)
}
pub async fn create_hierarchy(
&self,
base_path: &str,
max_depth: usize,
containers_per_level: usize,
files_per_container: usize,
) -> (String, Vec<(String, String)>) {
let resource_type = if base_path.starts_with("/dav/card/") {
DavResourceName::Card
} else if base_path.starts_with("/dav/cal/") {
DavResourceName::Cal
} else {
DavResourceName::File
};
let mut created_resources = Vec::new();
self.create_hierarchy_recursive(
resource_type,
base_path,
max_depth,
containers_per_level,
files_per_container,
0,
&mut created_resources,
)
.await;
let root_folder = created_resources.first().unwrap().0.clone();
created_resources.sort_unstable_by(|a, b| a.0.cmp(&b.0));
(root_folder, created_resources)
}
#[allow(clippy::too_many_arguments)]
async fn create_hierarchy_recursive(
&self,
resource_type: DavResourceName,
base_path: &str,
max_depth: usize,
containers_per_level: usize,
files_per_container: usize,
current_depth: usize,
created_resources: &mut Vec<(String, String)>,
) {
let folder_name = generate_random_name(4);
let folder_path = format!("{base_path}/Folder_{folder_name}");
self.mkcol("MKCOL", &folder_path, [], [])
.await
.with_status(StatusCode::CREATED);
created_resources.push((format!("{folder_path}/"), "".to_string()));
for _ in 0..files_per_container {
let file_name = generate_random_name(8);
let file_path = format!(
"{folder_path}/{file_name}.{}",
match resource_type {
DavResourceName::Card => "vcf",
DavResourceName::Cal => "ics",
DavResourceName::File => "txt",
_ => unreachable!(),
}
);
let content = match resource_type {
DavResourceName::Card => generate_random_vcard(),
DavResourceName::Cal => generate_random_ical(),
DavResourceName::File => generate_random_content(100, 500),
_ => unreachable!(),
};
self.request("PUT", &file_path, &content)
.await
.with_status(StatusCode::CREATED);
created_resources.push((file_path, content));
}
if current_depth < max_depth {
for _ in 0..containers_per_level {
Box::pin(self.create_hierarchy_recursive(
resource_type,
&folder_path,
max_depth,
containers_per_level,
files_per_container,
current_depth + 1,
created_resources,
))
.await;
}
}
}
pub async fn validate_values(&self, items: &[(String, String)]) {
for (path, value) in items {
if !path.ends_with('/') {
self.request("GET", path, "")
.await
.with_status(StatusCode::OK)
.with_body(value);
}
}
}
pub async fn delete_default_containers(&self) {
self.delete_default_containers_by_account(self.name).await;
}
pub async fn delete_default_containers_by_account(&self, account: &str) {
for col in ["card", "cal"] {
self.request("DELETE", &format!("/dav/{col}/{}/default", self.name), "")
self.request("DELETE", &format!("/dav/{col}/{account}/default"), "")
.await
.with_status(StatusCode::NO_CONTENT);
}
@@ -551,7 +700,7 @@ impl DummyWebDavClient {
}
impl DavResponse {
pub fn with_status(&self, status: StatusCode) -> &Self {
pub fn with_status(self, status: StatusCode) -> Self {
if self.status != status {
self.dump_response();
panic!("Expected {status} but got {}", self.status)
@@ -559,12 +708,12 @@ impl DavResponse {
self
}
pub fn with_redirect_to(&self, url: &str) -> &Self {
pub fn with_redirect_to(self, url: &str) -> Self {
self.with_status(StatusCode::TEMPORARY_REDIRECT)
.with_header("location", url)
}
pub fn with_header(&self, header: &str, value: &str) -> &Self {
pub fn with_header(self, header: &str, value: &str) -> Self {
if self.headers.get(header).is_some_and(|v| v == value) {
self
} else {
@@ -573,7 +722,7 @@ impl DavResponse {
}
}
pub fn with_body(&self, expect_body: impl AsRef<str>) -> &Self {
pub fn with_body(self, expect_body: impl AsRef<str>) -> Self {
let expect_body = expect_body.as_ref();
if self.body.is_ok() {
let body = self.body.as_ref().unwrap();
@@ -588,6 +737,20 @@ impl DavResponse {
}
}
pub fn with_empty_body(self) -> Self {
if self.body.is_ok() {
let body = self.body.as_ref().unwrap();
if !body.is_empty() {
self.dump_response();
panic!("Expected empty body but got {body:?}");
}
self
} else {
self.dump_response();
panic!("Expected empty body but no body was returned.")
}
}
pub fn header(&self, header: &str) -> &str {
if let Some(value) = self.headers.get(header) {
value
@@ -601,6 +764,24 @@ impl DavResponse {
self.header("etag")
}
pub fn sync_token(&self) -> &str {
self.find_keys("D:multistatus.D:sync-token")
.next()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| {
self.dump_response();
panic!("Sync token not found.")
})
}
pub fn hrefs(&self) -> Vec<&str> {
let mut hrefs = self
.find_keys("D:multistatus.D:response.D:href")
.collect::<Vec<_>>();
hrefs.sort_unstable();
hrefs
}
fn dump_response(&self) {
eprintln!("-------------------------------------");
eprintln!("Status: {}", self.status);
@@ -625,7 +806,7 @@ impl DavResponse {
}
// Poor man's XPath
pub fn match_one(&self, query: &str, expect: impl AsRef<str>) -> &Self {
pub fn match_one(self, query: &str, expect: impl AsRef<str>) -> Self {
let expect = expect.as_ref();
if let Some(value) = self.find_keys(query).next() {
if value != expect {
@@ -639,7 +820,7 @@ impl DavResponse {
self
}
pub fn match_many<I, T>(&self, query: &str, expect: I) -> &Self
pub fn match_many<I, T>(self, query: &str, expect: I) -> Self
where
I: IntoIterator<Item = T>,
T: AsRef<str>,
@@ -654,7 +835,7 @@ impl DavResponse {
self
}
pub fn with_failed_precondition(&self, precondition: &str, value: &str) -> &Self {
pub fn with_failed_precondition(self, precondition: &str, value: &str) -> Self {
let error = format!("D:error.{precondition}");
if self.find_keys(&error).next().is_none_or(|v| v != value) {
self.dump_response();
@@ -825,3 +1006,123 @@ END:DAYLIGHT
END:VTIMEZONE
END:VCALENDAR
"#;
pub trait GenerateTestDavResource {
fn generate(&self) -> String;
}
impl GenerateTestDavResource for DavResourceName {
fn generate(&self) -> String {
match self {
DavResourceName::Card => generate_random_vcard(),
DavResourceName::Cal => generate_random_ical(),
DavResourceName::File => generate_random_content(100, 200),
_ => unreachable!(),
}
}
}
fn generate_random_vcard() -> String {
r#"BEGIN:VCARD
VERSION:4.0
UID:$UID
FN:$NAME
END:VCARD
"#
.replace("$UID", &generate_random_name(8))
.replace("$NAME", &generate_random_name(10))
.replace('\n', "\r\n")
}
fn generate_random_ical() -> String {
r#"BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:$UID
SUMMARY:$SUMMARY
DESCRIPTION:$DESCRIPTION
END:VEVENT
END:VCALENDAR
"#
.replace("$UID", &generate_random_name(8))
.replace("$SUMMARY", &generate_random_name(10))
.replace("$DESCRIPTION", &generate_random_name(20))
.replace('\n', "\r\n")
}
fn generate_random_content(min_chars: usize, max_chars: usize) -> String {
let mut rng = rng();
let length = rng.random_range(min_chars..=max_chars);
let words = [
"lorem",
"ipsum",
"dolor",
"sit",
"amet",
"consectetur",
"adipiscing",
"elit",
"sed",
"do",
"eiusmod",
"tempor",
"incididunt",
"ut",
"labore",
"et",
"dolore",
"magna",
"aliqua",
"ut",
"enim",
"ad",
"minim",
"veniam",
"quis",
"nostrud",
"exercitation",
"ullamco",
"laboris",
"nisi",
"ut",
"aliquip",
"ex",
"ea",
"commodo",
"consequat",
];
let mut content = String::with_capacity(length);
while content.len() < length {
let word_idx = rng.random_range(0..words.len());
if !content.is_empty() {
content.push(' ');
}
if rng.random_ratio(1, 10) {
content.push('.');
let word = words[word_idx];
let mut chars = word.chars();
if let Some(first_char) = chars.next() {
content.push_str(&first_char.to_uppercase().to_string());
content.push_str(chars.as_str());
}
} else {
content.push_str(words[word_idx]);
}
}
if !content.ends_with('.') {
content.push('.');
}
content
}
fn generate_random_name(length: usize) -> String {
let mut rng = rng();
(0..length)
.map(|_| rng.sample(Alphanumeric) as char)
.collect()
}

View File

@@ -136,12 +136,14 @@ pub async fn test(test: &WebDavTest) {
while chunky_contents.len() < max_size {
chunky_contents.push_str(contents);
}
let response = client.request("PUT", path, chunky_contents).await;
response.with_status(
expect
.map(|_| StatusCode::PRECONDITION_FAILED)
.unwrap_or(StatusCode::PAYLOAD_TOO_LARGE),
);
let response = client
.request("PUT", path, chunky_contents)
.await
.with_status(
expect
.map(|_| StatusCode::PRECONDITION_FAILED)
.unwrap_or(StatusCode::PAYLOAD_TOO_LARGE),
);
if let Some(expect) = expect {
response.with_failed_precondition(expect, &max_size.to_string());
}