WebDAV PROPFIND/PROPPATCH tests

This commit is contained in:
mdecimus 2025-05-02 15:43:53 +02:00
parent 5416e35d4d
commit 2b9e0816eb
15 changed files with 1311 additions and 142 deletions

View file

@ -127,6 +127,10 @@ impl ArchivedDeadProperty {
}
impl DeadElementTag {
pub fn new(name: String, attrs: Option<String>) -> Self {
DeadElementTag { name, attrs }
}
pub fn size(&self) -> usize {
self.name.len() + self.attrs.as_ref().map_or(0, |attrs| attrs.len())
}

View file

@ -228,6 +228,12 @@ impl DavProperty {
}
}
impl AsRef<str> for DavProperty {
fn as_ref(&self) -> &str {
self.tag_name().0
}
}
impl Display for ReportSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<D:supported-report><D:report>")?;

View file

@ -316,7 +316,7 @@ impl Rfc1123DateTime {
}
impl DavProperty {
pub const ALL_PROPS: [DavProperty; 17] = [
pub const ALL_PROPS: [DavProperty; 11] = [
DavProperty::WebDav(WebDavProperty::CreationDate),
DavProperty::WebDav(WebDavProperty::DisplayName),
DavProperty::WebDav(WebDavProperty::GetETag),
@ -325,15 +325,9 @@ impl DavProperty {
DavProperty::WebDav(WebDavProperty::LockDiscovery),
DavProperty::WebDav(WebDavProperty::SupportedLock),
DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal),
DavProperty::WebDav(WebDavProperty::SyncToken),
DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet),
DavProperty::WebDav(WebDavProperty::AclRestrictions),
DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet),
DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet),
DavProperty::WebDav(WebDavProperty::GetContentLanguage),
DavProperty::WebDav(WebDavProperty::GetContentLength),
DavProperty::WebDav(WebDavProperty::GetContentType),
DavProperty::WebDav(WebDavProperty::SupportedReportSet),
];
pub fn is_all_prop(&self) -> bool {
@ -347,15 +341,9 @@ impl DavProperty {
| DavProperty::WebDav(WebDavProperty::LockDiscovery)
| DavProperty::WebDav(WebDavProperty::SupportedLock)
| DavProperty::WebDav(WebDavProperty::CurrentUserPrincipal)
| DavProperty::WebDav(WebDavProperty::SyncToken)
| DavProperty::WebDav(WebDavProperty::SupportedPrivilegeSet)
| DavProperty::WebDav(WebDavProperty::AclRestrictions)
| DavProperty::WebDav(WebDavProperty::CurrentUserPrivilegeSet)
| DavProperty::WebDav(WebDavProperty::PrincipalCollectionSet)
| DavProperty::WebDav(WebDavProperty::GetContentLanguage)
| DavProperty::WebDav(WebDavProperty::GetContentLength)
| DavProperty::WebDav(WebDavProperty::GetContentType)
| DavProperty::WebDav(WebDavProperty::SupportedReportSet)
| DavProperty::DeadProperty(_)
)
}

View file

@ -22,6 +22,7 @@ use trc::AddContext;
use crate::{
DavError, DavMethod, PropStatBuilder,
common::{
ExtractETag,
lock::{LockRequestHandler, ResourceState},
uri::DavUriResource,
},
@ -130,17 +131,20 @@ impl CalendarMkColRequestHandler for Server {
calendar
.insert(access_token, account_id, document_id, &mut batch)
.caused_by(trc::location!())?;
let etag = batch.etag();
self.commit_batch(batch).await.caused_by(trc::location!())?;
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body(
MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CalDav)
.with_mkcalendar(is_mkcalendar)
.to_string(),
))
Ok(HttpResponse::new(StatusCode::CREATED)
.with_xml_body(
MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CalDav)
.with_mkcalendar(is_mkcalendar)
.to_string(),
)
.with_etag_opt(etag))
} else {
Ok(HttpResponse::new(StatusCode::CREATED))
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
}
}
}

View file

