Files
Oxicloud/tests/api/webdav_patch_consistency.hurl
2026-08-21 23:56:25 +02:00

323 lines
13 KiB
Plaintext

# =============================================================
# 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 "$.user.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