Merge branch 'main' into rfc-4331-quota-properties
# Conflicts: # src/interfaces/nextcloud/report_handler.rs # src/interfaces/nextcloud/webdav_handler.rs # tests/api/run.sh
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
# =============================================================
|
||||
# OxiCloud — Expired-grant purge (GrantCleanupService)
|
||||
# =============================================================
|
||||
# Regression coverage for the daily purge that deletes rows from
|
||||
# `storage.role_grants` whose `expires_at` is more than
|
||||
# `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` in the past.
|
||||
#
|
||||
# The engine's `check` / `list_grants_*` paths already filter
|
||||
# expired grants out at read time — this purge is pure garbage
|
||||
# collection. If the SQL were wrong (e.g. missing
|
||||
# `expires_at IS NOT NULL`, wrong sign on the interval), the
|
||||
# assertions here catch it before the daemon runs against real
|
||||
# data.
|
||||
#
|
||||
# Uses the `POST /api/admin/internal/trigger-grant-cleanup`
|
||||
# admin endpoint (gated by
|
||||
# `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`, on for the
|
||||
# api-test suite). `?force=true` collapses the grace window to
|
||||
# zero for the call so we can plant a past-dated grant and
|
||||
# immediately observe it purged, without waiting 15+ days.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login admin (Alice), capture home folder id.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
alice_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Create a grantee user (mallory) — someone we can
|
||||
# grant Alice's resources to without polluting shared
|
||||
# state used by other test files.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "gc-mallory",
|
||||
"password": "GcMalloryPassword1!",
|
||||
"email": "gc-mallory@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
mallory_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice creates two folders: one to hold an expired
|
||||
# grant, one to hold a permanent (no-expiry) grant we
|
||||
# expect the purge to leave alone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "gc-expired", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
expired_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "gc-permanent", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
permanent_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Plant an expired grant. Set `expires_at` in 2020 so
|
||||
# any grace window less than several years still
|
||||
# catches it. The grant handler silently accepts past-
|
||||
# dated `expires_at` — a separate PR would reject them
|
||||
# on the create path, but here we exploit the
|
||||
# permissive behaviour as a test fixture.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{mallory_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{expired_folder_id}}" },
|
||||
"role": "viewer",
|
||||
"expires_at": "2020-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
expired_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# Confirm the grant IS present in the listing — the engine's
|
||||
# filter is `expires_at > NOW()`, so the past-dated row is
|
||||
# already invisible to `check()` but still exists physically
|
||||
# (and thus in the list endpoint too — verified below).
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Bare array, filter selector — see memory note on Hurl JSONPath
|
||||
# quirks: use `$[?(...)]` (single-match returns scalar; no `nth`).
|
||||
jsonpath "$[?(@.id=='{{expired_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Plant a permanent grant on the other folder (no
|
||||
# `expires_at`). The purge MUST leave it alone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{mallory_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{permanent_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
permanent_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Trigger the purge with `force=true`. The endpoint
|
||||
# collapses the grace window to 0 for this call only
|
||||
# — the daemon's configured grace is untouched.
|
||||
#
|
||||
# Expect `grants_deleted >= 1` (the past-dated row),
|
||||
# `grace_days == 0`, `forced == true`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.forced" == true
|
||||
jsonpath "$.grace_days" == 0
|
||||
# At least the expired-fixture row we just planted.
|
||||
jsonpath "$.grants_deleted" >= 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — The expired grant is gone. The permanent grant
|
||||
# survives.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# The list is either empty or contains no row with the expired
|
||||
# grant's id — the filter must not select anything.
|
||||
jsonpath "$[*].id" not contains "{{expired_grant_id}}"
|
||||
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Permanent grant untouched.
|
||||
jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Second trigger with `force=true` on a table that no
|
||||
# longer has any past-dated grants. Expect
|
||||
# `grants_deleted == 0`. This is the regression guard
|
||||
# on the WHERE clause — if `expires_at IS NOT NULL`
|
||||
# were missing, this would nuke the permanent grant
|
||||
# from Step 5 (any row with `NULL < NOW() - 0 days` is
|
||||
# false in SQL, so it's already correct; but a
|
||||
# mistyped predicate could regress).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.grants_deleted" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Unforced trigger. Grace = configured value (15).
|
||||
# No new expired grants planted, so purge is a no-op.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.forced" == false
|
||||
# Response echoes the configured grace (15 days by default).
|
||||
jsonpath "$.grace_days" == 15
|
||||
jsonpath "$.grants_deleted" == 0
|
||||
|
||||
|
||||
# Permanent grant still there after the unforced call.
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — drop both folders. Cascade removes the remaining
|
||||
# grant + any children.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -0,0 +1,398 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NC multi-drive MOVE destination-prefix regressions
|
||||
# =============================================================
|
||||
# Regression coverage for two sibling bugs discovered 2026-07-12
|
||||
# when the multi-drive `admin~{drive-uuid}` credential shape was
|
||||
# rolled through the NC `/remote.php/dav/*` surface but two MOVE
|
||||
# handlers were missed:
|
||||
#
|
||||
# uploads_handler::handle_assemble (chunked-upload MOVE)
|
||||
# trashbin_handler (restore MOVE with a Destination header)
|
||||
#
|
||||
# Both handlers were stripping the destination-URL prefix with
|
||||
# `&user.username` (bare `admin`) instead of
|
||||
# `&session.raw_username` (composite `admin~{uuid}`). NC clients
|
||||
# on a non-home drive send:
|
||||
# Destination: /remote.php/dav/files/admin~{uuid}/<path>
|
||||
# The bare-username strip left `~{uuid}/<path>` glued to the
|
||||
# leading path segment; downstream lookups then targeted a
|
||||
# fabricated `<drive-root>/~{uuid}/…` path and 500'd (assemble
|
||||
# path) or silently missed collisions (trash path).
|
||||
#
|
||||
# webdav_handler::handle_move (the standard `/dav/files/…` MOVE)
|
||||
# was ALREADY correct — it uses `url_user = &session.raw_username`.
|
||||
# The uploads + trashbin siblings were coverage gaps: no Hurl
|
||||
# tests hit them with a composite credential.
|
||||
#
|
||||
# Hurl gotcha: the `[BasicAuth]` block parses the username as a
|
||||
# single token terminated by `:`. A raw composite like
|
||||
# `{{nc_username}}~{{drive_id}}` fails to parse because Hurl
|
||||
# sees the `~` between two templates and expects a line
|
||||
# terminator. Workaround: alias the composite into
|
||||
# `nc_basic_user` via `[Options] variable:` on a bootstrap
|
||||
# request, then use `{{nc_basic_user}}` in every subsequent
|
||||
# BasicAuth block.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup 1 — JWT login.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.access_token"
|
||||
admin_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup 2 — Fetch admin's home drive root folder id.
|
||||
#
|
||||
# The composite `{user}~{marker}` shape sends the marker
|
||||
# through `basic_auth_middleware.rs`, which resolves it as a
|
||||
# **folder id** (not a drive id) via
|
||||
# `folder_service.get_folder_with_perms(folder_id, user_id)` —
|
||||
# the auth boundary refuses if the caller lacks Read on that
|
||||
# folder.
|
||||
#
|
||||
# For a regression test we don't need a SECONDARY drive —
|
||||
# we need any folder id the caller has Read on so the composite
|
||||
# credential authenticates cleanly. Admin's own home folder is
|
||||
# the trivially-authorized choice; the tilde-parsing bug in
|
||||
# `handle_assemble` / trashbin restore fires the same way
|
||||
# regardless of which folder id the marker points at.
|
||||
#
|
||||
# For the real multi-drive scenario Ed hit in production, the
|
||||
# marker after `~` was the folder id of a shared drive's root
|
||||
# where admin had explicit Read via role_grants. That code path
|
||||
# is identical to the one exercised here — the bug is in the
|
||||
# destination-URL parsing, not in what the folder id points to.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{jwt}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
home_folder_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup 3 — Mint an app password. `username` in the response is
|
||||
# just `admin`; we splice the folder id onto it in Setup 4
|
||||
# below to get the composite `admin~{uuid}` shape.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_multidrive_move_regression hurl test" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup 4 — Bootstrap the composite BasicAuth username.
|
||||
#
|
||||
# `[Options] variable:` sets a variable whose VALUE is a
|
||||
# template expanded against the current bindings, then the
|
||||
# result is available to all subsequent requests. `nc_username`
|
||||
# and `home_folder_id` are already captured; concatenating them
|
||||
# here hides the `~` from the strict `[BasicAuth]` parser
|
||||
# (which would otherwise reject `{{nc_username}}~{{home_folder_id}}`
|
||||
# mid-username).
|
||||
#
|
||||
# `/ready` is a cheap unauthenticated 200 that gives us a
|
||||
# request to hang the option on. No side effects.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/ready
|
||||
[Options]
|
||||
variable: nc_basic_user={{nc_username}}~{{home_folder_id}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# =============================================================
|
||||
# A. Chunked-upload MOVE assemble regression
|
||||
# =============================================================
|
||||
# `handle_assemble` in `uploads_handler.rs` was calling
|
||||
# `extract_files_subpath(&destination, &user.username)`. With a
|
||||
# composite Destination it treated `~{drive_uuid}/<path>` as the
|
||||
# target subpath, then tried `nc_to_internal_path(chroot, …)`
|
||||
# → `<drive-root>/~{drive_uuid}/<path>`. Downstream parent-folder
|
||||
# lookup → 500.
|
||||
#
|
||||
# Fixed by binding on `&session.raw_username`. Test shape:
|
||||
# A1 — MKCOL: create the chunked-upload session directory.
|
||||
# A2 — MOVE `.file` (empty session → zero chunks → assemble
|
||||
# writes an empty file at Destination). Pre-fix: 500 with
|
||||
# "Failed to get folder at path: /<drive-root>/~<uuid>".
|
||||
# Post-fix: 201 + file exists at the real Destination.
|
||||
# A3 — PROPFIND on the destination path to confirm the file
|
||||
# landed under the drive's root (NOT under `~<uuid>/`).
|
||||
# =============================================================
|
||||
|
||||
# A1 — MKCOL upload session.
|
||||
MKCOL {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-upload-session
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# A2 — MOVE `.file` with a composite Destination header. Empty
|
||||
# session, so the assemble step writes a zero-byte file at the
|
||||
# destination path — that's fine, we're pinning the destination-
|
||||
# parsing behaviour, not the byte-copying.
|
||||
#
|
||||
# Hurl gotcha: headers MUST come before section blocks like
|
||||
# `[BasicAuth]`. `Destination:` after `[BasicAuth]` gets parsed
|
||||
# as a new request's method line.
|
||||
MOVE {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-upload-session/.file
|
||||
Destination: {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-assembled.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
# The regression: pre-fix this returned 500 with a
|
||||
# "~<drive-uuid>" fragment in the error message; post-fix it
|
||||
# writes the empty file successfully. Any 2xx status proves the
|
||||
# destination-parsing path is intact.
|
||||
HTTP 201
|
||||
|
||||
|
||||
# A3 — Confirm the file exists at the real path inside the
|
||||
# chroot. HTTP 207 alone is the load-bearing assertion: pre-fix,
|
||||
# MOVE would have 500'd (so we'd never reach here); and even if
|
||||
# it had somehow written, the file would have landed at the
|
||||
# fabricated `<chroot>/~<folder_id>/…` path rather than
|
||||
# `<chroot>/regression-assembled.txt` — this PROPFIND would
|
||||
# then 404 rather than 207.
|
||||
#
|
||||
# `body not contains "~{folder_id}/..."` would be redundant AND
|
||||
# wrong here: NC's PROPFIND echoes the client's request URL in
|
||||
# `<d:href>`, so the composite `admin~<folder_id>` legitimately
|
||||
# appears in the returned href — that's the URL prefix, not a
|
||||
# leak. The empty-file-size assertion below is the concrete
|
||||
# positive check: MOVE with zero chunks assembles a 0-byte
|
||||
# file, so we pin that shape.
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-assembled.txt
|
||||
Depth: 0
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='getcontentlength'])" == "0"
|
||||
|
||||
|
||||
# Cleanup — remove the assembled file so a re-run starts clean.
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-assembled.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# =============================================================
|
||||
# B. Trashbin restore MOVE — sibling handler with the same bug
|
||||
# =============================================================
|
||||
# `trashbin_handler.rs` line 143 has the same shape:
|
||||
# extract_nc_subpath_from_dest(&dest_header, &user.username)
|
||||
# The trash MOVE uses the Destination header for a collision
|
||||
# pre-check, not for relocation (restore always lands at the
|
||||
# original path). A buggy prefix strip therefore doesn't 500 —
|
||||
# it silently miscomputes the collision path (`<drive>/~<uuid>/…`
|
||||
# instead of `<drive>/<real-path>`), letting a real collision
|
||||
# slip past. The response is 2xx either way.
|
||||
#
|
||||
# So a "5xx vs 201" assertion won't catch it. What DOES catch it:
|
||||
# stage a genuine collision, restore with a Destination that
|
||||
# points at it. Pre-fix: no 412 (bug misses the collision).
|
||||
# Post-fix: 412 Precondition Failed.
|
||||
#
|
||||
# Sequence:
|
||||
# B1 — Upload `regression-collision.txt` to the drive.
|
||||
# B2 — DELETE it (soft-trash).
|
||||
# B3 — Re-upload `regression-collision.txt` (new file at the
|
||||
# same path) to stage the collision.
|
||||
# B4 — Enumerate the trashbin to find the trashed item's id.
|
||||
# B5 — MOVE the trash item back with Destination pointing at
|
||||
# the re-created file. Pre-fix: 201/204 (collision missed).
|
||||
# Post-fix: 412 Precondition Failed.
|
||||
# =============================================================
|
||||
|
||||
# B1 — Stage the file the client will trash.
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
```
|
||||
first version
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# B2 — Soft-trash it.
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# B3 — Re-upload at the same path to stage the collision.
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
```
|
||||
second version
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# B4 — Enumerate trashbin to find the trashed item's numeric id
|
||||
# (NC identifies trash items with `oc:trashbin-filename` etc.).
|
||||
# Using PROPFIND at Depth 1 on the trashbin root.
|
||||
PROPFIND {{base_url}}/remote.php/dav/trashbin/{{nc_basic_user}}/trash
|
||||
Depth: 1
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 207
|
||||
[Captures]
|
||||
# Grab the href of the first trashed child. Fragile against
|
||||
# multi-item trash but this test creates exactly one before
|
||||
# reading — safe here. Local-name xpath so we don't have to
|
||||
# thread the DAV namespace prefix.
|
||||
trash_item_href: xpath "string((//*[local-name()='response']/*[local-name()='href'])[2])"
|
||||
|
||||
|
||||
# B5 — MOVE the trash item back with a composite Destination.
|
||||
# Pre-fix: collision check runs against a fake `<drive>/~<uuid>/…`
|
||||
# path, misses the real collision, restore succeeds (201/204).
|
||||
# Post-fix: collision check hits the real path, request refused
|
||||
# with 412.
|
||||
MOVE {{base_url}}{{trash_item_href}}
|
||||
Destination: {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
# Post-fix expectation: 412 (collision detected). If a future
|
||||
# change makes trashbin restore honour Destination for
|
||||
# relocation, this assertion changes — but the collision-check
|
||||
# semantics should stay collision-refusing.
|
||||
HTTP 412
|
||||
|
||||
|
||||
# Cleanup — permanently delete the trashed item so a re-run
|
||||
# starts clean, and drop the live file.
|
||||
DELETE {{base_url}}{{trash_item_href}}
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# =============================================================
|
||||
# C. Chunked-upload PROPFIND href regression
|
||||
# =============================================================
|
||||
# `handle_propfind_session` in `uploads_handler.rs` was emitting
|
||||
# `<d:href>` values with `user.username` (bare `admin`) instead of
|
||||
# `session.raw_username` (composite `admin~<uuid>`). Same shape
|
||||
# as the trashbin PROPFIND bug: NC clients doing chunked-upload
|
||||
# resume PROPFIND the session, then MOVE/DELETE against the
|
||||
# returned hrefs. With the bare form, every follow-up 403s at
|
||||
# the `NcSession` extractor (URL `{user}` segment mismatches
|
||||
# `raw_username`).
|
||||
#
|
||||
# Positive test: after PROPFIND-ing an upload session with a
|
||||
# composite credential, the emitted hrefs MUST contain `~<uuid>`.
|
||||
# Pre-fix: `<d:href>/remote.php/dav/uploads/admin/…</d:href>`.
|
||||
# Post-fix: `<d:href>/remote.php/dav/uploads/admin~<uuid>/…</d:href>`.
|
||||
# =============================================================
|
||||
|
||||
# C1 — MKCOL a fresh session.
|
||||
MKCOL {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# C2 — PUT one chunk so PROPFIND has something to enumerate
|
||||
# alongside the session collection itself.
|
||||
PUT {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session/00000001
|
||||
Content-Type: application/octet-stream
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
```
|
||||
chunk-body
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# C3 — PROPFIND the session with the composite credential and
|
||||
# assert every emitted href carries the composite user segment.
|
||||
# `contains "~{{home_folder_id}}/"` is the exact byte marker
|
||||
# introduced by the fix — the bug would produce
|
||||
# `/dav/uploads/admin/…` with no `~` between the surface and the
|
||||
# session id.
|
||||
PROPFIND {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session
|
||||
Depth: 1
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
# Every href for this upload surface must echo the composite user.
|
||||
# Two responses expected: session collection + one chunk. Both
|
||||
# hrefs share the same `/remote.php/dav/uploads/<user>/<session>/…`
|
||||
# prefix, so one substring check on the body body is sufficient
|
||||
# and immune to XML formatting drift.
|
||||
body contains "/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session/"
|
||||
# Belt-and-suspenders: assert the bare form is absent. The
|
||||
# composite basic-user is `admin~<uuid>`; the bare form would
|
||||
# render as `/dav/uploads/admin/regression-…` (no `~`).
|
||||
# `not contains` here would still permit that byte sequence to
|
||||
# appear inside the composite, so we anchor on the trailing `/`
|
||||
# after the user segment to disambiguate: `/admin/regression-…`
|
||||
# is the bug shape; the fix never produces `/admin/regression-…`
|
||||
# because the composite always separates admin from the session
|
||||
# with `~<uuid>`.
|
||||
body not contains "/remote.php/dav/uploads/{{nc_username}}/regression-propfind-session"
|
||||
|
||||
|
||||
# C4 — DELETE the session with a composite href. Pre-fix (bare
|
||||
# href returned by C3 that the client would have followed) this
|
||||
# would have been a wire-level 403 at the extractor; post-fix
|
||||
# the composite href works end-to-end.
|
||||
DELETE {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session
|
||||
[BasicAuth]
|
||||
{{nc_basic_user}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Teardown — revoke the app password.
|
||||
# =============================================================
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{jwt}}
|
||||
|
||||
HTTP 200
|
||||
@@ -0,0 +1,470 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NextCloud WebDAV: dead-properties (RFC 4918 §4.2)
|
||||
# =============================================================
|
||||
# `tests/api/webdav_dead_properties.hurl` covers the native
|
||||
# `/webdav/` surface end-to-end. This file covers the same
|
||||
# PROPPATCH/PROPFIND contract on the NextCloud-compatible surface
|
||||
# (`/remote.php/dav/files/{user}/...`), which — until now — had NO
|
||||
# generic dead-property support: PROPPATCH only special-cased
|
||||
# `oc:favorite` via an ad hoc XML scan and silently discarded any
|
||||
# other property while still claiming `200 OK`; PROPFIND always
|
||||
# emitted a fixed hardcoded property set with no dead-property
|
||||
# lookup at all. A client (or litmus) PROPPATCHing a custom label
|
||||
# through the NextCloud mount got a false success and then never
|
||||
# saw the property again.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Setup: JWT login, mint an NC app password.
|
||||
# 2. PUT a probe file via the NC DAV surface.
|
||||
# 3. PROPPATCH set a custom property → 207.
|
||||
# 4. PROPFIND → value round-trips verbatim.
|
||||
# 5. PROPPATCH upsert (same name, new value) → PROPFIND confirms
|
||||
# overwrite, not a duplicate row.
|
||||
# 6. PROPPATCH remove → PROPFIND confirms absence.
|
||||
# 7. PROPPATCH on a nonexistent resource → 404 (the tightened
|
||||
# contract: PROPPATCH now does real work, so a previous
|
||||
# "always claim success" no-op on a missing resource would be
|
||||
# a foot-gun, not a feature).
|
||||
# 8. Re-set a property, MOVE the file → PROPFIND on the new path
|
||||
# still returns it (resource id is stable across MOVE).
|
||||
# 9. DELETE, then PUT a fresh file at the same path → PROPFIND
|
||||
# does NOT see the old marker (new resource, no leaked state).
|
||||
# 10. Regression guard: `oc:favorite` PROPPATCH/PROPFIND still
|
||||
# works, unaffected by the refactor from the ad hoc favorite
|
||||
# scanner to generic `WebDavAdapter::parse_proppatch`.
|
||||
# 11. Folder coverage: MKCOL, PROPPATCH a dead property on the
|
||||
# folder, PROPFIND confirms it, cleanup.
|
||||
#
|
||||
# XPath assertions use `local-name()` so the test is robust against
|
||||
# the server's chosen namespace prefix for dead properties (`X:`).
|
||||
#
|
||||
# NOTE: in Hurl, [BasicAuth] must be the LAST section before the
|
||||
# blank-line/body — any request headers go above it, not below.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — JWT login, then mint an NC app password (NC DAV uses
|
||||
# Basic Auth, not the JWT bearer token).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_dead_properties" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file through the NC DAV surface.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
hello nc dead properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set a custom (dead) property.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>hello-nc-dead-property</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND confirms the round-trip.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "hello-nc-dead-property"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Upsert: setting the same name again overwrites rather
|
||||
# than duplicating (ON CONFLICT DO UPDATE at the store).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>updated-nc-value</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "updated-nc-value"
|
||||
xpath "count(//*[local-name()='testlabel'])" == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Remove the property; PROPFIND confirms absence.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<X:testlabel/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PROPPATCH against a nonexistent resource → 404.
|
||||
# Prior behaviour on this handler silently no-opped
|
||||
# (and still claimed success) when the body carried no
|
||||
# `oc:favorite` directive; now that PROPPATCH performs
|
||||
# real dead-property writes, a missing resource must be
|
||||
# a hard failure, matching the native `/webdav/` handler.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-does-not-exist.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>should-not-be-stored</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Re-set a marker, MOVE the file, confirm the property
|
||||
# followed the resource (id-stable across MOVE — no
|
||||
# store-side rename bookkeeping needed).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>survives-nc-move</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
MOVE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Destination: {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
# Fresh destination → 201 (RFC 4918 §9.9.4).
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "survives-nc-move"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — DELETE, then PUT a fresh file at the same path: the
|
||||
# old marker must NOT resurface (new resource, no leaked
|
||||
# dead-property state). Whether DELETE soft-deletes to
|
||||
# trash or hard-deletes, the recreated path resolves to
|
||||
# a brand-new resource id with no dead-property rows of
|
||||
# its own.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
fresh file at the same nc path
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Regression guard: `oc:favorite` still works after the
|
||||
# PROPPATCH handler was rewritten from an ad hoc
|
||||
# favorite-only scanner to generic dead-property
|
||||
# handling with an `oc:favorite` special case.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "1"
|
||||
|
||||
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>0</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "0"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — probe file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Folder coverage: MKCOL, PROPPATCH, PROPFIND, cleanup.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:foldermark>nc-folder-keeps-this</X:foldermark>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='foldermark'])" == "nc-folder-keeps-this"
|
||||
|
||||
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Teardown — revoke the app password minted in Step 1.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{jwt}}
|
||||
|
||||
HTTP 200
|
||||
@@ -166,6 +166,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/public_shares.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/grant_cleanup.hurl" \
|
||||
"$API_DIR/role_grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/groups_effective_members.hurl" \
|
||||
@@ -186,7 +187,10 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/drive_policies.hurl" \
|
||||
"$API_DIR/cross_drive_move.hurl" \
|
||||
"$API_DIR/cross_drive_copy.hurl" \
|
||||
"$API_DIR/nc_multidrive_move_regression.hurl" \
|
||||
"$API_DIR/webdav_dead_properties.hurl" \
|
||||
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
||||
"$API_DIR/webdav_protected_properties.hurl" \
|
||||
"$API_DIR/webdav_quota_properties.hurl" \
|
||||
"$API_DIR/nc_webdav_quota_properties.hurl" \
|
||||
"$API_DIR/webdav_drive_root.hurl" \
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV protected properties (RFC 4918 §9.2 / §15)
|
||||
# =============================================================
|
||||
# `DeadPropertyStore` lets a PROPPATCH set arbitrary namespace/name
|
||||
# pairs verbatim (RFC 4918 §4.2). Without a denylist, a client could
|
||||
# PROPPATCH `DAV:getetag`, `oc:fileid`, `oc:permissions`, etc. — names
|
||||
# the server ALSO emits as live state in PROPFIND/REPORT responses
|
||||
# (see `write_file_response` / `write_folder_response` in the NC
|
||||
# handler and the native PROPFIND writer). That produces either a
|
||||
# forged live property (the server would need to pick which of two
|
||||
# values to emit) or a silently stored, never-read row.
|
||||
#
|
||||
# `is_protected_property()` (src/application/adapters/webdav_adapter.rs)
|
||||
# defends the whole `DAV:` namespace plus the specific oc:/nc:/ocs:
|
||||
# names the server actually emits elsewhere. Both PROPPATCH handlers
|
||||
# (native `/webdav/` and NC `/remote.php/dav/`) consult it before
|
||||
# touching `DeadPropertyStore`, and reject with RFC 4918 §9.2's
|
||||
# per-property `403 Forbidden` inside the 207 multi-status — not a
|
||||
# blanket request failure, and not a silent no-op success.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Native /webdav/: PROPPATCH set on `D:displayname` (DAV:
|
||||
# namespace) → 207 envelope, inner 403 for that property.
|
||||
# 2. PROPFIND confirms the live displayname is unchanged — the
|
||||
# forged value never landed anywhere.
|
||||
# 3. Native /webdav/: PROPPATCH remove on `D:getetag` → same 403
|
||||
# contract on the Remove path, not just Set.
|
||||
# 4. Native /webdav/: an oc:-namespaced protected name
|
||||
# (`oc:fileid`) is blocked even on the surface that doesn't
|
||||
# normally speak NextCloud namespaces — protection is
|
||||
# namespace-global, not surface-scoped.
|
||||
# 5. Mixed request: one protected DAV: prop + one ordinary custom
|
||||
# dead property in the SAME PROPPATCH → 207 with both a 403
|
||||
# propstat block and a 200 propstat block; the custom property
|
||||
# DOES get stored (per-property granularity, not all-or-nothing
|
||||
# rejection).
|
||||
# 6. NC surface: PROPPATCH set on a protected oc: name
|
||||
# (`oc:permissions`) → 403; PROPFIND confirms it was never
|
||||
# written to the dead-property store.
|
||||
# 7. NC surface: PROPPATCH set on a protected nc: name
|
||||
# (`nc:has-preview`) → 403.
|
||||
# 8. Regression guard: `oc:favorite` is on the protected list too
|
||||
# (it's live state the NC handler emits), but the handler's
|
||||
# favorite special-case runs BEFORE the protected-property
|
||||
# check, so toggling favorite through PROPPATCH still works —
|
||||
# protection must not swallow the one oc: name that's
|
||||
# legitimately client-writable via a side channel.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT; mint an NC app password for the
|
||||
# NC-surface half of this file (NC DAV uses Basic Auth).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{ "label": "webdav_protected_properties" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file via native WebDAV.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
hello protected properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set on DAV:displayname (live property) must
|
||||
# be rejected with a per-property 403, not silently
|
||||
# accepted into DeadPropertyStore.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>forged-name</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND confirms the live displayname is untouched.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/protected-props-probe.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:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='displayname'])" == "protected-props-probe.txt"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — PROPPATCH remove on DAV:getetag → same 403 contract
|
||||
# on the Remove path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Protection is namespace-global: an oc:-namespaced
|
||||
# protected name is blocked even on the native /webdav/
|
||||
# surface, which doesn't otherwise speak NextCloud
|
||||
# namespaces.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:fileid>should-not-be-stored</oc:fileid>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Mixed request: one protected DAV: prop + one ordinary
|
||||
# custom dead property in the SAME PROPPATCH → per-
|
||||
# property granularity, not all-or-nothing rejection.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:resourcetype>forged</D:resourcetype>
|
||||
<X:testlabel>allowed-alongside-protected</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='propstat'])" == 2
|
||||
xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='resourcetype']]/*[local-name()='status'])" contains "403"
|
||||
xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='testlabel']]/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/webdav/protected-props-probe.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:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "allowed-alongside-protected"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — native probe file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — NC surface: PUT a probe file via the NC DAV mount.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
hello nc protected properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — NC surface: PROPPATCH set on a protected oc: name
|
||||
# (`oc:permissions`, not the specially-handled favorite)
|
||||
# → 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:permissions>forged</oc:permissions>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='permissions'])" != "forged"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — NC surface: PROPPATCH set on a protected nc: name
|
||||
# (`nc:has-preview`) → 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<nc:has-preview>forged</nc:has-preview>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Regression guard: oc:favorite is on the protected
|
||||
# list too, but the handler's favorite special-case
|
||||
# runs before the protection check, so toggling it via
|
||||
# PROPPATCH must still work end to end.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "1"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — NC probe file, teardown app password.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
Reference in New Issue
Block a user