fix(webdav): RFC 4918 litmus compliance

This commit is contained in:
M.Schmidt
2026-06-22 21:37:14 +02:00
parent 3a7ca6722c
commit f9999cdd0f
8 changed files with 594 additions and 20 deletions
+343
View File
@@ -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}"
);
}
+123
View File
@@ -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."