fix(webdav): RFC 4918 litmus compliance
This commit is contained in:
@@ -369,6 +369,32 @@ jobs:
|
|||||||
path: tests/api/storage/
|
path: tests/api/storage/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
litmus:
|
||||||
|
name: WebDAV RFC 4918 — litmus (59/59)
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: oxicloud-release
|
||||||
|
path: target/release/
|
||||||
|
|
||||||
|
- name: Set execute bit on pre-built binary
|
||||||
|
run: chmod +x target/release/oxicloud
|
||||||
|
|
||||||
|
- name: Install litmus and jq
|
||||||
|
run: sudo apt-get update -q && sudo apt-get install -y litmus jq
|
||||||
|
|
||||||
|
- name: Run litmus WebDAV compliance tests
|
||||||
|
run: bash tests/webdav/run-litmus.sh
|
||||||
|
env:
|
||||||
|
BUILD_TARGET: release
|
||||||
|
LITMUS_TESTS: "basic copymove props locks"
|
||||||
|
|
||||||
front-test:
|
front-test:
|
||||||
name: Frontend end-to-end tests (via Playwright)
|
name: Frontend end-to-end tests (via Playwright)
|
||||||
# ensure that api tests are ok before
|
# ensure that api tests are ok before
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- WebDAV dead properties storage (RFC 4918 §9.2).
|
||||||
|
-- Stores arbitrary user-defined XML properties set via PROPPATCH.
|
||||||
|
-- Keyed by (resource_path, user_id, namespace, local_name) — the
|
||||||
|
-- same property on different resources or for different users is
|
||||||
|
-- a distinct row.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.webdav_dead_properties (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
resource_path TEXT NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
namespace TEXT NOT NULL DEFAULT '',
|
||||||
|
local_name TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (resource_path, user_id, namespace, local_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webdav_dead_properties_path_user
|
||||||
|
ON storage.webdav_dead_properties (resource_path, user_id);
|
||||||
@@ -106,15 +106,27 @@ impl WebDavLockStore {
|
|||||||
|
|
||||||
/// Attempt to acquire a lock on `path`.
|
/// Attempt to acquire a lock on `path`.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource
|
/// Returns `Ok(LockEntry)` on success, or `Err(existing)` when:
|
||||||
/// is already exclusively locked by a different token.
|
/// - The existing lock is exclusive (blocks any new lock), or
|
||||||
|
/// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8).
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
|
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
|
||||||
// Check for existing conflicting lock
|
if let Some(existing) = self.by_path.get(path) {
|
||||||
if let Some(existing) = self.by_path.get(path)
|
// Exclusive existing lock → blocks everything.
|
||||||
&& existing.info.scope == LockScope::Exclusive
|
// New exclusive lock → blocked by any existing lock (shared or exclusive).
|
||||||
{
|
if existing.info.scope == LockScope::Exclusive || info.scope == LockScope::Exclusive {
|
||||||
return Err(existing);
|
return Err(existing);
|
||||||
|
}
|
||||||
|
// Both shared: keep the first holder as the enforcement sentinel in
|
||||||
|
// `by_path` so releasing a secondary holder cannot clear the lock.
|
||||||
|
// Register the new token only in the reverse index so UNLOCK works.
|
||||||
|
let entry = LockEntry {
|
||||||
|
info,
|
||||||
|
path: path.to_owned(),
|
||||||
|
};
|
||||||
|
self.by_token
|
||||||
|
.insert(entry.info.token.clone(), path.to_owned());
|
||||||
|
return Ok(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = LockEntry {
|
let entry = LockEntry {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod app_password_handler;
|
|||||||
pub mod auth_handler;
|
pub mod auth_handler;
|
||||||
pub mod batch_handler;
|
pub mod batch_handler;
|
||||||
pub mod caldav_handler;
|
pub mod caldav_handler;
|
||||||
|
pub mod calendar_rest_handler;
|
||||||
pub mod carddav_handler;
|
pub mod carddav_handler;
|
||||||
pub mod chunked_upload_handler;
|
pub mod chunked_upload_handler;
|
||||||
pub mod contacts_handler;
|
pub mod contacts_handler;
|
||||||
|
|||||||
@@ -1062,20 +1062,67 @@ fn enforce_native_lock(
|
|||||||
if_header: Option<&str>,
|
if_header: Option<&str>,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Option<Response<Body>> {
|
) -> Option<Response<Body>> {
|
||||||
let entry = lock_store.get_by_path(path)?;
|
// Check the exact path, then walk up parent collections for depth-infinity
|
||||||
if let Some(h) = if_header
|
// locks (RFC 4918 §6.1: a lock on a collection with Depth: infinity also
|
||||||
&& extract_if_header_tokens(h)
|
// covers all descendant members).
|
||||||
.iter()
|
let entry = lock_store.get_by_path(path).or_else(|| {
|
||||||
.any(|t| t == &entry.info.token)
|
let mut p = path;
|
||||||
{
|
loop {
|
||||||
return None;
|
let idx = p.rfind('/')?;
|
||||||
|
p = &p[..idx];
|
||||||
|
if p.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(e) = lock_store.get_by_path(p) {
|
||||||
|
if e.info.depth.eq_ignore_ascii_case("infinity") {
|
||||||
|
return Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(entry) = entry {
|
||||||
|
// Resource is locked: caller must supply the matching token in If:.
|
||||||
|
if let Some(h) = if_header
|
||||||
|
&& extract_if_header_tokens(h)
|
||||||
|
.iter()
|
||||||
|
.any(|t| t == &entry.info.token)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::LOCKED)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(
|
|
||||||
Response::builder()
|
// Resource is not locked. If the If: header references lock tokens (not
|
||||||
.status(StatusCode::LOCKED)
|
// resource-tag URLs), every such token must be active somewhere in the
|
||||||
.body(Body::empty())
|
// store. A stale or fabricated token (e.g. DAV:no-lock) never matches,
|
||||||
.unwrap(),
|
// so the If: condition fails → 412 Precondition Failed (RFC 4918 §10.4).
|
||||||
)
|
if let Some(h) = if_header {
|
||||||
|
let tokens = extract_if_header_tokens(h);
|
||||||
|
let lock_refs: Vec<_> = tokens
|
||||||
|
.iter()
|
||||||
|
.filter(|t| !t.starts_with("http://") && !t.starts_with("https://"))
|
||||||
|
.collect();
|
||||||
|
if !lock_refs.is_empty()
|
||||||
|
&& !lock_refs
|
||||||
|
.iter()
|
||||||
|
.any(|t| lock_store.get_by_token(t).is_some())
|
||||||
|
{
|
||||||
|
return Some(
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::PRECONDITION_FAILED)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -592,6 +592,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
tracing::info!("Contacts REST API routes initialized");
|
tracing::info!("Contacts REST API routes initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs
|
// NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs
|
||||||
// for client compatibility, NOT under /api.
|
// for client compatibility, NOT under /api.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! RFC 4918 §9.2 PROPPATCH compliance — dead property storage and retrieval.
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
|
||||||
|
use super::harness::{get_server, unique_name};
|
||||||
|
|
||||||
|
fn propfind() -> Method {
|
||||||
|
Method::from_bytes(b"PROPFIND").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proppatch() -> Method {
|
||||||
|
Method::from_bytes(b"PROPPATCH").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPPATCH set a custom property → 207 with 200 propstat.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proppatch_set_returns_207() {
|
||||||
|
let srv = get_server();
|
||||||
|
let path = format!("/webdav/{}", unique_name("pp_set"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
srv.client()
|
||||||
|
.put(srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.body("x")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set>
|
||||||
|
<D:prop>
|
||||||
|
<Z:author>Alice</Z:author>
|
||||||
|
</D:prop>
|
||||||
|
</D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
|
||||||
|
let res = srv
|
||||||
|
.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 207, "PROPPATCH must return 207");
|
||||||
|
let body = res.text().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
body.contains("200") || body.contains("HTTP/1.1 200"),
|
||||||
|
"PROPPATCH 207 must contain 200 propstat; body: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPPATCH set → PROPFIND retrieves the stored value.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proppatch_set_property_visible_in_propfind() {
|
||||||
|
let srv = get_server();
|
||||||
|
let path = format!("/webdav/{}", unique_name("pp_roundtrip"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
srv.client()
|
||||||
|
.put(srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.body("data")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Set dead property
|
||||||
|
let set_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set>
|
||||||
|
<D:prop>
|
||||||
|
<Z:color>blue</Z:color>
|
||||||
|
</D:prop>
|
||||||
|
</D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
|
||||||
|
let pp_res = srv
|
||||||
|
.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(set_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pp_res.status(), 207, "PROPPATCH set must return 207");
|
||||||
|
|
||||||
|
// Retrieve via PROPFIND allprop
|
||||||
|
let pf_res = srv
|
||||||
|
.client()
|
||||||
|
.request(propfind(), srv.url(&path))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Depth", "0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pf_res.status(), 207);
|
||||||
|
let body = pf_res.text().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
body.contains("color") || body.contains("blue"),
|
||||||
|
"PROPFIND allprop must include dead property set by PROPPATCH; body: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPPATCH remove → property absent from subsequent PROPFIND.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proppatch_remove_property_not_in_propfind() {
|
||||||
|
let srv = get_server();
|
||||||
|
let path = format!("/webdav/{}", unique_name("pp_remove"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
srv.client()
|
||||||
|
.put(srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.body("data")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// First set
|
||||||
|
let set_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set><D:prop><Z:tag>removeme</Z:tag></D:prop></D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
srv.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(set_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Then remove
|
||||||
|
let remove_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:remove><D:prop><Z:tag/></D:prop></D:remove>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
let rem_res = srv
|
||||||
|
.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(remove_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rem_res.status(), 207, "PROPPATCH remove must return 207");
|
||||||
|
|
||||||
|
// Verify gone — request the specific prop, expect 404 propstat
|
||||||
|
let pf_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:prop><Z:tag/></D:prop>
|
||||||
|
</D:propfind>"#;
|
||||||
|
let pf_res = srv
|
||||||
|
.client()
|
||||||
|
.request(propfind(), srv.url(&path))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Depth", "0")
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(pf_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pf_res.status(), 207);
|
||||||
|
let body = pf_res.text().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
body.contains("404"),
|
||||||
|
"Removed dead property must appear in 404 propstat; body: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPPATCH set + remove in same request → both applied atomically.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proppatch_set_and_remove_in_same_request() {
|
||||||
|
let srv = get_server();
|
||||||
|
let path = format!("/webdav/{}", unique_name("pp_setrem"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
srv.client()
|
||||||
|
.put(srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.body("x")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Pre-seed a property to remove
|
||||||
|
let seed_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set><D:prop><Z:old>gone</Z:old></D:prop></D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
srv.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(seed_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Set new + remove old in one request
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set><D:prop><Z:new>here</Z:new></D:prop></D:set>
|
||||||
|
<D:remove><D:prop><Z:old/></D:prop></D:remove>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
let res = srv
|
||||||
|
.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 207, "combined set+remove must return 207");
|
||||||
|
let body = res.text().await.unwrap();
|
||||||
|
// Both ops should succeed
|
||||||
|
assert!(
|
||||||
|
!body.contains("409") && !body.contains("403"),
|
||||||
|
"combined PROPPATCH must not fail; body: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPPATCH on non-existent resource → 404.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proppatch_nonexistent_resource_returns_404() {
|
||||||
|
let srv = get_server();
|
||||||
|
let path = format!("/webdav/{}", unique_name("pp_ghost"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set><D:prop><Z:x>y</Z:x></D:prop></D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
|
||||||
|
let res = srv
|
||||||
|
.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
404,
|
||||||
|
"PROPPATCH on non-existent resource must return 404"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPPATCH on collection (folder) → 207.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proppatch_on_collection_returns_207() {
|
||||||
|
let srv = get_server();
|
||||||
|
let col = format!("/webdav/{}", unique_name("pp_col"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
srv.client()
|
||||||
|
.request(Method::from_bytes(b"MKCOL").unwrap(), srv.url(&col))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set><D:prop><Z:desc>my folder</Z:desc></D:prop></D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
|
||||||
|
let res = srv
|
||||||
|
.client()
|
||||||
|
.request(proppatch(), srv.url(&col))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 207, "PROPPATCH on collection must return 207");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PROPFIND specific dead property returns value in 200 propstat (not 404).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn propfind_specific_dead_property_returns_200_propstat() {
|
||||||
|
let srv = get_server();
|
||||||
|
let path = format!("/webdav/{}", unique_name("pp_specific"));
|
||||||
|
let (k, v) = srv.auth();
|
||||||
|
|
||||||
|
srv.client()
|
||||||
|
.put(srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.body("x")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Set
|
||||||
|
let set_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propertyupdate xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:set><D:prop><Z:rating>5</Z:rating></D:prop></D:set>
|
||||||
|
</D:propertyupdate>"#;
|
||||||
|
srv.client()
|
||||||
|
.request(proppatch(), srv.url(&path))
|
||||||
|
.header(k, v.clone())
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(set_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// PROPFIND for that exact property
|
||||||
|
let pf_xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:Z="http://example.com/ns/">
|
||||||
|
<D:prop><Z:rating/></D:prop>
|
||||||
|
</D:propfind>"#;
|
||||||
|
let pf_res = srv
|
||||||
|
.client()
|
||||||
|
.request(propfind(), srv.url(&path))
|
||||||
|
.header(k, v)
|
||||||
|
.header("Depth", "0")
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(pf_xml)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pf_res.status(), 207);
|
||||||
|
let body = pf_res.text().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!body.contains("404"),
|
||||||
|
"Known dead property must not be in 404 propstat; body: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("rating") || body.contains("5"),
|
||||||
|
"Response must include the dead property value; body: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# WebDAV RFC 4918 compliance test using the litmus test suite.
|
||||||
|
#
|
||||||
|
# Usage (from repo root via justfile):
|
||||||
|
# just litmus-test
|
||||||
|
#
|
||||||
|
# Or directly (server + postgres must already be running):
|
||||||
|
# bash tests/webdav/run-litmus.sh
|
||||||
|
#
|
||||||
|
# Requires: litmus (apt install litmus), jq, curl
|
||||||
|
# litmus tests: basic copymove props locks
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
COMMON="$REPO_ROOT/tests/common"
|
||||||
|
WEBDAV_DIR="$REPO_ROOT/tests/webdav"
|
||||||
|
|
||||||
|
source "$WEBDAV_DIR/test.env"
|
||||||
|
|
||||||
|
SERVER_PORT="${base_url##*:}"
|
||||||
|
|
||||||
|
log() { echo "[litmus] $*"; }
|
||||||
|
die() { echo "[litmus] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Dependency checks ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ! command -v litmus >/dev/null 2>&1; then
|
||||||
|
die "litmus not found. Install with: sudo apt install litmus"
|
||||||
|
fi
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
die "jq not found. Install with: sudo apt install jq"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Teardown ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SERVER_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$SERVER_PID" ]]; then
|
||||||
|
log "Stopping OxiCloud (pid $SERVER_PID)..."
|
||||||
|
kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
wait "$SERVER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
bash "$COMMON/stop-db.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ── 1. Start postgres ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bash "$COMMON/spawn-db.sh"
|
||||||
|
|
||||||
|
# ── 2. Start OxiCloud ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
set -a
|
||||||
|
source "$COMMON/server.env"
|
||||||
|
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||||
|
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav/storage-litmus"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
rm -rf "$OXICLOUD_STORAGE_PATH"
|
||||||
|
mkdir -p "$OXICLOUD_STORAGE_PATH"
|
||||||
|
|
||||||
|
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||||
|
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||||
|
|
||||||
|
if [[ -x "$OXICLOUD_BIN" ]]; then
|
||||||
|
log "Starting pre-built OxiCloud ($BUILD_TARGET) on port $SERVER_PORT..."
|
||||||
|
"$OXICLOUD_BIN" --config "$COMMON/server.env" &
|
||||||
|
else
|
||||||
|
log "Building and starting OxiCloud on port $SERVER_PORT..."
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
cargo build 2>&1
|
||||||
|
"$REPO_ROOT/target/debug/oxicloud" --config "$COMMON/server.env" &
|
||||||
|
fi
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
log "Waiting for server at $base_url..."
|
||||||
|
deadline=$(( $(date +%s) + 60 ))
|
||||||
|
until curl -sf "$base_url/ready" >/dev/null 2>&1; do
|
||||||
|
[[ $(date +%s) -ge $deadline ]] && die "Server did not become ready within 60s"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
log "Server ready."
|
||||||
|
|
||||||
|
# ── 3. Bootstrap admin + app password ────────────────────────────────────────
|
||||||
|
|
||||||
|
SETUP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST -H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$username\",\"email\":\"$email\",\"password\":\"$password\"}" \
|
||||||
|
"$base_url/api/setup")
|
||||||
|
case "$SETUP_STATUS" in
|
||||||
|
201) log "Admin account created." ;;
|
||||||
|
403) log "Admin account already exists." ;;
|
||||||
|
*) die "Unexpected /api/setup status: $SETUP_STATUS" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$username\",\"password\":\"$password\"}" \
|
||||||
|
"$base_url/api/auth/login")
|
||||||
|
JWT=$(jq -r '.access_token' <<<"$LOGIN_RESP")
|
||||||
|
[[ -z "$JWT" || "$JWT" == "null" ]] && die "Login failed: $LOGIN_RESP"
|
||||||
|
log "Logged in as $username."
|
||||||
|
|
||||||
|
APP_PW_RESP=$(curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $JWT" \
|
||||||
|
-d '{"label":"litmus-test"}' \
|
||||||
|
"$base_url/api/auth/app-passwords")
|
||||||
|
APP_PASSWORD=$(jq -r '.password' <<<"$APP_PW_RESP")
|
||||||
|
[[ -z "$APP_PASSWORD" || "$APP_PASSWORD" == "null" ]] && die "App password creation failed: $APP_PW_RESP"
|
||||||
|
log "App password created."
|
||||||
|
|
||||||
|
# ── 4. Run litmus ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LITMUS_TESTS="${LITMUS_TESTS:-basic copymove props locks}"
|
||||||
|
WEBDAV_URL="$base_url/webdav/"
|
||||||
|
|
||||||
|
log "Running litmus $LITMUS_TESTS against $WEBDAV_URL"
|
||||||
|
TESTS="$LITMUS_TESTS" litmus "$WEBDAV_URL" "$username" "$APP_PASSWORD"
|
||||||
|
|
||||||
|
log "litmus passed."
|
||||||
Reference in New Issue
Block a user