Merge pull request #473 from EdouardVanbelle/fix/nextcloud+webdav

fix(nextcloud+webdav) fix bugs found via end to end tests
This commit is contained in:
Dionisio Pozo
2026-06-17 12:42:31 +02:00
committed by GitHub
17 changed files with 1081 additions and 703 deletions
+47 -9
View File
@@ -136,15 +136,45 @@ body contains "{{shared_file_id}}"
# ─────────────────────────────────────────────────────────────
# 8 — Direct file share: mint a share on the FILE itself
# (item_type=file) and access it via /api/s/{token}.
#
# KNOWN BUG: GET /api/s/{folder-token}/file/{file_id} (the
# "fetch a file from inside a shared folder" route at
# share_handler.rs:653) currently returns 500. We sidestep
# it here by sharing the file directly. When the folder-file
# path is fixed, add a new scenario asserting it returns
# 200 + body, and back-link this comment.
# 8 — Fetch a file from inside the FOLDER share via
# /api/s/{folder-token}/file/{file_id}. This is the path NC
# desktop and web clients use to download a single file out
# of a shared folder without zipping the whole tree. The
# handler must (a) accept the file_id only when the file
# lives in the share's subtree, and (b) refuse with 404 for
# any file outside the subtree (anti-enumeration: the same
# status as "file doesn't exist", so the caller can't probe
# for foreign file ids).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/s/{{share_token}}/file/{{shared_file_id}}
HTTP 200
[Asserts]
header "Content-Disposition" contains "hello.txt"
# A file the caller owns but that isn't inside the shared
# folder MUST 404 — same shape as "no such file", so the
# response can't be used to enumerate file ids.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{admin_home_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
outsider_file_id: jsonpath "$.id"
GET {{base_url}}/api/s/{{share_token}}/file/{{outsider_file_id}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# 8b — Direct file share: mint a share on the FILE itself
# (item_type=file) and access it via /api/s/{token}.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/shares
Authorization: Bearer {{admin_token}}
@@ -256,6 +286,14 @@ DELETE {{base_url}}/api/shares/{{file_share_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
# The "outsider" hello.txt sits in admin's home folder, not under the
# shared subtree — delete it explicitly so the next test in the
# runner (permissions.hurl) can upload its own hello.txt to the same
# folder without hitting the live-name unique index (409).
DELETE {{base_url}}/api/files/{{outsider_file_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/folders/{{share_folder_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
+8
View File
@@ -84,6 +84,14 @@ dav_curl() {
curl -s -H "Authorization: Bearer $TOKEN" "$@"
}
# Return the HTTP status code of a `PROPFIND Depth: 0` against the
# given NC URL. Used by existence assertions ("did this collection
# silently get auto-created?") where the only thing the caller cares
# about is the status (404 → absent, 207 → present).
nc_status_propfind_depth0() {
nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$1"
}
# Count `<d:response>` (or `<D:response>`) children in a multistatus
# body. Case-insensitive on the namespace prefix because OxiCloud's
# two DAV surfaces use different cases: the NC handler emits
+116 -117
View File
@@ -180,39 +180,18 @@ pass "M4: Range bytes=0-9 → 206 + 10 bytes"
# code path where it should actually work: root-level MOVE of a
# file PUT at root. If even this 404s, the bug is broader and
# native MOVE is unusable, not just nested.
echo " M5: MOVE /webdav/m3-sample.txt → /webdav/m5-moved.txt"
echo " M5: MOVE /webdav/m3-sample.txt → /webdav/m5-moved.txt → 201/204"
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $DAV_BASE/m5-moved.txt" \
"$DAV_BASE/m3-sample.txt")
case "$STATUS" in
201|204)
pass "M5: root-level MOVE → $STATUS"
;;
404)
# KNOWN BUG: native MOVE returns 404 on a file that was
# PUT at the same path, even at root level. The strict
# `resolve_path_for_user` SQL query doesn't match what
# the PUT's `save_file_from_temp_with_dedup` stored —
# most likely because the WebDAV dispatcher's path
# prepending (`resolve_webdav_path` → "My Folder - X/foo")
# doesn't match the user's actual home folder path
# field in the DB. Same root cause makes nested MOVE
# (see M3 comment) unusable too.
#
# Where the fix lives:
# `interfaces/api/handlers/webdav_handler.rs::handle_move`
# currently calls `resolver.resolve_path_for_user`. It
# should either:
# (a) fall back to `file_retrieval_service.get_file_by_path`
# (the same lookup GET uses successfully), or
# (b) normalise the source path through the same
# transformer the PUT writes through.
pass "M5: root-level MOVE → 404 (KNOWN BUG: resolve_path_for_user mismatch — pinned)"
;;
*)
fail "M5: unexpected status $STATUS"
;;
esac
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|| fail "M5: root-level MOVE expected 201/204, got $STATUS"
# Source is gone, destination present.
[[ "$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m3-sample.txt")" == "404" ]] \
|| fail "M5: source still resolvable after MOVE"
[[ "$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m5-moved.txt")" == "207" ]] \
|| fail "M5: destination not found after MOVE"
pass "M5: root-level MOVE → $STATUS, source gone, destination present"
# ─────────────────────────────────────────────────────────────
# M6 — MKCOL sub/ → 201
@@ -228,16 +207,11 @@ pass "M6: native MKCOL → 201"
# ─────────────────────────────────────────────────────────────
echo " M7: DELETE /webdav/m6-sub/ → 204"
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X DELETE "$DAV_BASE/m6-sub/")
case "$STATUS" in
204) pass "M7: native DELETE → 204" ;;
404)
# If DELETE also hits the resolve_path_for_user 404 trap
# (it uses the same resolver), pin as same root-cause
# KNOWN BUG.
pass "M7: native DELETE → 404 (KNOWN BUG: same resolve_path_for_user mismatch as M5 — pinned)"
;;
*) fail "M7: unexpected status $STATUS" ;;
esac
[[ "$STATUS" == "204" ]] \
|| fail "M7: native DELETE expected 204, got $STATUS"
[[ "$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m6-sub/")" == "404" ]] \
|| fail "M7: folder still resolvable after DELETE"
pass "M7: native DELETE → 204, folder gone"
# ─────────────────────────────────────────────────────────────
# M8 — COPY a.txt → b.txt (pin whatever current behaviour is)
@@ -245,57 +219,25 @@ esac
# M8 source depends on whether M5 MOVE actually worked. If M5 was
# pinned as KNOWN BUG (404), the source for M8 is still
# m3-sample.txt at root, not m5-moved.txt.
echo " M8: COPY native source → /webdav/m8-copy.txt"
M8_SOURCE_URL="$DAV_BASE/m3-sample.txt"
# If M5 actually moved the file, the source name changed.
if dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m5-moved.txt" | grep -q "207"; then
M8_SOURCE_URL="$DAV_BASE/m5-moved.txt"
echo " M8: COPY /webdav/m5-moved.txt → /webdav/m8-copy.txt"
# M5 now succeeds, so the source is at m5-moved.txt. (Kept fallback
# to m3-sample.txt to surface a clear error if M5 regressed.)
M8_SOURCE_URL="$DAV_BASE/m5-moved.txt"
if ! dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m5-moved.txt" | grep -q "207"; then
M8_SOURCE_URL="$DAV_BASE/m3-sample.txt"
fi
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X COPY \
-H "Destination: $DAV_BASE/m8-copy.txt" \
"$M8_SOURCE_URL")
case "$STATUS" in
201|204)
# Confirm source still exists (COPY != MOVE).
SRC_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$M8_SOURCE_URL")
DST_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m8-copy.txt")
[[ "$SRC_STATUS" == "207" ]] \
|| fail "M8: COPY removed source ($SRC_STATUS instead of 207) — that's MOVE behaviour, not COPY"
[[ "$DST_STATUS" == "207" ]] \
|| fail "M8: destination not present after COPY ($DST_STATUS)"
pass "M8: native COPY → $STATUS, source preserved, destination present"
;;
405)
pass "M8: native COPY → 405 METHOD_NOT_ALLOWED — handler not implemented, pinned"
;;
404)
pass "M8: native COPY → 404 (KNOWN BUG: same resolve_path_for_user mismatch as M5/M7 — pinned)"
;;
500)
# KNOWN BUG: the COPY file branch at
# `interfaces/api/handlers/webdav_handler.rs::handle_copy`
# line ~1639 passes `(file.id, user.id, target_folder_id)`
# to `copy_file_with_perms` — no destination NAME. The
# copy therefore lands in the target folder under the
# SOURCE's name, ignoring the rename the client requested.
# When source and destination resolve to the same folder
# (common for root-level COPY), this collides with the
# source itself → AlreadyExists → leaks as 500.
#
# Where the fix lives: same handler — either
# (a) extend `copy_file_with_perms` to accept an
# optional new name (the folder-tree branch on
# line ~1591 already passes a name into
# `copy_folder_tree_with_perms`), or
# (b) follow the copy with a `rename_file_with_perms`
# call if `dest_filename != source.name` (mirrors
# what MOVE does at line ~1347).
pass "M8: native COPY → 500 (KNOWN BUG: dest filename discarded, collides with source — pinned)"
;;
*)
fail "M8: unexpected COPY status $STATUS"
;;
esac
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|| fail "M8: native COPY expected 201/204, got $STATUS"
SRC_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$M8_SOURCE_URL")
DST_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m8-copy.txt")
[[ "$SRC_STATUS" == "207" ]] \
|| fail "M8: COPY removed source ($SRC_STATUS instead of 207) — that's MOVE behaviour, not COPY"
[[ "$DST_STATUS" == "207" ]] \
|| fail "M8: destination not present after COPY ($DST_STATUS)"
pass "M8: native COPY → $STATUS, source preserved, destination renamed correctly"
# ═════════════════════════════════════════════════════════════
# Group N — LOCK / UNLOCK
@@ -332,44 +274,101 @@ LOCK_TOKEN=$(grep -i '^lock-token:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\
pass "N1: LOCK → 200 + Lock-Token=$LOCK_TOKEN"
# ─────────────────────────────────────────────────────────────
# N2 — PUT without the lock token → 423 Locked
# ─────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────
# N2 — PUT to a locked file without the token
# N2 — PUT to a locked file without the token → 423 Locked
#
# RFC 4918 §9.10.4 + §6: a writeable resource under an
# exclusive lock MUST reject conflicting writes with 423
# Locked. OxiCloud's native handler currently does NOT consult
# the lock store before writing — LOCK just produces a token,
# and any PUT/DELETE/MOVE/PROPPATCH succeeds regardless. The
# class-2 DAV advertisement in M1 is therefore aspirational:
# the protocol surface exists, the enforcement doesn't.
#
# Where the fix lives:
# `interfaces/api/handlers/webdav_handler.rs::handle_put` (and
# the mutator paths in handle_delete / handle_move / handle_copy /
# handle_proppatch) — each needs to check the WebDAV lock service
# for an active lock on the target path and reject with 423 if
# the request doesn't carry a matching `If: (<token>)` header.
# The lock store itself already records tokens — confirmed by N1
# capturing one — so the gap is purely on the read-side check.
# Locked unless the request submits the lock token in `If:`.
# N2b verifies the inverse: same PUT with the correct
# `If: (<token>)` header succeeds, proving the gate isn't
# blocking legitimate updates from the lock owner.
# ─────────────────────────────────────────────────────────────
echo " N2: PUT /webdav/n-locked.txt without If:(<token>) — pinned: lock not enforced (RFC would 423)"
echo " N2: PUT /webdav/n-locked.txt without If:(<token>) → 423"
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \
-H "Content-Type: text/plain" \
--data-binary 'tampered contents' \
"$DAV_BASE/n-locked.txt")
case "$STATUS" in
204)
pass "N2: PUT succeeded despite active lock → 204 (KNOWN BUG: lock not enforced — pinned)"
;;
423)
fail "N2: server now returns 423 Locked. Lock enforcement was added — update this pin to assert == 423."
;;
*)
fail "N2: unexpected status $STATUS"
;;
esac
[[ "$STATUS" == "423" ]] \
|| fail "N2: expected 423 Locked for PUT to locked path without token, got $STATUS"
pass "N2: PUT to locked path without token → 423"
echo " N2b: PUT /webdav/n-locked.txt WITH If:(<token>) → 204"
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \
-H "Content-Type: text/plain" \
-H "If: (<$LOCK_TOKEN>)" \
--data-binary 'authorised update' \
"$DAV_BASE/n-locked.txt")
[[ "$STATUS" == "204" ]] \
|| fail "N2b: expected 204 No Content for PUT with correct lock token, got $STATUS"
pass "N2b: PUT with matching If:(<token>) → 204"
# ─────────────────────────────────────────────────────────────
# N2c–N2f — Lock enforcement on the other mutator methods
#
# RFC 4918 §9.10.4: a lock binds every mutating method, not just
# PUT. The native handler's `enforce_native_lock` helper was
# designed to be called by handle_delete / handle_move /
# handle_copy / handle_proppatch as well — these tests prove the
# wire is in. Each case uses the n-locked.txt resource locked
# above and a `WITHOUT If:` request, expecting 423. Positive
# (with-token) coverage is implicit: the M-series above already
# exercises each method on unlocked resources and asserts the
# success codes, so a regression that hard-rejected every call
# would fail there.
#
# Order matters: each must run while the lock is still held,
# i.e. before N3 below releases it.
# ─────────────────────────────────────────────────────────────
echo " N2c: DELETE /webdav/n-locked.txt without If:(<token>) → 423"
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X DELETE \
"$DAV_BASE/n-locked.txt")
[[ "$STATUS" == "423" ]] \
|| fail "N2c: expected 423 Locked for DELETE on locked path without token, got $STATUS"
# The file must still be present after a rejected DELETE.
[[ "$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/n-locked.txt")" == "207" ]] \
|| fail "N2c: file removed after rejected DELETE (423 was advisory only?)"
pass "N2c: DELETE on locked path without token → 423, resource preserved"
echo " N2d: MOVE /webdav/n-locked.txt without If:(<token>) → 423 (source-side lock)"
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $DAV_BASE/n-locked-moved.txt" \
"$DAV_BASE/n-locked.txt")
[[ "$STATUS" == "423" ]] \
|| fail "N2d: expected 423 Locked for MOVE on locked source without token, got $STATUS"
[[ "$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/n-locked.txt")" == "207" ]] \
|| fail "N2d: source disappeared after rejected MOVE"
[[ "$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/n-locked-moved.txt")" == "404" ]] \
|| fail "N2d: destination created after rejected MOVE"
pass "N2d: MOVE with locked source and no token → 423, no state mutated"
echo " N2e: COPY into /webdav/n-locked.txt (locked destination) without If:(<token>) → 423"
# Set up a fresh unlocked source for the COPY.
dav_curl -o /dev/null -X PUT -H "Content-Type: text/plain" \
--data-binary 'n2e copy source' \
"$DAV_BASE/n2e-copy-src.txt" > /dev/null
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X COPY \
-H "Destination: $DAV_BASE/n-locked.txt" \
"$DAV_BASE/n2e-copy-src.txt")
[[ "$STATUS" == "423" ]] \
|| fail "N2e: expected 423 Locked for COPY into locked destination without token, got $STATUS"
# The locked destination's content must not have been replaced.
BODY=$(dav_curl -s "$DAV_BASE/n-locked.txt")
[[ "$BODY" == "authorised update" ]] \
|| fail "N2e: locked destination's content was overwritten (got '$BODY')"
pass "N2e: COPY into locked destination without token → 423, target untouched"
echo " N2f: PROPPATCH /webdav/n-locked.txt without If:(<token>) → 423"
PROPPATCH_BODY='<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:">
<d:set><d:prop><d:displayname>tampered</d:displayname></d:prop></d:set>
</d:propertyupdate>'
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPPATCH \
-H "Content-Type: application/xml" \
--data "$PROPPATCH_BODY" \
"$DAV_BASE/n-locked.txt")
[[ "$STATUS" == "423" ]] \
|| fail "N2f: expected 423 Locked for PROPPATCH on locked path without token, got $STATUS"
pass "N2f: PROPPATCH on locked path without token → 423"
# ─────────────────────────────────────────────────────────────
# N3 — UNLOCK with token → 204; subsequent PUT succeeds
+98 -81
View File
@@ -108,64 +108,65 @@ STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
pass "G3: URL-encoded destination decoded correctly"
# ─────────────────────────────────────────────────────────────
# G4 / G5 — Overwrite header behaviour (pinned: not honoured)
# G4 / G5 / G5b — Overwrite header (RFC 4918 §9.9.4)
#
# G4 : Overwrite: F + destination exists → 412 (refuse)
# G5 : Overwrite: T + destination exists → 204 (replace)
# G5b : Overwrite header absent → default T per spec → 204
# G5c : Overwrite: F + destination ABSENT → 201 (normal create)
# ─────────────────────────────────────────────────────────────
echo " G4: MOVE with Overwrite: F to an existing path (pinned: SERVER BUG — leaks 500)"
echo " G4: MOVE with Overwrite: F to an existing path → 412"
put_nc_file "g4-src.txt" "G4 source"
put_nc_file "g4-dest.txt" "G4 destination (should remain)"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/g4-dest.txt" \
-H "Overwrite: F" \
"$NC_FILES_BASE/g4-src.txt")
case "$STATUS" in
500)
# KNOWN BUG: the NC MOVE handler doesn't intercept
# `Overwrite: F` and doesn't map the domain-layer
# `AlreadyExists` to 412. It tries to rename, the
# storage layer 409s "name already taken", and the
# handler bubbles that up as 500. NC desktop will
# interpret 500 as "server transient error" and
# retry, which masks the real conflict.
#
# The right fix is in `interfaces/nextcloud/webdav_handler.rs::handle_move`:
# check `Overwrite: F` BEFORE attempting the rename, return
# 412 on collision; OR when Overwrite is omitted/T, delete
# the destination first (replace semantics, → 204).
pass "G4: Overwrite: F → 500 (KNOWN BUG: should be 412 per RFC 4918 §9.9.4 — pinned)"
;;
412)
fail "G4: server now correctly returns 412 for Overwrite: F. Bug is fixed — update this pin to assert == 412."
;;
201|204)
fail "G4: server now silently overwrites despite Overwrite: F (status $STATUS) — this would be a *different* bug; RFC requires 412."
;;
*)
fail "G4: unexpected status $STATUS"
;;
esac
[[ "$STATUS" == "412" ]] \
|| fail "G4: expected 412 Precondition Failed for Overwrite: F + collision, got $STATUS"
# Source and destination must both still exist with original contents.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g4-src.txt")" == "207" ]] \
|| fail "G4: source disappeared after 412 (move should have been refused, not partially applied)"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g4-dest.txt")" == "207" ]] \
|| fail "G4: destination disappeared after 412"
pass "G4: Overwrite: F + collision → 412, source and destination intact"
echo " G5: MOVE with Overwrite: T to an existing path (pinned: SERVER BUG — leaks 500)"
echo " G5: MOVE with Overwrite: T to an existing path → 204"
put_nc_file "g5-src.txt" "G5 source"
put_nc_file "g5-dest.txt" "G5 destination (to be replaced)"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/g5-dest.txt" \
-H "Overwrite: T" \
"$NC_FILES_BASE/g5-src.txt")
case "$STATUS" in
500)
# Same root cause as G4: the handler doesn't consider the
# `Overwrite` header at all. With `Overwrite: T` it SHOULD
# delete the destination first and proceed (→ 204), but
# today it bubbles up the storage-layer "Already Exists".
pass "G5: Overwrite: T → 500 (KNOWN BUG: should be 204 per RFC 4918 §9.9.4 — pinned)"
;;
204)
fail "G5: server now correctly returns 204 for Overwrite: T. Bug is fixed — update this pin to assert == 204."
;;
*)
fail "G5: unexpected status $STATUS"
;;
esac
[[ "$STATUS" == "204" ]] \
|| fail "G5: expected 204 No Content for Overwrite: T + collision, got $STATUS"
# Source gone, destination now has the source's content.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g5-src.txt")" == "404" ]] \
|| fail "G5: source still present after successful overwrite move"
DEST_BODY=$(nc_curl -s "$NC_FILES_BASE/g5-dest.txt")
[[ "$DEST_BODY" == "G5 source" ]] \
|| fail "G5: destination content not replaced; got '$DEST_BODY'"
pass "G5: Overwrite: T + collision → 204, destination replaced"
echo " G5b: MOVE with no Overwrite header to an existing path → 204 (default T)"
put_nc_file "g5b-src.txt" "G5b source"
put_nc_file "g5b-dest.txt" "G5b destination (default-overwrite target)"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/g5b-dest.txt" \
"$NC_FILES_BASE/g5b-src.txt")
[[ "$STATUS" == "204" ]] \
|| fail "G5b: expected 204 No Content for missing Overwrite header (default T), got $STATUS"
pass "G5b: absent Overwrite defaults to T → 204"
echo " G5c: MOVE with Overwrite: F to a NEW path → 201 (no collision to refuse)"
put_nc_file "g5c-src.txt" "G5c source"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/g5c-fresh-dest.txt" \
-H "Overwrite: F" \
"$NC_FILES_BASE/g5c-src.txt")
[[ "$STATUS" == "201" ]] \
|| fail "G5c: expected 201 Created for Overwrite: F + no collision, got $STATUS"
pass "G5c: Overwrite: F + new destination → 201"
# ─────────────────────────────────────────────────────────────
# G6 — MOVE a folder (subtree)
@@ -255,7 +256,7 @@ pass "G8: DELETE → 204 + GET 404"
# descendant assertions below will trip and you can flip them
# to strict 404.
# ─────────────────────────────────────────────────────────────
echo " G9: DELETE folder (pinned: descendants currently orphan — KNOWN BUG)"
echo " G9: DELETE folder cascades soft-delete to descendants"
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/" > /dev/null
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/inner/" > /dev/null
put_nc_file "g9-tree/file.txt" "G9 file"
@@ -264,22 +265,49 @@ STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_FILES_BASE/g9-tre
[[ "$STATUS" == "204" ]] \
|| fail "G9: folder DELETE expected 204, got $STATUS"
# Folder itself: correctly 404.
# Folder itself: 404.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/")" == "404" ]] \
|| fail "G9: folder still present after DELETE — that part should always be 404"
|| fail "G9: folder still resolvable after DELETE"
# Descendants: pin the current (buggy) "still alive" status.
# Either current 207 (bug) or future 404 (fix) is acceptable;
# anything else means something has drifted unexpectedly.
CHILD_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")
DEEP_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")
if [[ "$CHILD_STATUS" == "207" && "$DEEP_STATUS" == "207" ]]; then
pass "G9: descendants still reachable (file=207, deep=207) — KNOWN BUG pinned: move_to_trash isn't recursive at the row level"
elif [[ "$CHILD_STATUS" == "404" && "$DEEP_STATUS" == "404" ]]; then
fail "G9: descendants now correctly 404 (file=$CHILD_STATUS, deep=$DEEP_STATUS) — bug is fixed, flip this case to strict 404 assertions."
else
fail "G9: mixed/unexpected descendant statuses (file=$CHILD_STATUS, deep=$DEEP_STATUS) — pin needs review"
fi
# Descendants must now also be 404 (cascade soft-delete reaches the
# whole subtree). Previous behaviour left them reachable at their
# full path while the parent was gone — a data-integrity drift that
# confused desktop-sync tree walks.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")" == "404" ]] \
|| fail "G9: direct-child file still resolvable after parent DELETE — cascade not working"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/")" == "404" ]] \
|| fail "G9: descendant folder still resolvable after parent DELETE — cascade not working"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")" == "404" ]] \
|| fail "G9: descendant file still resolvable after parent DELETE — cascade not working"
pass "G9: DELETE folder → 204, descendants all 404 (cascade reaches the whole subtree)"
# G9b — restore the trashed root and verify cascade-restore brings
# every descendant back with the same paths. Cascade-trashed
# descendants (original_parent_id IS NULL) get un-trashed; rows that
# were independently trashed before the folder went to trash stay
# trashed.
echo " G9b: restore the trashed g9-tree → cascade-restore reaches descendants"
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
G9_TRASHED_HREF=$(extract_response_href_containing "$BODY" "g9-tree")
G9_TRASHED_ID=$(basename "$G9_TRASHED_HREF")
[[ -n "$G9_TRASHED_ID" ]] || fail "G9b: trashed g9-tree not found via PROPFIND"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/g9-tree-restored/" \
"$NC_TRASH_BASE/$G9_TRASHED_ID")
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|| fail "G9b: restore expected 201/204, got $STATUS"
# The folder and ALL its descendants are reachable again at their
# original paths (restore goes to original location, not the
# Destination header).
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/")" == "207" ]] \
|| fail "G9b: root folder not back after restore"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")" == "207" ]] \
|| fail "G9b: direct-child file not restored alongside parent"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/")" == "207" ]] \
|| fail "G9b: descendant folder not restored alongside parent"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")" == "207" ]] \
|| fail "G9b: descendant file not restored alongside parent"
pass "G9b: restored g9-tree carries the whole subtree back"
# ═════════════════════════════════════════════════════════════
# Group K — Trashbin DAV (depends on G8's deletion above)
@@ -408,28 +436,17 @@ TRASHED_ID=$(basename "$TRASHED_HREF")
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/k5-conflict.txt" \
"$NC_TRASH_BASE/$TRASHED_ID")
case "$STATUS" in
201|204)
pass "K5: restore-onto-existing → $STATUS (current behaviour pinned: collision NOT prevented at this layer)"
;;
412)
pass "K5: restore-onto-existing → 412 (current behaviour pinned: precondition-style refusal)"
;;
409)
pass "K5: restore-onto-existing → 409 (current behaviour pinned: name conflict)"
;;
500)
# Same shape as the G4/G5 bug — restore is a MOVE under
# the hood, and the handler doesn't catch the storage-
# layer "Already Exists" before it becomes an internal
# error. Pinned because that's the actual current
# behaviour, not because it's correct.
pass "K5: restore-onto-existing → 500 (KNOWN BUG: same root cause as G4/G5 — pinned)"
;;
*)
fail "K5: unexpected status $STATUS — pin needs reviewing"
;;
esac
[[ "$STATUS" == "412" ]] \
|| fail "K5: expected 412 Precondition Failed for restore-onto-existing, got $STATUS"
# The trashed item must still be in the trash (refused restore mustn't
# half-delete the trash row).
[[ -n "$(extract_response_href_containing "$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")" "k5-doomed")" ]] \
|| fail "K5: trash entry vanished after a refused restore"
# The conflicting live file must still be there with its original content.
LIVE_BODY=$(nc_curl -s "$NC_FILES_BASE/k5-conflict.txt")
[[ "$LIVE_BODY" == "k5 original (stays)" ]] \
|| fail "K5: conflicting live file mutated; got '$LIVE_BODY'"
pass "K5: restore-onto-existing → 412, trash row and live file intact"
# ── Cleanup ──────────────────────────────────────────────────────────────────
echo " cleanup: empty trash + remove residual fixtures"
+97 -36
View File
@@ -145,27 +145,83 @@ ACTUAL=$(nc_curl "$NC_FILES_BASE/f1-small.txt")
pass "F4: GET after overwrite serves the new bytes (no stale-cache)"
# ─────────────────────────────────────────────────────────────
# F5 / F6 — Conditional PUT (pinned: currently no-op)
# F5 / F6 — Conditional PUT (RFC 7232 §3.1/§3.2, RFC 4918 §10)
#
# F5 covers `If-None-Match: *`: server MUST refuse the PUT with
# 412 when the target representation already exists (used by
# clients to do "create only if absent"). The mirror case — same
# header on a NEW path — must succeed; covered by F5b.
#
# F6 covers `If-Match: "<etag>"`: server MUST refuse the PUT with
# 412 when the supplied ETag doesn't strong-match the current
# representation (used by clients to do "update only if
# unchanged"). The mirror case — correct ETag → success — is
# covered by F6b.
# ─────────────────────────────────────────────────────────────
echo " F5: PUT with If-None-Match: * on existing path (pinned current: 204, RFC-4918 would be 412)"
echo " F5: PUT with If-None-Match: * on existing path → 412"
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
-H "If-None-Match: *" -H "Content-Type: text/plain" \
--data-binary 'F5-payload' \
"$NC_FILES_BASE/f1-small.txt")
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
[[ "$STATUS" == "204" || "$STATUS" == "201" ]] \
|| fail "F5: unexpected status $STATUS (expected 204 — current ignore-conditional behaviour)"
pass "F5: PUT honours no conditional headers today — pinned"
[[ "$STATUS" == "412" ]] \
|| fail "F5: expected 412 Precondition Failed for If-None-Match: * on existing path, got $STATUS"
pass "F5: If-None-Match: * on existing path → 412"
echo " F6: PUT with If-Match: \"wrong-etag\" (pinned current: succeeds, RFC-4918 would be 412)"
echo " F5b: PUT with If-None-Match: * on NEW path → 201/204"
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
-H "If-None-Match: *" -H "Content-Type: text/plain" \
--data-binary 'F5b-payload' \
"$NC_FILES_BASE/f5b-new.txt")
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|| fail "F5b: expected 201/204 for If-None-Match: * on new path, got $STATUS"
pass "F5b: If-None-Match: * on new path → $STATUS"
echo " F6: PUT with If-Match: \"wrong-etag\" → 412"
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
-H 'If-Match: "deadbeef-never-matches"' -H "Content-Type: text/plain" \
--data-binary 'F6-payload' \
"$NC_FILES_BASE/f1-small.txt")
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
[[ "$STATUS" == "204" || "$STATUS" == "201" ]] \
|| fail "F6: unexpected status $STATUS (expected 204 — current ignore-conditional behaviour)"
pass "F6: PUT honours no If-Match today — pinned"
[[ "$STATUS" == "412" ]] \
|| fail "F6: expected 412 Precondition Failed for non-matching If-Match, got $STATUS"
pass "F6: If-Match with non-matching ETag → 412"
echo " F6b: PUT with correct If-Match → 204"
# Fetch the current ETag of f1-small.txt via PROPFIND-ish HEAD,
# then re-PUT with that exact value as If-Match. Must succeed.
CURRENT_ETAG=$(nc_curl -D - -o /dev/null -X HEAD "$NC_FILES_BASE/f1-small.txt" \
| awk 'BEGIN{IGNORECASE=1} /^etag:/ {print $2}' | tr -d '\r')
[[ -n "$CURRENT_ETAG" ]] || fail "F6b: could not read current ETag via HEAD"
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
-H "If-Match: $CURRENT_ETAG" -H "Content-Type: text/plain" \
--data-binary 'F6b-payload' \
"$NC_FILES_BASE/f1-small.txt")
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
[[ "$STATUS" == "204" ]] \
|| fail "F6b: expected 204 for If-Match with correct ETag, got $STATUS"
pass "F6b: If-Match with current ETag → 204"
echo " F6c: PUT with If-Match: * on existing path → 204 (catch-all)"
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
-H 'If-Match: *' -H "Content-Type: text/plain" \
--data-binary 'F6c-payload' \
"$NC_FILES_BASE/f1-small.txt")
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
[[ "$STATUS" == "204" ]] \
|| fail "F6c: expected 204 for If-Match: * on existing path, got $STATUS"
pass "F6c: If-Match: * on existing path → 204"
echo " F6d: PUT with If-Match on NEW path → 412 (resource absent → cannot match)"
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
-H 'If-Match: "anything"' -H "Content-Type: text/plain" \
--data-binary 'F6d-payload' \
"$NC_FILES_BASE/f6d-new.txt")
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
[[ "$STATUS" == "412" ]] \
|| fail "F6d: expected 412 for If-Match on absent path, got $STATUS"
pass "F6d: If-Match on absent path → 412"
# ─────────────────────────────────────────────────────────────
# F7 — PUT a "large" file → succeeds, GET returns exact bytes
@@ -263,38 +319,43 @@ grep -q '<d:collection/>' <<< "$BODY" \
pass "F10: MKCOL creates folder, PROPFIND sees it as a collection"
# ─────────────────────────────────────────────────────────────
# F11 — MKCOL with missing intermediate parent
# F11 / F11b / F11c — MKCOL parent semantics (RFC 4918 §9.3.1)
#
# Pinned current behaviour: OxiCloud's MKCOL auto-creates
# missing intermediate parents (effectively `mkdir -p`
# semantics). Sending MKCOL on `/a/b/c/` where neither `a` nor
# `b` exists succeeds with 201 — both intermediates are
# silently created.
# F11 : missing intermediate parent → 409 Conflict
# F11b : parent exists, target new → 201 Created (positive case)
# F11c : target already exists → 405 Method Not Allowed
#
# Strict RFC 4918 §9.3.1 requires 409 Conflict here ("when the
# parent collection does not exist"). NC desktop tolerates
# either behaviour (it always MKCOLs ancestors one at a time
# during sync), so the auto-create behaviour is harmless in
# practice — but if you ever want strict mode, the fix lives
# in `interfaces/nextcloud/webdav_handler.rs::handle_mkcol`:
# look up the parent path before creating; 409 if missing.
# Sabre/DAV and the actual NC server both 409 on a missing
# intermediate; our previous `mkdir -p` behaviour deviated. NC
# desktop walks ancestors one MKCOL at a time during sync so
# nothing real breaks from dropping the auto-create.
# ─────────────────────────────────────────────────────────────
echo " F11: MKCOL with missing parent (pinned: auto-creates parents, RFC-4918 would 409)"
echo " F11: MKCOL with missing intermediate parent → 409"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL \
"$NC_FILES_BASE/f11-nonexistent-parent/inner/")
case "$STATUS" in
201)
pass "F11: MKCOL auto-created intermediate parents (201) — pinned current behaviour"
;;
409)
fail "F11: server now returns 409 (RFC-4918 strict). Bug? Improvement? — review and update pin to strict assertion."
;;
*)
fail "F11: unexpected status $STATUS"
;;
esac
# Cleanup the auto-created parent so subsequent tests don't see it.
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f11-nonexistent-parent/" > /dev/null 2>&1 || true
[[ "$STATUS" == "409" ]] \
|| fail "F11: expected 409 Conflict for MKCOL with missing parent, got $STATUS"
# The non-existent parent must NOT have been auto-created either.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/f11-nonexistent-parent/")" == "404" ]] \
|| fail "F11: intermediate parent was silently created — auto-create still happening"
pass "F11: MKCOL with missing parent → 409, parent not silently created"
echo " F11b: MKCOL with existing parent + new target → 201"
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/f11b-parent/" > /dev/null
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL \
"$NC_FILES_BASE/f11b-parent/child/")
[[ "$STATUS" == "201" ]] \
|| fail "F11b: expected 201 Created for MKCOL with existing parent, got $STATUS"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/f11b-parent/child/")" == "207" ]] \
|| fail "F11b: target collection not visible via PROPFIND after MKCOL"
pass "F11b: MKCOL with existing parent → 201, target reachable"
echo " F11c: MKCOL with target that already exists → 405"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL \
"$NC_FILES_BASE/f11b-parent/child/")
[[ "$STATUS" == "405" ]] \
|| fail "F11c: expected 405 Method Not Allowed for MKCOL on existing collection, got $STATUS"
pass "F11c: MKCOL on existing target → 405"
# ─────────────────────────────────────────────────────────────
# F12 — MKCOL on existing folder → 405