diff --git a/tests/api/run.sh b/tests/api/run.sh index 73f7a8b3..a268ee6a 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -166,7 +166,8 @@ 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" #bash "$API_DIR/dedup_bulk_upload.sh" diff --git a/tests/api/webdav_dead_properties.hurl b/tests/api/webdav_dead_properties.hurl new file mode 100644 index 00000000..45a06f05 --- /dev/null +++ b/tests/api/webdav_dead_properties.hurl @@ -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 ` +# hello`. 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 +``` + + + + + hello-dead-property + + + +``` + +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 +``` + + + + +``` + +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 +``` + + + + + updated-value + + + +``` + +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 +``` + + + + +``` + +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 +``` + + + + + + + + +``` + +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 +``` + + + + +``` + +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 +``` + + + + + survives-move + + + +``` + +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 +``` + + + + +``` + +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 +``` + + + + +``` + +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