@ -326,6 +326,7 @@ impl CalendarPropPatchRequestHandler for Server {
}
(DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => {
calendar.created = dt;
items.insert_ok(property.property);
}
(
DavProperty::WebDav(WebDavProperty::ResourceType),
@ -408,6 +409,7 @@ impl CalendarPropPatchRequestHandler for Server {
}
(DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => {
event.created = dt;
items.insert_ok(property.property);
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
if self.core.groupware.dead_property_size.is_some() =>

View file

@ -8,6 +8,7 @@ use super::proppatch::CardPropPatchRequestHandler;
use crate::{
DavError, DavMethod, PropStatBuilder,
common::{
ExtractETag,
lock::{LockRequestHandler, ResourceState},
uri::DavUriResource,
},
@ -110,16 +111,19 @@ impl CardMkColRequestHandler for Server {
.caused_by(trc::location!())?;
book.insert(access_token, account_id, document_id, &mut batch)
.caused_by(trc::location!())?;
let etag = batch.etag();
self.commit_batch(batch).await.caused_by(trc::location!())?;
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body(
MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CardDav)
.to_string(),
))
Ok(HttpResponse::new(StatusCode::CREATED)
.with_xml_body(
MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::CardDav)
.to_string(),
)
.with_etag_opt(etag))
} else {
Ok(HttpResponse::new(StatusCode::CREATED))
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
}
}
}

View file

