d57f7bfe3a
webdav_patch.hurl and nc_webdav_patch.hurl existed with real coverage since the original PATCH commits but were never added to tests/api/run.sh, so just api-test/CI silently skipped them. Wire both in, fix nc_webdav_patch.hurl's header-after-[BasicAuth] ordering bug that meant it had never actually passed, and add two new consistency-focused files chaining PATCH operations with cross-protocol/cross-surface verification: - webdav_patch_consistency.hurl: chained overwrites with ETag-change checks, GET/HEAD/PROPFIND cross-protocol agreement, quota-507 leaving the file byte-for-byte unchanged, direct_put_max_bytes prefix/suffix regression coverage. - nc_webdav_patch_consistency.hurl: Editor/Viewer/Outsider permission matrix, cross-surface lock interop, quota-507 via the NC surface. Running these surfaced two real bugs in the NC PATCH handler, both fixed here: - The write step mapped every error (including a legitimate anti-enum permission denial) to a raw 500 instead of AppError::from(e), unlike the plain surface. A Viewer without Update permission got a 500 leak instead of the expected 404. - nc_to_internal_path() didn't strip the leading '/' that chroot.path carries from StoragePath::to_string(), so a LOCK taken via /webdav/ silently failed to block PATCH via /remote.php/dav/ on the same file — the exact-string lock-store lookup never matched. Added a regression unit test.
254 lines
10 KiB
Plaintext
254 lines
10 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — WebDAV PATCH (RFC 5789) partial content update
|
|
# =============================================================
|
|
# RFC 4918 §9.7.1 forbids partial updates on PUT (a `Content-Range`
|
|
# on PUT is rejected, see webdav_handler.rs::handle_put). PATCH is
|
|
# the mechanism this server offers instead, via a dedicated
|
|
# `X-Update-Range` header: `bytes=<start>-<end>` (inclusive) or
|
|
# `append`. See webdav_handler.rs::handle_patch /
|
|
# parse_update_range for the implementation.
|
|
#
|
|
# Coverage:
|
|
# 1. Mid-file byte-range overwrite → 204, GET reflects the splice.
|
|
# 2. Append → 204, GET reflects the appended tail.
|
|
# 3. Out-of-range span (end >= size) → 416.
|
|
# 4. If-Match precondition failure → 412.
|
|
# 5. Locked resource without a lock token → 423.
|
|
# 6. PATCH on a directory → 409.
|
|
# 7. PATCH on a missing resource → 404.
|
|
# 8. PATCH without X-Update-Range → 400.
|
|
# 9. If-None-Match precondition failure (tag matches current ETag) → 412.
|
|
# 10. If-Match with a WEAK (`W/`) form of the current ETag → 412 (RFC 7232
|
|
# §3.1: If-Match requires a STRONG match; a weak validator in the
|
|
# request never satisfies it, even if the underlying tag value is
|
|
# identical — see `if_match_precondition_fails`).
|
|
#
|
|
# Hurl gotcha: a triple-backtick ``` multiline body appends a trailing
|
|
# `\n` the server counts as part of Content-Length — that silently
|
|
# breaks the exact `end - start + 1` span check on a byte-range PATCH.
|
|
# Plain-text bodies below use the single-backtick ONELINE string form
|
|
# (`` `text` ``) instead, which sends exactly the bytes between the
|
|
# backticks with no injected newline.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 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 — PUT a 10-byte probe file: "0123456789"
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: text/plain
|
|
`0123456789`
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
probe_etag: header "ETag"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Mid-file overwrite: replace bytes 3-5 (inclusive,
|
|
# 0-based) with "XYZ".
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=3-5
|
|
Content-Type: text/plain
|
|
`XYZ`
|
|
|
|
HTTP 204
|
|
[Asserts]
|
|
header "Content-Range" matches "^bytes 3-\\d+/\\d+$"
|
|
|
|
|
|
GET {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
body startsWith "012XYZ"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Append to the end of the file.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: append
|
|
Content-Type: text/plain
|
|
`-APPENDED`
|
|
|
|
HTTP 204
|
|
[Captures]
|
|
current_etag: header "ETag"
|
|
|
|
|
|
GET {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
body endsWith "-APPENDED"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Out-of-range span: `end` must be strictly within the
|
|
# current file size (growing via a byte-range PATCH
|
|
# isn't supported — use `append` for that).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=1000-1005
|
|
Content-Type: text/plain
|
|
`oops`
|
|
|
|
HTTP 416
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — If-Match precondition failure.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=0-2
|
|
If-Match: "not-the-real-etag"
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 412
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Locked resource without a matching lock token.
|
|
# ─────────────────────────────────────────────────────────────
|
|
LOCK {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/xml; charset=utf-8
|
|
```
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<D:lockinfo xmlns:D="DAV:">
|
|
<D:lockscope><D:exclusive/></D:lockscope>
|
|
<D:locktype><D:write/></D:locktype>
|
|
<D:owner>patch-test</D:owner>
|
|
</D:lockinfo>
|
|
```
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
lock_token: xpath "string(//*[local-name()='locktoken']/*[local-name()='href'])"
|
|
|
|
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=0-2
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 423
|
|
|
|
|
|
# Release the lock so cleanup below can proceed.
|
|
UNLOCK {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
Lock-Token: <{{lock_token}}>
|
|
|
|
HTTP 204
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — PATCH on a directory → 409 Conflict.
|
|
# ─────────────────────────────────────────────────────────────
|
|
MKCOL {{base_url}}/webdav/patch-probe-dir/
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP 201
|
|
|
|
|
|
PATCH {{base_url}}/webdav/patch-probe-dir/
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=0-2
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 409
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 9 — PATCH on a missing resource → 404.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe-does-not-exist.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=0-2
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 10 — PATCH without X-Update-Range → 400.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 11 — If-None-Match precondition failure: the header names the
|
|
# CURRENT ETag, so the "only if it does NOT match" condition
|
|
# is violated → 412.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=0-2
|
|
If-None-Match: {{current_etag}}
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 412
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 12 — If-Match with a WEAK form (`W/`) of the current ETag → 412.
|
|
# RFC 7232 §3.1 requires If-Match to STRONG-match; a request
|
|
# carrying a weak validator never satisfies it even when the
|
|
# underlying tag value is identical.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
X-Update-Range: bytes=0-2
|
|
If-Match: W/{{current_etag}}
|
|
Content-Type: text/plain
|
|
`NOP`
|
|
|
|
HTTP 412
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Cleanup
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/webdav/patch-probe.txt
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP 204
|
|
|
|
|
|
DELETE {{base_url}}/webdav/patch-probe-dir/
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP 204
|