Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
||||
use dav_proto::schema::property::{DavProperty, WebDavProperty};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let owner_client = test.account("[email protected]").webdav_client();
|
||||
let sharee_client = test.account("[email protected]").webdav_client();
|
||||
|
||||
for resource_type in [
|
||||
DavResourceName::File,
|
||||
DavResourceName::Cal,
|
||||
DavResourceName::Card,
|
||||
] {
|
||||
println!("Running ACL tests ({})...", resource_type.base_path());
|
||||
let is_file = resource_type == DavResourceName::File;
|
||||
let sharee_principal = format!(
|
||||
"{}/john%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
);
|
||||
let sharee_base_path = format!("{}/john%40example.com/", resource_type.base_path());
|
||||
let owner_principal = format!(
|
||||
"{}/bill%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
);
|
||||
let owner_base_path = format!("{}/bill%40example.com/", resource_type.base_path());
|
||||
|
||||
// Create a resource for the owner
|
||||
let owner_folder = format!("{owner_base_path}test-shared/");
|
||||
let owner_folder_private = format!("{owner_base_path}test-private/");
|
||||
let owner_file = format!("{owner_folder}test-file");
|
||||
let owner_file_content = resource_type.generate();
|
||||
let owner_file_private = format!("{owner_folder_private}test-file-private");
|
||||
let owner_file_content_private = resource_type.generate();
|
||||
let sharee_created_file = format!("{owner_folder}test-file-sharee");
|
||||
for (folder, file, content) in [
|
||||
(&owner_folder, &owner_file, &owner_file_content),
|
||||
(
|
||||
&owner_folder_private,
|
||||
&owner_file_private,
|
||||
&owner_file_content_private,
|
||||
),
|
||||
] {
|
||||
owner_client
|
||||
.request("MKCOL", folder, "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
owner_client
|
||||
.request("PUT", file, content)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// Create a resource for the sharee
|
||||
let sharee_folder = format!("{sharee_base_path}test-folder/");
|
||||
let sharee_file = format!("{sharee_folder}test-file");
|
||||
let sharee_file_content = resource_type.generate();
|
||||
sharee_client
|
||||
.request("MKCOL", &sharee_folder, "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
sharee_client
|
||||
.request("PUT", &sharee_file, &sharee_file_content)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
|
||||
// Test 1: Sharee should only see their own resources
|
||||
sharee_client
|
||||
.propfind_with_headers(
|
||||
resource_type.collection_path(),
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
[("prefer", "depth-noroot")],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([sharee_base_path.as_str()]);
|
||||
|
||||
// Test 2: Share a resource and make sure the root folder is visible
|
||||
owner_client
|
||||
.acl(&owner_folder, sharee_principal.as_str(), ["read"])
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
if is_file {
|
||||
owner_client
|
||||
.acl(&owner_file, sharee_principal.as_str(), ["read"])
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
sharee_client
|
||||
.propfind_with_headers(
|
||||
resource_type.collection_path(),
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
[("prefer", "depth-noroot")],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([sharee_base_path.as_str(), owner_base_path.as_str()]);
|
||||
|
||||
// Test 3: Verify that only the shared resource is visible
|
||||
sharee_client
|
||||
.propfind_with_headers(
|
||||
&owner_base_path,
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
[("prefer", "depth-noroot")],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([owner_folder.as_str()]);
|
||||
|
||||
// Test 4: Verify that the sharee can access the shared resource
|
||||
sharee_client
|
||||
.propfind(
|
||||
&owner_folder,
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([owner_folder.as_str(), owner_file.as_str()]);
|
||||
sharee_client
|
||||
.request("GET", &owner_file, "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_body(&owner_file_content);
|
||||
match resource_type {
|
||||
DavResourceName::Cal => {
|
||||
sharee_client
|
||||
.multiget_calendar(&owner_folder, &[&owner_file])
|
||||
.await
|
||||
.properties(&owner_file)
|
||||
.with_status(StatusCode::OK)
|
||||
.is_defined(DavProperty::WebDav(WebDavProperty::GetETag));
|
||||
sharee_client
|
||||
.request("REPORT", &owner_folder, CALENDAR_QUERY_ANY_VEVENT)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs([owner_file.as_str()]);
|
||||
}
|
||||
DavResourceName::Card => {
|
||||
sharee_client
|
||||
.multiget_addressbook(&owner_folder, &[&owner_file])
|
||||
.await
|
||||
.properties(&owner_file)
|
||||
.with_status(StatusCode::OK)
|
||||
.is_defined(DavProperty::WebDav(WebDavProperty::GetETag));
|
||||
sharee_client
|
||||
.request("REPORT", &owner_folder, ADDRESSBOOK_QUERY_ANY_FN)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs([owner_file.as_str()]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Test 5: Read ACL as owner
|
||||
let response = owner_client
|
||||
.propfind(&owner_folder, [DavProperty::WebDav(WebDavProperty::Acl)])
|
||||
.await;
|
||||
response
|
||||
.properties(&owner_folder)
|
||||
.get(DavProperty::WebDav(WebDavProperty::Acl))
|
||||
.with_values([
|
||||
format!("D:ace.D:principal.D:href:{sharee_principal}").as_str(),
|
||||
"D:ace.D:grant.D:privilege.D:read",
|
||||
"D:ace.D:grant.D:privilege.D:read-current-user-privilege-set",
|
||||
]);
|
||||
|
||||
// Test 6: acl-principal-prop-set REPORT
|
||||
let response = owner_client
|
||||
.request("REPORT", &owner_folder, ACL_PRINCIPAL_QUERY)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.into_propfind_response(None);
|
||||
response
|
||||
.properties(&sharee_principal)
|
||||
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
|
||||
.with_values(["John Doe"]);
|
||||
|
||||
// Test 7: Verify current-user-privilege-set and owner
|
||||
let response = sharee_client
|
||||
.propfind(
|
||||
&owner_folder,
|
||||
[
|
||||
DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet),
|
||||
DavProperty::WebDav(WebDavProperty::Owner),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
for href in [owner_folder.as_str(), owner_file.as_str()] {
|
||||
let props = response.properties(href);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet))
|
||||
.with_values([
|
||||
"D:privilege.D:read",
|
||||
"D:privilege.D:read-current-user-privilege-set",
|
||||
]);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::Owner))
|
||||
.with_values([format!("D:href:{owner_principal}").as_str()]);
|
||||
}
|
||||
|
||||
// Test 8: Write operations should fail
|
||||
for (path, dest, dest_copy) in [
|
||||
(
|
||||
&owner_folder,
|
||||
&sharee_folder,
|
||||
Some(format!("{sharee_base_path}copied/")),
|
||||
),
|
||||
(&owner_file, &sharee_file, None),
|
||||
] {
|
||||
sharee_client
|
||||
.proppatch(
|
||||
path,
|
||||
[(DavProperty::WebDav(WebDavProperty::DisplayName), "test")],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
sharee_client
|
||||
.request("DELETE", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
sharee_client
|
||||
.request_with_headers("MOVE", path, [("destination", dest.as_str())], "")
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
if let Some(dest_copy) = dest_copy {
|
||||
sharee_client
|
||||
.request_with_headers("COPY", path, [("destination", dest_copy.as_str())], "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
}
|
||||
}
|
||||
sharee_client
|
||||
.request("PUT", &owner_file, resource_type.generate())
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
sharee_client
|
||||
.request("PUT", &sharee_created_file, resource_type.generate())
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
|
||||
// Test 9: Grant write access to the sharee
|
||||
owner_client
|
||||
.acl(
|
||||
&owner_folder,
|
||||
sharee_principal.as_str(),
|
||||
["read", "write-content", "write-properties"],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
if is_file {
|
||||
owner_client
|
||||
.acl(
|
||||
&owner_file,
|
||||
sharee_principal.as_str(),
|
||||
["read", "write-content", "write-properties"],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
let response = owner_client
|
||||
.propfind(&owner_folder, [DavProperty::WebDav(WebDavProperty::Acl)])
|
||||
.await;
|
||||
response
|
||||
.properties(&owner_folder)
|
||||
.get(DavProperty::WebDav(WebDavProperty::Acl))
|
||||
.with_values([
|
||||
format!("D:ace.D:principal.D:href:{sharee_principal}").as_str(),
|
||||
"D:ace.D:grant.D:privilege.D:read",
|
||||
"D:ace.D:grant.D:privilege.D:read-current-user-privilege-set",
|
||||
"D:ace.D:grant.D:privilege.D:write-content",
|
||||
"D:ace.D:grant.D:privilege.D:write-properties",
|
||||
]);
|
||||
let response = sharee_client
|
||||
.propfind(
|
||||
&owner_folder,
|
||||
[DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)],
|
||||
)
|
||||
.await;
|
||||
for href in [owner_folder.as_str(), owner_file.as_str()] {
|
||||
response
|
||||
.properties(href)
|
||||
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet))
|
||||
.with_values([
|
||||
"D:privilege.D:read",
|
||||
"D:privilege.D:read-current-user-privilege-set",
|
||||
"D:privilege.D:write-content",
|
||||
"D:privilege.D:write-properties",
|
||||
]);
|
||||
}
|
||||
|
||||
// Test 10: Delete operations should fail
|
||||
for (path, dest) in [(&owner_folder, &sharee_folder), (&owner_file, &sharee_file)] {
|
||||
sharee_client
|
||||
.proppatch(
|
||||
path,
|
||||
[(DavProperty::WebDav(WebDavProperty::DisplayName), "test")],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS);
|
||||
sharee_client
|
||||
.request("DELETE", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
sharee_client
|
||||
.request_with_headers("MOVE", path, [("destination", dest.as_str())], "")
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
}
|
||||
sharee_client
|
||||
.request("PUT", &owner_file, &owner_file_content)
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
sharee_client
|
||||
.request("PUT", &sharee_created_file, resource_type.generate())
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
|
||||
// Test 11: Grant delete access to the sharee and verify
|
||||
owner_client
|
||||
.acl(&owner_folder, sharee_principal.as_str(), ["read", "write"])
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
if is_file {
|
||||
owner_client
|
||||
.acl(&owner_file, sharee_principal.as_str(), ["read", "write"])
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
owner_client
|
||||
.acl(
|
||||
&sharee_created_file,
|
||||
sharee_principal.as_str(),
|
||||
["read", "write"],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
sharee_client
|
||||
.request_with_headers(
|
||||
"MOVE",
|
||||
&owner_file,
|
||||
[("destination", sharee_file.as_str())],
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
sharee_client
|
||||
.request("DELETE", &sharee_created_file, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
sharee_client
|
||||
.request("DELETE", &owner_folder, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
|
||||
// Test 12: Share and unshare a resource
|
||||
owner_client
|
||||
.acl(&owner_folder_private, sharee_principal.as_str(), ["read"])
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
sharee_client
|
||||
.propfind_with_headers(
|
||||
resource_type.collection_path(),
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
[("prefer", "depth-noroot")],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([sharee_base_path.as_str(), owner_base_path.as_str()]);
|
||||
sharee_client
|
||||
.propfind_with_headers(
|
||||
&owner_base_path,
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
[("prefer", "depth-noroot")],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([owner_folder_private.as_str()]);
|
||||
owner_client
|
||||
.acl(&owner_folder_private, sharee_principal.as_str(), [])
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
sharee_client
|
||||
.propfind_with_headers(
|
||||
resource_type.collection_path(),
|
||||
[DavProperty::WebDav(WebDavProperty::GetETag)],
|
||||
[("prefer", "depth-noroot")],
|
||||
)
|
||||
.await
|
||||
.with_hrefs([sharee_base_path.as_str()]);
|
||||
|
||||
// Delete resources
|
||||
owner_client
|
||||
.request("DELETE", &owner_folder_private, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
sharee_client
|
||||
.request("DELETE", &sharee_folder, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
sharee_client
|
||||
.request("DELETE", &format!("{sharee_base_path}copied/"), "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
sharee_client.delete_default_containers().await;
|
||||
owner_client.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
const ACL_PRINCIPAL_QUERY: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:acl-principal-prop-set xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
</D:prop>
|
||||
</D:acl-principal-prop-set>"#;
|
||||
|
||||
const CALENDAR_QUERY_ANY_VEVENT: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop><D:getetag/></D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT"/>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"#;
|
||||
|
||||
const ADDRESSBOOK_QUERY_ANY_FN: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop><D:getetag/></D:prop>
|
||||
<C:filter>
|
||||
<C:prop-filter name="FN"/>
|
||||
</C:filter>
|
||||
</C:addressbook-query>"#;
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
use dav_proto::Depth;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running basic tests...");
|
||||
let john = test.account("[email protected]").webdav_client();
|
||||
let jane = test.account("[email protected]").webdav_client();
|
||||
|
||||
// Test OPTIONS request
|
||||
john.request("OPTIONS", "/dav/file", "")
|
||||
.await
|
||||
.with_header(
|
||||
"dav",
|
||||
concat!(
|
||||
"1, 2, 3, access-control, extended-mkcol, calendar-access, ",
|
||||
"calendar-auto-schedule, calendar-no-timezone, addressbook"
|
||||
),
|
||||
)
|
||||
.with_header(
|
||||
"allow",
|
||||
concat!(
|
||||
"OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, ",
|
||||
"MKCALENDAR, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, ACL"
|
||||
),
|
||||
);
|
||||
|
||||
// Test Discovery
|
||||
john.request("PROPFIND", "/.well-known/carddav", "")
|
||||
.await
|
||||
.with_values(
|
||||
"D:multistatus.D:response.D:href",
|
||||
["/dav/card/", "/dav/card/john%40example.com/"],
|
||||
);
|
||||
jane.request("PROPFIND", "/.well-known/caldav", "")
|
||||
.await
|
||||
.with_values(
|
||||
"D:multistatus.D:response.D:href",
|
||||
[
|
||||
"/dav/cal/",
|
||||
"/dav/cal/jane%40example.com/",
|
||||
"/dav/cal/support%40example.com/",
|
||||
],
|
||||
);
|
||||
|
||||
// Test 404 responses
|
||||
jane.sync_collection(
|
||||
"/dav/cal/jane%40example.com/default/",
|
||||
"",
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await;
|
||||
jane.sync_collection(
|
||||
"/dav/cal/jane%40example.com/test-404/",
|
||||
"",
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await;
|
||||
jane.request("PROPFIND", "/dav/cal/jane%40example.com/default/", "")
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS);
|
||||
jane.request(
|
||||
"REPORT",
|
||||
"/dav/cal/jane%40example.com/default/",
|
||||
concat!(
|
||||
r#"<CAL:calendar-query xmlns="DAV:" "#,
|
||||
r#"xmlns:CAL="urn:ietf:params:xml:ns:caldav"><prop><getetag />"#,
|
||||
r#"</prop><CAL:filter><CAL:comp-filter name="VCALENDAR">"#,
|
||||
r#"<CAL:comp-filter name="VTODO" /></CAL:comp-filter></CAL:filter>"#,
|
||||
r#"</CAL:calendar-query>"#
|
||||
),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS);
|
||||
jane.request(
|
||||
"REPORT",
|
||||
"/dav/cal/jane%40example.com/test-404/",
|
||||
concat!(
|
||||
r#"<CAL:calendar-query xmlns="DAV:" "#,
|
||||
r#"xmlns:CAL="urn:ietf:params:xml:ns:caldav"><prop><getetag />"#,
|
||||
r#"</prop><CAL:filter><CAL:comp-filter name="VCALENDAR">"#,
|
||||
r#"<CAL:comp-filter name="VTODO" /></CAL:comp-filter></CAL:filter>"#,
|
||||
r#"</CAL:calendar-query>"#
|
||||
),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS);
|
||||
jane.request("PROPFIND", "/dav/cal/jane%40example.com/test-404/", "")
|
||||
.await
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
|
||||
john.delete_default_containers().await;
|
||||
jane.delete_default_containers().await;
|
||||
jane.delete_default_containers_by_account("[email protected]")
|
||||
.await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
use email::cache::MessageCacheFetch;
|
||||
use hyper::StatusCode;
|
||||
use mail_parser::{DateTime, MessageParser};
|
||||
use store::write::now;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running calendar e-mail alarms tests...");
|
||||
let account = test.account("[email protected]");
|
||||
let client = account.webdav_client();
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
"/dav/cal/john%40example.com/default/its-alarming-how-charming-i-feel.ics",
|
||||
[("content-type", "text/calendar; charset=utf-8")],
|
||||
TEST_ALARM_1.replace(
|
||||
"$START",
|
||||
&DateTime::from_timestamp(now() as i64 + 5)
|
||||
.to_rfc3339()
|
||||
.replace(['-', ':'], ""),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
|
||||
// Check that the alarm was sent
|
||||
let messages = test
|
||||
.server
|
||||
.get_cached_messages(client.account_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(messages.emails.items.len(), 2);
|
||||
|
||||
for (idx, message) in messages.emails.items.iter().enumerate() {
|
||||
let contents = test
|
||||
.fetch_email(client.account_id, message.document_id)
|
||||
.await;
|
||||
|
||||
let message = MessageParser::new().parse(&contents).unwrap();
|
||||
let contents = message
|
||||
.html_bodies()
|
||||
.next()
|
||||
.unwrap()
|
||||
.text_contents()
|
||||
.unwrap();
|
||||
|
||||
if idx == 0 {
|
||||
// First alarm does not have a summary or description
|
||||
assert!(
|
||||
contents.contains("See the pretty girl in that mirror there"),
|
||||
"failed for {contents}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("What mirror where?!"),
|
||||
"failed for {contents}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
contents.contains("I feel pretty and witty and gay"),
|
||||
"failed for {contents}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("It's alarming how charming I feel."),
|
||||
"failed for {contents}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
contents.contains(concat!(
|
||||
"/dav/cal/john%40example.com/default/",
|
||||
"its-alarming-how-charming-i-feel.ics"
|
||||
)),
|
||||
"failed for {contents}"
|
||||
);
|
||||
|
||||
// The logo is an inline part, so the template must reference it by cid: URI
|
||||
let html = message.html_bodies().next().unwrap().contents().to_vec();
|
||||
let html = String::from_utf8(html).unwrap();
|
||||
assert!(
|
||||
html.contains("src=\"cid:logo."),
|
||||
"alarm logo must be referenced as a cid: URI: {html}"
|
||||
);
|
||||
|
||||
// A conference URI is rendered as a hyperlink
|
||||
assert!(
|
||||
html.contains("href=\"https://meet.example.com/west-side\""),
|
||||
"alarm must link the conference URI: {html}"
|
||||
);
|
||||
|
||||
if let Some(out_dir) = super::template_out_dir() {
|
||||
let path = out_dir.join(format!("alarm_template_{idx}.html"));
|
||||
std::fs::write(&path, &html).expect("Failed to write alarm template to file");
|
||||
println!("Alarm template {idx} -> {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
client.delete_default_containers().await;
|
||||
test.destroy_all_mailboxes(account).await;
|
||||
test.assert_is_empty().await
|
||||
}
|
||||
|
||||
const TEST_ALARM_1: &str = r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID: 2371c2d9-a136-43b0-bba3-f6ab249ad46e
|
||||
SUMMARY:See the pretty girl in that mirror there
|
||||
DESCRIPTION:What mirror where?!
|
||||
DTSTART:$START
|
||||
DTEND;TZID=America/New_York:21250221T180000
|
||||
LOCATION:West Side
|
||||
CONFERENCE;VALUE=URI;FEATURE=VIDEO:https://meet.example.com/west-side
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-P2S
|
||||
ACTION:EMAIL
|
||||
ATTENDEE:mailto:[email protected]
|
||||
SUMMARY:I feel pretty and witty and gay
|
||||
DESCRIPTION:I feel charming, Oh, so charming, It's alarming how charming I feel.
|
||||
END:VALARM
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-P4S
|
||||
ACTION:EMAIL
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"#;
|
||||
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use ahash::AHashMap;
|
||||
use calcard::{
|
||||
common::{IanaString, PartialDateTime},
|
||||
icalendar::{ICalendar, ICalendarProperty, ICalendarValue},
|
||||
};
|
||||
use groupware::scheduling::{
|
||||
ItipMessage, ItipSummary,
|
||||
event_cancel::itip_cancel,
|
||||
event_create::itip_create,
|
||||
event_update::itip_update,
|
||||
inbound::{MergeResult, itip_import_message, itip_merge_changes, itip_process_message},
|
||||
itip::itip_set_unreachable_status,
|
||||
snapshot::itip_snapshot,
|
||||
};
|
||||
use std::{collections::hash_map::Entry, path::PathBuf};
|
||||
|
||||
struct Test {
|
||||
test_name: String,
|
||||
command: Command,
|
||||
line_num: usize,
|
||||
parameters: Vec<String>,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Command {
|
||||
Put,
|
||||
Get,
|
||||
Delete(bool),
|
||||
Expect,
|
||||
Send,
|
||||
Reset,
|
||||
Itip,
|
||||
}
|
||||
|
||||
pub fn test() {
|
||||
for entry in std::fs::read_dir(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("itip"),
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != "txt") {
|
||||
continue;
|
||||
}
|
||||
let file_name = path.file_name().unwrap().to_str().unwrap();
|
||||
let rules = std::fs::read_to_string(&path).unwrap();
|
||||
let mut last_comment = "";
|
||||
let mut last_command = "";
|
||||
let mut last_line_num = 0;
|
||||
let mut payload = String::new();
|
||||
let mut commands = Vec::new();
|
||||
|
||||
for (line_num, line) in rules.lines().enumerate() {
|
||||
if line.starts_with('#') {
|
||||
last_comment = line.trim_start_matches('#').trim();
|
||||
} else if let Some(command) = line.strip_prefix("> ") {
|
||||
last_command = command.trim();
|
||||
last_line_num = line_num;
|
||||
} else if !line.is_empty() {
|
||||
payload.push_str(line);
|
||||
payload.push('\n');
|
||||
} else {
|
||||
if last_command.is_empty() && payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut command_and_args = last_command.split_whitespace();
|
||||
let command = match command_and_args
|
||||
.next()
|
||||
.expect("Command should not be empty")
|
||||
{
|
||||
"put" => Command::Put,
|
||||
"get" => Command::Get,
|
||||
"expect" => Command::Expect,
|
||||
"send" => Command::Send,
|
||||
"delete" => Command::Delete(false),
|
||||
"delete-force-send" => Command::Delete(true),
|
||||
"reset" => Command::Reset,
|
||||
"itip" => Command::Itip,
|
||||
_ => panic!("Unknown command: {}", last_command),
|
||||
};
|
||||
|
||||
commands.push(Test {
|
||||
command,
|
||||
test_name: last_comment.to_string(),
|
||||
line_num: last_line_num,
|
||||
parameters: command_and_args.map(String::from).collect(),
|
||||
payload: payload.trim().to_string(),
|
||||
});
|
||||
|
||||
last_command = "";
|
||||
last_line_num = 0;
|
||||
payload.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if commands.is_empty() {
|
||||
panic!("No commands found in file: {}", file_name);
|
||||
} else if !last_command.is_empty() {
|
||||
panic!(
|
||||
"File ended with command '{}' at line {} without payload",
|
||||
last_command, last_line_num
|
||||
);
|
||||
}
|
||||
|
||||
println!("====== Running test: {} ======", file_name);
|
||||
|
||||
let mut store: AHashMap<String, AHashMap<String, ICalendar>> = AHashMap::new();
|
||||
let mut dtstamp_map: AHashMap<PartialDateTime, usize> = AHashMap::new();
|
||||
let mut last_itip = None;
|
||||
|
||||
for command in &commands {
|
||||
if command.command != Command::Put {
|
||||
println!("{} (line {})", command.test_name, command.line_num);
|
||||
}
|
||||
match command.command {
|
||||
Command::Put => {
|
||||
let account = command
|
||||
.parameters
|
||||
.first()
|
||||
.expect("Account parameter is required");
|
||||
let name = command
|
||||
.parameters
|
||||
.get(1)
|
||||
.expect("Name parameter is required");
|
||||
let mut ical = ICalendar::parse(&command.payload)
|
||||
.expect("Failed to parse iCalendar payload");
|
||||
match store
|
||||
.entry(account.to_string())
|
||||
.or_default()
|
||||
.entry(name.to_string())
|
||||
{
|
||||
Entry::Occupied(mut entry) => {
|
||||
last_itip = Some(itip_update(
|
||||
&mut ical,
|
||||
entry.get_mut(),
|
||||
std::slice::from_ref(account),
|
||||
));
|
||||
itip_set_unreachable_status(&mut ical, std::slice::from_ref(account));
|
||||
entry.insert(ical);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
last_itip = Some(itip_create(&mut ical, std::slice::from_ref(account)));
|
||||
itip_set_unreachable_status(&mut ical, std::slice::from_ref(account));
|
||||
entry.insert(ical);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Get => {
|
||||
let account = command
|
||||
.parameters
|
||||
.first()
|
||||
.expect("Account parameter is required")
|
||||
.as_str();
|
||||
let name = command
|
||||
.parameters
|
||||
.get(1)
|
||||
.expect("Name parameter is required")
|
||||
.as_str();
|
||||
let ical = ICalendar::parse(&command.payload)
|
||||
.expect("Failed to parse iCalendar payload")
|
||||
.to_string()
|
||||
.replace("\r\n", "\n");
|
||||
store
|
||||
.get(account)
|
||||
.and_then(|account_store| account_store.get(name))
|
||||
.map(|stored_ical| {
|
||||
let stored_ical = normalize_ical(stored_ical.clone(), &mut dtstamp_map);
|
||||
if stored_ical != ical {
|
||||
panic!(
|
||||
"ICalendar mismatch for {}: expected {}, got {}",
|
||||
command.test_name, ical, stored_ical
|
||||
);
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"ICalendar not found for account: {}, name: {}",
|
||||
account, name
|
||||
);
|
||||
});
|
||||
}
|
||||
Command::Delete(force_send) => {
|
||||
let account = command
|
||||
.parameters
|
||||
.first()
|
||||
.expect("Account parameter is required")
|
||||
.as_str();
|
||||
let name = command
|
||||
.parameters
|
||||
.get(1)
|
||||
.expect("Name parameter is required")
|
||||
.as_str();
|
||||
let store = store.get_mut(account).expect("Account not found in store");
|
||||
|
||||
if let Some(ical) = store.remove(name) {
|
||||
last_itip = Some(
|
||||
itip_cancel(&ical, &[account.to_string()], force_send)
|
||||
.map(|message| vec![message]),
|
||||
);
|
||||
} else {
|
||||
panic!(
|
||||
"ICalendar not found for account: {}, name: {}",
|
||||
account, name
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Expect => {
|
||||
let last_itip_str = match last_itip
|
||||
.as_ref()
|
||||
.expect("No last iTIP message to compare against")
|
||||
{
|
||||
Ok(m) => {
|
||||
let mut result = String::new();
|
||||
for (i, m) in m.iter().enumerate() {
|
||||
if i > 0 {
|
||||
result.push_str("================================\n");
|
||||
}
|
||||
result.push_str(&m.to_string(&mut dtstamp_map));
|
||||
}
|
||||
result
|
||||
}
|
||||
Err(e) => format!("{e:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
command.payload.trim(),
|
||||
last_itip_str.trim(),
|
||||
"iTIP message mismatch for {} at line {}\nEXPECTED {}\n\nRECEIVED {}",
|
||||
command.test_name,
|
||||
command.line_num,
|
||||
command.payload,
|
||||
last_itip_str
|
||||
);
|
||||
}
|
||||
Command::Send => {
|
||||
let mut results = String::new();
|
||||
match last_itip {
|
||||
Some(Ok(messages)) => {
|
||||
for message in messages {
|
||||
for rcpt in &message.to {
|
||||
let result = match itip_snapshot(
|
||||
&message.message,
|
||||
std::slice::from_ref(rcpt),
|
||||
false,
|
||||
) {
|
||||
Ok(itip_snapshots) => {
|
||||
match store
|
||||
.entry(rcpt.to_string())
|
||||
.or_default()
|
||||
.entry(itip_snapshots.uid.to_string())
|
||||
{
|
||||
Entry::Occupied(mut entry) => {
|
||||
let ical = entry.get_mut();
|
||||
let snapshots = itip_snapshot(
|
||||
ical,
|
||||
std::slice::from_ref(rcpt),
|
||||
false,
|
||||
)
|
||||
.expect("Failed to create iTIP snapshot");
|
||||
|
||||
match itip_process_message(
|
||||
ical,
|
||||
snapshots,
|
||||
&message.message,
|
||||
itip_snapshots,
|
||||
message.from.clone(),
|
||||
) {
|
||||
Ok(result) => match result {
|
||||
MergeResult::Actions(changes) => {
|
||||
itip_merge_changes(ical, changes);
|
||||
Ok(None)
|
||||
}
|
||||
MergeResult::Message(message) => {
|
||||
Ok(Some(message))
|
||||
}
|
||||
MergeResult::None => Ok(None),
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
let mut message = message.message.clone();
|
||||
itip_import_message(&mut message)
|
||||
.expect("Failed to import iTIP message");
|
||||
entry.insert(message);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(Some(itip_message)) => {
|
||||
results.push_str(
|
||||
&itip_message.to_string(&mut dtstamp_map),
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
results.push_str(&format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
results.trim(),
|
||||
command.payload.trim(),
|
||||
"iTIP send result mismatch for {} at line {}: expected {}, got {}",
|
||||
command.test_name,
|
||||
command.line_num,
|
||||
command.payload,
|
||||
results
|
||||
);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
panic!(
|
||||
"Failed to create iTIP message for {} at line {}: {:?}",
|
||||
command.test_name, command.line_num, e
|
||||
);
|
||||
}
|
||||
None => {
|
||||
panic!(
|
||||
"No iTIP message to send for {} at line {}",
|
||||
command.test_name, command.line_num
|
||||
);
|
||||
}
|
||||
}
|
||||
last_itip = None;
|
||||
}
|
||||
Command::Itip => {
|
||||
let mut commands = command.parameters.iter();
|
||||
last_itip = Some(Ok(vec![ItipMessage {
|
||||
from_organizer: false,
|
||||
from: commands
|
||||
.next()
|
||||
.expect("From parameter is required")
|
||||
.to_string(),
|
||||
to: commands.map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
summary: ItipSummary::Invite(vec![]),
|
||||
message: ICalendar::parse(&command.payload)
|
||||
.expect("Failed to parse iCalendar payload"),
|
||||
}]))
|
||||
}
|
||||
Command::Reset => {
|
||||
store.clear();
|
||||
dtstamp_map.clear();
|
||||
last_itip = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait ItipMessageExt {
|
||||
fn to_string(&self, map: &mut AHashMap<PartialDateTime, usize>) -> String;
|
||||
}
|
||||
|
||||
impl ItipMessageExt for ItipMessage<ICalendar> {
|
||||
fn to_string(&self, map: &mut AHashMap<PartialDateTime, usize>) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut f = String::new();
|
||||
let mut to = self.to.iter().map(|t| t.as_str()).collect::<Vec<_>>();
|
||||
to.sort_unstable();
|
||||
writeln!(&mut f, "from: {}", self.from).unwrap();
|
||||
writeln!(&mut f, "to: {}", to.join(", ")).unwrap();
|
||||
write!(&mut f, "summary: ").unwrap();
|
||||
let mut fields = Vec::new();
|
||||
match &self.summary {
|
||||
ItipSummary::Invite(itip_fields) => {
|
||||
writeln!(&mut f, "invite").unwrap();
|
||||
fields.push(itip_fields);
|
||||
}
|
||||
ItipSummary::Update {
|
||||
method,
|
||||
current,
|
||||
previous,
|
||||
} => {
|
||||
writeln!(&mut f, "update {}", method.as_str()).unwrap();
|
||||
fields.push(current);
|
||||
fields.push(previous);
|
||||
}
|
||||
ItipSummary::Cancel(itip_fields) => {
|
||||
writeln!(&mut f, "cancel").unwrap();
|
||||
fields.push(itip_fields);
|
||||
}
|
||||
ItipSummary::Rsvp { part_stat, current } => {
|
||||
writeln!(&mut f, "rsvp {}", part_stat.as_str()).unwrap();
|
||||
fields.push(current);
|
||||
}
|
||||
}
|
||||
for (pos, fields) in fields.into_iter().enumerate() {
|
||||
let prefix = if pos > 0 { "~summary." } else { "summary." };
|
||||
let mut fields = fields
|
||||
.iter()
|
||||
.map(|f| format!("{}: {:?}", f.name.as_str().to_lowercase(), f.value))
|
||||
.collect::<Vec<_>>();
|
||||
fields.sort_unstable();
|
||||
for field in fields {
|
||||
writeln!(&mut f, "{prefix}{}", field).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
write!(&mut f, "{}", normalize_ical(self.message.clone(), map)).unwrap();
|
||||
f
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ical(mut ical: ICalendar, map: &mut AHashMap<PartialDateTime, usize>) -> String {
|
||||
let mut comps = ical
|
||||
.components
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(comp_id, _)| {
|
||||
ical.components[0]
|
||||
.component_ids
|
||||
.contains(&(*comp_id as u32))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
comps.sort_unstable_by_key(|(_, comp)| *comp);
|
||||
ical.components[0].component_ids = comps.iter().map(|(comp_id, _)| *comp_id as u32).collect();
|
||||
|
||||
for comp in &mut ical.components {
|
||||
for entry in &mut comp.entries {
|
||||
if let (ICalendarProperty::Dtstamp, Some(ICalendarValue::PartialDateTime(dt))) =
|
||||
(&entry.name, entry.values.first())
|
||||
{
|
||||
if let Some(index) = map.get(dt) {
|
||||
entry.values = vec![ICalendarValue::Integer(*index as i64)];
|
||||
} else {
|
||||
let index = map.len();
|
||||
map.insert(dt.as_ref().clone(), index);
|
||||
entry.values = vec![ICalendarValue::Integer(index as i64)];
|
||||
}
|
||||
}
|
||||
}
|
||||
comp.entries.sort_unstable();
|
||||
}
|
||||
ical.to_string().replace("\r\n", "\n")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
|
||||
use dav_proto::schema::property::{CardDavProperty, DavProperty, WebDavProperty};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running REPORT addressbook-query tests...");
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
// Create test data
|
||||
let default_path = format!(
|
||||
"{}/john%40example.com/default/",
|
||||
DavResourceName::Card.base_path()
|
||||
);
|
||||
let mut hrefs = Vec::with_capacity(3);
|
||||
for (i, vcard) in [VCARD1, VCARD2, VCARD3].iter().enumerate() {
|
||||
let href = format!("{default_path}contact-{i}.vcf",);
|
||||
client
|
||||
.request("PUT", &href, *vcard)
|
||||
.await
|
||||
.with_status(hyper::StatusCode::CREATED);
|
||||
hrefs.push(href);
|
||||
}
|
||||
let uri_sarah = hrefs[0].as_str();
|
||||
let uri_carlos = hrefs[1].as_str();
|
||||
let uri_acme = hrefs[2].as_str();
|
||||
|
||||
// Test 1: RFC6352 8.6.3 example 1
|
||||
let response = client
|
||||
.request("REPORT", &default_path, QUERY1)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs([uri_carlos])
|
||||
.into_propfind_response(None);
|
||||
let props = response.properties(uri_carlos);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::GetETag))
|
||||
.is_not_empty();
|
||||
props
|
||||
.get(DavProperty::CardDav(CardDavProperty::AddressData {
|
||||
properties: Default::default(),
|
||||
version: None,
|
||||
}))
|
||||
.with_values([r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Carlos Rodriguez-Martinez
|
||||
NICKNAME:Charlie
|
||||
EMAIL;TYPE=WORK,pref:[email protected]
|
||||
EMAIL;TYPE=HOME:[email protected]
|
||||
UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a
|
||||
END:VCARD
|
||||
"#
|
||||
.replace('\n', "\r\n")
|
||||
.as_str()]);
|
||||
|
||||
// Test 2: RFC6352 8.6.3 example 2
|
||||
let response = client
|
||||
.request("REPORT", &default_path, QUERY2)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs([uri_carlos, uri_sarah])
|
||||
.into_propfind_response(None);
|
||||
let props = response.properties(uri_carlos);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::GetETag))
|
||||
.is_not_empty();
|
||||
props
|
||||
.get(DavProperty::CardDav(CardDavProperty::AddressData {
|
||||
properties: Default::default(),
|
||||
version: None,
|
||||
}))
|
||||
.with_values([r#"BEGIN:VCARD
|
||||
FN:Carlos Rodriguez-Martinez
|
||||
BDAY:--0623
|
||||
CATEGORIES:Marketing,Management,International
|
||||
LANG;TYPE=WORK;PREF=1:es
|
||||
LANG;TYPE=WORK;PREF=2:en
|
||||
LANG;TYPE=WORK;PREF=3:pt
|
||||
END:VCARD
|
||||
"#
|
||||
.replace('\n', "\r\n")
|
||||
.as_str()]);
|
||||
let props = response.properties(uri_sarah);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::GetETag))
|
||||
.is_not_empty();
|
||||
props
|
||||
.get(DavProperty::CardDav(CardDavProperty::AddressData {
|
||||
properties: Default::default(),
|
||||
version: None,
|
||||
}))
|
||||
.with_values([r#"BEGIN:VCARD
|
||||
FN:Sarah Johnson
|
||||
BDAY:19850415
|
||||
CATEGORIES:Work,Research,VIP
|
||||
LANG;TYPE=WORK;PREF=1:en
|
||||
LANG;TYPE=WORK;PREF=2:fr
|
||||
END:VCARD
|
||||
"#
|
||||
.replace('\n', "\r\n")
|
||||
.as_str()]);
|
||||
|
||||
// Test 3: Search within parameters
|
||||
let response = client
|
||||
.request("REPORT", &default_path, QUERY3)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs([uri_acme])
|
||||
.into_propfind_response(None);
|
||||
let props = response.properties(uri_acme);
|
||||
props
|
||||
.get(DavProperty::CardDav(CardDavProperty::AddressData {
|
||||
properties: Default::default(),
|
||||
version: None,
|
||||
}))
|
||||
.with_values([VCARD3.replace('\n', "\r\n").as_str()]);
|
||||
|
||||
// Test 4: Search using limit
|
||||
client
|
||||
.request("REPORT", &default_path, QUERY4)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:status",
|
||||
"HTTP/1.1 507 Insufficient Storage",
|
||||
)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:error.D:number-of-matches-within-limits",
|
||||
"",
|
||||
)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:responsedescription",
|
||||
"The number of matches exceeds the limit of 2",
|
||||
)
|
||||
.with_href_count(3);
|
||||
|
||||
client.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
const QUERY1: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:addressbook-query xmlns:D="DAV:"
|
||||
xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:address-data>
|
||||
<C:prop name="VERSION"/>
|
||||
<C:prop name="UID"/>
|
||||
<C:prop name="NICKNAME"/>
|
||||
<C:prop name="EMAIL"/>
|
||||
<C:prop name="FN"/>
|
||||
</C:address-data>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:prop-filter name="NICKNAME">
|
||||
<C:text-match collation="i;unicode-casemap"
|
||||
match-type="equals"
|
||||
>charlie</C:text-match>
|
||||
</C:prop-filter>
|
||||
</C:filter>
|
||||
</C:addressbook-query>"#;
|
||||
|
||||
const QUERY2: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:addressbook-query xmlns:D="DAV:"
|
||||
xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:address-data>
|
||||
<C:prop name="FN"/>
|
||||
<C:prop name="BDAY"/>
|
||||
<C:prop name="CATEGORIES"/>
|
||||
<C:prop name="LANG"/>
|
||||
</C:address-data>
|
||||
</D:prop>
|
||||
<C:filter test="anyof">
|
||||
<C:prop-filter name="FN">
|
||||
<C:text-match collation="i;unicode-casemap"
|
||||
match-type="contains"
|
||||
>john</C:text-match>
|
||||
</C:prop-filter>
|
||||
<C:prop-filter name="EMAIL">
|
||||
<C:text-match collation="i;unicode-casemap"
|
||||
match-type="contains"
|
||||
>rodriguez</C:text-match>
|
||||
</C:prop-filter>
|
||||
</C:filter>
|
||||
</C:addressbook-query>"#;
|
||||
|
||||
const QUERY3: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:address-data/>
|
||||
</D:prop>
|
||||
<C:filter test="anyof">
|
||||
<C:prop-filter name="ADR">
|
||||
<C:param-filter name="LABEL">
|
||||
<C:text-match collation="i;unicode-casemap" match-type="contains">enterprise</C:text-match>
|
||||
</C:param-filter>
|
||||
</C:prop-filter>
|
||||
</C:filter>
|
||||
</C:addressbook-query>"#;
|
||||
|
||||
const QUERY4: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:addressbook-query xmlns:D="DAV:"
|
||||
xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
<C:filter test="anyof">
|
||||
<C:prop-filter name="ORG">
|
||||
<C:text-match collation="i;unicode-casemap"
|
||||
match-type="contains"
|
||||
>acme</C:text-match>
|
||||
</C:prop-filter>
|
||||
<C:prop-filter name="ORG">
|
||||
<C:text-match collation="i;unicode-casemap"
|
||||
match-type="contains"
|
||||
>global</C:text-match>
|
||||
</C:prop-filter>
|
||||
</C:filter>
|
||||
<C:limit>
|
||||
<C:nresults>2</C:nresults>
|
||||
</C:limit>
|
||||
</C:addressbook-query>"#;
|
||||
|
||||
const VCARD1: &str = r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Sarah Johnson
|
||||
N:Johnson;Sarah;Marie;Dr.;Ph.D.
|
||||
NICKNAME:Sadie
|
||||
GENDER:F
|
||||
BDAY:19850415
|
||||
ANNIVERSARY:20100610
|
||||
EMAIL;TYPE=work:[email protected]
|
||||
EMAIL;TYPE=home,pref:[email protected]
|
||||
TEL;TYPE=cell,voice,pref:+1-555-123-4567
|
||||
TEL;TYPE=work,voice:+1-555-987-6543
|
||||
TEL;TYPE=home,voice:+1-555-456-7890
|
||||
ADR;TYPE=work;LABEL="123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA":;;123 Business Ave;New York;NY;10001;USA
|
||||
ADR;TYPE=home,pref;LABEL="456 Residential St\nApt 7B\nBrooklyn, NY 11201\nUSA":;;456 Residential St;Brooklyn;NY;11201;USA
|
||||
ORG:Acme Technologies Inc.;Research Department
|
||||
TITLE:Senior Research Scientist
|
||||
ROLE:Team Lead
|
||||
CATEGORIES:Work,Research,VIP
|
||||
URL;TYPE=work:https://www.example.com/staff/sjohnson
|
||||
URL;TYPE=home:https://www.sarahjohnson.example.com
|
||||
KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&[email protected]
|
||||
NOTE:Sarah prefers video calls over phone calls. Available Mon-Thu 9-5 EST.
|
||||
LANG;TYPE=work;PREF=1:en
|
||||
LANG;TYPE=work;PREF=2:fr
|
||||
TZ:-0500
|
||||
GEO:40.7128;-74.0060
|
||||
UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
|
||||
REV:20220315T133000Z
|
||||
END:VCARD
|
||||
"#;
|
||||
|
||||
const VCARD2: &str = r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Carlos Rodriguez-Martinez
|
||||
N:Rodriguez-Martinez;Carlos;Alberto;Mr.;Jr.
|
||||
NICKNAME:Charlie
|
||||
GENDER:M
|
||||
BDAY:--0623
|
||||
ANNIVERSARY:20150809
|
||||
EMAIL;TYPE=work,pref:[email protected]
|
||||
EMAIL;TYPE=home:[email protected]
|
||||
TEL;TYPE=cell,voice,pref:+34-611-234-567
|
||||
TEL;TYPE=work,voice:+34-911-876-543
|
||||
TEL;TYPE=home,voice:+34-644-321-987
|
||||
TEL;TYPE=fax:+34-911-876-544
|
||||
ADR;TYPE=work;LABEL="Calle Empresarial 42\nPlanta 3\nMadrid, 28001\nSpain":;;Calle Empresarial 42;Madrid;;28001;Spain
|
||||
ADR;TYPE=home,pref;LABEL="Avenida Residencial 15\nPiso 7, Puerta C\nMadrid, 28045\nSpain":;;Avenida Residencial 15;Madrid;;28045;Spain
|
||||
ORG:Global Solutions S.L.;Marketing Division
|
||||
TITLE:Digital Marketing Director
|
||||
ROLE:Department Head
|
||||
CATEGORIES:Marketing,Management,International
|
||||
URL;TYPE=work:https://www.example-corp.com/team/carlos
|
||||
URL;TYPE=home:https://www.carlosrodriguez.example
|
||||
URL;TYPE=social:https://linkedin.com/in/carlosrodriguezm
|
||||
KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&[email protected]
|
||||
NOTE:Carlos speaks English, Spanish, and Portuguese fluently. Prefers communication via email. Do not contact after 7PM CET.
|
||||
LANG;TYPE=work;PREF=1:es
|
||||
LANG;TYPE=work;PREF=2:en
|
||||
LANG;TYPE=work;PREF=3:pt
|
||||
TZ:+0100
|
||||
GEO:40.4168;-3.7038
|
||||
UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a
|
||||
REV:20230712T092135Z
|
||||
SOURCE:https://contacts.example.com/carlosrodriguez.vcf
|
||||
KIND:individual
|
||||
MEMBER:urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af
|
||||
RELATED;TYPE=friend:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
|
||||
END:VCARD
|
||||
"#;
|
||||
|
||||
const VCARD3: &str = r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Acme Business Solutions Ltd.
|
||||
N:;;;;
|
||||
KIND:ORG
|
||||
ORG:Acme Business Solutions Ltd.;Technology Division
|
||||
EMAIL;TYPE=WORK,pref:[email protected]
|
||||
EMAIL;TYPE=support:[email protected]
|
||||
EMAIL;TYPE=sales:[email protected]
|
||||
TEL;TYPE=WORK,VOICE,pref:+44-20-1234-5678
|
||||
TEL;TYPE=FAX:+44-20-1234-5679
|
||||
TEL;TYPE=support:+44-800-987-6543
|
||||
ADR;TYPE=WORK;LABEL="10 Enterprise Way\nTech Park\nLondon, EC1A 1BB\nUnited
|
||||
Kingdom":;;10 Enterprise Way\, Tech Park;London;;EC1A 1BB;United Kingdom
|
||||
ADR;TYPE=branch;LABEL="25 Innovation Street\nManchester, M1 5QF\nUnited Kin
|
||||
gdom":;;25 Innovation Street;Manchester;;M1 5QF;United Kingdom
|
||||
URL;TYPE=WORK:https://www.acme-solutions.example
|
||||
URL;TYPE=support:https://support.acme-solutions.example
|
||||
CATEGORIES:Technology,B2B,Solutions,Services
|
||||
NOTE:Business hours: Mon-Fri 9:00-17:30 GMT. Closed on UK bank holidays. VA
|
||||
T Reg: GB123456789
|
||||
TZ:Z
|
||||
GEO:51.5074;-0.1278
|
||||
KEY;TYPE=PGP:https://pgp.example.com/pks/lookup?op=get&search=info@acme-sol
|
||||
utions.example
|
||||
UID:urn:uuid:a9e95948-7b1c-46e8-bd85-c729a9e910f2
|
||||
REV:20230415T153000Z
|
||||
LANG;TYPE=WORK;PREF=1:en
|
||||
LANG;TYPE=WORK;PREF=2:de
|
||||
LANG;TYPE=WORK;PREF=3:fr
|
||||
SOURCE:https://directory.example.com/acme.vcf
|
||||
RELATED;TYPE=CONTACT:urn:uuid:b9e93fdb-4d34-45fa-a1e2-47da0428c4a1
|
||||
RELATED;TYPE=CONTACT:urn:uuid:c8e74dfe-6b34-45fa-b1e2-47ea0428c4b2
|
||||
X-ABLabel:Company
|
||||
PRODID:-//Example Corp.//Contact Manager 3.0//EN
|
||||
END:VCARD
|
||||
"#;
|
||||
@@ -0,0 +1,904 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
server::TestServer,
|
||||
webdav::{DavResponse, GenerateTestDavResource},
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
use dav_proto::Depth;
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
use registry::schema::structs::Action;
|
||||
|
||||
pub async fn test(test: &TestServer, assisted_discovery: bool) {
|
||||
let admin = test.account("[email protected]");
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
let mike_noquota = test.account("[email protected]").webdav_client();
|
||||
|
||||
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%40example.com", resource_type.base_path());
|
||||
let group_base_path = format!("{}/support%40example.com", 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, None, ["D:getetag"])
|
||||
.await;
|
||||
|
||||
// TODO: Fix tests for assisted discovery
|
||||
assert_eq!(
|
||||
response.hrefs().len(),
|
||||
if resource_type == DavResourceName::File {
|
||||
1
|
||||
} else {
|
||||
2 + usize::from(assisted_discovery)
|
||||
},
|
||||
"{:?}",
|
||||
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,
|
||||
None,
|
||||
["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;
|
||||
|
||||
// Delete cache an resync
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
let response = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
prev_sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["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()
|
||||
);
|
||||
|
||||
// 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, None, ["D:getetag"])
|
||||
.await;
|
||||
replace_prefix(&mut hierarchy, &hierarchy_root, &new_hierarchy_root);
|
||||
assert_result(&response, &hierarchy);
|
||||
client.validate_values(&hierarchy).await;
|
||||
// Validate changes
|
||||
let changes = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await
|
||||
.with_href_count(2)
|
||||
.into_propfind_response(None);
|
||||
changes
|
||||
.properties(&hierarchy_root)
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
changes
|
||||
.properties(&new_hierarchy_root)
|
||||
.with_status(StatusCode::OK);
|
||||
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, None, ["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, None, ["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;
|
||||
let sync_token = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await
|
||||
.sync_token()
|
||||
.to_string();
|
||||
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, None, ["D:getetag"])
|
||||
.await;
|
||||
replace_prefix(&mut hierarchy, &new_hierarchy_root, &hierarchy_root);
|
||||
assert_result(&response, &hierarchy);
|
||||
client.validate_values(&hierarchy).await;
|
||||
// Validate changes
|
||||
let changes = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
&sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await
|
||||
.into_propfind_response(None);
|
||||
changes
|
||||
.properties(&new_hierarchy_root)
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
changes
|
||||
.properties(&hierarchy_root)
|
||||
.with_status(StatusCode::OK);
|
||||
|
||||
// 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, None, ["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, None, ["D:getetag"])
|
||||
.await;
|
||||
assert_result(&response, &orig_hierarchy);
|
||||
client.validate_values(&orig_hierarchy).await;
|
||||
let response = client
|
||||
.sync_collection(&group_base_path, "", Depth::Infinity, None, ["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, None, ["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, None, ["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, None, ["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, None, ["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, None, ["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, None, ["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, None, ["D:getetag"])
|
||||
.await;
|
||||
assert_result(&response, &hierarchy);
|
||||
client.validate_values(&hierarchy).await;
|
||||
let response = client
|
||||
.sync_collection(&group_base_path, "", Depth::Infinity, None, ["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, None, ["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, None, ["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);
|
||||
|
||||
// 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%40example.com/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%40example.com/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%40example.com/quota-test{i}",
|
||||
resource_type.base_path()
|
||||
),
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.delete_default_containers().await;
|
||||
client
|
||||
.delete_default_containers_by_account("[email protected]")
|
||||
.await;
|
||||
mike_noquota.delete_default_containers().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%40example.com/")
|
||||
&& !h.ends_with("/support%40example.com/")
|
||||
&& !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
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
||||
|
||||
use dav_proto::schema::property::{DavProperty, WebDavProperty};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
for resource_type in [
|
||||
DavResourceName::File,
|
||||
DavResourceName::Cal,
|
||||
DavResourceName::Card,
|
||||
] {
|
||||
println!(
|
||||
"Running LOCK/UNLOCK tests ({})...",
|
||||
resource_type.base_path()
|
||||
);
|
||||
let base_path = format!("{}/john%40example.com", 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.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.href",
|
||||
"super-owner",
|
||||
)
|
||||
.with_any_value(
|
||||
"D:prop.D:lockdiscovery.D:activelock.D:timeout",
|
||||
["Second-456", "Second-455"],
|
||||
);
|
||||
|
||||
// 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 lock under an infinity locked resource should fail
|
||||
let file_path = format!("{path}/file.txt");
|
||||
client
|
||||
.lock_create(&file_path, "super-owner", true, "0", "Second-123")
|
||||
.await
|
||||
.with_status(StatusCode::LOCKED)
|
||||
.with_value("D:error.D:lock-token-submitted.D:href", &path);
|
||||
|
||||
// Test 6: Creating a file under a locked resource without a lock token should fail
|
||||
let contents = resource_type.generate();
|
||||
client
|
||||
.request("PUT", &file_path, &contents)
|
||||
.await
|
||||
.with_status(StatusCode::LOCKED)
|
||||
.with_value("D:error.D:lock-token-submitted.D:href", &path);
|
||||
|
||||
// Test 7: 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 8: 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_some_values([
|
||||
"D:activelock.D:owner.href:super-owner",
|
||||
"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",
|
||||
])
|
||||
.with_any_values([
|
||||
"D:activelock.D:timeout:Second-456",
|
||||
"D:activelock.D:timeout:Second-455",
|
||||
]);
|
||||
}
|
||||
|
||||
// Test 9: 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 10: 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 11: 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::<String>()
|
||||
.as_str(),
|
||||
true,
|
||||
"infinity",
|
||||
"Second-123",
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
|
||||
// Test 12: 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;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use hyper::StatusCode;
|
||||
|
||||
use crate::webdav::{TEST_FILE_1, TEST_ICAL_1, TEST_VCARD_1, TEST_VTIMEZONE_1};
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running MKCOL tests...");
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
// Creating collections in root elements is not allowed
|
||||
for path in [
|
||||
"/dav/file/test",
|
||||
"/dav/card/test",
|
||||
"/dav/cal/test",
|
||||
"/dav/test",
|
||||
] {
|
||||
client
|
||||
.request("MKCOL", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Create collections using MKCOL (empty body)
|
||||
for path in [
|
||||
"/dav/file/john%40example.com/my-files",
|
||||
"/dav/card/john%40example.com/my-cards",
|
||||
"/dav/cal/john%40example.com/my-events",
|
||||
] {
|
||||
client
|
||||
.request("MKCOL", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// Create resources under the newly created collections
|
||||
for (path, content) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/my-files/file1.txt",
|
||||
TEST_FILE_1,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/my-cards/card1.vcf",
|
||||
TEST_VCARD_1,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/my-events/event1.ics",
|
||||
TEST_ICAL_1,
|
||||
),
|
||||
] {
|
||||
client
|
||||
.request("PUT", path, content)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// Creating a collection on a mapped resource should fail
|
||||
for path in [
|
||||
"/dav/file/john%40example.com/my-files",
|
||||
"/dav/card/john%40example.com/my-cards",
|
||||
"/dav/cal/john%40example.com/my-events",
|
||||
"/dav/file/john%40example.com/my-files/file1.txt",
|
||||
"/dav/card/john%40example.com/my-cards/card1.vcf",
|
||||
"/dav/cal/john%40example.com/my-events/event1.ics",
|
||||
] {
|
||||
client
|
||||
.request("MKCOL", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
// Creating a sub-collections is allowed in FileDAV but in CalDAV and CardDAV
|
||||
for (path, expected_status) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/my-files/my-sub-files",
|
||||
StatusCode::CREATED,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/my-cards/my-sub-cards",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/my-events/my-sub-events",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
] {
|
||||
client
|
||||
.request("MKCOL", path, "")
|
||||
.await
|
||||
.with_status(expected_status);
|
||||
}
|
||||
|
||||
// Extended MKCOL with an unsupported resource types should fail
|
||||
for (path, resource_type) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/my-named-files",
|
||||
"B:addressbook",
|
||||
),
|
||||
("/dav/card/john%40example.com/my-named-cards", "A:calendar"),
|
||||
(
|
||||
"/dav/cal/john%40example.com/my-named-events",
|
||||
"B:addressbook",
|
||||
),
|
||||
] {
|
||||
client
|
||||
.mkcol("MKCOL", path, ["D:collection", resource_type], [])
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN)
|
||||
.with_value(
|
||||
"D:mkcol-response.D:propstat.D:error.D:valid-resourcetype",
|
||||
"",
|
||||
)
|
||||
.with_value("D:mkcol-response.D:propstat.D:prop.D:resourcetype", "");
|
||||
}
|
||||
|
||||
// Create using extended MKCOL
|
||||
for (path, expected_properties, resource_types) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/my-named-files/",
|
||||
[("D:displayname", "Named Files")].as_slice(),
|
||||
["D:collection"].as_slice(),
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/my-named-cards/",
|
||||
[
|
||||
("D:displayname", "Named Cards"),
|
||||
("B:addressbook-description", "Some amazing contacts"),
|
||||
]
|
||||
.as_slice(),
|
||||
["D:collection", "B:addressbook"].as_slice(),
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/my-named-events/",
|
||||
[
|
||||
("D:displayname", "Named Events"),
|
||||
("A:calendar-description", "Some amazing events"),
|
||||
(
|
||||
"A:calendar-timezone",
|
||||
&TEST_VTIMEZONE_1.replace("\n", "\r\n"),
|
||||
),
|
||||
]
|
||||
.as_slice(),
|
||||
["D:collection", "A:calendar"].as_slice(),
|
||||
),
|
||||
] {
|
||||
let response = client
|
||||
.mkcol(
|
||||
"MKCOL",
|
||||
path,
|
||||
resource_types.iter().copied(),
|
||||
expected_properties.iter().copied(),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED)
|
||||
.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 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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Test MKCALENDAR
|
||||
client
|
||||
.mkcol(
|
||||
"MKCALENDAR",
|
||||
"/dav/cal/john%40example.com/my-named-events2",
|
||||
[],
|
||||
[
|
||||
("D:displayname", "Named Events 2"),
|
||||
("A:calendar-description", ""),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED)
|
||||
.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"],
|
||||
);
|
||||
client
|
||||
.mkcol(
|
||||
"MKCALENDAR",
|
||||
"/dav/cal/john%40example.com/my-named-events3",
|
||||
[],
|
||||
[
|
||||
("D:displayname", "Named Events 3"),
|
||||
(
|
||||
"A:supported-calendar-component-set",
|
||||
"<A:comp name=\"VEVENT\"/><A:comp name=\"VTODO\"/>",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED)
|
||||
.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"],
|
||||
);
|
||||
// Check the properties of the created calendars
|
||||
client
|
||||
.propfind(
|
||||
"/dav/cal/john%40example.com/my-named-events2/",
|
||||
["A:supported-calendar-component-set"],
|
||||
)
|
||||
.await
|
||||
.properties("/dav/cal/john%40example.com/my-named-events2/")
|
||||
.get("A:supported-calendar-component-set")
|
||||
.with_status(StatusCode::OK)
|
||||
.with_values([
|
||||
"A:comp.[name]:VJOURNAL",
|
||||
"A:comp.[name]:VTIMEZONE",
|
||||
"A:comp.[name]:VAVAILABILITY",
|
||||
"A:comp.[name]:VALARM",
|
||||
"A:comp.[name]:VRESOURCE",
|
||||
"A:comp.[name]:AVAILABLE",
|
||||
"A:comp.[name]:VTODO",
|
||||
"A:comp.[name]:VFREEBUSY",
|
||||
"A:comp.[name]:VEVENT",
|
||||
"A:comp.[name]:STANDARD",
|
||||
"A:comp.[name]:DAYLIGHT",
|
||||
"A:comp.[name]:VLOCATION",
|
||||
"A:comp.[name]:PARTICIPANT",
|
||||
]);
|
||||
client
|
||||
.propfind(
|
||||
"/dav/cal/john%40example.com/my-named-events3/",
|
||||
["A:supported-calendar-component-set"],
|
||||
)
|
||||
.await
|
||||
.properties("/dav/cal/john%40example.com/my-named-events3/")
|
||||
.get("A:supported-calendar-component-set")
|
||||
.with_status(StatusCode::OK)
|
||||
.with_values(["A:comp.[name]:VEVENT", "A:comp.[name]:VTODO"]);
|
||||
|
||||
// Resource names arriving through the URI are echoed back verbatim
|
||||
for name in ["My%20Folder", "file(1)+a:b", "%C3%9Cnterlagen", "Q&A"] {
|
||||
let path = format!("/dav/file/john%40example.com/{name}");
|
||||
let href = format!("{path}/");
|
||||
client
|
||||
.request("MKCOL", &path, "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
client
|
||||
.propfind(&path, ["D:getetag"])
|
||||
.await
|
||||
.with_hrefs([href.as_str()]);
|
||||
}
|
||||
|
||||
// Delete everything
|
||||
for path in [
|
||||
"/dav/file/john%40example.com/My%20Folder",
|
||||
"/dav/file/john%40example.com/file(1)+a:b",
|
||||
"/dav/file/john%40example.com/%C3%9Cnterlagen",
|
||||
"/dav/file/john%40example.com/Q&A",
|
||||
"/dav/file/john%40example.com/my-files",
|
||||
"/dav/card/john%40example.com/my-cards",
|
||||
"/dav/cal/john%40example.com/my-events",
|
||||
"/dav/file/john%40example.com/my-named-files",
|
||||
"/dav/card/john%40example.com/my-named-cards",
|
||||
"/dav/cal/john%40example.com/my-named-events",
|
||||
"/dav/cal/john%40example.com/my-named-events2",
|
||||
"/dav/cal/john%40example.com/my-named-events3",
|
||||
] {
|
||||
client
|
||||
.request("DELETE", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
client.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use ahash::AHashMap;
|
||||
use common::{DavResource, DavResources};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{Permission, StorageQuota},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
CalendarAlarm, CalendarScheduling, Expression, MtaStageAuth, Sharing, SystemSettings,
|
||||
WebDav,
|
||||
},
|
||||
},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::str;
|
||||
use std::time::Instant;
|
||||
|
||||
pub mod acl;
|
||||
pub mod basic;
|
||||
pub mod cal_alarm;
|
||||
pub mod cal_itip;
|
||||
pub mod cal_query;
|
||||
pub mod cal_scheduling;
|
||||
pub mod card_query;
|
||||
pub mod copy_move;
|
||||
pub mod lock;
|
||||
pub mod mkcol;
|
||||
pub mod multiget;
|
||||
pub mod principals;
|
||||
pub mod prop;
|
||||
pub mod put_get;
|
||||
pub mod sync;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn webdav_tests() {
|
||||
// Prepare settings
|
||||
let assisted_discovery = std::env::var("ASSISTED_DISCOVERY").unwrap_or_default() == "1";
|
||||
|
||||
let mut test = TestServerBuilder::new("webdav_tests")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
|
||||
// Create admin account
|
||||
let admin = test.create_admin_account("[email protected]").await;
|
||||
|
||||
// Create test users
|
||||
for (name, secret, description, aliases) in [
|
||||
(
|
||||
"[email protected]",
|
||||
"secret2 + some more text",
|
||||
"John Doe",
|
||||
&["[email protected]"],
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
"secret3 + some more text",
|
||||
"Jane Doe-Smith",
|
||||
&["[email protected]"],
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
"secret4 + some more text",
|
||||
"Bill Foobar",
|
||||
&["[email protected]"],
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
"secret5 + some more text",
|
||||
"Mike Noquota",
|
||||
&["[email protected]"],
|
||||
),
|
||||
] {
|
||||
let account = admin
|
||||
.create_user_account(
|
||||
name,
|
||||
secret,
|
||||
description,
|
||||
aliases,
|
||||
vec![
|
||||
Permission::UnlimitedRequests,
|
||||
Permission::UnlimitedUploads,
|
||||
Permission::DavPrincipalList,
|
||||
Permission::DavPrincipalSearch,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
if name == "[email protected]" {
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
account.id(),
|
||||
json!({
|
||||
Property::Quotas: { StorageQuota::MaxDiskQuota.as_str(): 1024}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
test.insert_account(account);
|
||||
}
|
||||
|
||||
// Create test group
|
||||
test.insert_account(
|
||||
admin
|
||||
.create_group_account("[email protected]", "Support Group", &[])
|
||||
.await,
|
||||
);
|
||||
|
||||
// Add Jane to the Support group
|
||||
let support_id = test.account("[email protected]").id();
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
test.account("[email protected]").id(),
|
||||
json!({
|
||||
"memberGroupIds": { support_id: true },
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Add test settings
|
||||
admin
|
||||
.registry_update_setting(
|
||||
SystemSettings {
|
||||
default_hostname: "webdav.example.org".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::DefaultHostname],
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaStageAuth {
|
||||
require: Expression {
|
||||
else_: "false".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(CalendarAlarm {
|
||||
min_trigger_interval: 1000u64.into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(Sharing {
|
||||
allow_directory_queries: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(CalendarScheduling {
|
||||
auto_add_invitations: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(WebDav {
|
||||
enable_assisted_discovery: assisted_discovery,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
test.insert_account(admin);
|
||||
|
||||
let start_time = Instant::now();
|
||||
if std::env::var("ITIP_TEMPLATES").is_ok() {
|
||||
cal_scheduling::test_build_itip_templates(&test).await;
|
||||
}
|
||||
basic::test(&test).await;
|
||||
put_get::test(&test).await;
|
||||
mkcol::test(&test).await;
|
||||
copy_move::test(&test, assisted_discovery).await;
|
||||
prop::test(&test, assisted_discovery).await;
|
||||
multiget::test(&test).await;
|
||||
sync::test(&test).await;
|
||||
lock::test(&test).await;
|
||||
principals::test(&test, assisted_discovery).await;
|
||||
acl::test(&test).await;
|
||||
card_query::test(&test).await;
|
||||
cal_query::test(&test).await;
|
||||
cal_alarm::test(&test).await;
|
||||
cal_itip::test();
|
||||
cal_scheduling::test(&test).await;
|
||||
|
||||
// Print elapsed time
|
||||
let elapsed = start_time.elapsed();
|
||||
println!(
|
||||
"Elapsed: {}.{:03}s",
|
||||
elapsed.as_secs(),
|
||||
elapsed.subsec_millis()
|
||||
);
|
||||
|
||||
// Remove test data
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DavResourcesTest {
|
||||
fn items(&self) -> Vec<DavResource>;
|
||||
}
|
||||
|
||||
impl DavResourcesTest for DavResources {
|
||||
fn items(&self) -> Vec<DavResource> {
|
||||
self.resources.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_out_dir() -> Option<std::path::PathBuf> {
|
||||
if std::env::var("ITIP_TEMPLATES").is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.ignore/itip_templates");
|
||||
std::fs::create_dir_all(&dir).expect("Failed to create template output directory");
|
||||
|
||||
Some(dir.canonicalize().unwrap_or(dir))
|
||||
}
|
||||
|
||||
pub const TEST_VCARD_1: &str = r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:18F098B5-7383-4FD6-B482-48F2181D73AA
|
||||
X-TEST:SEQ1
|
||||
N:Coyote;Wile;E.;;
|
||||
FN:Wile E. Coyote
|
||||
ORG:ACME Inc.;
|
||||
END:VCARD
|
||||
"#;
|
||||
|
||||
pub const TEST_VCARD_2: &str = r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:6exhjr32bt783wwlr9u0sr8lfqse5x7zqc8y
|
||||
X-TEST:SEQ1
|
||||
FN:Joe Citizen
|
||||
N:Citizen;Joe;;;
|
||||
NICKNAME:human_being
|
||||
EMAIL;TYPE=pref:[email protected]
|
||||
REV:20200411T072429Z
|
||||
END:VCARD
|
||||
"#;
|
||||
|
||||
pub const TEST_ICAL_1: &str = r#"BEGIN:VCALENDAR
|
||||
SOURCE;VALUE=URI:http://calendar.example.com/event_with_html.ics
|
||||
X-TEST:SEQ1
|
||||
BEGIN:VEVENT
|
||||
UID: 2371c2d9-a136-43b0-bba3-f6ab249ad46e
|
||||
SUMMARY:What a nice present: 🎁
|
||||
DTSTART;TZID=America/New_York:20190221T170000
|
||||
DTEND;TZID=America/New_York:20190221T180000
|
||||
LOCATION:Germany
|
||||
DESCRIPTION:<html><body><h1>Title</h1><p><ul><li><b>first</b> Row </li><li>
|
||||
<i>second</i> Row</li></ul></p></body></html>
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"#;
|
||||
|
||||
pub const TEST_ICAL_2: &str = r#"BEGIN:VCALENDAR
|
||||
X-TEST:SEQ1
|
||||
BEGIN:VEVENT
|
||||
UID:0000001
|
||||
SUMMARY:Treasure Hunting
|
||||
DTSTART;TZID=America/Los_Angeles:20150706T120000
|
||||
DTEND;TZID=America/Los_Angeles:20150706T130000
|
||||
RRULE:FREQ=DAILY;COUNT=10
|
||||
EXDATE;TZID=America/Los_Angeles:20150708T120000
|
||||
EXDATE;TZID=America/Los_Angeles:20150710T120000
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:0000001
|
||||
SUMMARY:More Treasure Hunting
|
||||
LOCATION:The other island
|
||||
DTSTART;TZID=America/Los_Angeles:20150709T150000
|
||||
DTEND;TZID=America/Los_Angeles:20150707T160000
|
||||
RECURRENCE-ID;TZID=America/Los_Angeles:20150707T120000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"#;
|
||||
|
||||
pub const TEST_FILE_1: &str = r#"this is a test file
|
||||
with some text
|
||||
and some more text
|
||||
|
||||
X-TEST:SEQ1
|
||||
"#;
|
||||
|
||||
pub const TEST_FILE_2: &str = r#"another test file
|
||||
with amazing content
|
||||
and some more text
|
||||
|
||||
X-TEST:SEQ1
|
||||
"#;
|
||||
|
||||
pub const TEST_VTIMEZONE_1: &str = r#"BEGIN:VCALENDAR
|
||||
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:US-Eastern
|
||||
LAST-MODIFIED:19870101T000000Z
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19671029T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
TZNAME:Eastern Standard Time (US Canada)
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19870405T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
TZNAME:Eastern Daylight Time (US Canada)
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
END:VCALENDAR
|
||||
"#;
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
||||
use dav_proto::schema::property::{CalDavProperty, CardDavProperty, DavProperty, WebDavProperty};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
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%40example.com/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%40example.com", resource_type.base_path());
|
||||
let response = client
|
||||
.multiget_calendar(&path, &[&paths[0].0, &paths[1].0])
|
||||
.await;
|
||||
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%40example.com", resource_type.base_path());
|
||||
let response = client
|
||||
.multiget_addressbook(&path, &[&paths[0].0, &paths[1].0])
|
||||
.await;
|
||||
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 {
|
||||
properties: Default::default(),
|
||||
version: None,
|
||||
}))
|
||||
.with_values([contents.as_str()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
use crate::webdav::prop::ALL_DAV_PROPERTIES;
|
||||
use dav_proto::schema::property::{DavProperty, PrincipalProperty, WebDavProperty};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer, assisted_discovery: bool) {
|
||||
println!("Running principals tests...");
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
let principal_path = format!("D:href:{}/", DavResourceName::Principal.base_path());
|
||||
let jane_principal_path = format!(
|
||||
"D:href:{}/jane%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
);
|
||||
|
||||
let path_support_card = format!(
|
||||
"D:href:{}/support%40example.com/",
|
||||
DavResourceName::Card.base_path()
|
||||
);
|
||||
let path_support_cal = format!(
|
||||
"D:href:{}/support%40example.com/",
|
||||
DavResourceName::Cal.base_path()
|
||||
);
|
||||
|
||||
// Test 1: PROPFIND on /dav/pal should return all principals
|
||||
let response = client
|
||||
.propfind(
|
||||
DavResourceName::Principal.collection_path(),
|
||||
ALL_DAV_PROPERTIES,
|
||||
)
|
||||
.await;
|
||||
for account_ in test.accounts.values().filter(|a| a.name().contains('@')) {
|
||||
let account_name = account_.name().replace('@', "%40");
|
||||
let email = account_.name();
|
||||
let description = account_.description();
|
||||
|
||||
let props = response.properties(&format!(
|
||||
"{}/{}/",
|
||||
DavResourceName::Principal.base_path(),
|
||||
account_name
|
||||
));
|
||||
let path_pal = format!(
|
||||
"D:href:{}/{}/",
|
||||
DavResourceName::Principal.base_path(),
|
||||
account_name
|
||||
);
|
||||
let path_card = format!(
|
||||
"D:href:{}/{}/",
|
||||
DavResourceName::Card.base_path(),
|
||||
account_name
|
||||
);
|
||||
let path_cal = format!(
|
||||
"D:href:{}/{}/",
|
||||
DavResourceName::Cal.base_path(),
|
||||
account_name
|
||||
);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
|
||||
.with_values([description])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal))
|
||||
.with_values([jane_principal_path.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::PrincipalURL))
|
||||
.with_values([path_pal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::Owner))
|
||||
.with_values([path_pal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
if account_name == "jane%40example.com" && !assisted_discovery {
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
|
||||
.with_values([path_cal.as_str(), path_support_cal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::AddressbookHomeSet,
|
||||
))
|
||||
.with_values([path_card.as_str(), path_support_card.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
} else {
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
|
||||
.with_values([path_cal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::AddressbookHomeSet,
|
||||
))
|
||||
.with_values([path_card.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet))
|
||||
.with_values([principal_path.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::SupportedReportSet))
|
||||
.with_values([
|
||||
"D:supported-report.D:report.D:principal-property-search",
|
||||
"D:supported-report.D:report.D:principal-search-property-set",
|
||||
"D:supported-report.D:report.D:principal-match",
|
||||
])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::ResourceType))
|
||||
.with_values(["D:principal", "D:collection"])
|
||||
.with_status(StatusCode::OK);
|
||||
|
||||
// Scheduling properties
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::CalendarUserAddressSet,
|
||||
))
|
||||
.with_values([format!("D:href:mailto:{email}",).as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarUserType))
|
||||
.with_values([if account_name == "support%40example.com" {
|
||||
"GROUP"
|
||||
} else {
|
||||
"INDIVIDUAL"
|
||||
}])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::ScheduleInboxURL))
|
||||
.with_values([format!(
|
||||
"D:href:{}/{account_name}/inbox/",
|
||||
DavResourceName::Scheduling.base_path()
|
||||
)
|
||||
.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL))
|
||||
.with_values([format!(
|
||||
"D:href:{}/{account_name}/outbox/",
|
||||
DavResourceName::Scheduling.base_path()
|
||||
)
|
||||
.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
|
||||
// Test 2: PROPFIND on /dav/[resource] should return user and shared resources
|
||||
for resource_type in [
|
||||
DavResourceName::File,
|
||||
DavResourceName::Cal,
|
||||
DavResourceName::Card,
|
||||
] {
|
||||
let supported_reports = match resource_type {
|
||||
DavResourceName::File => [
|
||||
"D:supported-report.D:report.D:sync-collection",
|
||||
"D:supported-report.D:report.D:acl-principal-prop-set",
|
||||
"D:supported-report.D:report.D:principal-match",
|
||||
]
|
||||
.as_slice(),
|
||||
DavResourceName::Cal => [
|
||||
"D:supported-report.D:report.A:free-busy-query",
|
||||
"D:supported-report.D:report.A:calendar-query",
|
||||
"D:supported-report.D:report.D:expand-property",
|
||||
"D:supported-report.D:report.D:sync-collection",
|
||||
"D:supported-report.D:report.D:acl-principal-prop-set",
|
||||
"D:supported-report.D:report.D:principal-match",
|
||||
"D:supported-report.D:report.A:calendar-multiget",
|
||||
]
|
||||
.as_slice(),
|
||||
DavResourceName::Card => [
|
||||
"D:supported-report.D:report.B:addressbook-query",
|
||||
"D:supported-report.D:report.D:acl-principal-prop-set",
|
||||
"D:supported-report.D:report.D:expand-property",
|
||||
"D:supported-report.D:report.B:addressbook-multiget",
|
||||
"D:supported-report.D:report.D:principal-match",
|
||||
"D:supported-report.D:report.D:sync-collection",
|
||||
]
|
||||
.as_slice(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let privilege_set = if resource_type == DavResourceName::Cal {
|
||||
[
|
||||
"D:privilege.D:read-current-user-privilege-set",
|
||||
"D:privilege.D:write-acl",
|
||||
"D:privilege.A:read-free-busy",
|
||||
"D:privilege.D:read-acl",
|
||||
"D:privilege.D:write-properties",
|
||||
"D:privilege.D:write",
|
||||
"D:privilege.D:write-content",
|
||||
"D:privilege.D:unlock",
|
||||
"D:privilege.D:all",
|
||||
"D:privilege.D:read",
|
||||
"D:privilege.D:bind",
|
||||
"D:privilege.D:unbind",
|
||||
]
|
||||
.as_slice()
|
||||
} else {
|
||||
[
|
||||
"D:privilege.D:all",
|
||||
"D:privilege.D:read",
|
||||
"D:privilege.D:write",
|
||||
"D:privilege.D:write-properties",
|
||||
"D:privilege.D:write-content",
|
||||
"D:privilege.D:unlock",
|
||||
"D:privilege.D:read-acl",
|
||||
"D:privilege.D:read-current-user-privilege-set",
|
||||
"D:privilege.D:write-acl",
|
||||
"D:privilege.D:bind",
|
||||
"D:privilege.D:unbind",
|
||||
]
|
||||
.as_slice()
|
||||
};
|
||||
|
||||
let response = client
|
||||
.propfind(resource_type.collection_path(), ALL_DAV_PROPERTIES)
|
||||
.await;
|
||||
let props = response.properties(resource_type.collection_path());
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::SupportedReportSet))
|
||||
.with_values(supported_reports.iter().copied())
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::ResourceType))
|
||||
.with_values(["D:collection"])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal))
|
||||
.with_values([jane_principal_path.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
if assisted_discovery {
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
|
||||
.with_values([format!(
|
||||
"D:href:{}/jane%40example.com/",
|
||||
DavResourceName::Cal.base_path()
|
||||
)
|
||||
.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::AddressbookHomeSet,
|
||||
))
|
||||
.with_values([format!(
|
||||
"D:href:{}/jane%40example.com/",
|
||||
DavResourceName::Card.base_path()
|
||||
)
|
||||
.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
} else {
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
|
||||
.with_values([
|
||||
format!(
|
||||
"D:href:{}/jane%40example.com/",
|
||||
DavResourceName::Cal.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
format!(
|
||||
"D:href:{}/support%40example.com/",
|
||||
DavResourceName::Cal.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::AddressbookHomeSet,
|
||||
))
|
||||
.with_values([
|
||||
format!(
|
||||
"D:href:{}/jane%40example.com/",
|
||||
DavResourceName::Card.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
format!(
|
||||
"D:href:{}/support%40example.com/",
|
||||
DavResourceName::Card.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
])
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
|
||||
for account_ in test
|
||||
.accounts
|
||||
.values()
|
||||
.filter(|account| ["[email protected]", "[email protected]"].contains(&account.name()))
|
||||
{
|
||||
let account_name = account_.name().replace('@', "%40");
|
||||
let description = account_.description();
|
||||
|
||||
let path_card = format!(
|
||||
"D:href:{}/{}/",
|
||||
DavResourceName::Card.base_path(),
|
||||
account_name
|
||||
);
|
||||
let path_cal = format!(
|
||||
"D:href:{}/{}/",
|
||||
DavResourceName::Cal.base_path(),
|
||||
account_name
|
||||
);
|
||||
let path_pal = format!(
|
||||
"D:href:{}/{}/",
|
||||
DavResourceName::Principal.base_path(),
|
||||
account_name
|
||||
);
|
||||
let props =
|
||||
response.properties(&format!("{}/{account_name}/", resource_type.base_path()));
|
||||
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
|
||||
.with_values([description])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::ResourceType))
|
||||
.with_values(["D:collection"])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal))
|
||||
.with_values([jane_principal_path.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet))
|
||||
.with_values(privilege_set.iter().copied())
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::SupportedReportSet))
|
||||
.with_values(supported_reports.iter().copied())
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::PrincipalURL))
|
||||
.with_values([path_pal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet))
|
||||
.with_values([principal_path.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::Owner))
|
||||
.with_values([path_pal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
if account_name == "jane%40example.com" && !assisted_discovery {
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
|
||||
.with_values([path_cal.as_str(), path_support_cal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::AddressbookHomeSet,
|
||||
))
|
||||
.with_values([path_card.as_str(), path_support_card.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
} else {
|
||||
props
|
||||
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
|
||||
.with_values([path_cal.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
props
|
||||
.get(DavProperty::Principal(
|
||||
PrincipalProperty::AddressbookHomeSet,
|
||||
))
|
||||
.with_values([path_card.as_str()])
|
||||
.with_status(StatusCode::OK);
|
||||
}
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::SyncToken))
|
||||
.with_status(StatusCode::OK)
|
||||
.is_not_empty();
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes))
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
props
|
||||
.get(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes))
|
||||
.with_status(StatusCode::OK)
|
||||
.is_not_empty();
|
||||
}
|
||||
|
||||
// Test 3: principal-match-query on resources
|
||||
let response = client
|
||||
.request(
|
||||
"REPORT",
|
||||
resource_type.collection_path(),
|
||||
PRINCIPAL_MATCH_QUERY,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.into_propfind_response(None);
|
||||
response.with_hrefs([
|
||||
format!("{}/jane%40example.com/", resource_type.base_path()).as_str(),
|
||||
format!("{}/support%40example.com/", resource_type.base_path()).as_str(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Test 4: principal-match-query on principals
|
||||
let response = client
|
||||
.request(
|
||||
"REPORT",
|
||||
DavResourceName::Principal.collection_path(),
|
||||
PRINCIPAL_MATCH_QUERY,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.into_propfind_response(None);
|
||||
response.with_hrefs([
|
||||
format!(
|
||||
"{}/jane%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
format!(
|
||||
"{}/support%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
]);
|
||||
|
||||
// Test 5: principal-search-property-set REPORT
|
||||
let response = client
|
||||
.request(
|
||||
"REPORT",
|
||||
DavResourceName::Principal.collection_path(),
|
||||
PRINCIPAL_SEARCH_PROPERTY_SET_QUERY,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::OK);
|
||||
response
|
||||
.with_value(
|
||||
"D:principal-search-property-set.D:principal-search-property.D:prop.D:displayname",
|
||||
"",
|
||||
)
|
||||
.with_value(
|
||||
"D:principal-search-property-set.D:principal-search-property.D:description",
|
||||
"Account or Group name",
|
||||
);
|
||||
|
||||
// Test 6: principal-property-search REPORT
|
||||
let response = client
|
||||
.request(
|
||||
"REPORT",
|
||||
DavResourceName::Principal.collection_path(),
|
||||
PRINCIPAL_PROPERTY_SEARCH_QUERY.replace("$NAME", "doe"),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.into_propfind_response(None);
|
||||
response.with_hrefs([
|
||||
format!(
|
||||
"{}/jane%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
format!(
|
||||
"{}/john%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
)
|
||||
.as_str(),
|
||||
]);
|
||||
response
|
||||
.properties(&format!(
|
||||
"{}/jane%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
))
|
||||
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
|
||||
.with_values([test.account("[email protected]").description()])
|
||||
.with_status(StatusCode::OK);
|
||||
client
|
||||
.request(
|
||||
"REPORT",
|
||||
DavResourceName::Principal.collection_path(),
|
||||
PRINCIPAL_PROPERTY_SEARCH_QUERY.replace("$NAME", "support"),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.into_propfind_response(None)
|
||||
.with_hrefs([format!(
|
||||
"{}/support%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
)
|
||||
.as_str()]);
|
||||
|
||||
client.delete_default_containers().await;
|
||||
client
|
||||
.delete_default_containers_by_account("[email protected]")
|
||||
.await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
const PRINCIPAL_MATCH_QUERY: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:principal-match xmlns:D="DAV:">
|
||||
<D:principal-property>
|
||||
<D:owner/>
|
||||
<D:displayname/>
|
||||
</D:principal-property>
|
||||
</D:principal-match>"#;
|
||||
|
||||
const PRINCIPAL_SEARCH_PROPERTY_SET_QUERY: &str =
|
||||
r#"<?xml version="1.0" encoding="utf-8" ?><D:principal-search-property-set xmlns:D="DAV:"/>"#;
|
||||
|
||||
const PRINCIPAL_PROPERTY_SEARCH_QUERY: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:principal-property-search xmlns:D="DAV:">
|
||||
<D:property-search>
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
</D:prop>
|
||||
<D:match>$NAME</D:match>
|
||||
</D:property-search>
|
||||
<D:prop xmlns:B="http://www.example.com/ns/">
|
||||
<D:displayname/>
|
||||
</D:prop>
|
||||
</D:principal-property-search>"#;
|
||||
@@ -0,0 +1,792 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
use crate::utils::webdav::GenerateTestDavResource;
|
||||
use crate::webdav::{TEST_ICAL_2, TEST_VTIMEZONE_1};
|
||||
use dav_proto::schema::property::{
|
||||
CalDavProperty, CardDavProperty, DavProperty, PrincipalProperty, WebDavProperty,
|
||||
};
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
use types::dead_property::DeadElementTag;
|
||||
|
||||
pub async fn test(test: &TestServer, assisted_discovery: bool) {
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
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%40example.com", resource_type.base_path());
|
||||
let group_base_path = format!("{}/support%40example.com", 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()]);
|
||||
|
||||
client
|
||||
.propfind_with_headers(
|
||||
&user_base_path,
|
||||
[DavProperty::WebDav(WebDavProperty::GetCTag)],
|
||||
[("depth", "0")],
|
||||
)
|
||||
.await
|
||||
.properties(&format!("{user_base_path}/"))
|
||||
.get(DavProperty::WebDav(WebDavProperty::GetCTag))
|
||||
.is_not_empty();
|
||||
|
||||
// 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
|
||||
client
|
||||
.request_with_headers("PROPFIND", &user_base_path, [("depth", "1")], "")
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs(
|
||||
[
|
||||
format!("{group_base_path}/default/").as_str(),
|
||||
format!("{user_base_path}/default/").as_str(),
|
||||
format!("{user_base_path}/").as_str(),
|
||||
&test_base_path,
|
||||
]
|
||||
.into_iter()
|
||||
.skip(if resource_type == DavResourceName::File {
|
||||
2
|
||||
} else if !assisted_discovery {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
);
|
||||
|
||||
// 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 PROPFIND with depth-no-root
|
||||
client
|
||||
.request_with_headers(
|
||||
"PROPFIND",
|
||||
&user_base_path,
|
||||
[("depth", "1"), ("prefer", "depth-noroot")],
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs(
|
||||
[
|
||||
format!("{group_base_path}/default/").as_str(),
|
||||
format!("{user_base_path}/default/").as_str(),
|
||||
&test_base_path,
|
||||
]
|
||||
.into_iter()
|
||||
.skip(if resource_type == DavResourceName::File {
|
||||
2
|
||||
} else if !assisted_discovery {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
);
|
||||
client
|
||||
.request_with_headers(
|
||||
"PROPFIND",
|
||||
&test_base_path,
|
||||
[("depth", "1"), ("prefer", "depth-noroot")],
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.with_hrefs([test_path.as_str()]);
|
||||
|
||||
// Test 8 PROPFIND with prefer return=minimal
|
||||
let response = client
|
||||
.propfind_with_headers(&test_base_path, ALL_DAV_PROPERTIES, [])
|
||||
.await;
|
||||
response
|
||||
.properties(&test_base_path)
|
||||
.is_defined(DavProperty::WebDav(WebDavProperty::GetETag))
|
||||
.is_defined(DavProperty::Principal(PrincipalProperty::GroupMembership));
|
||||
let response = client
|
||||
.propfind_with_headers(
|
||||
&test_base_path,
|
||||
ALL_DAV_PROPERTIES,
|
||||
[("prefer", "return=minimal")],
|
||||
)
|
||||
.await;
|
||||
response
|
||||
.properties(&test_base_path)
|
||||
.is_defined(DavProperty::WebDav(WebDavProperty::GetETag))
|
||||
.is_undefined(DavProperty::Principal(PrincipalProperty::GroupMembership));
|
||||
|
||||
// Test 9: Retrieve all static properties
|
||||
for (path, etag, is_file) in [
|
||||
(&test_base_path, &etag_folder, false),
|
||||
(&test_path, &etag_file, true),
|
||||
] {
|
||||
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%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
)
|
||||
.as_str()]);
|
||||
properties
|
||||
.get(DavProperty::WebDav(WebDavProperty::Owner))
|
||||
.with_values([format!(
|
||||
"D:href:{}/jane%40example.com/",
|
||||
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();
|
||||
assert!(used_bytes > 0);
|
||||
properties
|
||||
.get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes))
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
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:comp.[name]:VAVAILABILITY",
|
||||
"A:comp.[name]:AVAILABLE",
|
||||
"A:comp.[name]:VRESOURCE",
|
||||
"A:comp.[name]:VTODO",
|
||||
"A:comp.[name]:DAYLIGHT",
|
||||
"A:comp.[name]:STANDARD",
|
||||
"A:comp.[name]:VLOCATION",
|
||||
"A:comp.[name]:VTIMEZONE",
|
||||
"A:comp.[name]:VFREEBUSY",
|
||||
"A:comp.[name]:VEVENT",
|
||||
"A:comp.[name]:VJOURNAL",
|
||||
"A:comp.[name]:PARTICIPANT",
|
||||
"A:comp.[name]:VALARM",
|
||||
]);
|
||||
properties
|
||||
.get(DavProperty::CalDav(CalDavProperty::SupportedCalendarData))
|
||||
.with_values([
|
||||
concat!("A:calendar-data-type.", "[content-type]:text/calendar"),
|
||||
"A:calendar-data-type.[version]:2.0",
|
||||
"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:address-data-type.", "[content-type]:text/vcard"),
|
||||
"B:address-data-type.[version]:3.0",
|
||||
"B:address-data-type.[version]:4.0",
|
||||
"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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 10: expand-property report
|
||||
for path in [&test_base_path, &test_path] {
|
||||
let response = client
|
||||
.request("REPORT", path, EXPAND_REPORT_QUERY)
|
||||
.await
|
||||
.with_status(StatusCode::MULTI_STATUS)
|
||||
.into_propfind_response(None);
|
||||
let properties = response.properties(path);
|
||||
for prop in [
|
||||
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
|
||||
DavProperty::WebDav(WebDavProperty::Owner),
|
||||
] {
|
||||
properties.get(prop).with_some_values([
|
||||
format!(
|
||||
"D:response.D:href:{}/jane%40example.com/",
|
||||
DavResourceName::Principal.base_path(),
|
||||
)
|
||||
.as_str(),
|
||||
"D:response.D:propstat.D:prop.D:displayname:Jane Doe-Smith",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (path, etag, is_file) in [
|
||||
(&test_base_path, &etag_folder, false),
|
||||
(&test_path, &etag_file, true),
|
||||
] {
|
||||
// Test 11: 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 12: 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(
|
||||
"my-dead-element".to_string(),
|
||||
Some("xmlns=\"http://example.com/ns/\" prop=\"abc\"".to_string()),
|
||||
)),
|
||||
"this is a dead but exciting element",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
client
|
||||
.patch_and_check(
|
||||
path,
|
||||
[(
|
||||
DavProperty::DeadProperty(DeadElementTag::new(
|
||||
"my-dead-element".to_string(),
|
||||
Some("xmlns=\"http://example.com/ns/\" prop=\"xyz\"".to_string()),
|
||||
)),
|
||||
"this is a modified dead but exciting element",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Test 13: PROPPATCH remove on DAV properties
|
||||
let mut props = vec![
|
||||
(
|
||||
DavProperty::DeadProperty(DeadElementTag::new(
|
||||
"my-dead-element".to_string(),
|
||||
Some("xmlns=\"http://example.com/ns/\"".to_string()),
|
||||
)),
|
||||
"",
|
||||
),
|
||||
(DavProperty::WebDav(WebDavProperty::DisplayName), ""),
|
||||
];
|
||||
if !is_file {
|
||||
// DisplayName can't be removed from calendar/contact collections
|
||||
props.pop();
|
||||
}
|
||||
client.patch_and_check(path, props).await;
|
||||
|
||||
match resource_type {
|
||||
DavResourceName::File if is_file => {
|
||||
// Test 14: 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 15: 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 16: 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 17: PROPPATCH should fail on large properties
|
||||
let mut chunky_props = vec![
|
||||
DavProperty::WebDav(WebDavProperty::DisplayName),
|
||||
DavProperty::DeadProperty(DeadElementTag::new(
|
||||
"my-chunky-dead-element".to_string(),
|
||||
Some("xmlns=\"http://example.com/ns/\"".to_string()),
|
||||
)),
|
||||
];
|
||||
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::<String>();
|
||||
let chunky_dead_contents =
|
||||
(0..=(test.server.core.groupware.dead_property_size.unwrap() + 1))
|
||||
.map(|_| "a")
|
||||
.collect::<String>();
|
||||
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 18: 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");
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.request("DELETE", &test_base_path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
client.delete_default_containers().await;
|
||||
client
|
||||
.delete_default_containers_by_account("[email protected]")
|
||||
.await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
|
||||
const EXPAND_REPORT_QUERY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:expand-property xmlns:D="DAV:"
|
||||
xmlns:A="urn:ietf:params:xml:ns:caldav"
|
||||
xmlns:B="urn:ietf:params:xml:ns:carddav">
|
||||
<A:property name="calendar-description"/>
|
||||
<B:property name="addressbook-description"/>
|
||||
<D:property name="current-user-principal">
|
||||
<D:property name="displayname"/>
|
||||
</D:property>
|
||||
<D:property name="owner">
|
||||
<D:property name="displayname"/>
|
||||
</D:property>
|
||||
</D:expand-property>"#;
|
||||
|
||||
pub 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::CalDav(CalDavProperty::ScheduleDefaultCalendarURL),
|
||||
DavProperty::CalDav(CalDavProperty::ScheduleTag),
|
||||
DavProperty::CalDav(CalDavProperty::ScheduleCalendarTransp),
|
||||
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),
|
||||
DavProperty::Principal(PrincipalProperty::CalendarUserAddressSet),
|
||||
DavProperty::Principal(PrincipalProperty::CalendarUserType),
|
||||
DavProperty::Principal(PrincipalProperty::ScheduleInboxURL),
|
||||
DavProperty::Principal(PrincipalProperty::ScheduleOutboxURL),
|
||||
];
|
||||
@@ -0,0 +1,634 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use types::collection::Collection;
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
|
||||
use crate::utils::webdav::GenerateTestDavResource;
|
||||
use crate::webdav::*;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running PUT/GET tests...");
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
// Simple PUT
|
||||
let mut files = AHashMap::new();
|
||||
for (path, ct, content) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/file1.txt",
|
||||
"text/plain",
|
||||
TEST_FILE_1,
|
||||
),
|
||||
(
|
||||
"/dav/file/john%40example.com/file2.txt",
|
||||
"text/x-other",
|
||||
TEST_FILE_2,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/default/card1.vcf",
|
||||
"text/vcard; charset=utf-8",
|
||||
TEST_VCARD_1,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/default/card2.vcf",
|
||||
"text/vcard; charset=utf-8",
|
||||
TEST_VCARD_2,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/default/event1.ics",
|
||||
"text/calendar; charset=utf-8",
|
||||
TEST_ICAL_1,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/default/event2.ics",
|
||||
"text/calendar; charset=utf-8",
|
||||
TEST_ICAL_2,
|
||||
),
|
||||
] {
|
||||
let content = content.replace("\n", "\r\n");
|
||||
let etag = client
|
||||
.request_with_headers("PUT", path, [("content-type", ct)], &content)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED)
|
||||
.etag()
|
||||
.to_string();
|
||||
files.insert(path, (content, ct, etag));
|
||||
}
|
||||
|
||||
// Test GET
|
||||
for (path, (content, ct, etag)) in &files {
|
||||
client
|
||||
.request("GET", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_header("etag", etag)
|
||||
.with_header("content-type", ct)
|
||||
.with_body(content);
|
||||
}
|
||||
|
||||
// Test GET with a Range header
|
||||
let path = "/dav/file/john%40example.com/file1.txt";
|
||||
let (content, _, etag) = files.get(path).unwrap();
|
||||
let size = content.len();
|
||||
|
||||
for (range, expect_content_range, expect_body) in [
|
||||
("bytes=0-4", format!("bytes 0-4/{size}"), &content[..5]),
|
||||
("bytes=0-0", format!("bytes 0-0/{size}"), &content[..1]),
|
||||
(
|
||||
"bytes=5-",
|
||||
format!("bytes 5-{}/{size}", size - 1),
|
||||
&content[5..],
|
||||
),
|
||||
(
|
||||
"bytes=-6",
|
||||
format!("bytes {}-{}/{size}", size - 6, size - 1),
|
||||
&content[size - 6..],
|
||||
),
|
||||
(
|
||||
"bytes=0-100000",
|
||||
format!("bytes 0-{}/{size}", size - 1),
|
||||
&content[..],
|
||||
),
|
||||
] {
|
||||
client
|
||||
.request_with_headers("GET", path, [("range", range)], "")
|
||||
.await
|
||||
.with_status(StatusCode::PARTIAL_CONTENT)
|
||||
.with_header("content-range", &expect_content_range)
|
||||
.with_header("content-length", &expect_body.len().to_string())
|
||||
.with_header("accept-ranges", "bytes")
|
||||
.with_header("etag", etag)
|
||||
.with_body(expect_body);
|
||||
}
|
||||
|
||||
// Ranges outside the resource should fail
|
||||
for range in ["bytes=100000-", &format!("bytes={size}-"), "bytes=-0"] {
|
||||
client
|
||||
.request_with_headers("GET", path, [("range", range)], "")
|
||||
.await
|
||||
.with_status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.with_header("content-range", &format!("bytes */{size}"));
|
||||
}
|
||||
|
||||
// Multiple, invalid or unknown ranges should be ignored
|
||||
for range in ["bytes=0-4,6-8", "items=0-4", "bytes=4-2", "bytes=abc"] {
|
||||
client
|
||||
.request_with_headers("GET", path, [("range", range)], "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_header("accept-ranges", "bytes")
|
||||
.with_body(content);
|
||||
}
|
||||
|
||||
// Ranges should be ignored on HEAD requests
|
||||
client
|
||||
.request_with_headers("HEAD", path, [("range", "bytes=0-4")], "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_header("content-length", &size.to_string())
|
||||
.with_empty_body();
|
||||
|
||||
// Ranges should only be served when the If-Range validator matches
|
||||
let weak_etag = format!("W/{etag}");
|
||||
let last_modified = client
|
||||
.request("HEAD", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.header("last-modified")
|
||||
.to_string();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
for (if_range, expect_status) in [
|
||||
(etag.as_str(), StatusCode::PARTIAL_CONTENT),
|
||||
(last_modified.as_str(), StatusCode::PARTIAL_CONTENT),
|
||||
(weak_etag.as_str(), StatusCode::OK),
|
||||
("\"invalid-etag\"", StatusCode::OK),
|
||||
("Sun, 09 Aug 2020 12:00:00 GMT", StatusCode::OK),
|
||||
] {
|
||||
client
|
||||
.request_with_headers(
|
||||
"GET",
|
||||
path,
|
||||
[("range", "bytes=0-4"), ("if-range", if_range)],
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.with_status(expect_status);
|
||||
}
|
||||
|
||||
// If-Range without a Range header should be ignored
|
||||
client
|
||||
.request_with_headers("GET", path, [("if-range", "\"invalid-etag\"")], "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_body(content);
|
||||
|
||||
// Ranges on empty files should be ignored
|
||||
let empty_path = "/dav/file/john%40example.com/empty.txt";
|
||||
client
|
||||
.request_with_headers("PUT", empty_path, [("content-type", "text/plain")], "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
for range in ["bytes=0-4", "bytes=-5", "bytes=0-"] {
|
||||
client
|
||||
.request_with_headers("GET", empty_path, [("range", range)], "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_header("accept-ranges", "bytes")
|
||||
.with_empty_body();
|
||||
}
|
||||
client
|
||||
.request("DELETE", empty_path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
|
||||
// PUT under a non-existing parent should fail
|
||||
for (path, contents) in [
|
||||
("/dav/file/john%40example.com/foo/file1.txt", TEST_FILE_1),
|
||||
("/dav/card/john%40example.com/foo/card1.vcf", TEST_VCARD_1),
|
||||
("/dav/cal/john%40example.com/foo/event1.ics", TEST_ICAL_1),
|
||||
] {
|
||||
client
|
||||
.request("PUT", path, contents)
|
||||
.await
|
||||
.with_status(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
// PUT under resources should fail
|
||||
for (path, contents) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/file1.txt/other-file.txt",
|
||||
TEST_FILE_1,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/default/card1.vcf/other-file.vcf",
|
||||
TEST_VCARD_1,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/default/event1.ics/other-file.ical",
|
||||
TEST_ICAL_1,
|
||||
),
|
||||
] {
|
||||
client
|
||||
.request("PUT", path, contents)
|
||||
.await
|
||||
.with_status(StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
// PUT a non-vCard/iCalendar file should fail
|
||||
for (path, ct, content, precondition) in [
|
||||
(
|
||||
"/dav/card/john%40example.com/card3.vcf",
|
||||
"text/vcard; charset=utf-8",
|
||||
TEST_FILE_1,
|
||||
"B:supported-address-data",
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/event3.ics",
|
||||
"text/calendar; charset=utf-8",
|
||||
TEST_FILE_2,
|
||||
"A:supported-calendar-data",
|
||||
),
|
||||
] {
|
||||
client
|
||||
.request_with_headers("PUT", path, [("content-type", ct)], content)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED)
|
||||
.with_failed_precondition(precondition, "");
|
||||
}
|
||||
|
||||
// Exceeding the configured file limits should fail
|
||||
let conf = &test.server.core.groupware;
|
||||
for (path, contents, max_size, expect) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/chunky-file1.txt",
|
||||
TEST_FILE_1,
|
||||
conf.max_file_size,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/chunky-card1.vcf",
|
||||
TEST_VCARD_1,
|
||||
conf.max_vcard_size,
|
||||
Some("B:max-resource-size"),
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/chunky-event1.ics",
|
||||
TEST_ICAL_1,
|
||||
conf.max_ical_size,
|
||||
Some("A:max-resource-size"),
|
||||
),
|
||||
] {
|
||||
let mut chunky_contents = String::with_capacity(max_size + contents.len());
|
||||
while chunky_contents.len() < max_size {
|
||||
chunky_contents.push_str(contents);
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
// PUT requests cannot exceed quota
|
||||
let mike_noquota = test.account("[email protected]").webdav_client();
|
||||
for resource_type in [
|
||||
DavResourceName::File,
|
||||
DavResourceName::Card,
|
||||
DavResourceName::Cal,
|
||||
] {
|
||||
let path = format!(
|
||||
"{}/mike%40example.com/quota-test/",
|
||||
resource_type.base_path()
|
||||
);
|
||||
mike_noquota
|
||||
.mkcol("MKCOL", &path, [], [])
|
||||
.await
|
||||
.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
|
||||
let modseq = [
|
||||
test.resources("[email protected]", Collection::FileNode)
|
||||
.await
|
||||
.highest_change_id,
|
||||
test.resources("[email protected]", Collection::Calendar)
|
||||
.await
|
||||
.highest_change_id,
|
||||
test.resources("[email protected]", Collection::AddressBook)
|
||||
.await
|
||||
.highest_change_id,
|
||||
];
|
||||
for (path, ct, content) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/file1.txt",
|
||||
"text/plain",
|
||||
TEST_FILE_1,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/default/card1.vcf",
|
||||
"text/vcard; charset=utf-8",
|
||||
TEST_VCARD_1,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/default/event1.ics",
|
||||
"text/calendar; charset=utf-8",
|
||||
TEST_ICAL_1,
|
||||
),
|
||||
] {
|
||||
let content = content.replace("\n", "\r\n");
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
path,
|
||||
[("content-type", ct), ("if-none-match", "*")],
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
path,
|
||||
[("content-type", ct), ("overwrite", "F")],
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
path,
|
||||
[("content-type", ct), ("if", "([\"3827\"])")],
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
path,
|
||||
[
|
||||
("content-type", ct),
|
||||
("if", "([\"3827\"])"),
|
||||
("prefer", "return=representation"),
|
||||
],
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED)
|
||||
.with_header("preference-applied", "return=representation")
|
||||
.with_body(&content);
|
||||
}
|
||||
assert_eq!(
|
||||
[
|
||||
test.resources("[email protected]", Collection::FileNode)
|
||||
.await
|
||||
.highest_change_id,
|
||||
test.resources("[email protected]", Collection::Calendar)
|
||||
.await
|
||||
.highest_change_id,
|
||||
test.resources("[email protected]", Collection::AddressBook)
|
||||
.await
|
||||
.highest_change_id,
|
||||
],
|
||||
modseq
|
||||
);
|
||||
|
||||
// Update files using etags
|
||||
for (path, (content, ct, etag)) in &mut files {
|
||||
let condition = format!("([{}])", etag);
|
||||
*content = content.replace("X-TEST:SEQ1", "X-TEST:SEQ2");
|
||||
*etag = client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
path,
|
||||
[("content-type", &**ct), ("if", condition.as_str())],
|
||||
content.as_str(),
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT)
|
||||
.etag()
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Test GET
|
||||
for (path, (content, ct, etag)) in &files {
|
||||
client
|
||||
.request("GET", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_header("etag", etag)
|
||||
.with_header("content-type", ct)
|
||||
.with_body(content);
|
||||
}
|
||||
|
||||
// PUT requests require unique UIDs
|
||||
for (path, ct, content, precond_key, precond_value) in [
|
||||
(
|
||||
"/dav/card/john%40example.com/default/card5.vcf",
|
||||
"text/vcard; charset=utf-8",
|
||||
TEST_VCARD_1,
|
||||
"B:no-uid-conflict.D:href",
|
||||
"/dav/card/john%40example.com/default/card1.vcf",
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/default/event5.ics",
|
||||
"text/calendar; charset=utf-8",
|
||||
TEST_ICAL_1,
|
||||
"A:no-uid-conflict.D:href",
|
||||
"/dav/cal/john%40example.com/default/event1.ics",
|
||||
),
|
||||
] {
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
path,
|
||||
[("content-type", ct), ("if-none-match", "*")],
|
||||
content,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED)
|
||||
.with_failed_precondition(precond_key, precond_value);
|
||||
}
|
||||
|
||||
// iCal containing different component types should fail
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
"/dav/cal/john%40example.com/default/invalid.ics",
|
||||
[
|
||||
("content-type", "text/calendar; charset=utf-8"),
|
||||
("if-none-match", "*"),
|
||||
],
|
||||
r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:1234567890
|
||||
SUMMARY:Test Event
|
||||
DTSTART;TZID=Europe/London:20231001T120000
|
||||
DTEND;TZID=Europe/London:20231001T130000
|
||||
END:VEVENT
|
||||
BEGIN:VTODO
|
||||
UID:1234567890
|
||||
SUMMARY:Test Task
|
||||
DTSTART;TZID=Europe/London:20231001T120000
|
||||
DTEND;TZID=Europe/London:20231001T130000
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED)
|
||||
.with_failed_precondition("A:valid-calendar-object-resource", "");
|
||||
|
||||
// iCal referencing more than one UID should fail
|
||||
client
|
||||
.request_with_headers(
|
||||
"PUT",
|
||||
"/dav/cal/john%40example.com/default/invalid.ics",
|
||||
[
|
||||
("content-type", "text/calendar; charset=utf-8"),
|
||||
("if-none-match", "*"),
|
||||
],
|
||||
r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:1234567890
|
||||
SUMMARY:Test Event 1
|
||||
DTSTART;TZID=Europe/London:20231001T120000
|
||||
DTEND;TZID=Europe/London:20231001T130000
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:1234567891
|
||||
SUMMARY:Test Event 2
|
||||
DTSTART;TZID=Europe/London:20231001T120000
|
||||
DTEND;TZID=Europe/London:20231001T130000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED)
|
||||
.with_failed_precondition("A:valid-calendar-object-resource", "");
|
||||
|
||||
// Deleting unknown/invalid destinations should fail
|
||||
for (path, expect) in [
|
||||
(
|
||||
"/dav/file/john%40example.com/unknown.txt",
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
(
|
||||
"/dav/card/john%40example.com/default/unknown.txt",
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
(
|
||||
"/dav/cal/john%40example.com/default/unknown.txt",
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
("/dav/file/john%40example.com", StatusCode::FORBIDDEN),
|
||||
("/dav/cal/john%40example.com", StatusCode::FORBIDDEN),
|
||||
("/dav/card/john%40example.com", StatusCode::FORBIDDEN),
|
||||
(
|
||||
"/dav/pal/john%40example.com",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
("/dav/file", StatusCode::FORBIDDEN),
|
||||
("/dav/cal", StatusCode::FORBIDDEN),
|
||||
("/dav/card", StatusCode::FORBIDDEN),
|
||||
("/dav/pal", StatusCode::METHOD_NOT_ALLOWED),
|
||||
] {
|
||||
client.request("DELETE", path, "").await.with_status(expect);
|
||||
}
|
||||
|
||||
// Resource names containing characters that are legal in a path segment
|
||||
for (resource_type, container, name) in [
|
||||
(DavResourceName::Cal, "default", "foo+bar(1)&x:[email protected]"),
|
||||
(DavResourceName::Card, "default", "foo+bar(1)&x:[email protected]"),
|
||||
(DavResourceName::Cal, "cal+1(a)", "event.ics"),
|
||||
(DavResourceName::Card, "book+1(a)", "card.vcf"),
|
||||
] {
|
||||
let container_path = format!(
|
||||
"{}/john%40example.com/{container}",
|
||||
resource_type.base_path()
|
||||
);
|
||||
if container != "default" {
|
||||
client
|
||||
.request("MKCOL", &container_path, "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
}
|
||||
let path = format!("{container_path}/{name}");
|
||||
let content = resource_type.generate();
|
||||
client
|
||||
.request("PUT", &path, &content)
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
client
|
||||
.propfind(&path, ["D:getetag"])
|
||||
.await
|
||||
.with_hrefs([path.as_str()]);
|
||||
client
|
||||
.request("GET", &path, "")
|
||||
.await
|
||||
.with_status(StatusCode::OK)
|
||||
.with_body(&content);
|
||||
client
|
||||
.request("DELETE", &path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
if container != "default" {
|
||||
client
|
||||
.request("DELETE", &container_path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files
|
||||
for (path, (_, _, etag)) in &files {
|
||||
client
|
||||
.request_with_headers("DELETE", path, [("if", "([\"3827\"])")], "")
|
||||
.await
|
||||
.with_status(StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
let condition = format!("([{}])", etag);
|
||||
client
|
||||
.request_with_headers("DELETE", path, [("if", condition.as_str())], "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
|
||||
client
|
||||
.request("DELETE", path, "")
|
||||
.await
|
||||
.with_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
client.delete_default_containers().await;
|
||||
mike_noquota.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
||||
|
||||
use ahash::AHashSet;
|
||||
use dav_proto::Depth;
|
||||
use groupware::DavResourceName;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let client = test.account("[email protected]").webdav_client();
|
||||
|
||||
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%40example.com/", resource_type.base_path());
|
||||
|
||||
// Test 1: Initial sync
|
||||
let response = client
|
||||
.sync_collection(&user_base_path, "", Depth::Infinity, None, ["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,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.hrefs(), Vec::<String>::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,
|
||||
None,
|
||||
["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,
|
||||
None,
|
||||
["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,
|
||||
None,
|
||||
["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,
|
||||
None,
|
||||
["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,
|
||||
None,
|
||||
["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();
|
||||
|
||||
// Test 9: Limit
|
||||
let mut sync_token = client
|
||||
.sync_collection(
|
||||
&new_collection,
|
||||
&sync_token_3,
|
||||
Depth::Zero,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await
|
||||
.sync_token()
|
||||
.to_string();
|
||||
let (folder_name, files) = client
|
||||
.create_hierarchy(user_base_path.trim_end_matches('/'), 1, 0, 10)
|
||||
.await;
|
||||
let mut expected_changes = files
|
||||
.iter()
|
||||
.map(|x| x.0.as_str())
|
||||
.chain([folder_name.as_str()])
|
||||
.collect::<AHashSet<_>>();
|
||||
for _ in 0..10 {
|
||||
let response = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
&sync_token,
|
||||
Depth::Infinity,
|
||||
2.into(),
|
||||
["D:getetag"],
|
||||
)
|
||||
.await;
|
||||
sync_token = response.sync_token().to_string();
|
||||
let hrefs = response.hrefs();
|
||||
if hrefs.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut has_user_base_path = false;
|
||||
let mut item_count = 0;
|
||||
for href in hrefs {
|
||||
if href == user_base_path {
|
||||
has_user_base_path = true;
|
||||
} else if expected_changes.remove(href) {
|
||||
item_count += 1;
|
||||
} else {
|
||||
panic!("Unexpected href: {href}");
|
||||
}
|
||||
}
|
||||
if has_user_base_path {
|
||||
assert_eq!(item_count, 2);
|
||||
response
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:status",
|
||||
"HTTP/1.1 507 Insufficient Storage",
|
||||
)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:error.D:number-of-matches-within-limits",
|
||||
"",
|
||||
)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:responsedescription",
|
||||
"The number of matches exceeds the limit of 2",
|
||||
);
|
||||
} else {
|
||||
assert!(item_count <= 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(expected_changes.is_empty(), "{:?}", expected_changes);
|
||||
|
||||
// Test 10: Expect changes after deletion
|
||||
client
|
||||
.request("DELETE", &new_file, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
let response = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
&sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await;
|
||||
sync_token = response.sync_token().to_string();
|
||||
response
|
||||
.with_href_count(1)
|
||||
.with_value("D:multistatus.D:response.D:href", &new_file)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:status",
|
||||
"HTTP/1.1 404 Not Found",
|
||||
);
|
||||
client
|
||||
.request("DELETE", &new_collection, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
let response = client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
&sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await;
|
||||
sync_token = response.sync_token().to_string();
|
||||
response
|
||||
.with_href_count(1)
|
||||
.with_value("D:multistatus.D:response.D:href", &new_collection)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:status",
|
||||
"HTTP/1.1 404 Not Found",
|
||||
);
|
||||
client
|
||||
.request("DELETE", &folder_name, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
client
|
||||
.sync_collection(
|
||||
&user_base_path,
|
||||
&sync_token,
|
||||
Depth::Infinity,
|
||||
None,
|
||||
["D:getetag"],
|
||||
)
|
||||
.await
|
||||
.with_href_count(1)
|
||||
.with_value("D:multistatus.D:response.D:href", &folder_name)
|
||||
.with_value(
|
||||
"D:multistatus.D:response.D:status",
|
||||
"HTTP/1.1 404 Not Found",
|
||||
);
|
||||
}
|
||||
|
||||
client.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
}
|
||||
Reference in New Issue
Block a user