@ -273,6 +273,7 @@ impl CardPropPatchRequestHandler for Server {
}
(DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => {
address_book.created = dt;
items.insert_ok(property.property);
}
(
DavProperty::WebDav(WebDavProperty::ResourceType),
@ -354,6 +355,7 @@ impl CardPropPatchRequestHandler for Server {
}
(DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => {
card.created = dt;
items.insert_ok(property.property);
}
(DavProperty::DeadProperty(dead), DavValue::DeadProperty(values))
if self.core.groupware.dead_property_size.is_some() =>

View file

@ -129,6 +129,7 @@ impl PropFindRequestHandler for Server {
Depth::Zero => false,
Depth::Infinity => {
if resource.account_id.is_none()
|| resource.resource.is_none()
|| matches!(resource.collection, Collection::FileNode)
{
return Err(DavErrorCondition::new(
@ -1009,7 +1010,7 @@ impl PropFindRequestHandler for Server {
))
.with_supported_privilege(SupportedPrivilege::new(
Privilege::Unbind,
"Add resources to a collection",
"Remove resources from a collection",
))
.with_supported_privilege(SupportedPrivilege::new(
Privilege::Unlock,
@ -1193,8 +1194,10 @@ impl PropFindRequestHandler for Server {
if let ArchivedTimezone::IANA(tz) =
&calendar.inner.preferences(account_id).time_zone
{
fields
.push(DavPropertyValue::new(property.clone(), tz.to_string()));
fields.push(DavPropertyValue::new(
property.clone(),
Tz::from_id(tz.to_native()).unwrap_or(Tz::UTC).to_string(),
));
} else {
fields_not_found.push(DavPropertyValue::empty(property.clone()));
}
@ -1238,13 +1241,13 @@ impl PropFindRequestHandler for Server {
(CalDavProperty::MinDateTime, ArchivedResource::Calendar(_)) => {
fields.push(DavPropertyValue::new(
property.clone(),
DavValue::Timestamp(i64::MIN),
DavValue::String("0001-01-01T00:00:00Z".to_string()),
));
}
(CalDavProperty::MaxDateTime, ArchivedResource::Calendar(_)) => {
fields.push(DavPropertyValue::new(
property.clone(),
DavValue::Timestamp(32531605200),
DavValue::String("9999-12-31T23:59:59Z".to_string()),
));
}
(CalDavProperty::MaxInstances, ArchivedResource::Calendar(_)) => {

View file

@ -19,6 +19,7 @@ use trc::AddContext;
use crate::{
DavMethod, PropStatBuilder,
common::{
ExtractETag,
acl::DavAclHandler,
lock::{LockRequestHandler, ResourceState},
uri::DavUriResource,
@ -125,16 +126,19 @@ impl FileMkColRequestHandler for Server {
.create_document(document_id)
.custom(ObjectIndexBuilder::<(), _>::new().with_changes(node))
.caused_by(trc::location!())?;
let etag = batch.etag();
self.commit_batch(batch).await.caused_by(trc::location!())?;
if let Some(prop_stat) = return_prop_stat {
Ok(HttpResponse::new(StatusCode::CREATED).with_xml_body(
MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::Dav)
.to_string(),
))
Ok(HttpResponse::new(StatusCode::CREATED)
.with_xml_body(
MkColResponse::new(prop_stat.build())
.with_namespace(Namespace::Dav)
.to_string(),
)
.with_etag_opt(etag))
} else {
Ok(HttpResponse::new(StatusCode::CREATED))
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
}
}
}

View file

@ -186,6 +186,7 @@ impl FilePropPatchRequestHandler for Server {
}
(DavProperty::WebDav(WebDavProperty::CreationDate), DavValue::Timestamp(dt)) => {
file.created = dt;
items.insert_ok(property.property);
}
(DavProperty::WebDav(WebDavProperty::GetContentType), DavValue::String(name))
if file.file.is_some() =>

View file

@ -13,6 +13,7 @@ use hyper::StatusCode;
pub async fn test(test: &WebDavTest) {
let client = test.client("jane");
let mike_noquota = test.client("mike");
for resource_type in [
DavResourceName::File,
@ -649,10 +650,72 @@ pub async fn test(test: &WebDavTest) {
.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/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/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/quota-test{i}", resource_type.base_path()),
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
}
}
}
client.delete_default_containers().await;
client.delete_default_containers_by_account("support").await;
mike_noquota.delete_default_containers().await;
test.assert_is_empty().await;
}

View file

@ -8,7 +8,7 @@ use hyper::StatusCode;
use crate::webdav::{TEST_FILE_1, TEST_ICAL_1, TEST_VCARD_1, TEST_VTIMEZONE_1};
use super::WebDavTest;
use super::{DavResponse, DummyWebDavClient, WebDavTest};
pub async fn test(test: &WebDavTest) {
println!("Running MKCOL tests...");
@ -102,7 +102,7 @@ pub async fn test(test: &WebDavTest) {
}
// Create using extended MKCOL
for (path, properties, resource_types) in [
for (path, expected_properties, resource_types) in [
(
"/dav/file/john/my-named-files/",
[("D:displayname", "Named Files")].as_slice(),
@ -131,38 +131,34 @@ pub async fn test(test: &WebDavTest) {
["D:collection", "A:calendar"].as_slice(),
),
] {
let mut response = client
let response = client
.mkcol(
"MKCOL",
path,
resource_types.iter().copied(),
properties.iter().copied(),
expected_properties.iter().copied(),
)
.await
.with_status(StatusCode::CREATED)
.match_many("D:mkcol-response.D:propstat.D:status", ["HTTP/1.1 200 OK"]);
for (property, _) in properties {
response = response.match_one(
&format!("D:mkcol-response.D:propstat.D:prop.{property}"),
"",
);
.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 mut response = client
.propfind(path, properties.iter().map(|x| x.0))
.await
.with_status(StatusCode::MULTI_STATUS)
.match_one("D:multistatus.D:response.D:href", path)
.match_one(
"D:multistatus.D:response.D:propstat.D:status",
"HTTP/1.1 200 OK",
);
for (property, value) in properties {
response = response.match_one(
&format!("D:multistatus.D:response.D:propstat.D:prop.{property}"),
value,
);
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]);
}
}
@ -200,3 +196,44 @@ pub async fn test(test: &WebDavTest) {
client.delete_default_containers().await;
test.assert_is_empty().await;
}
impl DummyWebDavClient {
pub async fn mkcol(
&self,
method: &str,
path: &str,
resource_types: impl IntoIterator<Item = &str>,
properties: impl IntoIterator<Item = (&str, &str)>,
) -> DavResponse {
let mut request = concat!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
"<D:mkcol xmlns:D=\"DAV:\" xmlns:A=\"urn:ietf:params:xml:ns:caldav\" xmlns:B=\"urn:ietf:params:xml:ns:carddav\">",
"<D:set><D:prop>"
)
.to_string();
let mut has_resource_type = false;
for (idx, resource_type) in resource_types.into_iter().enumerate() {
if idx == 0 {
request.push_str("<D:resourcetype>");
}
request.push_str(&format!("<{resource_type}/>"));
has_resource_type = true;
}
if has_resource_type {
request.push_str("</D:resourcetype>");
}
for (key, value) in properties {
request.push_str(&format!("<{key}>{value}</{key}>"));
}
request.push_str("</D:prop></D:set></D:mkcol>");
if method == "MKCALENDAR" {
request = request.replace("D:mkcol", "A:mkcalendar");
}
self.request(method, path, &request).await
}
}

View file

@ -10,7 +10,7 @@ use crate::{
};
use ::managesieve::core::ManageSieveSessionManager;
use ::store::Stores;
use ahash::AHashMap;
use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose::STANDARD};
use common::{
Caches, Core, Data, DavResource, DavResources, Inner, Server,
@ -21,7 +21,10 @@ use common::{
core::BuildServer,
manager::boot::build_ipc,
};
use dav_proto::Depth;
use dav_proto::{
Depth,
schema::property::{DavProperty, WebDavProperty},
};
use groupware::{DavResourceName, hierarchy::DavHierarchy};
use http::HttpSessionManager;
use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION};
@ -44,6 +47,7 @@ use utils::config::Config;
pub mod basic;
pub mod copy_move;
pub mod mkcol;
pub mod prop;
pub mod put_get;
const SERVER: &str = r#"
@ -316,7 +320,7 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest
("john", "secret2", "John Doe", "jdoe@example.com"),
("jane", "secret3", "Jane Smith", "jane.smith@example.com"),
("bill", "secret4", "Bill Foobar", "bill@example,com"),
("mike", "secret5", "Mile Noquota", "mike@example,com"),
("mike", "secret5", "Mike Noquota", "mike@example,com"),
] {
let account_id = store
.create_test_user(account, secret, name, &[email])
@ -326,7 +330,7 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest
DummyWebDavClient::new(account_id, account, secret, email),
);
if account == "mike" {
store.set_test_quota(account, 10).await;
store.set_test_quota(account, 1024).await;
}
}
store
@ -358,6 +362,7 @@ pub async fn webdav_tests() {
put_get::test(&handle).await;
mkcol::test(&handle).await;
copy_move::test(&handle).await;
prop::test(&handle).await;
// Print elapsed time
let elapsed = start_time.elapsed();
@ -400,6 +405,7 @@ pub struct DummyWebDavClient {
credentials: String,
}
#[derive(Debug)]
pub struct DavResponse {
headers: AHashMap<String, String>,
status: StatusCode,
@ -488,66 +494,6 @@ impl DummyWebDavClient {
}
}
pub async fn mkcol(
&self,
method: &str,
path: &str,
resource_types: impl IntoIterator<Item = &str>,
properties: impl IntoIterator<Item = (&str, &str)>,
) -> DavResponse {
let mut request = concat!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
"<D:mkcol xmlns:D=\"DAV:\" xmlns:A=\"urn:ietf:params:xml:ns:caldav\" xmlns:B=\"urn:ietf:params:xml:ns:carddav\">",
"<D:set><D:prop>"
)
.to_string();
let mut has_resource_type = false;
for (idx, resource_type) in resource_types.into_iter().enumerate() {
if idx == 0 {
request.push_str("<D:resourcetype>");
}
request.push_str(&format!("<{resource_type}/>"));
has_resource_type = true;
}
if has_resource_type {
request.push_str("</D:resourcetype>");
}
for (key, value) in properties {
request.push_str(&format!("<{key}>{value}</{key}>"));
}
request.push_str("</D:prop></D:set></D:mkcol>");
if method == "MKCALENDAR" {
request = request.replace("D:mkcol", "A:mkcalendar");
}
self.request(method, path, &request).await
}
pub async fn propfind(
&self,
path: &str,
properties: impl IntoIterator<Item = &str>,
) -> DavResponse {
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\">",
"<D:prop>"
)
.to_string();
for property in properties {
request.push_str(&format!("<{property}/>"));
}
request.push_str("</D:prop></D:propfind>");
self.request("PROPFIND", path, &request).await
}
pub async fn sync_collection(
&self,
path: &str,
@ -581,6 +527,19 @@ impl DummyWebDavClient {
.with_status(StatusCode::MULTI_STATUS)
}
pub async fn available_quota(&self, path: &str) -> u64 {
self.propfind(
path,
[DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)],
)
.await
.properties(path)
.get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes))
.value()
.parse()
.unwrap()
}
pub async fn create_hierarchy(
&self,
base_path: &str,
@ -782,6 +741,26 @@ impl DavResponse {
hrefs
}
pub fn with_hrefs<'x>(self, hrefs: impl IntoIterator<Item = &'x str>) -> Self {
let expected_hrefs = hrefs.into_iter().collect::<AHashSet<_>>();
let hrefs = self
.find_keys("D:multistatus.D:response.D:href")
.collect::<AHashSet<_>>();
if expected_hrefs != hrefs {
self.dump_response();
println!("\nMissing: {:?}", expected_hrefs.difference(&hrefs));
println!("\nExtra: {:?}", hrefs.difference(&expected_hrefs));
panic!(
"Hierarchy mismatch: expected {} items, received {} items",
expected_hrefs.len(),
hrefs.len()
);
}
self
}
fn dump_response(&self) {
eprintln!("-------------------------------------");
eprintln!("Status: {}", self.status);
@ -868,19 +847,34 @@ fn flatten_xml(xml: &str) -> Vec<(String, String)> {
Event::Start(ref e) => {
let name = str::from_utf8(e.name().as_ref()).unwrap().to_string();
path.push(name);
let base_path = path.join(".");
for attr in e.attributes() {
let attr = attr.unwrap();
let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string();
let value = attr.unescape_value().unwrap();
let value_str = value.trim().to_string();
result.push((format!("{}.[{}]", path.join("."), key), value_str));
result.push((format!("{}.[{}]", base_path, key), value_str));
}
text_content = None;
}
Event::Empty(ref e) => {
let name = str::from_utf8(e.name().as_ref()).unwrap().to_string();
result.push((format!("{}.{}", path.join("."), name), "".to_string()));
let base_path = format!("{}.{}", path.join("."), name);
let mut has_attrs = false;
for attr in e.attributes() {
let attr = attr.unwrap();
let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string();
let value = attr.unescape_value().unwrap();
let value_str = value.trim().to_string();
has_attrs = true;
result.push((format!("{}.[{}]", base_path, key), value_str));
}
if !has_attrs {
result.push((base_path, "".to_string()));
}
}
Event::Text(e) => {
let text = e.unescape().unwrap();

1033
tests/src/webdav/prop.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -151,24 +151,48 @@ pub async fn test(test: &WebDavTest) {
// PUT requests cannot exceed quota
let mike_noquota = test.client("mike");
for (path, ct, content) in [
("/dav/file/mike/file1.txt", "text/plain", TEST_FILE_1),
(
"/dav/card/mike/default/card1.vcf",
"text/vcard; charset=utf-8",
TEST_VCARD_1,
),
(
"/dav/cal/mike/default/event1.ics",
"text/calendar; charset=utf-8",
TEST_ICAL_1,
),
for resource_type in [
DavResourceName::File,
DavResourceName::Card,
DavResourceName::Cal,
] {
let path = format!("{}/mike/quota-test/", resource_type.base_path());
mike_noquota
.request_with_headers("PUT", path, [("content-type", ct)], content)
.mkcol("MKCOL", &path, [], [])
.await
.with_status(StatusCode::PRECONDITION_FAILED)
.with_failed_precondition("D:quota-not-exceeded", "");
.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