Merge pull request #537 from EdouardVanbelle/feat/webdav-dead-properties
This commit is contained in:
@@ -100,6 +100,7 @@ tests/e2e/test-results/
|
||||
tests/e2e/blob-report/
|
||||
tests/e2e/playwright/.cache/
|
||||
tests/e2e/playwright/.auth/
|
||||
tests/webdav/storage-litmus/
|
||||
|
||||
# Test fixtures generated on-the-fly by tests/api/run.sh
|
||||
tests/fixtures/chunk-over-cap-*.bin
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Fix: descendant path/lpath cascade silently stopped firing after D6
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The D6 migration `20260807000000_cascade_drive_id_on_folder_move.sql` was
|
||||
-- written to add `drive_id` to the cascade trigger's column list. Its stated
|
||||
-- intent (per its own comment) was "add `drive_id` to the column list", but
|
||||
-- the re-registration replaced `name, parent_id, path, lpath` with
|
||||
-- `path, lpath, drive_id` — dropping `name` and `parent_id` in the process:
|
||||
--
|
||||
-- -- D6 as shipped (BUG):
|
||||
-- CREATE OR REPLACE TRIGGER trg_folders_cascade_path
|
||||
-- AFTER UPDATE OF path, lpath, drive_id ON storage.folders
|
||||
-- FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
|
||||
--
|
||||
-- PostgreSQL's `UPDATE OF <cols>` predicate matches against the statement's
|
||||
-- explicit SET clause — NOT against what a BEFORE trigger derives. The
|
||||
-- rename SQL the app issues is `UPDATE storage.folders SET name = $1, ...`
|
||||
-- and the move SQL is `UPDATE storage.folders SET parent_id = $1, ...`.
|
||||
-- Neither touches path/lpath/drive_id in its SET list. Net effect of D6:
|
||||
--
|
||||
-- * Folder rename: BEFORE trigger (trg_folders_path) correctly rewrites
|
||||
-- the renamed row's `path` and `lpath` columns directly. AFTER cascade
|
||||
-- trigger never fires → every DESCENDANT folder retains its old `path`
|
||||
-- and `lpath` indefinitely. Hidden until a path-keyed lookup misses.
|
||||
-- * Folder move (intra-drive): same regression, same hidden state.
|
||||
-- * Folder move (cross-drive): drive_id IS in the SET clause for some of
|
||||
-- the cross-drive code paths, so D6's drive_id branch fires there. But
|
||||
-- the path/lpath branch in the same function never fires on rename/move
|
||||
-- because the trigger gate excludes the SET columns the app uses.
|
||||
--
|
||||
-- Discovery: litmus `copymove → move_coll` (test #10) — `DELETE
|
||||
-- /webdav/litmus/mvdest/subcoll/` returns 404 because `subcoll`'s path
|
||||
-- column is still `Personal/litmus/mvsrc/subcoll`. The 10 leaf files
|
||||
-- foo.0..foo.9 directly under mvdest delete fine because their lookup
|
||||
-- joins through their parent folder's row (mvdest itself), and the BEFORE
|
||||
-- trigger DID update mvdest's own path correctly on rename. Only DESCENDANT
|
||||
-- folder rows are affected.
|
||||
--
|
||||
-- Fix: re-register the trigger with the column list that covers every
|
||||
-- statement the app actually issues against storage.folders:
|
||||
-- - `name` — folder rename
|
||||
-- - `parent_id` — folder move (intra-drive)
|
||||
-- - `path`, `lpath` — direct rewrites (migrations, future tooling)
|
||||
-- - `drive_id` — folder move (cross-drive); preserved from D6
|
||||
--
|
||||
-- The cascade function body itself is unchanged. The pg_trigger_depth() > 1
|
||||
-- guard inside it still stops the descendant-rewrite UPDATE from
|
||||
-- recursively re-firing the trigger on its own writes.
|
||||
|
||||
-- DROP-then-CREATE for PG 13 compatibility (no CREATE OR REPLACE TRIGGER
|
||||
-- pre-14). Idempotent thanks to IF EXISTS / IF NOT EXISTS semantics.
|
||||
DROP TRIGGER IF EXISTS trg_folders_cascade_path ON storage.folders;
|
||||
CREATE TRIGGER trg_folders_cascade_path
|
||||
AFTER UPDATE OF name, parent_id, path, lpath, drive_id ON storage.folders
|
||||
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
|
||||
|
||||
-- ── Repair: rebuild stale descendant path/lpath on existing databases ────
|
||||
-- Any folder rename or intra-drive move that happened between D6 deploying
|
||||
-- and this fix landing left descendants stranded at their pre-rename path
|
||||
-- and lpath. The same canonical-rebuild CTE used in
|
||||
-- `20260730000001_statement_tree_etag.sql` heals the pile in a single
|
||||
-- statement: walk the tree from each root, derive (path, lpath) from the
|
||||
-- parent chain, write back only the stale rows.
|
||||
--
|
||||
-- Two safety properties of this repair:
|
||||
-- * The repair UPDATE sets `path` and `lpath` directly. The newly-
|
||||
-- correct trigger column list above DOES include those columns, but
|
||||
-- `cascade_folder_path()` only descends to children when OLD differs
|
||||
-- from NEW *for that row* — descendants are walked level by level by
|
||||
-- the recursive CTE, so by the time the trigger fires on a child, the
|
||||
-- child's parent already has its correct path and the child's row is
|
||||
-- also being rewritten to its correct path. No double-write, no fan-
|
||||
-- out: the CTE finishes before any trigger could redo the work.
|
||||
-- * The statement-level tree-ETag bump triggers run their column filter
|
||||
-- against `(name, parent_id, is_trashed, updated_at)` — none of which
|
||||
-- change in this UPDATE — so existing sync clients see no spurious
|
||||
-- ETag churn.
|
||||
|
||||
WITH RECURSIVE canon AS (
|
||||
SELECT id,
|
||||
name::text AS path,
|
||||
replace(id::text, '-', '_')::ltree AS lpath
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT f.id,
|
||||
c.path || '/' || f.name,
|
||||
c.lpath || replace(f.id::text, '-', '_')::ltree
|
||||
FROM storage.folders f
|
||||
JOIN canon c ON f.parent_id = c.id
|
||||
)
|
||||
UPDATE storage.folders f
|
||||
SET path = c.path, lpath = c.lpath
|
||||
FROM canon c
|
||||
WHERE f.id = c.id
|
||||
AND (f.path IS DISTINCT FROM c.path OR f.lpath IS DISTINCT FROM c.lpath);
|
||||
@@ -3,10 +3,18 @@
|
||||
//! RFC 4918 §4.2 defines "dead properties" as those stored verbatim by the
|
||||
//! server without interpreting their value. Properties are persisted to
|
||||
//! `storage.webdav_dead_properties` and survive server restarts.
|
||||
//!
|
||||
//! Queries here use `sqlx::query()` (runtime-bound) rather than the
|
||||
//! compile-time-checked `sqlx::query!()` macro. The macro would require either
|
||||
//! a live DB at compile time OR committed `.sqlx/` offline metadata; the rest
|
||||
//! of this codebase consistently uses the runtime variant (see
|
||||
//! `user_pg_repository.rs` for the canonical style), so a fresh checkout
|
||||
//! compiles without any DB connection. Trading the macro's compile-time column
|
||||
//! check for that bootstrap-friendliness is the project's standing convention.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::QualifiedName;
|
||||
@@ -29,7 +37,7 @@ impl DeadPropertyStore {
|
||||
name: QualifiedName,
|
||||
value: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query!(
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO storage.webdav_dead_properties
|
||||
(resource_path, user_id, namespace, local_name, value)
|
||||
@@ -37,12 +45,12 @@ impl DeadPropertyStore {
|
||||
ON CONFLICT (resource_path, user_id, namespace, local_name)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||
"#,
|
||||
path,
|
||||
user_id,
|
||||
name.namespace,
|
||||
name.name,
|
||||
value,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(user_id)
|
||||
.bind(&name.namespace)
|
||||
.bind(&name.name)
|
||||
.bind(&value)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("set: {e}")))?;
|
||||
@@ -56,15 +64,15 @@ impl DeadPropertyStore {
|
||||
user_id: Uuid,
|
||||
name: &QualifiedName,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query!(
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.webdav_dead_properties
|
||||
WHERE resource_path = $1 AND user_id = $2
|
||||
AND namespace = $3 AND local_name = $4",
|
||||
path,
|
||||
user_id,
|
||||
name.namespace,
|
||||
name.name,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(user_id)
|
||||
.bind(&name.namespace)
|
||||
.bind(&name.name)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("remove: {e}")))?;
|
||||
@@ -77,20 +85,25 @@ impl DeadPropertyStore {
|
||||
path: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(QualifiedName, Option<String>)>, DomainError> {
|
||||
let rows = sqlx::query!(
|
||||
let rows = sqlx::query(
|
||||
"SELECT namespace, local_name, value
|
||||
FROM storage.webdav_dead_properties
|
||||
WHERE resource_path = $1 AND user_id = $2",
|
||||
path,
|
||||
user_id,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("get_all: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| (QualifiedName::new(r.namespace, r.local_name), r.value))
|
||||
.map(|r| {
|
||||
let namespace: String = r.get("namespace");
|
||||
let local_name: String = r.get("local_name");
|
||||
let value: Option<String> = r.get("value");
|
||||
(QualifiedName::new(namespace, local_name), value)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -102,30 +115,30 @@ impl DeadPropertyStore {
|
||||
user_id: Uuid,
|
||||
name: &QualifiedName,
|
||||
) -> Result<Option<Option<String>>, DomainError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"SELECT value FROM storage.webdav_dead_properties
|
||||
WHERE resource_path = $1 AND user_id = $2
|
||||
AND namespace = $3 AND local_name = $4",
|
||||
path,
|
||||
user_id,
|
||||
name.namespace,
|
||||
name.name,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(user_id)
|
||||
.bind(&name.namespace)
|
||||
.bind(&name.name)
|
||||
.fetch_optional(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("get: {e}")))?;
|
||||
|
||||
Ok(row.map(|r| r.value))
|
||||
Ok(row.map(|r| r.get::<Option<String>, _>("value")))
|
||||
}
|
||||
|
||||
/// Delete all dead properties for `path` (called on DELETE).
|
||||
pub async fn remove_resource(&self, path: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query!(
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.webdav_dead_properties
|
||||
WHERE resource_path = $1 AND user_id = $2",
|
||||
path,
|
||||
user_id,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(user_id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -146,26 +159,26 @@ impl DeadPropertyStore {
|
||||
DomainError::internal_error("DeadPropertyStore", format!("rename_resource tx: {e}"))
|
||||
})?;
|
||||
|
||||
sqlx::query!(
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.webdav_dead_properties
|
||||
WHERE resource_path = $1 AND user_id = $2",
|
||||
new_path,
|
||||
user_id,
|
||||
)
|
||||
.bind(new_path)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("DeadPropertyStore", format!("rename_resource delete: {e}"))
|
||||
})?;
|
||||
|
||||
sqlx::query!(
|
||||
sqlx::query(
|
||||
"UPDATE storage.webdav_dead_properties
|
||||
SET resource_path = $2
|
||||
WHERE resource_path = $1 AND user_id = $3",
|
||||
old_path,
|
||||
new_path,
|
||||
user_id,
|
||||
)
|
||||
.bind(old_path)
|
||||
.bind(new_path)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
@@ -487,7 +487,10 @@ async fn handle_propfind(
|
||||
.await;
|
||||
}
|
||||
Ok(ResolvedResource::File(file)) => {
|
||||
let dead_props = state.webdav_dead_props.get_all(&path, user.id).await
|
||||
let dead_props = state
|
||||
.webdav_dead_props
|
||||
.get_all(&path, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_href = webdav_href(&client_path);
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
@@ -541,7 +544,10 @@ async fn handle_propfind(
|
||||
.await
|
||||
{
|
||||
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
|
||||
let dead_props = state.webdav_dead_props.get_all(&path, user.id).await
|
||||
let dead_props = state
|
||||
.webdav_dead_props
|
||||
.get_all(&path, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_href = webdav_href(&client_path);
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
@@ -785,13 +791,18 @@ async fn handle_proppatch(
|
||||
for op in &ops {
|
||||
match op {
|
||||
PropPatchOp::Set(pv) => {
|
||||
dead_props.set(&path, user.id, pv.name.clone(), pv.value.clone()).await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to store dead property: {e}")))?;
|
||||
dead_props
|
||||
.set(&path, user.id, pv.name.clone(), pv.value.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to store dead property: {e}"))
|
||||
})?;
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) => {
|
||||
dead_props.remove(&path, user.id, name).await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to remove dead property: {e}")))?;
|
||||
dead_props.remove(&path, user.id, name).await.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove dead property: {e}"))
|
||||
})?;
|
||||
results.push((name, true));
|
||||
}
|
||||
}
|
||||
@@ -1112,7 +1123,9 @@ fn enforce_native_lock(
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(e) = lock_store.get_by_path(p) && e.info.depth.eq_ignore_ascii_case("infinity") {
|
||||
if let Some(e) = lock_store.get_by_path(p)
|
||||
&& e.info.depth.eq_ignore_ascii_case("infinity")
|
||||
{
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
@@ -1574,6 +1587,23 @@ async fn handle_delete(
|
||||
None => return Err(AppError::not_found(format!("Resource not found: {}", path))),
|
||||
}
|
||||
|
||||
// Reap dead properties so a future resource at the same path
|
||||
// doesn't inherit tombstone metadata from the deleted one. Best-
|
||||
// effort: a failure to clear leaves orphan rows but the user-
|
||||
// facing DELETE has succeeded, so we don't propagate the error.
|
||||
// Caught by tests/api/webdav_dead_properties.hurl Step 10.
|
||||
if let Err(e) = state
|
||||
.webdav_dead_props
|
||||
.remove_resource(&path, user.id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
user_id = %user.id,
|
||||
path = %path,
|
||||
"dead-property cleanup on DELETE failed: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
|
||||
@@ -126,8 +126,20 @@ HTTP 201
|
||||
file_id: jsonpath "$.id"
|
||||
|
||||
# Baseline used_bytes after the upload settles.
|
||||
#
|
||||
# `[Options] delay: 200ms` is the workaround for the
|
||||
# trigger-sweep-vs-spawn-hook race documented in
|
||||
# bug_trigger_sweep_vs_spawn_hook_race.md: the upload responds 201 as
|
||||
# soon as the row lands, but the storage-usage delta hook is
|
||||
# tokio::spawn'd — without the delay, trigger-sweep can run while
|
||||
# that hook is still in flight, the sweep then snapshots stale
|
||||
# numbers, the late hook adds its delta on top, and used_bytes ends
|
||||
# up high by exactly one file's size. Symptom: expected 32, got 64.
|
||||
# Real fix is await'ing the hook inline server-side.
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -161,6 +173,8 @@ shared_file_id: jsonpath "$.successful[0].id"
|
||||
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -336,6 +350,8 @@ jsonpath "$.name" == "dc-subtree-inner"
|
||||
# drive_id.
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
@@ -123,10 +123,12 @@ file_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# Baseline used_bytes after the upload settles. Trigger-sweep is
|
||||
# the deterministic sync point — without it the fire-and-forget
|
||||
# delta hook may not yet have landed in the row when we read it.
|
||||
# the deterministic sync point — but only after the spawn'd hook
|
||||
# has had a chance to land (bug_trigger_sweep_vs_spawn_hook_race.md).
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -161,6 +163,8 @@ HTTP 200
|
||||
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -229,8 +233,22 @@ nested_file_id: jsonpath "$.id"
|
||||
|
||||
# Baseline post-creation. Personal holds both hello.txt (32 B) +
|
||||
# nested hello-copy.txt (32 B) = 64. Shared is empty.
|
||||
#
|
||||
# `[Options] delay: 200ms` is the workaround for the
|
||||
# trigger-sweep-vs-spawn-hook race documented in
|
||||
# bug_trigger_sweep_vs_spawn_hook_race.md: the upload responds 201 as
|
||||
# soon as the row is written, but the storage-usage delta hook is
|
||||
# tokio::spawn'd — without the delay, trigger-sweep can run while the
|
||||
# hook from THIS upload (or a prior move) is still in flight, the
|
||||
# sweep then recomputes from stale numbers, the late hook adds its
|
||||
# delta on top, and used_bytes ends up too high by exactly one file's
|
||||
# size. Symptom: expected 64, got 96 (one extra hook landed late).
|
||||
# Real fix is await'ing the hook inline server-side; until then this
|
||||
# delay deflakes the test.
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -262,6 +280,8 @@ HTTP 200
|
||||
# drive_id wasn't cascaded by the trigger.
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[Options]
|
||||
delay: 200ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
+26
-12
@@ -299,14 +299,25 @@ jsonpath "$.items[*].resource.name" not contains "bob-attack-2"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 16 – Bob crafts a path that looks like it targets admin's
|
||||
# home. The WebDAV handler rewrites the path to live
|
||||
# under bob's home, so the request succeeds (201) but
|
||||
# the new folders land in BOB's tree — never admin's.
|
||||
# home. Pre-43cf4a2b the WebDAV handler silently
|
||||
# rewrote `My Folder - admin/...` into the caller's own
|
||||
# home folder, so this MKCOL succeeded with 201 but the
|
||||
# new folders landed in BOB's tree (defense via
|
||||
# redirect). 43cf4a2b made MKCOL strictly RFC 4918
|
||||
# §9.3.1 compliant: 409 when the parent collection is
|
||||
# missing, no auto-creation of ancestors. Bob's MKCOL
|
||||
# now fails because `My Folder - admin` is not a folder
|
||||
# bob can reach — defense via rejection rather than
|
||||
# silent rewrite. The 4xx range allows for 403/404/409
|
||||
# depending on which gate fires first.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/My%20Folder%20-%20admin/bob-webdav-attack
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 201
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 400
|
||||
status < 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -324,13 +335,16 @@ HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 18 – Bob's home now contains:
|
||||
# - "bob-webdav-own" (from Step 17, normal MKCOL)
|
||||
# - "My Folder - admin" (from Step 16 — the prefix
|
||||
# rewrite turned admin's home name into a literal
|
||||
# sub-folder name inside bob's tree).
|
||||
# This proves the path prefix re-rooted the attack
|
||||
# into bob's own namespace.
|
||||
# Step 18 – Bob's home contains "bob-webdav-own" (from Step 17's
|
||||
# legitimate MKCOL) and does NOT contain "My Folder -
|
||||
# admin". Pre-43cf4a2b the path-prefix rewrite would
|
||||
# have created that name literally as a sub-folder in
|
||||
# bob's tree (defense via redirect); post-43cf4a2b the
|
||||
# MKCOL is rejected outright (defense via rejection),
|
||||
# so no such folder exists in bob's namespace either.
|
||||
# Both are correct security outcomes — the wire signal
|
||||
# just changed from "succeeded but didn't reach admin"
|
||||
# to "didn't succeed at all."
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders/{{bob_home_id}}/resources?resource_types=folder
|
||||
Authorization: Bearer {{bob_token}}
|
||||
@@ -338,7 +352,7 @@ Authorization: Bearer {{bob_token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.items[*].resource.name" contains "bob-webdav-own"
|
||||
jsonpath "$.items[*].resource.name" contains "My Folder - admin"
|
||||
jsonpath "$.items[*].resource.name" not contains "My Folder - admin"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
+3
-1
@@ -166,7 +166,9 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/user_envelope_quota.hurl" \
|
||||
"$API_DIR/drive_policies.hurl" \
|
||||
"$API_DIR/cross_drive_move.hurl" \
|
||||
"$API_DIR/cross_drive_copy.hurl"
|
||||
"$API_DIR/cross_drive_copy.hurl" \
|
||||
"$API_DIR/webdav_dead_properties.hurl" \
|
||||
"$API_DIR/webdav_nested_move_cascade.hurl"
|
||||
|
||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV dead-properties (RFC 4918 §4.2) end-to-end
|
||||
# =============================================================
|
||||
# Exercises the PROPPATCH/PROPFIND round-trip backed by
|
||||
# `storage.webdav_dead_properties` (the table introduced in
|
||||
# migration 20260825000000) and the DeadPropertyStore service at
|
||||
# src/infrastructure/services/webdav_dead_property_store.rs.
|
||||
#
|
||||
# Dead properties are client-authored XML that the server stores
|
||||
# verbatim — Thunderbird, DAVx5, NextCloud-desktop, Cyberduck all
|
||||
# use them to persist per-resource labels / sync state. A
|
||||
# regression where PROPPATCH succeeds but PROPFIND returns nothing
|
||||
# is silently catastrophic for those clients (they think the
|
||||
# server is broken; OxiCloud sees nothing wrong in its logs).
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Setup admin, capture JWT, PUT a probe file.
|
||||
# 2. PROPPATCH set → 207
|
||||
# 3. PROPFIND get → value round-trips verbatim
|
||||
# 4. PROPPATCH upsert (set same name → new value) → 207
|
||||
# 5. PROPFIND get → new value (upsert worked)
|
||||
# 6. PROPPATCH remove → 207
|
||||
# 7. PROPFIND get → property absent
|
||||
# 8. MOVE file → properties follow the path (rename_resource)
|
||||
# 9. DELETE file → properties cleaned up (no orphan rows)
|
||||
#
|
||||
# XPath assertions deliberately use `local-name()` so the test
|
||||
# is robust against the server's choice of namespace prefix —
|
||||
# DeadPropertyStore generates `X:` but a future implementation
|
||||
# is free to pick something else as long as `xmlns:X` is correct.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# Resolve the user's home folder so the WebDAV path lives somewhere
|
||||
# valid. tests/api/files-folders.hurl runs before us and may have
|
||||
# left state; we deliberately pick a unique filename below to
|
||||
# avoid collisions.
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file via native WebDAV. The dead-property
|
||||
# handler keys on the resource path; we need a real file
|
||||
# there so MOVE/DELETE assertions later are meaningful.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
hello dead properties
|
||||
```
|
||||
|
||||
# Post 43cf4a2b: PUT returns 201 on create, 204 on overwrite.
|
||||
# This file is fresh (no prior PUT in the test), so 201 is the
|
||||
# canonical answer.
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set a single dead property.
|
||||
#
|
||||
# The XML body sets `<X:testlabel xmlns:X="oxi:test">
|
||||
# hello</X:testlabel>`. RFC 4918 §9.2 says PROPPATCH
|
||||
# MUST return 207 Multi-Status with a per-property
|
||||
# status; we assert both the envelope status and the
|
||||
# inner 200 OK for our property.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>hello-dead-property</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
# At least one propstat reports success for the property we set.
|
||||
# Using local-name() so we don't have to bind a prefix to DAV:.
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND. The dead-property propstat block should
|
||||
# contain `testlabel` with the value we set. The server's
|
||||
# response uses an `X:` prefix bound via `xmlns:X` to our
|
||||
# original namespace — we match by local-name() to stay
|
||||
# decoupled from that choice.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "hello-dead-property"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Upsert: setting the same property with a new value
|
||||
# must overwrite, not duplicate (ON CONFLICT DO UPDATE).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>updated-value</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PROPFIND confirms the new value AND that there's still
|
||||
# only one such property (no duplicate row in the DB).
|
||||
# `count(//*[local-name()='testlabel'])` is the
|
||||
# dup-detection assertion.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "updated-value"
|
||||
xpath "count(//*[local-name()='testlabel'])" == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Remove the dead property.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<X:testlabel/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — PROPFIND now returns no instance of `testlabel`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Re-set a property, then MOVE the file. The
|
||||
# rename_resource path in DeadPropertyStore must
|
||||
# re-key the row to the new path so the property
|
||||
# follows the file (a regression that leaves the row
|
||||
# at the old path would silently break every client
|
||||
# that does a MOVE then a PROPFIND).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>survives-move</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
MOVE {{base_url}}/webdav/dead-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Destination: {{base_url}}/webdav/dead-props-moved.txt
|
||||
|
||||
# RFC 4918 §9.9.4: MOVE returns 201 Created when the destination
|
||||
# didn't exist (the resource appears there for the first time);
|
||||
# 204 No Content when overwriting an existing destination. The
|
||||
# destination is fresh here → 201.
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/webdav/dead-props-moved.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "survives-move"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — DELETE the file; remove_resource() must reap the
|
||||
# dead-property rows so they don't accumulate as
|
||||
# tombstones the next time a file is created at the
|
||||
# same path. We verify by recreating the same path
|
||||
# and PROPFIND'ing — a leak would resurface the old
|
||||
# "survives-move" value.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/dead-props-moved.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
PUT {{base_url}}/webdav/dead-props-moved.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
fresh file at the same path
|
||||
```
|
||||
|
||||
# Fresh resource at the same path after DELETE → 201, same shape
|
||||
# as Step 2's initial PUT.
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/webdav/dead-props-moved.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
# Old value MUST NOT come back — proves DELETE cleaned up.
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/dead-props-moved.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -0,0 +1,164 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV: nested-folder MOVE descendant cascade
|
||||
# =============================================================
|
||||
# Regression guard for the litmus `copymove → move_coll`
|
||||
# scenario: when a parent folder is renamed (or moved), every
|
||||
# DESCENDANT folder row must have its `path` / `lpath` columns
|
||||
# rewritten by the AFTER cascade trigger
|
||||
# `trg_folders_cascade_path` so that path-keyed lookups
|
||||
# (WebDAV, CalDAV, CardDAV, and a handful of REST endpoints)
|
||||
# resolve the descendant at its new location.
|
||||
#
|
||||
# Why this needs a dedicated test:
|
||||
# * REST API tests overwhelmingly use folder IDs, not paths —
|
||||
# a stale `folders.path` column is invisible to `WHERE id =
|
||||
# $1` lookups. So they can't catch this regression even when
|
||||
# they MOVE.
|
||||
# * Existing WebDAV tests are flat: MOVE a file, or MKCOL +
|
||||
# DELETE on a single-level folder. None combine "MOVE a
|
||||
# folder that has folder descendants" with "look the
|
||||
# descendant up by its post-move path".
|
||||
# * litmus's `copymove → move_coll` IS this test, but litmus
|
||||
# isn't installed on every contributor's machine — it lives
|
||||
# on the CI side only. This Hurl scenario runs in every
|
||||
# standard `just api-test`.
|
||||
#
|
||||
# Bug shape it catches: the `UPDATE OF <cols>` column list on
|
||||
# `trg_folders_cascade_path` must include `name` AND `parent_id`
|
||||
# (not just `path, lpath, drive_id`) — otherwise the AFTER
|
||||
# trigger never fires on the rename SQL `UPDATE folders SET
|
||||
# name = $1` or the move SQL `UPDATE folders SET parent_id =
|
||||
# $1`, descendant rows stay at their pre-move path, and any
|
||||
# subsequent path-keyed lookup of a descendant returns 404.
|
||||
#
|
||||
# What this test does:
|
||||
# 1. Login (admin).
|
||||
# 2. MKCOL /webdav/regress-cascade-a/
|
||||
# 3. MKCOL /webdav/regress-cascade-a/b/ (descendant folder)
|
||||
# 4. PUT /webdav/regress-cascade-a/b/leaf.txt (leaf file)
|
||||
# 5. MOVE /webdav/regress-cascade-a/ → /webdav/regress-cascade-c/
|
||||
# 6. DELETE /webdav/regress-cascade-c/b/leaf.txt ← path-based file
|
||||
# lookup at the
|
||||
# new descendant
|
||||
# location
|
||||
# 7. DELETE /webdav/regress-cascade-c/b/ ← path-based
|
||||
# descendant
|
||||
# folder lookup
|
||||
# (this is the
|
||||
# one that 404s
|
||||
# when the bug
|
||||
# is present)
|
||||
# 8. DELETE /webdav/regress-cascade-c/ (cleanup root)
|
||||
#
|
||||
# Steps 6 and 7 are the load-bearing assertions; without the
|
||||
# cascade, the descendant's `path` column is still
|
||||
# `Personal/regress-cascade-a/b` and both DELETEs return 404.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — MKCOL the parent collection. Fresh names; 201 expected.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/regress-cascade-a/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — MKCOL the descendant collection inside the parent.
|
||||
# This is the folder row whose `path` column the
|
||||
# cascade trigger must rewrite when the parent is
|
||||
# renamed in Step 5.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/regress-cascade-a/b/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PUT a leaf file inside the descendant. We use it in
|
||||
# Step 6 to verify the post-move file lookup works
|
||||
# (files resolve via their parent folder's `path`, so
|
||||
# this branch caught fire too when the cascade was
|
||||
# broken — even though `storage.files` has no `path`
|
||||
# column of its own).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/regress-cascade-a/b/leaf.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
nested cascade regression probe
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — MOVE the parent collection. The SQL the service
|
||||
# issues is `UPDATE storage.folders SET name = $1, ...`
|
||||
# on the parent row (intra-drive same-parent rename).
|
||||
# The BEFORE trigger `trg_folders_path` rewrites the
|
||||
# parent's own `path`; the AFTER trigger
|
||||
# `trg_folders_cascade_path` must fire to rewrite
|
||||
# every descendant folder's `path` / `lpath`.
|
||||
#
|
||||
# RFC 4918 §9.9.4: destination is fresh → 201 Created.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MOVE {{base_url}}/webdav/regress-cascade-a/
|
||||
Authorization: Bearer {{token}}
|
||||
Destination: {{base_url}}/webdav/regress-cascade-c/
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Resolve the leaf FILE by its post-move path. The
|
||||
# DELETE handler's resolver joins `storage.files`
|
||||
# against `storage.folders` on `folder_id`, then
|
||||
# filters `fo.path = 'Personal/regress-cascade-c/b'`.
|
||||
# That match depends on the descendant folder's
|
||||
# `path` column having been cascade-rewritten in
|
||||
# Step 5.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/regress-cascade-c/b/leaf.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Resolve the descendant FOLDER by its post-move path.
|
||||
# This is the assertion that was failing as litmus
|
||||
# test 10. Lookup SQL: `SELECT … FROM storage.folders
|
||||
# WHERE path = 'Personal/regress-cascade-c/b' …`.
|
||||
# Without the cascade, the row still has path
|
||||
# `Personal/regress-cascade-a/b` → 0 rows → 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/regress-cascade-c/b/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Cleanup: DELETE the moved root so subsequent test
|
||||
# runs start clean even on a non-pristine DB.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/regress-cascade-c/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -1,343 +0,0 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
@@ -116,18 +116,24 @@ for REMOTE in "$FILE_A" "$FILE_B"; do
|
||||
done
|
||||
|
||||
# ── Step 1: Upload file A ─────────────────────────────────────────────────────
|
||||
# Post commit 43cf4a2b, PUT distinguishes create (201) from overwrite (204)
|
||||
# per RFC 7231 §4.3.4. Both files are NEW here (the purge_from_trash loop
|
||||
# above wiped any leftover state), so we expect 201 on each PUT.
|
||||
|
||||
echo " step 1: PUT $FILE_A..."
|
||||
STATUS=$(webdav_put "$FILE_A" "$FIXTURE" "video/mp4")
|
||||
[[ "$STATUS" == "204" ]] || fail "PUT $FILE_A expected 204, got $STATUS"
|
||||
pass "PUT $FILE_A → 204 (new manifest, 8 chunk blobs created)"
|
||||
[[ "$STATUS" == "201" ]] || fail "PUT $FILE_A expected 201, got $STATUS"
|
||||
pass "PUT $FILE_A → 201 (new manifest, 8 chunk blobs created)"
|
||||
|
||||
# ── Step 2: Upload file B (same content, different name → dedup hit) ──────────
|
||||
# File B is a distinct resource (new path), so PUT still emits 201 even though
|
||||
# the underlying blob is dedup'd. 201 vs 204 reflects "is this a new HTTP
|
||||
# resource at this URL", not "is the byte content novel".
|
||||
|
||||
echo " step 2: PUT $FILE_B (same bytes → dedup hit)..."
|
||||
STATUS=$(webdav_put "$FILE_B" "$FIXTURE" "video/mp4")
|
||||
[[ "$STATUS" == "204" ]] || fail "PUT $FILE_B expected 204, got $STATUS"
|
||||
pass "PUT $FILE_B → 204 (dedup hit: manifest ref_count → 2, chunks unchanged)"
|
||||
[[ "$STATUS" == "201" ]] || fail "PUT $FILE_B expected 201, got $STATUS"
|
||||
pass "PUT $FILE_B → 201 (dedup hit: manifest ref_count → 2, chunks unchanged)"
|
||||
|
||||
# ── Resolve file IDs ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -122,18 +122,23 @@ for REMOTE in "$FILE_A" "$FILE_B"; do
|
||||
done
|
||||
|
||||
# ── Step 1: Upload file A ─────────────────────────────────────────────────────
|
||||
# Post commit 43cf4a2b, PUT distinguishes create (201) from overwrite (204)
|
||||
# per RFC 7231 §4.3.4. The wipe loop above ensures A and B are NEW resources
|
||||
# here, so we expect 201. Step 5 below tests the overwrite case (expects 204).
|
||||
|
||||
echo " step 1: PUT $FILE_A (dedup-test.jpg)..."
|
||||
STATUS=$(webdav_put "$FILE_A" "$FIXTURE_A" "image/jpeg")
|
||||
[[ "$STATUS" == "204" ]] || fail "PUT $FILE_A expected 204, got $STATUS"
|
||||
pass "PUT $FILE_A → 204"
|
||||
[[ "$STATUS" == "201" ]] || fail "PUT $FILE_A expected 201, got $STATUS"
|
||||
pass "PUT $FILE_A → 201"
|
||||
|
||||
# ── Step 2: Upload file B (identical content, different name) ─────────────────
|
||||
# Distinct resource (new path), so PUT emits 201 even though the underlying
|
||||
# blob dedup-hits. 201 vs 204 reflects URL freshness, not byte freshness.
|
||||
|
||||
echo " step 2: PUT $FILE_B (dedup-test-2.jpg, same bytes)..."
|
||||
STATUS=$(webdav_put "$FILE_B" "$FIXTURE_B" "image/jpeg")
|
||||
[[ "$STATUS" == "204" ]] || fail "PUT $FILE_B expected 204, got $STATUS"
|
||||
pass "PUT $FILE_B → 204"
|
||||
[[ "$STATUS" == "201" ]] || fail "PUT $FILE_B expected 201, got $STATUS"
|
||||
pass "PUT $FILE_B → 201"
|
||||
|
||||
# ── Step 3: Resolve file IDs and assert two distinct records ──────────────────
|
||||
|
||||
|
||||
@@ -140,22 +140,21 @@ pass "M2: 5 responses, trailing-slash semantics correct on native /webdav/ surfa
|
||||
# the lifecycle (which the existing test_dedup_webdav_* scripts
|
||||
# also exercise at root) actually validates.
|
||||
|
||||
echo " M3: PUT /webdav/m3-sample.txt (pinned: native always 204, NC would be 201 on new)"
|
||||
echo " M3: PUT /webdav/m3-sample.txt → 201 (new resource, post 43cf4a2b)"
|
||||
# Post commit 43cf4a2b, the native WebDAV handler differentiates
|
||||
# new-vs-overwrite per RFC 7231 §4.3.4: 201 Created for a fresh PUT,
|
||||
# 204 No Content when replacing an existing resource. Aligns with the
|
||||
# NC handler — there's no more native-vs-NC split on this point.
|
||||
# (Prior to 43cf4a2b the native handler returned 204 for both; the M3
|
||||
# `case` block was a forward-looking trip-wire telling the next reader
|
||||
# to update this pin once the split happened. That moment is now.)
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'sample contents — exactly 31 bytes' \
|
||||
"$DAV_BASE/m3-sample.txt")
|
||||
case "$STATUS" in
|
||||
204)
|
||||
pass "M3: native PUT new → 204 (pinned current behaviour; differs from NC's 201/204 split)"
|
||||
;;
|
||||
201)
|
||||
fail "M3: native PUT now returns 201 for new — handler differentiates new-vs-overwrite. Update pin if intentional."
|
||||
;;
|
||||
*)
|
||||
fail "M3: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "M3: native PUT new expected 201, got $STATUS"
|
||||
pass "M3: native PUT new → 201"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M4 — Range GET bytes=0-9 → 206 + 10 bytes
|
||||
|
||||
@@ -143,13 +143,18 @@ else
|
||||
fi
|
||||
|
||||
# ── Step 1: PUT dedup-test.jpg ───────────────────────────────
|
||||
# /webdav always returns 204 (update_file_streaming handles create+update)
|
||||
# Post commit 43cf4a2b, /webdav distinguishes create (201) from
|
||||
# overwrite (204) per RFC 7231 §4.3.4. The cleanup loop above
|
||||
# (regular-listing + trash purge) guarantees this is a fresh
|
||||
# resource, so we expect 201. Step 2 below tests the overwrite
|
||||
# case (expects 204) — the 201/204 split itself is the regression
|
||||
# guard.
|
||||
|
||||
echo " step 1: PUT $REMOTE..."
|
||||
STATUS=$(webdav_put "$REMOTE" "$FIXTURE_V1" "image/jpeg")
|
||||
echo " step 1: WebDAV PUT → $STATUS"
|
||||
[[ "$STATUS" == "204" ]] || fail "WebDAV PUT expected 204, got $STATUS"
|
||||
pass "WebDAV PUT dedup-test.jpg → 204"
|
||||
[[ "$STATUS" == "201" ]] || fail "WebDAV PUT expected 201, got $STATUS"
|
||||
pass "WebDAV PUT dedup-test.jpg → 201"
|
||||
|
||||
# ── find file_id from REST listing ───────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user