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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+438
View File
@@ -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>"#;
+107
View File
@@ -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;
}
+132
View File
@@ -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&#39;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
"#;
+451
View File
@@ -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
+341
View File
@@ -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
"#;
+904
View File
@@ -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
}
+203
View File
@@ -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;
}
+293
View File
@@ -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;
}
+330
View File
@@ -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
"#;
+76
View File
@@ -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;
}
+515
View File
@@ -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>"#;
+792
View File
@@ -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),
];
+634
View File
@@ -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;
}
+292
View File
@@ -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;
}