WebDAV REPORT expand-property, principal-*

This commit is contained in:
mdecimus 2025-05-03 17:34:41 +02:00
parent d72ae41058
commit 9b002c2d3d
8 changed files with 640 additions and 98 deletions

View file

@ -247,21 +247,18 @@ impl<'x> DavQuery<'x> {
expand: ExpandProperty,
headers: RequestHeaders<'x>,
) -> Self {
let mut props = Vec::with_capacity(expand.properties.len());
for item in expand.properties {
if !matches!(item.property, DavProperty::DeadProperty(_))
&& !props.contains(&item.property)
{
props.push(item.property);
}
}
Self {
resource: DavQueryResource::Uri(resource),
propfind: PropFind::Prop(
expand
.properties
.into_iter()
.filter_map(|item| {
if !matches!(item.property, DavProperty::DeadProperty(_)) {
Some(item.property)
} else {
None
}
})
.collect(),
),
propfind: PropFind::Prop(props),
depth: match headers.depth {
Depth::Zero => 0,
_ => 1,

View file

@ -491,7 +491,7 @@ impl PropFindRequestHandler for Server {
} else {
containers.contains(item.document_id)
}
})
}) && (!query.depth_no_root || item.name != resource)
})
.map(|item| {
PropFindItem::new(resources.format_resource(item), account_id, item)

View file

@ -20,7 +20,11 @@ use store::roaring::RoaringBitmap;
use crate::{
DavError,
common::{DavQuery, DavQueryResource, propfind::PropFindRequestHandler, uri::DavUriResource},
common::{
DavQuery, DavQueryResource,
propfind::PropFindRequestHandler,
uri::{DavUriResource, UriResource},
},
};
use super::propfind::PrincipalPropFind;
@ -41,45 +45,54 @@ impl PrincipalMatching for Server {
headers: RequestHeaders<'_>,
mut request: PrincipalMatch,
) -> crate::Result<HttpResponse> {
let resource = self
.validate_uri(access_token, headers.uri)
.await
.and_then(|uri| uri.into_owned_uri())?;
let resource = self.validate_uri(access_token, headers.uri).await?;
match resource.collection {
Collection::AddressBook | Collection::Calendar | Collection::FileNode => {
self.handle_dav_query(
access_token,
DavQuery {
resource: DavQueryResource::Uri(resource),
propfind: PropFind::Prop(request.properties),
depth: usize::MAX,
ret: headers.ret,
depth_no_root: headers.depth_no_root,
..Default::default()
},
)
.await
}
Collection::Principal => {
let mut response = MultiStatus::new(Vec::with_capacity(16));
if request.properties.is_empty() {
request
.properties
.push(DavProperty::WebDav(WebDavProperty::DisplayName));
.push(DavProperty::WebDav(WebDavProperty::Owner));
}
if let Some(account_id) = resource.account_id {
return self
.handle_dav_query(
access_token,
DavQuery {
resource: DavQueryResource::Uri(UriResource {
collection: resource.collection,
account_id,
resource: resource.resource,
}),
propfind: PropFind::Prop(request.properties),
depth: usize::MAX,
ret: headers.ret,
depth_no_root: headers.depth_no_root,
..Default::default()
},
)
.await;
}
let request = PropFind::Prop(request.properties);
self.prepare_principal_propfind_response(
access_token,
Collection::Principal,
RoaringBitmap::from_iter(access_token.all_ids()).into_iter(),
&request,
&mut response,
)
.await?;
Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string()))
}
_ => Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)),
Collection::Principal => {}
_ => return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)),
}
let mut response = MultiStatus::new(Vec::with_capacity(16));
if request.properties.is_empty() {
request
.properties
.push(DavProperty::WebDav(WebDavProperty::DisplayName));
}
let request = PropFind::Prop(request.properties);
self.prepare_principal_propfind_response(
access_token,
resource.collection,
RoaringBitmap::from_iter(access_token.all_ids()).into_iter(),
&request,
&mut response,
)
.await?;
Ok(HttpResponse::new(StatusCode::MULTI_STATUS).with_xml_body(response.to_string()))
}
}

View file

