# ============================================================= # OxiCloud — WebDAV per-role permissions + cross-drive MOVE policy # ============================================================= # End-to-end coverage for the two WebDAV authz axes exposed by the # `@drive` URL scheme: # # 1. Per-role gates through the drive-scope resolver: a Viewer on a # shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An # Editor can. AuthZ denials return `NotFound` (anti-enum), so # a probing caller can't tell a genuinely-missing folder from # one they simply lack Create on. # # 2. Drive policy `forbid_cross_drive_move` gates MOVE at the # SOURCE drive (see `DrivePolicies::refuse_cross_drive_move` # in `src/domain/entities/drive.rs`) — even a fully-authorised # Editor can't move content OUT of a drive whose owner has # forbidden cross-drive movement. Rejection is 405 # (`ErrorKind::UnsupportedOperation` → `METHOD_NOT_ALLOWED`). # # Assumes the default `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` config — # runs alongside the other tests in `tests/api/run.sh`. Uses the # `@drive/` selector so the paths don't collide with any # drive-name-collision oddities. # ============================================================= # ───────────────────────────────────────────────────────────── # Step 1 — Login as admin (bootstrapped by `setup.hurl`). # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/login Content-Type: application/json { "username": "{{username}}", "password": "{{password}}" } HTTP 200 [Captures] admin_token: jsonpath "$.access_token" admin_user_id: jsonpath "$.user.id" # ───────────────────────────────────────────────────────────── # Step 2 — Create a fresh user "webdav_bob" via the admin # endpoint, log him in. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/admin/users Authorization: Bearer {{admin_token}} Content-Type: application/json { "username": "webdav_bob", "password": "WebdavBobPassword1!", "email": "webdav_bob@example.com", "role": "user" } HTTP 201 [Captures] bob_user_id: jsonpath "$.id" POST {{base_url}}/api/auth/login Content-Type: application/json { "username": "webdav_bob", "password": "WebdavBobPassword1!" } HTTP 200 [Captures] bob_token: jsonpath "$.access_token" # Capture Bob's default personal drive id — used by the cross-drive # MOVE scenario. Bob is not a member of any shared drive yet, so his # `/api/drives` listing has exactly one entry (his own default). GET {{base_url}}/api/drives Authorization: Bearer {{bob_token}} HTTP 200 [Captures] bob_personal_drive_id: jsonpath "$[0].id" # ───────────────────────────────────────────────────────────── # Step 3 — Admin creates a shared drive owned by admin. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/drives Authorization: Bearer {{admin_token}} Content-Type: application/json { "kind": "shared", "name": "webdav-perm-shared", "owner": { "type": "user", "id": "{{admin_user_id}}" } } HTTP 201 [Captures] shared_drive_id: jsonpath "$.id" # ───────────────────────────────────────────────────────────── # Step 4 — Grant Bob VIEWER on the shared drive via /api/grants. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/grants Authorization: Bearer {{admin_token}} Content-Type: application/json { "subject": { "type": "user", "id": "{{bob_user_id}}" }, "resource": { "type": "drive", "id": "{{shared_drive_id}}" }, "role": "viewer" } HTTP 201 # ───────────────────────────────────────────────────────────── # Step 5 — Bob (VIEWER) CAN PROPFIND the shared drive root. # Depth 0 to keep the assertion minimal; a 207 with the # drive's own href suffices as "Bob has Read". # ───────────────────────────────────────────────────────────── PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/ Authorization: Bearer {{bob_token}} Depth: 0 HTTP 207 # ───────────────────────────────────────────────────────────── # Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive. # `authz.require(Create, Folder)` denial returns # `DomainError::not_found` (anti-enum), which maps to 404. # ───────────────────────────────────────────────────────────── MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder Authorization: Bearer {{bob_token}} HTTP 404 # ───────────────────────────────────────────────────────────── # Step 7 — Bob (VIEWER) CANNOT PUT a file. # ───────────────────────────────────────────────────────────── PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt Authorization: Bearer {{bob_token}} Content-Type: text/plain ``` viewer should not upload ``` HTTP 404 # ───────────────────────────────────────────────────────────── # Step 8 — Admin creates a probe folder in the shared drive so # the Editor-can-rename step below has a real target. # ───────────────────────────────────────────────────────────── MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder Authorization: Bearer {{admin_token}} HTTP 201 # ───────────────────────────────────────────────────────────── # Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder. # MOVE requires Update on the source, which Viewer # doesn't have. Same anti-enum 404 shape. # ───────────────────────────────────────────────────────────── MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder Authorization: Bearer {{bob_token}} Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed HTTP 404 # ───────────────────────────────────────────────────────────── # Step 10 — Promote Bob from VIEWER to EDITOR. # `PATCH /api/drives/{id}/members/{subject-type}/{id}` # mutates the role in-place. # ───────────────────────────────────────────────────────────── PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{bob_user_id}} Authorization: Bearer {{admin_token}} Content-Type: application/json { "role": "editor" } HTTP 200 # ───────────────────────────────────────────────────────────── # Step 11 — Bob (EDITOR) CAN MKCOL a new folder. # ───────────────────────────────────────────────────────────── MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder Authorization: Bearer {{bob_token}} HTTP 201 # ───────────────────────────────────────────────────────────── # Step 12 — Bob (EDITOR) CAN PUT a file. # ───────────────────────────────────────────────────────────── PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder/hello.txt Authorization: Bearer {{bob_token}} Content-Type: text/plain ``` editor uploaded content ``` HTTP 201 # ───────────────────────────────────────────────────────────── # Step 13 — Bob (EDITOR) CAN MOVE (rename) the probe folder. # ───────────────────────────────────────────────────────────── MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder Authorization: Bearer {{bob_token}} Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed HTTP 201 # ───────────────────────────────────────────────────────────── # Step 14 — Bob puts a file in his OWN personal drive as the # source for the cross-drive MOVE test below. # ───────────────────────────────────────────────────────────── PUT {{base_url}}/webdav/xdrive-probe.txt Authorization: Bearer {{bob_token}} Content-Type: text/plain ``` cross-drive probe payload ``` HTTP 201 # ───────────────────────────────────────────────────────────── # Step 15 — Admin flips `forbid_cross_drive_move` ON for Bob's # PERSONAL drive. The policy sits on the SOURCE drive # per `DrivePolicies::refuse_cross_drive_move`; only # OxiCloud-admin can PATCH policies. # ───────────────────────────────────────────────────────────── PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies Authorization: Bearer {{admin_token}} Content-Type: application/json { "forbid_cross_drive_move": true } HTTP 200 [Asserts] jsonpath "$.forbid_cross_drive_move" == true # ───────────────────────────────────────────────────────────── # Step 16 — Bob tries to MOVE `xdrive-probe.txt` from his # PERSONAL drive to the SHARED drive. Blocked at the # service layer by the policy — `OperationNotSupported` # maps to 405 Method Not Allowed. # ───────────────────────────────────────────────────────────── MOVE {{base_url}}/webdav/xdrive-probe.txt Authorization: Bearer {{bob_token}} Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt HTTP 405 # ───────────────────────────────────────────────────────────── # Step 17 — Admin flips the policy OFF. # ───────────────────────────────────────────────────────────── PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies Authorization: Bearer {{admin_token}} Content-Type: application/json { "forbid_cross_drive_move": false } HTTP 200 [Asserts] jsonpath "$.forbid_cross_drive_move" == false # ───────────────────────────────────────────────────────────── # Step 18 — Bob retries the same MOVE. Now the policy is off, # Bob has Update on source (his own personal drive) + # Create on dest parent (Editor on shared drive), so # the move succeeds. 201 on rename/move to a new URL, # per `handle_move`'s existing convention. # ───────────────────────────────────────────────────────────── MOVE {{base_url}}/webdav/xdrive-probe.txt Authorization: Bearer {{bob_token}} Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt HTTP 201 # ───────────────────────────────────────────────────────────── # Step 19 — Verify the destination now exists and the source # is gone. Both PROPFINDs use Bob's token to also # re-confirm the AuthZ gates on the destination side. # ───────────────────────────────────────────────────────────── PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt Authorization: Bearer {{bob_token}} Depth: 0 HTTP 207 PROPFIND {{base_url}}/webdav/xdrive-probe.txt Authorization: Bearer {{bob_token}} Depth: 0 HTTP 404