# ============================================================= # 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 resource id automatically # (no rename_resource() call; the row's file_id is stable # across MOVE so dead-props travel with the resource). # 9. PROPFIND on moved path returns the property. # 10. DELETE via WebDAV → FK CASCADE reaps dead-prop rows. # 11. PROPPATCH + REST DELETE `/api/files/{id}` → FK CASCADE # reaps via the REST-side delete path too. This is the # new coverage unlocked by migration 20260830000001 — the # old path-keyed store had no way to clean up here, so # the SvelteKit web UI (which deletes via REST) was # silently leaking tombstones every time a user deleted # a file that had ever carried dead properties. # 12. Folder MOVE preserves dead properties (id-stable # guarantee under rename). The Hurl suite had no folder- # side coverage of this until 20260830000001; only the # file MOVE case (step 9) was guarded. # # 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. Post-rekey # (migration 20260830000001) the dead-property row # keys on `file_id`, which never changes across MOVE # or RENAME — so properties follow the resource by a # database invariant, without any store-side call. # A regression that broke this would be a regression # on the id-stability guarantee in the move SQL itself # (i.e. it would surface elsewhere too); this assertion # locks it in for sync clients that do MOVE → 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 via WebDAV; the FK # `webdav_dead_properties.file_id → storage.files.id # ON DELETE CASCADE` (migration 20260830000001) must # reap the dead-property rows automatically, 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 # ───────────────────────────────────────────────────────────── # Step 11 — Same FK-cascade property test but via the REST API # delete path. The path-based store would have leaked # here forever (REST DELETE receives a file_id, not a # path; the old store had no efficient way to clean # up). The id-keyed schema reaps the dead-property # row through the same FK CASCADE on `storage.files`, # so this proves the new coverage end-to-end. # # Sequence: # a. PROPPATCH a marker dead property on the file. # b. PROPFIND — confirm it's stored. # c. Resolve the file's id via REST listing of the # home folder. # d. DELETE via `/api/files/{id}` — pure REST, # never touches the WebDAV surface. # e. PUT a fresh file at the same WebDAV path. # f. PROPFIND — must not see the marker. # ───────────────────────────────────────────────────────────── # Step 11a — set a new marker dead property on the just-PUT file PROPPATCH {{base_url}}/webdav/dead-props-moved.txt Authorization: Bearer {{token}} Content-Type: application/xml; charset=utf-8 ``` rest-delete-coverage ``` HTTP 207 # Step 11b — confirm the marker is stored 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()='restmarker'])" == "rest-delete-coverage" # Step 11c — resolve the file id from the home folder listing. # The home folder is whatever `GET /api/folders` returns as the # first root-level entry for the admin user (Personal drive root, # post drive-no-wrapper). GET {{base_url}}/api/folders Authorization: Bearer {{token}} HTTP 200 [Captures] home_folder_id: jsonpath "$[0].id" GET {{base_url}}/api/files?folder_id={{home_folder_id}} Authorization: Bearer {{token}} HTTP 200 [Captures] rest_file_id: jsonpath "$[?(@.name=='dead-props-moved.txt')].id" nth 0 # Step 11d — REST DELETE. No webdav, no dead-prop API call — # the cleanup must happen via the FK CASCADE on storage.files. DELETE {{base_url}}/api/files/{{rest_file_id}} Authorization: Bearer {{token}} # The REST delete handler returns 204 No Content on success. HTTP 204 # Step 11e — recreate the file at the same WebDAV path PUT {{base_url}}/webdav/dead-props-moved.txt Authorization: Bearer {{token}} Content-Type: text/plain ``` fresh file post REST DELETE ``` HTTP 201 # Step 11f — PROPFIND must not surface the old marker PROPFIND {{base_url}}/webdav/dead-props-moved.txt Authorization: Bearer {{token}} Depth: 0 Content-Type: application/xml; charset=utf-8 ``` ``` HTTP 207 [Asserts] # If REST DELETE failed to cascade, the marker would still be # attached to the (recreated) path under the old `(path, user_id)` # key — but the new schema keys by file_id, and the REST DELETE # took the storage.files row with it. Asserting absence proves # the cascade fired. xpath "count(//*[local-name()='restmarker'])" == 0 # ───────────────────────────────────────────────────────────── # Cleanup # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/webdav/dead-props-moved.txt Authorization: Bearer {{token}} HTTP 204 # ───────────────────────────────────────────────────────────── # Step 12 — Folder MOVE dead-property preservation. # Same invariant as step 9 (id-stability under MOVE) # but for folders. The Hurl suite had no folder-side # coverage of this until now, so a regression that # broke folder dead-property preservation could # silently land — calendar / contacts / NextCloud # clients that PROPPATCH per-folder sync state would # lose it on every rename. # # Sequence: # a. MKCOL a fresh test folder. # b. PROPPATCH a dead property on it. # c. MOVE / rename the folder. # d. PROPFIND the new collection path; assert # the property survived. # e. Cleanup: DELETE the renamed folder. # ───────────────────────────────────────────────────────────── # Step 12a — fresh collection (no prior state at this path) MKCOL {{base_url}}/webdav/dead-props-folder/ Authorization: Bearer {{token}} HTTP 201 # Step 12b — attach a marker dead property to the FOLDER row PROPPATCH {{base_url}}/webdav/dead-props-folder/ Authorization: Bearer {{token}} Content-Type: application/xml; charset=utf-8 ``` folder-keeps-this ``` HTTP 207 # Step 12c — rename the folder via MOVE. Same-parent rename # (intra-collection name change) — the most common shape clients # issue and the one that previously needed `rename_resource()` # to keep dead properties attached. MOVE {{base_url}}/webdav/dead-props-folder/ Authorization: Bearer {{token}} Destination: {{base_url}}/webdav/dead-props-folder-renamed/ HTTP 201 # Step 12d — PROPFIND the new collection path; the dead property # must still be attached. If the folder row's id had changed # under MOVE (it doesn't), or if anything had reaped the # webdav_dead_properties row, the property would be gone. PROPFIND {{base_url}}/webdav/dead-props-folder-renamed/ Authorization: Bearer {{token}} Depth: 0 Content-Type: application/xml; charset=utf-8 ``` ``` HTTP 207 [Asserts] xpath "string(//*[local-name()='foldermark'])" == "folder-keeps-this" # Step 12e — cleanup. The DELETE cascades the foldermark row # away via FK ON DELETE CASCADE, leaving the schema clean for # any subsequent test that touches this path. DELETE {{base_url}}/webdav/dead-props-folder-renamed/ Authorization: Bearer {{token}} HTTP 204