1034 lines
41 KiB
Rust
1034 lines
41 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use super::{DavResponse, DummyWebDavClient, WebDavTest};
|
|
use crate::webdav::{GenerateTestDavResource, TEST_ICAL_2, TEST_VTIMEZONE_1};
|
|
use ahash::{AHashMap, AHashSet};
|
|
use dav_proto::schema::{
|
|
property::{CalDavProperty, CardDavProperty, DavProperty, PrincipalProperty, WebDavProperty},
|
|
request::DeadElementTag,
|
|
};
|
|
use groupware::DavResourceName;
|
|
use hyper::StatusCode;
|
|
|
|
pub async fn test(test: &WebDavTest) {
|
|
let client = test.client("jane");
|
|
|
|
for resource_type in [
|
|
DavResourceName::File,
|
|
DavResourceName::Cal,
|
|
DavResourceName::Card,
|
|
] {
|
|
println!(
|
|
"Running PROPFIND/PROPPATCH tests ({})...",
|
|
resource_type.base_path()
|
|
);
|
|
let user_base_path = format!("{}/jane", resource_type.base_path());
|
|
let group_base_path = format!("{}/support", resource_type.base_path());
|
|
|
|
// Create a new test container and file
|
|
let test_base_path = format!("{user_base_path}/PropFind_Folder/");
|
|
let etag_folder = client
|
|
.mkcol("MKCOL", &test_base_path, [], [])
|
|
.await
|
|
.with_status(StatusCode::CREATED)
|
|
.etag()
|
|
.to_string();
|
|
let test_contents = resource_type.generate();
|
|
let test_path = format!("{test_base_path}test_file");
|
|
let etag_file = client
|
|
.request_with_headers(
|
|
"PUT",
|
|
&test_path,
|
|
[("content-type", "text/x-other")],
|
|
test_contents.as_str(),
|
|
)
|
|
.await
|
|
.with_status(StatusCode::CREATED)
|
|
.etag()
|
|
.to_string();
|
|
|
|
// Test 1: PROPFIND Depth 0 on root
|
|
client
|
|
.request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "0")], "")
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.with_hrefs([resource_type.collection_path()]);
|
|
|
|
// Test 2: PROPFIND Depth 0 on user base path
|
|
client
|
|
.request_with_headers("PROPFIND", &user_base_path, [("depth", "0")], "")
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.with_hrefs([format!("{user_base_path}/").as_str()]);
|
|
|
|
// Test 3: PROPFIND Depth 1 on root
|
|
client
|
|
.request_with_headers("PROPFIND", resource_type.base_path(), [("depth", "1")], "")
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.with_hrefs([
|
|
resource_type.collection_path(),
|
|
format!("{user_base_path}/").as_str(),
|
|
format!("{group_base_path}/").as_str(),
|
|
]);
|
|
|
|
// Test 4: Infinity depth is not allowed
|
|
for path in [resource_type.base_path(), user_base_path.as_str()] {
|
|
client
|
|
.request_with_headers("PROPFIND", path, [("depth", "infinity")], "")
|
|
.await
|
|
.with_status(StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// Test 5: PROPFIND Depth 1 on user base path
|
|
let response = client
|
|
.request_with_headers("PROPFIND", &user_base_path, [("depth", "1")], "")
|
|
.await;
|
|
if resource_type != DavResourceName::File {
|
|
response.with_status(StatusCode::MULTI_STATUS).with_hrefs([
|
|
format!("{user_base_path}/").as_str(),
|
|
format!("{user_base_path}/default/").as_str(),
|
|
&test_base_path,
|
|
]);
|
|
} else {
|
|
response
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.with_hrefs([format!("{user_base_path}/").as_str(), &test_base_path]);
|
|
}
|
|
|
|
// Test 6: PROPFIND Depth 1 on created collection
|
|
client
|
|
.request_with_headers("PROPFIND", &test_base_path, [("depth", "1")], "")
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.with_hrefs([test_base_path.as_str(), test_path.as_str()]);
|
|
|
|
// Test 7: Infinity depth is not allowed on file containers
|
|
client
|
|
.request_with_headers("PROPFIND", &test_base_path, [("depth", "infinity")], "")
|
|
.await
|
|
.with_status(if resource_type == DavResourceName::File {
|
|
StatusCode::FORBIDDEN
|
|
} else {
|
|
StatusCode::MULTI_STATUS
|
|
});
|
|
|
|
// Test 8: Retrieve all static properties
|
|
for (path, etag, is_file) in [
|
|
(&test_base_path, &etag_folder, false),
|
|
(&test_path, &etag_file, true),
|
|
] {
|
|
let response = client.propfind(path, ALL_DAV_PROPERTIES).await;
|
|
let properties = response.properties(path);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::CreationDate))
|
|
.is_not_empty();
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::GetLastModified))
|
|
.is_not_empty();
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::SyncToken))
|
|
.is_not_empty();
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::GetETag))
|
|
.with_values([etag.as_str()]);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::SupportedLock))
|
|
.with_values([
|
|
"D:lockentry.D:lockscope.D:exclusive",
|
|
"D:lockentry.D:locktype.D:write",
|
|
"D:lockentry.D:lockscope.D:shared",
|
|
"D:lockentry.D:locktype.D:write",
|
|
]);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal))
|
|
.with_values([
|
|
format!("D:href:{}/jane/", DavResourceName::Principal.base_path()).as_str(),
|
|
]);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::Owner))
|
|
.with_values([
|
|
format!("D:href:{}/jane/", DavResourceName::Principal.base_path()).as_str(),
|
|
]);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet))
|
|
.is_not_empty();
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::AclRestrictions))
|
|
.with_values(["D:grant-only", "D:no-invert"]);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet))
|
|
.with_values([
|
|
format!("D:href:{}", DavResourceName::Principal.collection_path()).as_str(),
|
|
]);
|
|
|
|
if is_file {
|
|
// File specific properties
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::GetContentType))
|
|
.with_values([match resource_type {
|
|
DavResourceName::File => "text/x-other",
|
|
DavResourceName::Cal => "text/calendar",
|
|
DavResourceName::Card => "text/vcard",
|
|
_ => unreachable!(),
|
|
}]);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::GetContentLength))
|
|
.with_values([test_contents.len().to_string().as_str()]);
|
|
} else {
|
|
// Collection specific properties
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::GetCTag))
|
|
.is_not_empty();
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::ResourceType))
|
|
.with_values(match resource_type {
|
|
DavResourceName::File => ["D:collection"].as_slice().iter().copied(),
|
|
DavResourceName::Cal => {
|
|
["D:collection", "A:calendar"].as_slice().iter().copied()
|
|
}
|
|
DavResourceName::Card => {
|
|
["D:collection", "B:addressbook"].as_slice().iter().copied()
|
|
}
|
|
_ => unreachable!(),
|
|
});
|
|
let used_bytes: u64 = properties
|
|
.get(DavProperty::WebDav(WebDavProperty::QuotaUsedBytes))
|
|
.value()
|
|
.parse()
|
|
.unwrap();
|
|
let available_bytes: u64 = properties
|
|
.get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes))
|
|
.value()
|
|
.parse()
|
|
.unwrap();
|
|
assert!(used_bytes > 0);
|
|
assert!(available_bytes > 0);
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::SupportedReportSet))
|
|
.with_values(match resource_type {
|
|
DavResourceName::File => [
|
|
"D:supported-report.D:report.D:sync-collection",
|
|
"D:supported-report.D:report.D:acl-principal-prop-set",
|
|
"D:supported-report.D:report.D:principal-match",
|
|
]
|
|
.as_slice()
|
|
.iter()
|
|
.copied(),
|
|
DavResourceName::Cal => [
|
|
"D:supported-report.D:report.A:calendar-query",
|
|
"D:supported-report.D:report.D:sync-collection",
|
|
"D:supported-report.D:report.D:acl-principal-prop-set",
|
|
"D:supported-report.D:report.D:expand-property",
|
|
"D:supported-report.D:report.A:free-busy-query",
|
|
"D:supported-report.D:report.A:calendar-multiget",
|
|
"D:supported-report.D:report.D:principal-match",
|
|
]
|
|
.as_slice()
|
|
.iter()
|
|
.copied(),
|
|
DavResourceName::Card => [
|
|
"D:supported-report.D:report.B:addressbook-multiget",
|
|
"D:supported-report.D:report.D:sync-collection",
|
|
"D:supported-report.D:report.D:acl-principal-prop-set",
|
|
"D:supported-report.D:report.D:principal-match",
|
|
"D:supported-report.D:report.B:addressbook-query",
|
|
"D:supported-report.D:report.D:expand-property",
|
|
]
|
|
.as_slice()
|
|
.iter()
|
|
.copied(),
|
|
_ => unreachable!(),
|
|
});
|
|
|
|
if resource_type == DavResourceName::Cal {
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet))
|
|
.with_values([
|
|
"D:privilege.D:all",
|
|
"D:privilege.D:read",
|
|
"D:privilege.D:write",
|
|
"D:privilege.D:write-properties",
|
|
"D:privilege.D:write-content",
|
|
"D:privilege.D:unlock",
|
|
"D:privilege.D:read-acl",
|
|
"D:privilege.D:read-current-user-privilege-set",
|
|
"D:privilege.D:write-acl",
|
|
"D:privilege.D:bind",
|
|
"D:privilege.D:unbind",
|
|
"D:privilege.A:read-free-busy",
|
|
]);
|
|
properties
|
|
.get(DavProperty::CalDav(
|
|
CalDavProperty::SupportedCalendarComponentSet,
|
|
))
|
|
.with_values([
|
|
"A:supported-calendar-component-set.A:comp.[name]:VAVAILABILITY",
|
|
"A:supported-calendar-component-set.A:comp.[name]:AVAILABLE",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VRESOURCE",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VTODO",
|
|
"A:supported-calendar-component-set.A:comp.[name]:DAYLIGHT",
|
|
"A:supported-calendar-component-set.A:comp.[name]:STANDARD",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VLOCATION",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VTIMEZONE",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VFREEBUSY",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VEVENT",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VJOURNAL",
|
|
"A:supported-calendar-component-set.A:comp.[name]:PARTICIPANT",
|
|
"A:supported-calendar-component-set.A:comp.[name]:VALARM",
|
|
]);
|
|
properties
|
|
.get(DavProperty::CalDav(CalDavProperty::SupportedCalendarData))
|
|
.with_values([
|
|
concat!(
|
|
"A:supported-calendar-data.A:calendar-data-type.",
|
|
"[content-type]:text/calendar"
|
|
),
|
|
"A:supported-calendar-data.A:calendar-data-type.[version]:2.0",
|
|
"A:supported-calendar-data.A:calendar-data-type.[version]:1.0",
|
|
]);
|
|
properties
|
|
.get(DavProperty::CalDav(CalDavProperty::SupportedCollationSet))
|
|
.with_values([
|
|
"A:supported-collation:i;unicode-casemap",
|
|
"A:supported-collation:i;ascii-casemap",
|
|
]);
|
|
properties
|
|
.get(DavProperty::CalDav(CalDavProperty::MinDateTime))
|
|
.with_values(["0001-01-01T00:00:00Z"]);
|
|
properties
|
|
.get(DavProperty::CalDav(CalDavProperty::MaxDateTime))
|
|
.with_values(["9999-12-31T23:59:59Z"]);
|
|
for (key, value) in [
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::MaxResourceSize),
|
|
test.server.core.groupware.max_ical_size,
|
|
),
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::MaxInstances),
|
|
test.server.core.groupware.max_ical_instances,
|
|
),
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance),
|
|
test.server.core.groupware.max_ical_attendees_per_instance,
|
|
),
|
|
] {
|
|
properties
|
|
.get(key)
|
|
.with_values([value.to_string().as_str()]);
|
|
}
|
|
} else {
|
|
if resource_type == DavResourceName::Card {
|
|
properties
|
|
.get(DavProperty::CardDav(CardDavProperty::SupportedAddressData))
|
|
.with_values([
|
|
concat!(
|
|
"B:supported-address-data.B:address-data-type.",
|
|
"[content-type]:text/vcard"
|
|
),
|
|
"B:supported-address-data.B:address-data-type.[version]:3.0",
|
|
"B:supported-address-data.B:address-data-type.[version]:4.0",
|
|
"B:supported-address-data.B:address-data-type.[version]:2.1",
|
|
]);
|
|
properties
|
|
.get(DavProperty::CardDav(CardDavProperty::SupportedCollationSet))
|
|
.with_values([
|
|
"B:supported-collation:i;unicode-casemap",
|
|
"B:supported-collation:i;ascii-casemap",
|
|
]);
|
|
properties
|
|
.get(DavProperty::CardDav(CardDavProperty::MaxResourceSize))
|
|
.with_values([test
|
|
.server
|
|
.core
|
|
.groupware
|
|
.max_vcard_size
|
|
.to_string()
|
|
.as_str()]);
|
|
}
|
|
|
|
properties
|
|
.get(DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet))
|
|
.with_values([
|
|
"D:privilege.D:all",
|
|
"D:privilege.D:read",
|
|
"D:privilege.D:write",
|
|
"D:privilege.D:write-properties",
|
|
"D:privilege.D:write-content",
|
|
"D:privilege.D:unlock",
|
|
"D:privilege.D:read-acl",
|
|
"D:privilege.D:read-current-user-privilege-set",
|
|
"D:privilege.D:write-acl",
|
|
"D:privilege.D:bind",
|
|
"D:privilege.D:unbind",
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (path, etag, is_file) in [
|
|
(&test_base_path, &etag_folder, false),
|
|
(&test_path, &etag_file, true),
|
|
] {
|
|
// Test 9: PROPPATCH should fail when a precondition fails
|
|
client
|
|
.proppatch(
|
|
path,
|
|
[(
|
|
DavProperty::WebDav(WebDavProperty::DisplayName),
|
|
"Magnific name",
|
|
)],
|
|
[],
|
|
[("if", format!("(Not [{etag}])").as_str())],
|
|
)
|
|
.await
|
|
.with_status(StatusCode::PRECONDITION_FAILED);
|
|
client
|
|
.proppatch(
|
|
path,
|
|
[(
|
|
DavProperty::WebDav(WebDavProperty::DisplayName),
|
|
"Magnific name - second try",
|
|
)],
|
|
[],
|
|
[("if", format!("([{etag}])").as_str())],
|
|
)
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS);
|
|
client
|
|
.propfind(path, [DavProperty::WebDav(WebDavProperty::GetETag)])
|
|
.await
|
|
.properties(path)
|
|
.get(DavProperty::WebDav(WebDavProperty::GetETag))
|
|
.with_status(StatusCode::OK)
|
|
.without_values([etag.as_str()]);
|
|
|
|
// Test 10: PROPPATCH set on DAV properties
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[
|
|
(
|
|
DavProperty::WebDav(WebDavProperty::DisplayName),
|
|
"New display name",
|
|
),
|
|
(
|
|
DavProperty::WebDav(WebDavProperty::CreationDate),
|
|
"2000-01-01T00:00:00Z",
|
|
),
|
|
(
|
|
DavProperty::DeadProperty(DeadElementTag::new(
|
|
"C:my-dead-element".to_string(),
|
|
None,
|
|
)),
|
|
"this is a dead but exciting element",
|
|
),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
// Test 11: PROPPATCH remove on DAV properties
|
|
let mut props = vec![
|
|
(
|
|
DavProperty::DeadProperty(DeadElementTag::new(
|
|
"C:my-dead-element".to_string(),
|
|
None,
|
|
)),
|
|
"",
|
|
),
|
|
(DavProperty::WebDav(WebDavProperty::DisplayName), ""),
|
|
];
|
|
if !is_file && resource_type == DavResourceName::Cal {
|
|
// DisplayName can be removed from calendar collections
|
|
props.pop();
|
|
}
|
|
client.patch_and_check(path, props).await;
|
|
|
|
match resource_type {
|
|
DavResourceName::File if is_file => {
|
|
// Test 12: Change a file's content-type
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[(
|
|
DavProperty::WebDav(WebDavProperty::GetContentType),
|
|
"text/x-yadda-yadda",
|
|
)],
|
|
)
|
|
.await;
|
|
}
|
|
DavResourceName::Cal if !is_file => {
|
|
// Test 13: Change a calendar's properties
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::CalendarDescription),
|
|
"New calendar description",
|
|
),
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::TimezoneId),
|
|
"Europe/Ljubljana",
|
|
),
|
|
],
|
|
)
|
|
.await;
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[
|
|
(DavProperty::CalDav(CalDavProperty::CalendarDescription), ""),
|
|
(DavProperty::CalDav(CalDavProperty::TimezoneId), ""),
|
|
],
|
|
)
|
|
.await;
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[(
|
|
DavProperty::CalDav(CalDavProperty::CalendarTimezone),
|
|
TEST_VTIMEZONE_1.replace('\n', "\r\n").as_str(),
|
|
)],
|
|
)
|
|
.await;
|
|
}
|
|
DavResourceName::Card if !is_file => {
|
|
// Test 14: Change an addressbook's properties
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[(
|
|
DavProperty::CardDav(CardDavProperty::AddressbookDescription),
|
|
"New calendar description",
|
|
)],
|
|
)
|
|
.await;
|
|
client
|
|
.patch_and_check(
|
|
path,
|
|
[(
|
|
DavProperty::CardDav(CardDavProperty::AddressbookDescription),
|
|
"",
|
|
)],
|
|
)
|
|
.await;
|
|
}
|
|
_ => (),
|
|
}
|
|
|
|
// Test 15: PROPPATCH should fail on large properties
|
|
let mut chunky_props = vec![
|
|
DavProperty::WebDav(WebDavProperty::DisplayName),
|
|
DavProperty::DeadProperty(DeadElementTag::new(
|
|
"C:my-chunky-dead-element".to_string(),
|
|
None,
|
|
)),
|
|
];
|
|
if !is_file {
|
|
if resource_type == DavResourceName::Cal {
|
|
chunky_props.push(DavProperty::CalDav(CalDavProperty::CalendarDescription));
|
|
} else if resource_type == DavResourceName::Card {
|
|
chunky_props.push(DavProperty::CardDav(
|
|
CardDavProperty::AddressbookDescription,
|
|
));
|
|
}
|
|
}
|
|
let chunky_live_contents = (0..=(test.server.core.groupware.live_property_size + 1))
|
|
.map(|_| "a")
|
|
.collect::<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 16: PROPPATCH should fail on invalid calendar property values
|
|
if !is_file && resource_type == DavResourceName::Cal {
|
|
let response = client
|
|
.proppatch(
|
|
path,
|
|
[
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::TimezoneId),
|
|
"unknown/zone",
|
|
),
|
|
(
|
|
DavProperty::CalDav(CalDavProperty::CalendarTimezone),
|
|
TEST_ICAL_2,
|
|
),
|
|
],
|
|
[],
|
|
[],
|
|
)
|
|
.await
|
|
.into_propfind_response(None);
|
|
let props = response.properties(path);
|
|
props
|
|
.get(DavProperty::CalDav(CalDavProperty::TimezoneId))
|
|
.with_status(StatusCode::PRECONDITION_FAILED)
|
|
.with_description("Invalid timezone ID");
|
|
props
|
|
.get(DavProperty::CalDav(CalDavProperty::CalendarTimezone))
|
|
.with_status(StatusCode::PRECONDITION_FAILED)
|
|
.with_description("Invalid calendar timezone");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct DavMultiStatus {
|
|
pub response: DavResponse,
|
|
pub hrefs: AHashMap<String, DavProperties>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
pub struct DavItem {
|
|
#[serde(serialize_with = "serialize_status_code")]
|
|
pub status: StatusCode,
|
|
pub values: AHashMap<String, Vec<String>>,
|
|
pub error: Vec<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
pub struct DavProperties(Vec<DavItem>);
|
|
|
|
impl DavMultiStatus {
|
|
pub fn properties(&self, href: &str) -> DavPropertyResult<'_> {
|
|
DavPropertyResult {
|
|
response: &self.response,
|
|
properties: self.hrefs.get(href).unwrap_or_else(|| {
|
|
self.response.dump_response();
|
|
panic!(
|
|
"No properties found for href: {href} in {}",
|
|
serde_json::to_string_pretty(&self.hrefs).unwrap()
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct DavPropertyResult<'x> {
|
|
pub response: &'x DavResponse,
|
|
pub properties: &'x DavProperties,
|
|
}
|
|
|
|
pub struct DavQueryResult<'x> {
|
|
pub response: &'x DavResponse,
|
|
pub prop: &'x DavItem,
|
|
pub values: &'x [String],
|
|
}
|
|
|
|
impl DavPropertyResult<'_> {
|
|
pub fn get(&self, name: impl AsRef<str>) -> DavQueryResult<'_> {
|
|
let name = name.as_ref();
|
|
self.properties
|
|
.0
|
|
.iter()
|
|
.find_map(|prop| {
|
|
prop.values.get(name).map(|values| DavQueryResult {
|
|
response: self.response,
|
|
prop,
|
|
values,
|
|
})
|
|
})
|
|
.unwrap_or_else(|| {
|
|
self.response.dump_response();
|
|
panic!(
|
|
"No property found for name: {name} in {}",
|
|
serde_json::to_string_pretty(&self.properties.0).unwrap()
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<'x> DavQueryResult<'x> {
|
|
pub fn with_values(&self, expected_values: impl IntoIterator<Item = &'x str>) -> &Self {
|
|
let expected_values = AHashSet::from_iter(expected_values);
|
|
let values = self
|
|
.values
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect::<AHashSet<_>>();
|
|
|
|
if values != expected_values {
|
|
self.response.dump_response();
|
|
panic!("Expected {expected_values:?} values, but got {values:?}",);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn without_values(&self, expected_values: impl IntoIterator<Item = &'x str>) -> &Self {
|
|
let expected_values = AHashSet::from_iter(expected_values);
|
|
let values = self
|
|
.values
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect::<AHashSet<_>>();
|
|
|
|
if !expected_values.is_disjoint(&values) {
|
|
self.response.dump_response();
|
|
panic!("Expected no {expected_values:?} values, but got {values:?}",);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn is_not_empty(&self) -> &Self {
|
|
if self.values.is_empty() || self.values.iter().all(|s| s.is_empty()) {
|
|
self.response.dump_response();
|
|
panic!("Expected non-empty values, but got {:?}", self.values);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn value(&self) -> &str {
|
|
if let Some(value) = self.values.iter().find(|s| !s.is_empty()) {
|
|
value
|
|
} else {
|
|
self.response.dump_response();
|
|
panic!("Expected a value, but got {:?}", self.values);
|
|
}
|
|
}
|
|
|
|
pub fn with_status(&self, status: StatusCode) -> &Self {
|
|
if self.prop.status != status {
|
|
self.response.dump_response();
|
|
panic!("Expected status {status}, but got {}", self.prop.status);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn with_description(&self, description: &str) -> &Self {
|
|
if self.prop.description.as_deref() != Some(description) {
|
|
self.response.dump_response();
|
|
panic!(
|
|
"Expected description {description}, but got {:?}",
|
|
self.prop.description
|
|
);
|
|
}
|
|
self
|
|
}
|
|
pub fn with_error(&self, error: &str) -> &Self {
|
|
if !self.prop.error.contains(&error.to_string()) {
|
|
self.response.dump_response();
|
|
panic!("Expected error {error}, but got {:?}", self.prop.error);
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
impl DavResponse {
|
|
pub fn into_propfind_response(mut self, prop_prefix: Option<&str>) -> DavMultiStatus {
|
|
if let Some(prop_prefix) = prop_prefix {
|
|
for (key, _) in self.xml.iter_mut() {
|
|
if let Some(suffix) = key.strip_prefix(prop_prefix) {
|
|
*key = format!("D:multistatus.D:response{suffix}");
|
|
}
|
|
}
|
|
self.xml.push((
|
|
"D:multistatus.D:response.D:href".to_string(),
|
|
"".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut result = DavMultiStatus {
|
|
response: self,
|
|
hrefs: AHashMap::new(),
|
|
};
|
|
let mut href = None;
|
|
let mut props = Vec::new();
|
|
let mut prop = DavItem::default();
|
|
|
|
for (key, value) in &result.response.xml {
|
|
match key.as_str() {
|
|
"D:multistatus.D:response.D:href" => {
|
|
if let Some(href) = href.take() {
|
|
if !prop.is_empty() {
|
|
props.push(std::mem::take(&mut prop));
|
|
}
|
|
result
|
|
.hrefs
|
|
.insert(href, DavProperties(std::mem::take(&mut props)));
|
|
}
|
|
href = Some(value.to_string());
|
|
}
|
|
"D:multistatus.D:response.D:propstat.D:status" => {
|
|
prop.status = value
|
|
.split_ascii_whitespace()
|
|
.nth(1)
|
|
.unwrap_or_default()
|
|
.parse()
|
|
.unwrap();
|
|
}
|
|
"D:multistatus.D:response.D:propstat.D:responsedescription" => {
|
|
prop.description = Some(value.to_string());
|
|
}
|
|
|
|
_ => {
|
|
if let Some(prop_name) =
|
|
key.strip_prefix("D:multistatus.D:response.D:propstat.D:prop.")
|
|
{
|
|
if prop.status != StatusCode::PROXY_AUTHENTICATION_REQUIRED {
|
|
props.push(std::mem::take(&mut prop));
|
|
}
|
|
|
|
let (prop_name, prop_value) =
|
|
if let Some((prop_name, prop_sub_name)) = prop_name.split_once('.') {
|
|
if value.is_empty() {
|
|
(prop_name, prop_sub_name.to_string())
|
|
} else {
|
|
(prop_name, format!("{}:{}", prop_sub_name, value))
|
|
}
|
|
} else {
|
|
(prop_name, value.to_string())
|
|
};
|
|
prop.values
|
|
.entry(prop_name.to_string())
|
|
.or_default()
|
|
.push(prop_value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(href) = href.take() {
|
|
if !prop.is_empty() {
|
|
props.push(prop);
|
|
}
|
|
result.hrefs.insert(href, DavProperties(props));
|
|
}
|
|
|
|
result
|
|
}
|
|
}
|
|
|
|
impl DummyWebDavClient {
|
|
pub async fn patch_and_check<T>(
|
|
&self,
|
|
path: &str,
|
|
properties: impl IntoIterator<Item = (T, &str)>,
|
|
) where
|
|
T: AsRef<str> + Clone,
|
|
{
|
|
let mut expect_set = Vec::new();
|
|
let mut expect_remove = Vec::new();
|
|
|
|
for (key, value) in properties {
|
|
if !value.is_empty() {
|
|
expect_set.push((key, value));
|
|
} else {
|
|
expect_remove.push(key);
|
|
}
|
|
}
|
|
|
|
let response = self
|
|
.proppatch(
|
|
path,
|
|
expect_set.iter().cloned(),
|
|
expect_remove.iter().cloned(),
|
|
[],
|
|
)
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.into_propfind_response(None);
|
|
let patch_prop = response.properties(path);
|
|
for (key, _) in &expect_set {
|
|
patch_prop.get(key.as_ref()).with_status(StatusCode::OK);
|
|
}
|
|
for key in &expect_remove {
|
|
patch_prop
|
|
.get(key.as_ref())
|
|
.with_status(StatusCode::NO_CONTENT);
|
|
}
|
|
|
|
let response = self
|
|
.propfind(
|
|
path,
|
|
expect_set
|
|
.iter()
|
|
.map(|(k, _)| k)
|
|
.chain(expect_remove.iter()),
|
|
)
|
|
.await;
|
|
let prop = response.properties(path);
|
|
|
|
for (key, value) in expect_set {
|
|
prop.get(key.as_ref())
|
|
.with_values([value])
|
|
.with_status(StatusCode::OK);
|
|
}
|
|
|
|
for key in expect_remove {
|
|
prop.get(key.as_ref()).with_status(StatusCode::NOT_FOUND);
|
|
}
|
|
}
|
|
|
|
pub async fn propfind<I, T>(&self, path: &str, properties: I) -> DavMultiStatus
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: AsRef<str>,
|
|
{
|
|
let mut request = concat!(
|
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
|
|
"<D:propfind xmlns:D=\"DAV:\" xmlns:A=\"urn:ietf:params:xml:ns:caldav\" ",
|
|
"xmlns:B=\"urn:ietf:params:xml:ns:carddav\" xmlns:C=\"http://calendarserver.org/ns/\">",
|
|
"<D:prop>"
|
|
)
|
|
.to_string();
|
|
|
|
for property in properties {
|
|
request.push_str(&format!("<{}/>", property.as_ref()));
|
|
}
|
|
|
|
request.push_str("</D:prop></D:propfind>");
|
|
|
|
self.request("PROPFIND", path, &request)
|
|
.await
|
|
.with_status(StatusCode::MULTI_STATUS)
|
|
.into_propfind_response(None)
|
|
}
|
|
|
|
pub async fn proppatch<T>(
|
|
&self,
|
|
path: &str,
|
|
set: impl IntoIterator<Item = (T, &str)>,
|
|
clear: impl IntoIterator<Item = T>,
|
|
headers: impl IntoIterator<Item = (&'static str, &str)>,
|
|
) -> DavResponse
|
|
where
|
|
T: AsRef<str>,
|
|
{
|
|
let mut request = concat!(
|
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
|
|
"<D:propertyupdate xmlns:D=\"DAV:\" xmlns:A=\"urn:ietf:params:xml:ns:caldav\" ",
|
|
"xmlns:B=\"urn:ietf:params:xml:ns:carddav\" xmlns:C=\"http://calendarserver.org/ns/\">",
|
|
"<D:remove><D:prop>"
|
|
)
|
|
.to_string();
|
|
|
|
for property in clear {
|
|
request.push_str(&format!("<{}/>", property.as_ref()));
|
|
}
|
|
|
|
request.push_str("</D:prop></D:remove><D:set><D:prop>");
|
|
|
|
for (key, value) in set {
|
|
let key = key.as_ref();
|
|
request.push_str(&format!("<{key}>{value}</{key}>"));
|
|
}
|
|
|
|
request.push_str("</D:prop></D:set></D:propertyupdate>");
|
|
|
|
self.request_with_headers("PROPPATCH", path, headers, &request)
|
|
.await
|
|
}
|
|
}
|
|
|
|
impl DavItem {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.values.is_empty()
|
|
&& self.status == StatusCode::PROXY_AUTHENTICATION_REQUIRED
|
|
&& self.error.is_empty()
|
|
&& self.description.is_none()
|
|
}
|
|
}
|
|
|
|
impl Default for DavItem {
|
|
fn default() -> Self {
|
|
DavItem {
|
|
status: StatusCode::PROXY_AUTHENTICATION_REQUIRED,
|
|
values: AHashMap::new(),
|
|
error: Vec::new(),
|
|
description: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
const ALL_DAV_PROPERTIES: &[DavProperty] = &[
|
|
DavProperty::WebDav(WebDavProperty::CreationDate),
|
|
DavProperty::WebDav(WebDavProperty::DisplayName),
|
|
DavProperty::WebDav(WebDavProperty::GetContentLanguage),
|
|
DavProperty::WebDav(WebDavProperty::GetContentLength),
|
|
DavProperty::WebDav(WebDavProperty::GetContentType),
|
|
DavProperty::WebDav(WebDavProperty::GetETag),
|
|
DavProperty::WebDav(WebDavProperty::GetLastModified),
|
|
DavProperty::WebDav(WebDavProperty::ResourceType),
|
|
DavProperty::WebDav(WebDavProperty::LockDiscovery),
|
|
DavProperty::WebDav(WebDavProperty::SupportedLock),
|
|
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
|
|
DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes),
|
|
DavProperty::WebDav(WebDavProperty::QuotaUsedBytes),
|
|
DavProperty::WebDav(WebDavProperty::SupportedReportSet),
|
|
DavProperty::WebDav(WebDavProperty::SyncToken),
|
|
DavProperty::WebDav(WebDavProperty::Owner),
|
|
DavProperty::WebDav(WebDavProperty::Group),
|
|
DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet),
|
|
DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet),
|
|
DavProperty::WebDav(WebDavProperty::Acl),
|
|
DavProperty::WebDav(WebDavProperty::AclRestrictions),
|
|
DavProperty::WebDav(WebDavProperty::InheritedAclSet),
|
|
DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet),
|
|
DavProperty::WebDav(WebDavProperty::GetCTag),
|
|
DavProperty::CardDav(CardDavProperty::AddressbookDescription),
|
|
DavProperty::CardDav(CardDavProperty::SupportedAddressData),
|
|
DavProperty::CardDav(CardDavProperty::SupportedCollationSet),
|
|
DavProperty::CardDav(CardDavProperty::MaxResourceSize),
|
|
DavProperty::CalDav(CalDavProperty::CalendarDescription),
|
|
DavProperty::CalDav(CalDavProperty::CalendarTimezone),
|
|
DavProperty::CalDav(CalDavProperty::SupportedCalendarComponentSet),
|
|
DavProperty::CalDav(CalDavProperty::SupportedCalendarData),
|
|
DavProperty::CalDav(CalDavProperty::SupportedCollationSet),
|
|
DavProperty::CalDav(CalDavProperty::MaxResourceSize),
|
|
DavProperty::CalDav(CalDavProperty::MinDateTime),
|
|
DavProperty::CalDav(CalDavProperty::MaxDateTime),
|
|
DavProperty::CalDav(CalDavProperty::MaxInstances),
|
|
DavProperty::CalDav(CalDavProperty::MaxAttendeesPerInstance),
|
|
DavProperty::CalDav(CalDavProperty::TimezoneServiceSet),
|
|
DavProperty::CalDav(CalDavProperty::TimezoneId),
|
|
DavProperty::Principal(PrincipalProperty::AlternateURISet),
|
|
DavProperty::Principal(PrincipalProperty::PrincipalURL),
|
|
DavProperty::Principal(PrincipalProperty::GroupMemberSet),
|
|
DavProperty::Principal(PrincipalProperty::GroupMembership),
|
|
DavProperty::Principal(PrincipalProperty::CalendarHomeSet),
|
|
DavProperty::Principal(PrincipalProperty::AddressbookHomeSet),
|
|
DavProperty::Principal(PrincipalProperty::PrincipalAddress),
|
|
];
|
|
|
|
fn serialize_status_code<S>(status_code: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_str(&status_code.to_string())
|
|
}
|