feat(drive): fix webdav back-compat

add env variable `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`
    which is by default:
    `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`

    so `/webdav/` -> points to user's personal drive (**backward compatibilit**y)
    `/web/dav/@drive/{uuid|drive name}/` points to the respective drive

    if admins want directly `/webdav/` pointing to list of drives they need to:
    `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`

    + ensure lock is per user (RFC 4918 §9.11)

    fix: #554
This commit is contained in:
Edouard Vanbelle
2026-07-06 20:45:21 +02:00
parent 3d74bed326
commit 7e34045ff8
16 changed files with 1576 additions and 232 deletions
+2
View File
@@ -185,6 +185,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/cross_drive_move.hurl" \
"$API_DIR/cross_drive_copy.hurl" \
"$API_DIR/webdav_dead_properties.hurl" \
"$API_DIR/webdav_drive_root.hurl" \
"$API_DIR/webdav_permissions.hurl" \
"$API_DIR/webdav_nested_move_cascade.hurl" \
"$API_DIR/wopi_authz.hurl"
+229
View File
@@ -0,0 +1,229 @@
# =============================================================
# OxiCloud — WebDAV drive-root URL scheme
# =============================================================
# Exercises the native WebDAV URL scheme documented in
# `src/interfaces/api/handlers/webdav_handler.rs::resolve_webdav_scope`:
#
# Default deployment (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`):
# * `/webdav/` → default drive's contents
# * `/webdav/@drive/` → drive listing (per-drive
# virtual folders)
# * `/webdav/@drive/<uuid>/…` → explicit drive by UUID
# * `/webdav/@drive/<name>/…` → explicit drive by name
#
# Coverage:
# 1. Login, capture JWT
# 2. Resolve caller's default drive (id + display name)
# 3. Create a magic folder under the home root via REST
# 4. PROPFIND `/webdav/` — Depth: 1 lists the magic folder as
# an immediate child of the default drive. This is the
# user-visible bug fix: pre-refactor, `/webdav/` returned a
# drive listing instead of the default drive's contents.
# 5. PROPFIND `/webdav/@drive/` — Depth: 1 lists each drive as
# a virtual child (at least the caller's default is present).
# 6. PROPFIND `/webdav/@drive/<uuid>/` — descends into the
# selected drive by UUID; magic folder appears here too.
# 7. PROPFIND `/webdav/@drive/<name>/` — same via display name.
# 8. Cleanup: DELETE the magic folder via REST.
#
# The magic folder name embeds a run-scoped marker so parallel
# `hurl --jobs N` runs don't step on each other and repeat runs
# against a shared DB don't collide.
# =============================================================
# ─────────────────────────────────────────────────────────────
# 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"
# ─────────────────────────────────────────────────────────────
# Step 2 — Resolve caller's default drive (id + display name).
# `GET /api/drives` returns rows in a stable order:
# the caller's default personal drive first, then by
# display name. See `DriveRepository::list_readable_by`.
# `default_for_user` on the DTO is present-only for
# default rows (`Option<Uuid>` with `skip_serializing_if`),
# so `$[0]` — combined with the stable order — is the
# default drive for a fresh admin account.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/drives
Authorization: Bearer {{token}}
HTTP 200
[Captures]
default_drive_id: jsonpath "$[0].id"
default_drive_name: jsonpath "$[0].name"
# ─────────────────────────────────────────────────────────────
# Step 3 — Resolve the caller's home root folder id.
# A default personal drive has exactly one root folder
# (the drive-root itself). We need its id to create the
# magic folder as its child.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{token}}
HTTP 200
[Captures]
home_folder_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 4 — Create a magic folder under the home root via REST.
# The name is deterministic-yet-unique so PROPFIND
# assertions below can find it by exact string match,
# and parallel test runs can't collide.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hurl-drive-root-magic-marker",
"parent_id": "{{home_folder_id}}"
}
HTTP 201
[Captures]
magic_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 5 — PROPFIND on `/webdav/` (bare root). The default
# deployment maps this to the caller's DEFAULT drive
# contents, so Depth: 1 must include the magic folder.
#
# Pre-refactor this returned a drive listing instead —
# the exact regression that broke back-compat with
# pre-multi-drive WebDAV clients.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 6 — PROPFIND on `/webdav/@drive/`. This is the explicit
# drive picker — Depth: 1 returns one virtual child
# per drive the caller has Read on. The default drive
# must appear (by its display name).
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists
# ─────────────────────────────────────────────────────────────
# Step 7 — PROPFIND on `/webdav/@drive/<uuid>/`. The explicit
# by-UUID selector — descends INTO the chosen drive.
# Depth: 1 lists that drive's top-level children —
# the magic folder must be one of them.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/{{default_drive_id}}/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 8 — PROPFIND on `/webdav/@drive/<name>/`. The explicit
# by-name selector — same result as the UUID form.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/{{default_drive_name}}/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 9 — Reject MKCOL at `/webdav/@drive/` (bare pseudo-root).
# The drive-listing target has no writable parent
# folder — 405 Method Not Allowed. This guard prevents
# a client from silently succeeding at "creating a
# drive by MKCOL" (the drive-create surface is
# `POST /api/drives`, not WebDAV).
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/
Authorization: Bearer {{token}}
HTTP 405
# ─────────────────────────────────────────────────────────────
# Step 10 — Reject MKCOL at `/webdav/@drive/<not-a-drive>`.
# `<not-a-drive>` gets interpreted as a drive selector;
# no drive with that name/UUID exists → 404. Sits
# adjacent to Step 9 so any future maintainer touching
# the pseudo-root rejection sees BOTH shapes at once
# (bare listing = 405, unknown selector = 404).
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/hurl-not-a-real-drive
Authorization: Bearer {{token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 11 — Reject PUT at `/webdav/@drive/<not-a-drive>/x.txt`.
# Same rejection shape as MKCOL — trying to write a
# file into a non-existent drive.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/hurl-not-a-real-drive/probe.txt
Authorization: Bearer {{token}}
Content-Type: text/plain
```
probe
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 11b — Reject PUT at `/webdav/@drive/test.txt`. The URL
# segment immediately after `@drive/` is ALWAYS a
# drive selector — never a filename. A caller that
# bookmarks a file URL under `@drive` with a name
# that doesn't match any drive must get 404, not
# silently create a file at the drive-listing level.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/test.txt
Authorization: Bearer {{token}}
Content-Type: text/plain
```
probe
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup: DELETE the magic folder via REST so
# subsequent test runs / other hurl files don't see
# our marker.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{magic_folder_id}}
Authorization: Bearer {{token}}
HTTP 204
+300
View File
@@ -0,0 +1,300 @@
# =============================================================
# 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/<uuid>` 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
+85
View File
@@ -0,0 +1,85 @@
# Shared test-server environment variables.
# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node).
# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here —
# each test suite sets those to avoid port/directory conflicts.
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_STATIC_PATH=./static
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
OXICLOUD_ENABLE_AUTH=true
OXICLOUD_ENABLE_TRASH=true
OXICLOUD_ENABLE_SEARCH=true
OXICLOUD_ENABLE_FILE_SHARING=true
OXICLOUD_ENABLE_MUSIC=true
OXICLOUD_EXPOSE_SYSTEM_USERS=true
OXICLOUD_WOPI_ENABLED=true
# Fixed secret so the Hurl WOPI test can hand-craft valid access
# tokens with a known signing key. Prod deployments MUST override
# this to a random per-deployment value.
OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod
# Discovery URL points at a black hole — VERB endpoints don't need
# discovery, and the WOPI Hurl suite deliberately does NOT touch
# `/api/wopi/editor-url` (the only path that would fetch it), so
# an unreachable URL keeps startup fast and hermetic.
OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml
OXICLOUD_WOPI_TOKEN_TTL_SECS=3600
OXICLOUD_OIDC_ENABLED=false
OXICLOUD_NEXTCLOUD_ENABLED=true
# Test-only sweep triggers (`/api/admin/internal/trigger-sweep`,
# `/api/admin/internal/trigger-gc`). Off by default in production;
# the Hurl suite needs them to assert post-delete quota convergence
# without waiting out the 600 s reconciliation tick.
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
RUST_LOG="warn,audit=info,sqlx::migrate=info"
#RUST_LOG="warn,audit=info,oxicloud::quota=debug"
#RUST_LOG=debug
#RUST_LOG=info
# Per-chunk upload cap, exercised by chunked_upload_cap.hurl.
# 4 MiB: lets the existing grants.hurl single-chunk test (2.76 MB) pass
# under the cap, while the cap test sends a 5 MiB fixture to trigger 413.
OXICLOUD_CHUNK_MAX_BYTES=4194304
# Direct-PUT (non-chunked) cap, exercised by chunked_upload_cap.hurl.
# 4 MiB: same threshold as the chunked cap so the existing 5 MiB
# fixture (chunk-over-cap-5mb.bin) can prove BOTH caps with one
# generated file. All existing direct-PUT tests
# (test_dedup_webdav_multichunk.sh = 2.76 MB, _ref_count = ~66 KB,
# _nextcloud_put_blake3 = 32 B) stay safely under this cap.
OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304
# grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600
OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
# outbound message in-process so external_users.hurl can retrieve the
# invitation body and follow the magic-link URL. The `SMTP_FROM` value
# is required so the mock can build a valid Message; host/port are
# irrelevant in mock mode but kept set for completeness.
OXICLOUD_SMTP_MOCK=true
OXICLOUD_SMTP_HOST=localhost
OXICLOUD_SMTP_PORT=25
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
OXICLOUD_SMTP_TLS=none
OXICLOUD_ALLOW_EXTERNAL_USERS=true
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
# exercise the cap behaviour with a small, deterministic request count.
# Production defaults are 50 / 5 / 200 respectively (see example.env).
OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3
OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2
OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50
# permits IP spoofing for tests
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
# /webdav/ will points directly to list of drives
OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""
+4 -3
View File
@@ -34,9 +34,10 @@ wipe_storage() {
fi
# Sanity check: must end in tests/<name>/storage where <name> is
# lowercase alphanumeric. Stops `rm -rf` from ever running against
# an unexpected expansion of a callerʼs path.
if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then
# lowercase alphanumeric (hyphens allowed so multi-word runner names
# like `webdav-drive-root` pass). Stops `rm -rf` from ever running
# against an unexpected expansion of a caller's path.
if [[ ! "$path" =~ /tests/[a-z0-9][a-z0-9-]*/storage$ ]]; then
echo "[wipe_storage] ERROR: '$path' does not match .../tests/<name>/storage — refusing to wipe" >&2
return 1
fi
@@ -0,0 +1,186 @@
# =============================================================
# OxiCloud — WebDAV drive-root URL scheme, `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` variant
# =============================================================
# Companion to `webdav_drive_root.hurl`. That file exercises the
# default config (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`); this
# one exercises the empty-string config where `/webdav/` IS the
# drive listing and there's no default-drive shortcut.
#
# Server env for this test: `tests/common/server-webdav-drive-root.env`
# sets `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`. This file assumes that
# config is active — it is NOT part of the standard `run.sh`
# invocation (which starts the default-config server).
#
# Coverage:
# 1. Login, capture JWT
# 2. Resolve caller's default drive (id + display name)
# 3. Create a magic folder under the home root via REST
# 4. PROPFIND `/webdav/` — drive listing (default drive
# appears as a virtual child under its display name).
# 5. PROPFIND `/webdav/<uuid>/` — descend into a drive by
# UUID. Magic folder appears.
# 6. PROPFIND `/webdav/<name>/` — descend into a drive by
# display name. Magic folder appears.
# 7. `/webdav/@drive/` returns 404 in this mode — the sigil
# has no reserved meaning when `webdav_drive_listing_prefix=""`.
# A drive genuinely named `@drive` would resolve here; the
# 404 comes from "no such drive," not the sigil.
# 8. Cleanup: DELETE the magic folder via REST.
# =============================================================
# ─────────────────────────────────────────────────────────────
# 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"
# ─────────────────────────────────────────────────────────────
# Step 2 — Resolve caller's default drive (id + display name).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/drives
Authorization: Bearer {{token}}
HTTP 200
[Captures]
default_drive_id: jsonpath "$[0].id"
default_drive_name: jsonpath "$[0].name"
# ─────────────────────────────────────────────────────────────
# Step 3 — Resolve the caller's home root folder id.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{token}}
HTTP 200
[Captures]
home_folder_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 4 — Create a magic folder under the home root via REST.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hurl-drive-root-empty-magic-marker",
"parent_id": "{{home_folder_id}}"
}
HTTP 201
[Captures]
magic_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 5 — PROPFIND on `/webdav/` (bare root). With
# `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` this IS the drive
# listing — the default drive appears as a virtual
# child under its display name. The magic folder does
# NOT appear here (it lives one level deeper).
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists
# Magic folder is one level deeper — must NOT show up at root.
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" not exists
# ─────────────────────────────────────────────────────────────
# Step 6 — PROPFIND on `/webdav/<uuid>/`. Descends into the
# default drive; magic folder is a top-level child.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/{{default_drive_id}}/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 7 — PROPFIND on `/webdav/<name>/`. Same descent via
# display name.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/{{default_drive_name}}/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 8 — `/webdav/@drive/` has no reserved meaning in the
# empty-config mode. `@drive` is treated as a plain
# drive selector; no drive by that name → 404.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/
Authorization: Bearer {{token}}
Depth: 1
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 9 — Reject MKCOL at `/webdav/` (bare pseudo-root).
# In the empty-config mode `/webdav/` IS the drive
# listing — there's no writable parent, so 405
# Method Not Allowed. This guard prevents a client
# from creating something at "root" that shadows a
# drive name.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/
Authorization: Bearer {{token}}
HTTP 405
# ─────────────────────────────────────────────────────────────
# Step 10 — Reject MKCOL at `/webdav/<not-a-drive>`. The first
# URL segment is the drive selector in this config;
# an unknown selector yields 404. A client cannot
# "create a drive" via MKCOL — the drive-create
# surface is `POST /api/drives`.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/hurl-not-a-real-drive
Authorization: Bearer {{token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 11 — Reject PUT at `/webdav/<not-a-drive>/x.txt`. Same
# rejection shape as MKCOL.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/hurl-not-a-real-drive/probe.txt
Authorization: Bearer {{token}}
Content-Type: text/plain
```
probe
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup: DELETE the magic folder via REST.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{magic_folder_id}}
Authorization: Bearer {{token}}
HTTP 204
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# WebDAV drive-root URL-scheme variant runner.
#
# Exercises `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` — the config where the
# WebDAV `@drive` path segment is disabled and `/webdav/` IS the
# drive listing. `tests/api/webdav_drive_root.hurl` covers the
# default `"@drive"` config in the main API run; this runner
# starts a separately-configured server to cover the empty-string
# case, mirroring the OIDC runner's shape.
#
# Usage (from repo root):
# bash tests/webdav-drive-root/run.sh
#
# Prerequisites: docker, cargo, hurl ≥ 4.0
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMMON="$REPO_ROOT/tests/common"
TEST_DIR="$REPO_ROOT/tests/webdav-drive-root"
# shellcheck source=test.env
source "$TEST_DIR/test.env"
SERVER_PORT="${base_url##*:}"
log() { echo "[webdav-drive-root] $*"; }
die() { echo "[webdav-drive-root] ERROR: $*" >&2; exit 1; }
wait_for_http() {
local url="$1" timeout="${2:-60}"
local deadline=$(( $(date +%s) + timeout ))
until curl -sf "$url" >/dev/null 2>&1; do
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
sleep 1
done
}
# ── Teardown (always runs on exit) ────────────────────────────────────────────
SERVER_PID=""
cleanup() {
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh"
}
trap cleanup EXIT
# ── 1. Start postgres ─────────────────────────────────────────────────────────
bash "$COMMON/spawn-db.sh"
# ── 2. Load the drive-root-variant server env + port ──────────────────────────
set -a
# shellcheck source=../common/server-webdav-drive-root.env
source "$COMMON/server-webdav-drive-root.env"
OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav-drive-root/storage"
set +a
# shellcheck source=../common/wipe-storage.sh
source "$COMMON/wipe-storage.sh"
wipe_storage "$OXICLOUD_STORAGE_PATH"
# ── 3. Start OxiCloud server with the drive-root-variant config ───────────────
BUILD_TARGET="${BUILD_TARGET:-debug}"
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
if [[ ! -x "$OXICLOUD_BIN" ]]; then
log "Building OxiCloud server ($BUILD_TARGET)..."
case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
esac
fi
log "Starting OxiCloud server with WEBDAV_DRIVE_LISTING_PREFIX='' on port $SERVER_PORT..."
"$OXICLOUD_BIN" --config "$COMMON/server-webdav-drive-root.env" &
SERVER_PID=$!
log "Waiting for server at $base_url..."
wait_for_http "$base_url/ready" 120
log "Server is ready."
# ── 4. Run Hurl tests ─────────────────────────────────────────────────────────
#
# `setup.hurl` from the shared api/ suite bootstraps the initial admin
# account via `POST /api/setup` — the endpoint locks after the first
# admin exists, so it's a one-shot idempotency-by-server-state seed.
# We reuse the file rather than duplicating the setup body so credential
# / schema changes in the api tests automatically flow here.
log "Running Hurl tests..."
hurl --variables-file "$TEST_DIR/test.env" \
--file-root "$REPO_ROOT/tests" \
--test --jobs 1 \
"$REPO_ROOT/tests/api/setup.hurl" \
"$TEST_DIR/drive_root_empty_config.hurl"
log "webdav-drive-root tests passed."
+9
View File
@@ -0,0 +1,9 @@
# Test credentials for the WebDAV drive-root variant runner — NOT real secrets.
# Runs on a separate port from tests/api and tests/webdav so a
# `just api-test` chain doesn't collide when the previous runner's
# teardown is still in progress.
base_url=http://localhost:8089
username=admin
email=admin@example.com
# gitguardian:ignore
password=TestPassword1!