@ -234,13 +234,9 @@ impl PrincipalPropFind for Server {
}
},
DavProperty::Principal(principal_property) => match principal_property {
PrincipalProperty::AlternateURISet => {
fields.push(DavPropertyValue::empty(property.clone()));
}
PrincipalProperty::GroupMemberSet => {
fields.push(DavPropertyValue::empty(property.clone()));
}
PrincipalProperty::GroupMembership => {
PrincipalProperty::AlternateURISet
| PrincipalProperty::GroupMemberSet
| PrincipalProperty::GroupMembership => {
fields.push(DavPropertyValue::empty(property.clone()));
}
PrincipalProperty::PrincipalURL => {

View file

@ -81,8 +81,15 @@ pub async fn test(test: &WebDavTest) {
.await
.with_status(StatusCode::CREATED);
// Test 5: Creating a file under a locked resource without a lock token should fail
// 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)
@ -90,7 +97,7 @@ pub async fn test(test: &WebDavTest) {
.with_status(StatusCode::LOCKED)
.with_value("D:error.D:lock-token-submitted.D:href", &path);
// Test 6: Creating a file under a locked resource with a lock token should succeed
// Test 7: Creating a file under a locked resource with a lock token should succeed
client
.request_with_headers(
"PUT",
@ -101,7 +108,7 @@ pub async fn test(test: &WebDavTest) {
.await
.with_status(StatusCode::CREATED);
// Test 7: Locks should be included in propfind responses
// Test 8: Locks should be included in propfind responses
let response = client
.propfind(&path, [DavProperty::WebDav(WebDavProperty::LockDiscovery)])
.await;
@ -120,7 +127,7 @@ pub async fn test(test: &WebDavTest) {
]);
}
// Test 8: Delete with and without a lock token
// Test 9: Delete with and without a lock token
client
.request("DELETE", &path, "")
.await
@ -136,7 +143,7 @@ pub async fn test(test: &WebDavTest) {
.await
.with_status(StatusCode::NO_CONTENT);
// Test 9: Unlock with and without a lock token
// Test 10: Unlock with and without a lock token
client
.unlock(&path, "urn:stalwart:davlock:1234")
.await
@ -147,7 +154,7 @@ pub async fn test(test: &WebDavTest) {
.await
.with_status(StatusCode::NO_CONTENT);
// Test 10: Locking with a large dead property should fail
// Test 11: Locking with a large dead property should fail
let path = format!("{base_path}/invalid-lock");
client
.lock_create(
@ -163,7 +170,7 @@ pub async fn test(test: &WebDavTest) {
.await
.with_status(StatusCode::PAYLOAD_TOO_LARGE);
// Test 11: Too many locks should fail
// Test 12: Too many locks should fail
for i in 0..test.server.core.groupware.max_locks_per_user {
client
.lock_create(

View file

@ -49,6 +49,7 @@ 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;
@ -218,6 +219,19 @@ disabled-events = ["network.*"]
"#;
pub const TEST_DAV_USERS: &[(&str, &str, &str, &str)] = &[
("admin", "secret1", "Superuser", "admin@example,com"),
("john", "secret2", "John Doe", "jdoe@example.com"),
(
"jane",
"secret3",
"Jane Doe-Smith",
"jane.smith@example.com",
),
("bill", "secret4", "Bill Foobar", "bill@example,com"),
("mike", "secret5", "Mike Noquota", "mike@example,com"),
];
#[allow(dead_code)]
pub struct WebDavTest {
server: Server,
@ -318,21 +332,15 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest
// Create test accounts
let mut clients = AHashMap::new();
for (account, secret, name, email) in [
("admin", "secret1", "Superuser", "admin@example,com"),
("john", "secret2", "John Doe", "jdoe@example.com"),
("jane", "secret3", "Jane Smith", "jane.smith@example.com"),
("bill", "secret4", "Bill Foobar", "bill@example,com"),
("mike", "secret5", "Mike Noquota", "mike@example,com"),
] {
for (account, secret, name, email) in TEST_DAV_USERS {
let account_id = store
.create_test_user(account, secret, name, &[email])
.await;
clients.insert(
account,
*account,
DummyWebDavClient::new(account_id, account, secret, email),
);
if account == "mike" {
if *account == "mike" {
store.set_test_quota(account, 1024).await;
}
}
@ -364,11 +372,6 @@ pub async fn webdav_tests() {
/*
TODO:
- Principals:
- PrincipalMatch
- PrincipalPropertySearch
- PrincipalSearchPropertySet
- Expand property
- ACLs:
- ACL Method
- AclPrincipalPropSet
@ -387,6 +390,7 @@ pub async fn webdav_tests() {
multiget::test(&handle).await;
sync::test(&handle).await;
lock::test(&handle).await;
principals::test(&handle).await;
// Print elapsed time
let elapsed = start_time.elapsed();

View file

@ -0,0 +1,356 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::WebDavTest;
use crate::webdav::{TEST_DAV_USERS, prop::ALL_DAV_PROPERTIES};
use dav_proto::schema::property::{DavProperty, PrincipalProperty, WebDavProperty};
use groupware::DavResourceName;
use hyper::StatusCode;
pub async fn test(test: &WebDavTest) {
println!("Running principals tests...");
let client = test.client("jane");
let principal_path = format!("D:href:{}/", DavResourceName::Principal.base_path());
let jane_principal_path = format!("D:href:{}/jane/", DavResourceName::Principal.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, _, name, _) in TEST_DAV_USERS {
let props = response.properties(&format!(
"{}/{}/",
DavResourceName::Principal.base_path(),
account
));
let path_pal = format!(
"D:href:{}/{}/",
DavResourceName::Principal.base_path(),
account
);
let path_card = format!("D:href:{}/{}/", DavResourceName::Card.base_path(), account);
let path_cal = format!("D:href:{}/{}/", DavResourceName::Cal.base_path(), account);
props
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
.with_values([*name])
.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);
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);
}
// 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);
props
.get(DavProperty::Principal(PrincipalProperty::CalendarHomeSet))
.with_values([format!("D:href:{}/jane/", DavResourceName::Cal.base_path()).as_str()])
.with_status(StatusCode::OK);
props
.get(DavProperty::Principal(
PrincipalProperty::AddressbookHomeSet,
))
.with_values([format!("D:href:{}/jane/", DavResourceName::Card.base_path()).as_str()])
.with_status(StatusCode::OK);
for (account, _, name, _) in TEST_DAV_USERS
.iter()
.filter(|(account, _, _, _)| ["jane", "support"].contains(account))
{
let path_card = format!("D:href:{}/{}/", DavResourceName::Card.base_path(), account);
let path_cal = format!("D:href:{}/{}/", DavResourceName::Cal.base_path(), account);
let path_pal = format!(
"D:href:{}/{}/",
DavResourceName::Principal.base_path(),
account
);
let props = response.properties(&format!("{}/{account}/", resource_type.base_path()));
props
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
.with_values([*name])
.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);
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::OK)
.is_not_empty();
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/", resource_type.base_path()).as_str(),
format!("{}/support/", 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/", DavResourceName::Principal.base_path()).as_str(),
format!("{}/support/", 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/", DavResourceName::Principal.base_path()).as_str(),
format!("{}/john/", DavResourceName::Principal.base_path()).as_str(),
]);
response
.properties(&format!("{}/jane/", DavResourceName::Principal.base_path()))
.get(DavProperty::WebDav(WebDavProperty::DisplayName))
.with_values([TEST_DAV_USERS
.iter()
.find(|(account, _, _, _)| *account == "jane")
.unwrap()
.2])
.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/", DavResourceName::Principal.base_path()).as_str()]);
client.delete_default_containers().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>"#;

View file

@ -85,20 +85,23 @@ pub async fn test(test: &WebDavTest) {
}
// Test 5: PROPFIND Depth 1 on user base path
let response = client
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]);
}
.await
.with_status(StatusCode::MULTI_STATUS)
.with_hrefs(
[
format!("{user_base_path}/default/").as_str(),
format!("{user_base_path}/").as_str(),
&test_base_path,
]
.into_iter()
.skip(if resource_type == DavResourceName::File {
1
} else {
0
}),
);
// Test 6: PROPFIND Depth 1 on created collection
client
@ -117,7 +120,60 @@ pub async fn test(test: &WebDavTest) {
StatusCode::MULTI_STATUS
});
// Test 8: Retrieve all static properties
// 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!("{user_base_path}/default/").as_str(),
&test_base_path,
]
.into_iter()
.skip(if resource_type == DavResourceName::File {
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),
@ -370,11 +426,34 @@ pub async fn test(test: &WebDavTest) {
}
}
// 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/",
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 9: PROPPATCH should fail when a precondition fails
// Test 11: PROPPATCH should fail when a precondition fails
client
.proppatch(
path,
@ -407,7 +486,7 @@ pub async fn test(test: &WebDavTest) {
.with_status(StatusCode::OK)
.without_values([etag.as_str()]);
// Test 10: PROPPATCH set on DAV properties
// Test 12: PROPPATCH set on DAV properties
client
.patch_and_check(
path,
@ -431,7 +510,7 @@ pub async fn test(test: &WebDavTest) {
)
.await;
// Test 11: PROPPATCH remove on DAV properties
// Test 13: PROPPATCH remove on DAV properties
let mut props = vec![
(
DavProperty::DeadProperty(DeadElementTag::new(
@ -450,7 +529,7 @@ pub async fn test(test: &WebDavTest) {
match resource_type {
DavResourceName::File if is_file => {
// Test 12: Change a file's content-type
// Test 14: Change a file's content-type
client
.patch_and_check(
path,
@ -462,7 +541,7 @@ pub async fn test(test: &WebDavTest) {
.await;
}
DavResourceName::Cal if !is_file => {
// Test 13: Change a calendar's properties
// Test 15: Change a calendar's properties
client
.patch_and_check(
path,
@ -498,7 +577,7 @@ pub async fn test(test: &WebDavTest) {
.await;
}
DavResourceName::Card if !is_file => {
// Test 14: Change an addressbook's properties
// Test 16: Change an addressbook's properties
client
.patch_and_check(
path,
@ -521,7 +600,7 @@ pub async fn test(test: &WebDavTest) {
_ => (),
}
// Test 15: PROPPATCH should fail on large properties
// Test 17: PROPPATCH should fail on large properties
let mut chunky_props = vec![
DavProperty::WebDav(WebDavProperty::DisplayName),
DavProperty::DeadProperty(DeadElementTag::new(
@ -572,7 +651,7 @@ pub async fn test(test: &WebDavTest) {
.with_description("Property value is too long");
}
// Test 16: PROPPATCH should fail on invalid calendar property values
// Test 18: PROPPATCH should fail on invalid calendar property values
if !is_file && resource_type == DavResourceName::Cal {
let response = client
.proppatch(
@ -603,7 +682,16 @@ pub async fn test(test: &WebDavTest) {
.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("support").await;
test.assert_is_empty().await;
}
#[derive(Debug)]
@ -637,6 +725,16 @@ impl DavMultiStatus {
}),
}
}
pub fn with_hrefs<'x>(&self, expect_hrefs: impl IntoIterator<Item = &'x str>) -> &Self {
let expect_hrefs: AHashSet<_> = expect_hrefs.into_iter().collect();
let hrefs: AHashSet<_> = self.hrefs.keys().map(|s| s.as_str()).collect();
if hrefs != expect_hrefs {
self.response.dump_response();
panic!("Expected hrefs {expect_hrefs:?}, but got {hrefs:?}",);
}
self
}
}
pub struct DavPropertyResult<'x> {
@ -671,6 +769,33 @@ impl DavPropertyResult<'_> {
)
})
}
pub fn is_defined(&self, name: impl AsRef<str>) -> &Self {
if self
.properties
.0
.iter()
.any(|prop| prop.values.contains_key(name.as_ref()))
{
self
} else {
self.response.dump_response();
panic!("Expected property {} to be defined", name.as_ref());
}
}
pub fn is_undefined(&self, name: impl AsRef<str>) -> &Self {
if self
.properties
.0
.iter()
.any(|prop| prop.values.contains_key(name.as_ref()))
{
self.response.dump_response();
panic!("Expected property {} to be undefined", name.as_ref());
}
self
}
}
impl<'x> DavQueryResult<'x> {
@ -689,6 +814,23 @@ impl<'x> DavQueryResult<'x> {
self
}
pub fn with_some_values(&self, expected_values: impl IntoIterator<Item = &'x str>) -> &Self {
let values = self
.values
.iter()
.map(|s| s.as_str())
.collect::<AHashSet<_>>();
for expected_value in expected_values {
if !values.contains(expected_value) {
self.response.dump_response();
panic!("Expected at least one of {expected_value:?} 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
@ -895,6 +1037,19 @@ impl DummyWebDavClient {
}
pub async fn propfind<I, T>(&self, path: &str, properties: I) -> DavMultiStatus
where
I: IntoIterator<Item = T>,
T: AsRef<str>,
{
self.propfind_with_headers(path, properties, []).await
}
pub async fn propfind_with_headers<I, T>(
&self,
path: &str,
properties: I,
headers: impl IntoIterator<Item = (&'static str, &str)>,
) -> DavMultiStatus
where
I: IntoIterator<Item = T>,
T: AsRef<str>,
@ -913,7 +1068,7 @@ impl DummyWebDavClient {
request.push_str("</D:prop></D:propfind>");
self.request("PROPFIND", path, &request)
self.request_with_headers("PROPFIND", path, headers, &request)
.await
.with_status(StatusCode::MULTI_STATUS)
.into_propfind_response(None)
@ -975,7 +1130,21 @@ impl Default for DavItem {
}
}
const ALL_DAV_PROPERTIES: &[DavProperty] = &[
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),