fix(webdav): wire orphaned PATCH tests + fix NC error-mapping and path bugs they caught
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.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV PATCH data-consistency chain (RFC 5789)
|
||||
# =============================================================
|
||||
# `webdav_patch.hurl` covers the PATCH contract itself (ranges, append,
|
||||
# preconditions, locks). This file chains multiple PATCHes against the
|
||||
# SAME resource and asserts the server stays consistent afterward —
|
||||
# the concern behind the review-fix commit that added quota
|
||||
# enforcement, an ETag re-check, and a `direct_put_max_bytes`
|
||||
# prefix/suffix accounting bug (see webdav_handler.rs::handle_patch).
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Sequential overlapping-range PATCHes on one file: each step's
|
||||
# GET reflects the splice, and the ETag changes every time (no
|
||||
# stale-tag reuse across writes).
|
||||
# 2. Cross-protocol consistency: HEAD and PROPFIND report the same
|
||||
# size/ETag as the GET right after the last PATCH.
|
||||
# 3. Quota rejection (507) leaves the file BYTE-FOR-BYTE unchanged —
|
||||
# the ingested blob is discarded before it's ever attached
|
||||
# (`upload_ingest::discard_ingested`).
|
||||
# 4. `direct_put_max_bytes` bounds only the EDIT span, not the whole
|
||||
# file: a small edit on a file already bigger than the cap still
|
||||
# succeeds, but an edit whose OWN body exceeds the cap still 413s.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part A — Sequential overlapping PATCHes + cross-protocol check
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a 20-byte probe: "0123456789ABCDEFGHIJ"
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
`0123456789ABCDEFGHIJ`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
etag0: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Overwrite bytes 5-9 ("56789") with "XXXXX".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=5-9
|
||||
Content-Type: text/plain
|
||||
`XXXXX`
|
||||
|
||||
HTTP 204
|
||||
[Captures]
|
||||
etag1: header "ETag"
|
||||
[Asserts]
|
||||
header "ETag" != {{etag0}}
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "01234XXXXXABCDEFGHIJ"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Overwrite bytes 10-14 ("ABCDE") with "YYYYY".
|
||||
# Overlaps neither previous edit but chains off it —
|
||||
# proves each PATCH sees the result of the last one, not
|
||||
# a stale copy.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=10-14
|
||||
Content-Type: text/plain
|
||||
`YYYYY`
|
||||
|
||||
HTTP 204
|
||||
[Captures]
|
||||
etag2: header "ETag"
|
||||
[Asserts]
|
||||
header "ETag" != {{etag1}}
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "01234XXXXXYYYYYFGHIJ"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — HEAD reports the same size/ETag as the last GET.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
HEAD {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Length" == "20"
|
||||
# GET/HEAD/PROPFIND quote the ETag (`"<tag>"`) while PUT/PATCH return
|
||||
# it raw/unquoted (compare webdav_handler.rs's `handle_head` vs
|
||||
# `handle_patch` response builders) — `contains` tolerates that
|
||||
# formatting difference instead of asserting byte-for-byte equality.
|
||||
header "ETag" contains {{etag2}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PROPFIND (named getcontentlength/getetag) agrees with
|
||||
# HEAD/GET — no drift between the WebDAV property layer
|
||||
# and the plain-file read path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/patch-consist-chain.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:prop>
|
||||
<D:getcontentlength/>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "number(//*[local-name()='getcontentlength'])" == 20
|
||||
xpath "string(//*[local-name()='getetag'])" contains {{etag2}}
|
||||
|
||||
|
||||
# Cleanup Part A.
|
||||
DELETE {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part B — Quota rejection leaves the file untouched
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Dedicated low-quota user so this doesn't cap the shared admin
|
||||
# account used by the rest of the suite.
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Provision `patch_quota_owner` with a 50-byte quota.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "patch_quota_owner",
|
||||
"password": "PatchQuotaOwnerPwd1!",
|
||||
"email": "patch_quota_owner@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_owner_id: jsonpath "$.id"
|
||||
|
||||
|
||||
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{ "quota_bytes": 50 }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "patch_quota_owner", "password": "PatchQuotaOwnerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
quota_owner_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Seed a 10-byte file (well under the 50-byte quota).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_probe_etag: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Append enough bytes to push the file's new total size
|
||||
# (110 bytes) well past the 50-byte quota → 507. The
|
||||
# ingested blob is discarded before commit — the file
|
||||
# must come back completely unchanged.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
X-Update-Range: append
|
||||
Content-Type: text/plain
|
||||
`this-is-a-100-byte-ish-payload-that-blows-past-the-fifty-byte-quota-set-for-this-throwaway-user-abc`
|
||||
|
||||
HTTP 507
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "0123456789"
|
||||
header "ETag" contains {{quota_probe_etag}}
|
||||
|
||||
|
||||
# Cleanup Part B.
|
||||
DELETE {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{quota_owner_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part C — direct_put_max_bytes bounds the EDIT, not the whole file
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# `OXICLOUD_DIRECT_PUT_MAX_BYTES` (4 MiB) can't be exceeded by a
|
||||
# direct PUT, so a file bigger than the cap must be seeded through
|
||||
# the chunk-agnostic multipart upload endpoint instead. Reuses the
|
||||
# 5 MiB all-zero fixture `run.sh` already generates for the chunk/
|
||||
# direct-PUT cap tests.
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Resolve the home folder id, seed a 5 MiB file in it.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
home_folder_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{home_folder_id}}
|
||||
file: file,fixtures/chunk-over-cap-5mb.bin; application/octet-stream
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
big_file_name: jsonpath "$.name"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — A SMALL mid-file edit succeeds even though the file's
|
||||
# total size (5 MiB) is already over the 4 MiB cap.
|
||||
# Pre-fix, the cap comparison counted prefix+suffix+edit
|
||||
# against the raw cap and would have wrongly 413'd any
|
||||
# edit on a file this size; post-fix only the edit span
|
||||
# itself is bounded.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=100-104
|
||||
Content-Type: application/octet-stream
|
||||
`PATCH`
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "PATCH"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — An edit whose OWN body meets/exceeds the cap still
|
||||
# 413s — the cap still bites real over-cap edits, this
|
||||
# isn't a blanket bypass. Replaces the ENTIRE file (no
|
||||
# prefix/suffix at all) with a 5 MiB body.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-5242879
|
||||
Content-Type: application/octet-stream
|
||||
file,fixtures/chunk-over-cap-5mb.bin;
|
||||
|
||||
HTTP 413
|
||||
|
||||
|
||||
# Cleanup Part C.
|
||||
DELETE {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
Reference in New Issue
Block a user