Merge pull request #596 from swissiety/rfc-5789-http-patch
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NextCloud HTTP PATCH partial content updates (RFC 5789)
|
||||
# =============================================================
|
||||
# The NextCloud-compatible WebDAV surface (/remote.php/dav/…) had no
|
||||
# PATCH dispatch arm at all (fell through to 405), unlike the plain-file
|
||||
# surface (see api/handlers/webdav_handler.rs::handle_patch). See
|
||||
# nextcloud/webdav_handler.rs::handle_patch, which reuses the plain
|
||||
# surface's `parse_update_range` and `upload_ingest::
|
||||
# ingest_range_patch_to_cas` — both surface-agnostic.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. PATCH an explicit byte range (`X-Update-Range: bytes=<start>-<end>`)
|
||||
# → 204, Content-Range header, and the resulting content reflects
|
||||
# the patched span with the untouched prefix/suffix intact.
|
||||
# 2. PATCH with `X-Update-Range: append` → 204, content grows.
|
||||
# 3. PATCH with a Content-Range header → 400 (must use X-Update-Range).
|
||||
# 4. PATCH without X-Update-Range → 400.
|
||||
# 5. PATCH on a nonexistent file → 404.
|
||||
# 6. PATCH on a directory → 409 (not 404 — the NC surface previously
|
||||
# had no folder-existence check and returned 404 for both a missing
|
||||
# file AND an existing directory; the fix commit added an explicit
|
||||
# check so the two cases are distinguishable again, matching the
|
||||
# plain-surface behavior).
|
||||
#
|
||||
# Hurl gotcha: headers MUST come before section blocks like
|
||||
# `[BasicAuth]` in a request — a header line placed after `[BasicAuth]`
|
||||
# is parsed as the START OF A NEW REQUEST instead (see
|
||||
# `nc_multidrive_move_regression.hurl`'s note on the same gotcha).
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup 1 — JWT login (to mint the app password used for NC Basic Auth).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup 2 — Mint an app password for NC Basic Auth.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_patch hurl test" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Seed a 10-byte probe file: "0123456789".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PATCH bytes 3-5 ("345") with "XYZ".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
X-Update-Range: bytes=3-5
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`XYZ`
|
||||
|
||||
HTTP 204
|
||||
[Asserts]
|
||||
header "Content-Range" == "bytes 3-9/10"
|
||||
|
||||
|
||||
GET {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "012XYZ6789"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PATCH append.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
X-Update-Range: append
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`END`
|
||||
|
||||
HTTP 204
|
||||
[Asserts]
|
||||
header "Content-Range" == "bytes 10-12/13"
|
||||
|
||||
|
||||
GET {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "012XYZ6789END"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Content-Range header on PATCH is rejected.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Range: bytes 0-2/13
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`abc`
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Missing X-Update-Range header.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`abc`
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PATCH on a nonexistent file → 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-does-not-exist.txt
|
||||
X-Update-Range: append
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`abc`
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PATCH on a directory → 409 Conflict (not 404).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe-dir/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe-dir/
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 409
|
||||
|
||||
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe-dir/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{jwt}}
|
||||
HTTP 200
|
||||
@@ -0,0 +1,545 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NextCloud PATCH data-consistency + authz/lock gaps
|
||||
# =============================================================
|
||||
# `nc_webdav_patch.hurl` covers the PATCH contract on the NC surface.
|
||||
# This file targets the specific gaps closed by the review-fix commit
|
||||
# (see nextcloud/webdav_handler.rs::handle_patch):
|
||||
#
|
||||
# 1. AuthZ: the NC surface previously called `get_file_by_path`
|
||||
# (which performs NO authorization check) with no follow-up
|
||||
# `authz.require` at all — any caller with a valid app password
|
||||
# could learn a file's size/ETag via PATCH's precondition/range
|
||||
# responses regardless of their actual permission on that file.
|
||||
# The fix added the same `Permission::Read` check the plain
|
||||
# surface already had. That Read check is only an early
|
||||
# existence-proof gate, though — the actual write a few lines
|
||||
# later goes through `update_file_streaming_with_perms`, which
|
||||
# independently requires `Permission::Update`. So the full
|
||||
# permission chain for PATCH is: EDITOR (has Update) can PATCH;
|
||||
# VIEWER (Read only, no Update) gets past the early gate but is
|
||||
# still denied — anti-enum 404 — at the write step; a caller
|
||||
# with NO grant at all can't even establish the composite-marker
|
||||
# chroot. Tested via the multi-drive composite `{user}~{folder_id}`
|
||||
# credential shape (see `nc_multidrive_move_regression.hurl` for
|
||||
# the mechanism).
|
||||
# 2. Cross-surface lock interop: a LOCK taken via the plain
|
||||
# `/webdav/` surface now also blocks PATCH via `/remote.php/dav/`
|
||||
# for the same file — proves the two surfaces share one lock
|
||||
# store, not two independent ones.
|
||||
# 3. Quota/507 via the NC surface (previously missing entirely —
|
||||
# the fix added the same per-user quota check the plain surface
|
||||
# already enforced), and the failed PATCH leaves the file intact.
|
||||
#
|
||||
# Self-contained: provisions its own throwaway users/drive so it can
|
||||
# run alongside the rest of the suite.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup — Admin JWT login.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_jwt: jsonpath "$.access_token"
|
||||
admin_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part A — AuthZ: Editor can PATCH; Viewer (Read only) and a
|
||||
# no-grant outsider both can't
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A1 — Provision `ncpatch_editor` (will get EDITOR),
|
||||
# `ncpatch_viewer` (will get VIEWER), and
|
||||
# `ncpatch_outsider` (gets NO grant at all).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncpatch_editor",
|
||||
"password": "NcPatchEditorPwd1!",
|
||||
"email": "ncpatch_editor@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
editor_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncpatch_viewer",
|
||||
"password": "NcPatchViewerPwd1!",
|
||||
"email": "ncpatch_viewer@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
viewer_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncpatch_outsider",
|
||||
"password": "NcPatchOutsiderPwd1!",
|
||||
"email": "ncpatch_outsider@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
outsider_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A2 — Log all three in, mint an NC app password for each.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncpatch_editor", "password": "NcPatchEditorPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
editor_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{editor_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_patch_consistency (editor)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
editor_nc_username: jsonpath "$.username"
|
||||
editor_nc_password: jsonpath "$.password"
|
||||
editor_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncpatch_viewer", "password": "NcPatchViewerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
viewer_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{viewer_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_patch_consistency (viewer)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
viewer_nc_username: jsonpath "$.username"
|
||||
viewer_nc_password: jsonpath "$.password"
|
||||
viewer_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncpatch_outsider", "password": "NcPatchOutsiderPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
outsider_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{outsider_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_patch_consistency (outsider)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
outsider_nc_username: jsonpath "$.username"
|
||||
outsider_nc_password: jsonpath "$.password"
|
||||
outsider_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A3 — Admin creates a shared drive, grants `ncpatch_editor`
|
||||
# EDITOR (Read + Update) and `ncpatch_viewer` VIEWER
|
||||
# (Read only). `ncpatch_outsider` gets no grant at all.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "ncpatch-shared",
|
||||
"owner": { "type": "user", "id": "{{admin_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
shared_drive_id: jsonpath "$.id"
|
||||
shared_root_id: jsonpath "$.root_folder_id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{editor_user_id}}" },
|
||||
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{viewer_user_id}}" },
|
||||
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A4 — Admin seeds a file in the shared drive via the plain
|
||||
# WebDAV surface (`@drive/<id>/` scheme).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/ncpatch-file.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A5 — Bootstrap the composite BasicAuth usernames (Hurl's
|
||||
# [BasicAuth] parser chokes on a literal `~` split across
|
||||
# two templates — alias it via [Options] variable: first,
|
||||
# same workaround as nc_multidrive_move_regression.hurl).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/ready
|
||||
[Options]
|
||||
variable: nc_basic_editor={{editor_nc_username}}~{{shared_root_id}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/ready
|
||||
[Options]
|
||||
variable: nc_basic_viewer={{viewer_nc_username}}~{{shared_root_id}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/ready
|
||||
[Options]
|
||||
variable: nc_basic_outsider={{outsider_nc_username}}~{{shared_root_id}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A6 — EDITOR (has Update via the drive grant) CAN PATCH.
|
||||
# This is the positive check: the fix's authz.require(Read)
|
||||
# gate plus the write step's Update requirement must not
|
||||
# accidentally lock out a legitimate Update-holder.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_basic_editor}}/ncpatch-file.txt
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_editor}}: {{editor_nc_password}}
|
||||
`XYZ`
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/remote.php/dav/files/{{nc_basic_editor}}/ncpatch-file.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_editor}}: {{editor_nc_password}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "XYZ3456789"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A7 — VIEWER (has Read via the grant, but not Update) is
|
||||
# denied. The early authz.require(Read) the fix added is
|
||||
# only an existence-proof gate; the actual write goes
|
||||
# through `update_file_streaming_with_perms`, which
|
||||
# independently requires Update. Since the Viewer CAN
|
||||
# read the file, `require`'s graduated-denial policy
|
||||
# (authorization_ports.rs::require) surfaces this as 403,
|
||||
# not the anti-enum 404 — the caller can already see the
|
||||
# resource, so hiding its existence leaks nothing new.
|
||||
# Before fixing the NC surface's error-mapping bug found
|
||||
# via this test (see nextcloud/webdav_handler.rs's PATCH
|
||||
# write-step error mapping), this denial leaked as a raw
|
||||
# 500 instead of the correct 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_basic_viewer}}/ncpatch-file.txt
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_viewer}}: {{viewer_nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 403
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A8 — OUTSIDER (no grant at all on this drive) cannot reach
|
||||
# the file — denied before PATCH's own logic ever runs.
|
||||
# Accept the broader 4xx-non-2xx shape here since the
|
||||
# denial may surface at the app-password/session boundary
|
||||
# rather than the domain authz layer.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_basic_outsider}}/ncpatch-file.txt
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_outsider}}: {{outsider_nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 400
|
||||
status < 500
|
||||
|
||||
|
||||
# Cleanup Part A.
|
||||
DELETE {{base_url}}/webdav/@drive/{{shared_drive_id}}/ncpatch-file.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{editor_ap_id}}
|
||||
Authorization: Bearer {{editor_jwt}}
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{viewer_ap_id}}
|
||||
Authorization: Bearer {{viewer_jwt}}
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{outsider_ap_id}}
|
||||
Authorization: Bearer {{outsider_jwt}}
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part B — Cross-surface lock interop
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step B1 — Mint admin's own NC app password (bare-username
|
||||
# surface — admin's personal drive, same file tree as
|
||||
# `/webdav/`).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_patch_consistency (lock interop)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
lock_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step B2 — Seed the file via the plain surface, LOCK it there.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/nc-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
LOCK {{base_url}}/webdav/nc-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:lockinfo xmlns:D="DAV:">
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner>nc-lock-interop-test</D:owner>
|
||||
</D:lockinfo>
|
||||
```
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
interop_lock_token: xpath "string(//*[local-name()='locktoken']/*[local-name()='href'])"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step B3 — PATCH the SAME file via the NC surface, no lock token
|
||||
# → 423. Pre-fix, the NC surface didn't consult the
|
||||
# plain surface's lock store at all.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-lock-interop-probe.txt
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 423
|
||||
|
||||
|
||||
# Release the lock via the plain surface so cleanup below works.
|
||||
UNLOCK {{base_url}}/webdav/nc-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Lock-Token: <{{interop_lock_token}}>
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# Cleanup Part B.
|
||||
DELETE {{base_url}}/webdav/nc-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part C — Quota/507 via the NC surface leaves the file untouched
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step C1 — Provision `ncpatch_quota_owner` with a 50-byte quota.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncpatch_quota_owner",
|
||||
"password": "NcPatchQuotaOwnerPwd1!",
|
||||
"email": "ncpatch_quota_owner@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_owner_id: jsonpath "$.id"
|
||||
|
||||
|
||||
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "quota_bytes": 50 }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncpatch_quota_owner", "password": "NcPatchQuotaOwnerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
quota_owner_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{quota_owner_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_patch_consistency (quota)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
quota_nc_username: jsonpath "$.username"
|
||||
quota_nc_password: jsonpath "$.password"
|
||||
quota_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step C2 — Seed a 10-byte file (under quota), then append past
|
||||
# it → 507. File must come back unchanged.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-quota-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_probe_etag: header "ETag"
|
||||
|
||||
|
||||
PATCH {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-quota-probe.txt
|
||||
X-Update-Range: append
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
`this-is-a-100-byte-ish-payload-that-blows-past-the-fifty-byte-quota-set-for-this-throwaway-user-abc`
|
||||
|
||||
HTTP 507
|
||||
|
||||
|
||||
GET {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-quota-probe.txt
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "0123456789"
|
||||
header "ETag" contains {{quota_probe_etag}}
|
||||
|
||||
|
||||
# Cleanup Part C.
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-quota-probe.txt
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{quota_ap_id}}
|
||||
Authorization: Bearer {{quota_owner_jwt}}
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{lock_ap_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Teardown
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
DELETE {{base_url}}/api/admin/users/{{editor_user_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{viewer_user_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{outsider_user_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{quota_owner_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
@@ -0,0 +1,505 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NextCloud PUT gaps closed by bringing handle_put up to
|
||||
# parity with handle_patch
|
||||
# =============================================================
|
||||
# `nc_webdav_patch_consistency.hurl` covers the same four gap classes
|
||||
# for PATCH; this file targets the NC surface's `handle_put`
|
||||
# (nextcloud/webdav_handler.rs), which had fallen behind PATCH's
|
||||
# hardening across the RFC 5789 commits:
|
||||
#
|
||||
# 1. Error mapping: the write step mapped every `DomainError` to a
|
||||
# raw 500 (`AppError::internal_error(format!("Failed to store
|
||||
# file: {}", e))`) instead of `AppError::from(e)` — a VIEWER
|
||||
# (Read only, no Update) overwriting a file got a 500 leak
|
||||
# instead of the graduated-denial 403 the rest of the codebase
|
||||
# relies on (Read granted → visible → 403; no Read at all →
|
||||
# hidden → 404 anti-enum).
|
||||
# 2. Cross-surface lock interop: PUT via `/remote.php/dav/` didn't
|
||||
# consult the lock store a LOCK taken via the plain `/webdav/`
|
||||
# surface writes to at all.
|
||||
# 3. Quota/507: PUT via the NC surface bypassed
|
||||
# `check_storage_quota` entirely (PATCH already enforced it).
|
||||
# 4. Existence-check depth (RFC 4918 §9.7.1): PUT to an existing
|
||||
# directory should be 400, and PUT under a missing parent folder
|
||||
# should be 409 — neither check existed on the NC surface; both
|
||||
# failure modes fell through to whatever `update_file_streaming_
|
||||
# with_perms` did internally.
|
||||
#
|
||||
# Self-contained: provisions its own throwaway users/drive so it can
|
||||
# run alongside the rest of the suite.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup — Admin JWT login.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_jwt: jsonpath "$.access_token"
|
||||
admin_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part A — Error mapping: Editor can overwrite via PUT; Viewer
|
||||
# (Read only) gets 404, not a raw 500
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A1 — Provision `ncput_editor` (EDITOR) and `ncput_viewer`
|
||||
# (VIEWER, Read only).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncput_editor",
|
||||
"password": "NcPutEditorPwd1!",
|
||||
"email": "ncput_editor@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
editor_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncput_viewer",
|
||||
"password": "NcPutViewerPwd1!",
|
||||
"email": "ncput_viewer@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
viewer_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A2 — Log both in, mint an NC app password for each.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncput_editor", "password": "NcPutEditorPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
editor_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{editor_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_put_gaps (editor)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
editor_nc_username: jsonpath "$.username"
|
||||
editor_nc_password: jsonpath "$.password"
|
||||
editor_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncput_viewer", "password": "NcPutViewerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
viewer_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{viewer_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_put_gaps (viewer)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
viewer_nc_username: jsonpath "$.username"
|
||||
viewer_nc_password: jsonpath "$.password"
|
||||
viewer_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A3 — Admin creates a shared drive, grants `ncput_editor`
|
||||
# EDITOR (Read + Update) and `ncput_viewer` VIEWER
|
||||
# (Read only).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "ncput-shared",
|
||||
"owner": { "type": "user", "id": "{{admin_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
shared_drive_id: jsonpath "$.id"
|
||||
shared_root_id: jsonpath "$.root_folder_id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{editor_user_id}}" },
|
||||
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{viewer_user_id}}" },
|
||||
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A4 — Admin seeds a file in the shared drive via the plain
|
||||
# WebDAV surface (`@drive/<id>/` scheme).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/ncput-file.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A5 — Bootstrap the composite BasicAuth usernames (see
|
||||
# nc_multidrive_move_regression.hurl for the mechanism).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/ready
|
||||
[Options]
|
||||
variable: nc_basic_editor={{editor_nc_username}}~{{shared_root_id}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/ready
|
||||
[Options]
|
||||
variable: nc_basic_viewer={{viewer_nc_username}}~{{shared_root_id}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A6 — EDITOR (has Update via the drive grant) CAN overwrite
|
||||
# via PUT.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_editor}}/ncput-file.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_editor}}: {{editor_nc_password}}
|
||||
`XYZ`
|
||||
|
||||
HTTP 204
|
||||
|
||||
GET {{base_url}}/remote.php/dav/files/{{nc_basic_editor}}/ncput-file.txt
|
||||
[BasicAuth]
|
||||
{{nc_basic_editor}}: {{editor_nc_password}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "XYZ"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step A7 — VIEWER (has Read via the grant, but not Update) is
|
||||
# denied, not a raw 500. Viewer CAN read the file, so
|
||||
# the graduated-denial policy (authorization_ports.rs::
|
||||
# require) surfaces 403, not the anti-enum 404 — that
|
||||
# shape is reserved for callers with no Read at all.
|
||||
# Before the fix, `handle_put`'s write step mapped every
|
||||
# `DomainError` (including this authz denial) to
|
||||
# `AppError::internal_error(...)`, leaking a 500.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_viewer}}/ncput-file.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_basic_viewer}}: {{viewer_nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 403
|
||||
|
||||
|
||||
# Cleanup Part A.
|
||||
DELETE {{base_url}}/webdav/@drive/{{shared_drive_id}}/ncput-file.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{editor_ap_id}}
|
||||
Authorization: Bearer {{editor_jwt}}
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{viewer_ap_id}}
|
||||
Authorization: Bearer {{viewer_jwt}}
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part B — Cross-surface lock interop
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step B1 — Mint admin's own NC app password (bare-username
|
||||
# surface — admin's personal drive, same file tree as
|
||||
# `/webdav/`).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_put_gaps (lock interop)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
lock_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step B2 — Seed the file via the plain surface, LOCK it there.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
LOCK {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:lockinfo xmlns:D="DAV:">
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner>nc-put-lock-interop-test</D:owner>
|
||||
</D:lockinfo>
|
||||
```
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
interop_lock_token: xpath "string(//*[local-name()='locktoken']/*[local-name()='href'])"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step B3 — PUT the SAME file via the NC surface, no lock token
|
||||
# → 423. Pre-fix, the NC surface's `handle_put` didn't
|
||||
# consult the plain surface's lock store at all.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-lock-interop-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 423
|
||||
|
||||
|
||||
# Release the lock via the plain surface so cleanup below works.
|
||||
UNLOCK {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Lock-Token: <{{interop_lock_token}}>
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# Cleanup Part B.
|
||||
DELETE {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part C — Quota/507 via the NC surface leaves the file untouched
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step C1 — Provision `ncput_quota_owner` with a 50-byte quota.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ncput_quota_owner",
|
||||
"password": "NcPutQuotaOwnerPwd1!",
|
||||
"email": "ncput_quota_owner@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_owner_id: jsonpath "$.id"
|
||||
|
||||
|
||||
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "quota_bytes": 50 }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ncput_quota_owner", "password": "NcPutQuotaOwnerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
quota_owner_jwt: jsonpath "$.access_token"
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{quota_owner_jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_put_gaps (quota)" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
quota_nc_username: jsonpath "$.username"
|
||||
quota_nc_password: jsonpath "$.password"
|
||||
quota_ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step C2 — Seed a 10-byte file (under quota), then overwrite it
|
||||
# with a payload that blows past the 50-byte quota → 507.
|
||||
# File must come back unchanged.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_probe_etag: header "ETag"
|
||||
|
||||
|
||||
PUT {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
`this-is-a-100-byte-ish-payload-that-blows-past-the-fifty-byte-quota-set-for-this-throwaway-user-abc`
|
||||
|
||||
HTTP 507
|
||||
|
||||
|
||||
GET {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "0123456789"
|
||||
header "ETag" contains {{quota_probe_etag}}
|
||||
|
||||
|
||||
# Cleanup Part C.
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||
[BasicAuth]
|
||||
{{quota_nc_username}}: {{quota_nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{quota_ap_id}}
|
||||
Authorization: Bearer {{quota_owner_jwt}}
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part D — Existence-check depth (RFC 4918 §9.7.1): folder-collision
|
||||
# and missing-parent, previously unchecked on the NC surface
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step D1 — PUT to an existing directory → 400 (not whatever the
|
||||
# write step's internals happened to produce).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-probe-dir/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-probe-dir/
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-probe-dir/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step D2 — PUT under a nonexistent parent folder → 409 Conflict
|
||||
# (RFC 4918 §9.7.1), not a generic error from further down
|
||||
# the write path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-missing-parent/probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
`NOP`
|
||||
|
||||
HTTP 409
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{lock_ap_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Teardown
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
DELETE {{base_url}}/api/admin/users/{{editor_user_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{viewer_user_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{quota_owner_id}}
|
||||
Authorization: Bearer {{admin_jwt}}
|
||||
|
||||
HTTP 200
|
||||
@@ -205,6 +205,11 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/webdav_protected_properties.hurl" \
|
||||
"$API_DIR/webdav_quota_properties.hurl" \
|
||||
"$API_DIR/nc_webdav_quota_properties.hurl" \
|
||||
"$API_DIR/webdav_patch.hurl" \
|
||||
"$API_DIR/nc_webdav_patch.hurl" \
|
||||
"$API_DIR/webdav_patch_consistency.hurl" \
|
||||
"$API_DIR/nc_webdav_patch_consistency.hurl" \
|
||||
"$API_DIR/nc_webdav_put_gaps.hurl" \
|
||||
"$API_DIR/webdav_drive_root.hurl" \
|
||||
"$API_DIR/webdav_permissions.hurl" \
|
||||
"$API_DIR/webdav_nested_move_cascade.hurl" \
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV PATCH (RFC 5789) partial content update
|
||||
# =============================================================
|
||||
# RFC 4918 §9.7.1 forbids partial updates on PUT (a `Content-Range`
|
||||
# on PUT is rejected, see webdav_handler.rs::handle_put). PATCH is
|
||||
# the mechanism this server offers instead, via a dedicated
|
||||
# `X-Update-Range` header: `bytes=<start>-<end>` (inclusive) or
|
||||
# `append`. See webdav_handler.rs::handle_patch /
|
||||
# parse_update_range for the implementation.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Mid-file byte-range overwrite → 204, GET reflects the splice.
|
||||
# 2. Append → 204, GET reflects the appended tail.
|
||||
# 3. Out-of-range span (end >= size) → 416.
|
||||
# 4. If-Match precondition failure → 412.
|
||||
# 5. Locked resource without a lock token → 423.
|
||||
# 6. PATCH on a directory → 409.
|
||||
# 7. PATCH on a missing resource → 404.
|
||||
# 8. PATCH without X-Update-Range → 400.
|
||||
# 9. If-None-Match precondition failure (tag matches current ETag) → 412.
|
||||
# 10. If-Match with a WEAK (`W/`) form of the current ETag → 412 (RFC 7232
|
||||
# §3.1: If-Match requires a STRONG match; a weak validator in the
|
||||
# request never satisfies it, even if the underlying tag value is
|
||||
# identical — see `if_match_precondition_fails`).
|
||||
#
|
||||
# Hurl gotcha: a triple-backtick ``` multiline body appends a trailing
|
||||
# `\n` the server counts as part of Content-Length — that silently
|
||||
# breaks the exact `end - start + 1` span check on a byte-range PATCH.
|
||||
# Plain-text bodies below use the single-backtick ONELINE string form
|
||||
# (`` `text` ``) instead, which sends exactly the bytes between the
|
||||
# backticks with no injected newline.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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 — PUT a 10-byte probe file: "0123456789"
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
probe_etag: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Mid-file overwrite: replace bytes 3-5 (inclusive,
|
||||
# 0-based) with "XYZ".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=3-5
|
||||
Content-Type: text/plain
|
||||
`XYZ`
|
||||
|
||||
HTTP 204
|
||||
[Asserts]
|
||||
header "Content-Range" matches "^bytes 3-\\d+/\\d+$"
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body startsWith "012XYZ"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Append to the end of the file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: append
|
||||
Content-Type: text/plain
|
||||
`-APPENDED`
|
||||
|
||||
HTTP 204
|
||||
[Captures]
|
||||
current_etag: header "ETag"
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body endsWith "-APPENDED"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Out-of-range span: `end` must be strictly within the
|
||||
# current file size (growing via a byte-range PATCH
|
||||
# isn't supported — use `append` for that).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=1000-1005
|
||||
Content-Type: text/plain
|
||||
`oops`
|
||||
|
||||
HTTP 416
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — If-Match precondition failure.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-2
|
||||
If-Match: "not-the-real-etag"
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 412
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Locked resource without a matching lock token.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
LOCK {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:lockinfo xmlns:D="DAV:">
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner>patch-test</D:owner>
|
||||
</D:lockinfo>
|
||||
```
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
lock_token: xpath "string(//*[local-name()='locktoken']/*[local-name()='href'])"
|
||||
|
||||
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 423
|
||||
|
||||
|
||||
# Release the lock so cleanup below can proceed.
|
||||
UNLOCK {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Lock-Token: <{{lock_token}}>
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — PATCH on a directory → 409 Conflict.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/patch-probe-dir/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PATCH {{base_url}}/webdav/patch-probe-dir/
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 409
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — PATCH on a missing resource → 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe-does-not-exist.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-2
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — PATCH without X-Update-Range → 400.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — If-None-Match precondition failure: the header names the
|
||||
# CURRENT ETag, so the "only if it does NOT match" condition
|
||||
# is violated → 412.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-2
|
||||
If-None-Match: {{current_etag}}
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 412
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — If-Match with a WEAK form (`W/`) of the current ETag → 412.
|
||||
# RFC 7232 §3.1 requires If-Match to STRONG-match; a request
|
||||
# carrying a weak validator never satisfies it even when the
|
||||
# underlying tag value is identical.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-2
|
||||
If-Match: W/{{current_etag}}
|
||||
Content-Type: text/plain
|
||||
`NOP`
|
||||
|
||||
HTTP 412
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/patch-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/webdav/patch-probe-dir/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -0,0 +1,322 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV PATCH data-consistency chain (RFC 5789)
|
||||
# =============================================================
|
||||
# `webdav_patch.hurl` covers the PATCH contract itself (ranges, append,
|
||||
# preconditions, locks). This file chains multiple PATCHes against the
|
||||
# SAME resource and asserts the server stays consistent afterward —
|
||||
# the concern behind the review-fix commit that added quota
|
||||
# enforcement, an ETag re-check, and a `direct_put_max_bytes`
|
||||
# prefix/suffix accounting bug (see webdav_handler.rs::handle_patch).
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Sequential overlapping-range PATCHes on one file: each step's
|
||||
# GET reflects the splice, and the ETag changes every time (no
|
||||
# stale-tag reuse across writes).
|
||||
# 2. Cross-protocol consistency: HEAD and PROPFIND report the same
|
||||
# size/ETag as the GET right after the last PATCH.
|
||||
# 3. Quota rejection (507) leaves the file BYTE-FOR-BYTE unchanged —
|
||||
# the ingested blob is discarded before it's ever attached
|
||||
# (`upload_ingest::discard_ingested`).
|
||||
# 4. `direct_put_max_bytes` bounds only the EDIT span, not the whole
|
||||
# file: a small edit on a file already bigger than the cap still
|
||||
# succeeds, but an edit whose OWN body exceeds the cap still 413s.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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"
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part A — Sequential overlapping PATCHes + cross-protocol check
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a 20-byte probe: "0123456789ABCDEFGHIJ"
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
`0123456789ABCDEFGHIJ`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
etag0: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Overwrite bytes 5-9 ("56789") with "XXXXX".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=5-9
|
||||
Content-Type: text/plain
|
||||
`XXXXX`
|
||||
|
||||
HTTP 204
|
||||
[Captures]
|
||||
etag1: header "ETag"
|
||||
[Asserts]
|
||||
header "ETag" != {{etag0}}
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "01234XXXXXABCDEFGHIJ"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Overwrite bytes 10-14 ("ABCDE") with "YYYYY".
|
||||
# Overlaps neither previous edit but chains off it —
|
||||
# proves each PATCH sees the result of the last one, not
|
||||
# a stale copy.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=10-14
|
||||
Content-Type: text/plain
|
||||
`YYYYY`
|
||||
|
||||
HTTP 204
|
||||
[Captures]
|
||||
etag2: header "ETag"
|
||||
[Asserts]
|
||||
header "ETag" != {{etag1}}
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "01234XXXXXYYYYYFGHIJ"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — HEAD reports the same size/ETag as the last GET.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
HEAD {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Length" == "20"
|
||||
# GET/HEAD/PROPFIND quote the ETag (`"<tag>"`) while PUT/PATCH return
|
||||
# it raw/unquoted (compare webdav_handler.rs's `handle_head` vs
|
||||
# `handle_patch` response builders) — `contains` tolerates that
|
||||
# formatting difference instead of asserting byte-for-byte equality.
|
||||
header "ETag" contains {{etag2}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PROPFIND (named getcontentlength/getetag) agrees with
|
||||
# HEAD/GET — no drift between the WebDAV property layer
|
||||
# and the plain-file read path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/patch-consist-chain.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:prop>
|
||||
<D:getcontentlength/>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "number(//*[local-name()='getcontentlength'])" == 20
|
||||
xpath "string(//*[local-name()='getetag'])" contains {{etag2}}
|
||||
|
||||
|
||||
# Cleanup Part A.
|
||||
DELETE {{base_url}}/webdav/patch-consist-chain.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part B — Quota rejection leaves the file untouched
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Dedicated low-quota user so this doesn't cap the shared admin
|
||||
# account used by the rest of the suite.
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Provision `patch_quota_owner` with a 50-byte quota.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "patch_quota_owner",
|
||||
"password": "PatchQuotaOwnerPwd1!",
|
||||
"email": "patch_quota_owner@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_owner_id: jsonpath "$.id"
|
||||
|
||||
|
||||
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{ "quota_bytes": 50 }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "patch_quota_owner", "password": "PatchQuotaOwnerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
quota_owner_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Seed a 10-byte file (well under the 50-byte quota).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
Content-Type: text/plain
|
||||
`0123456789`
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
quota_probe_etag: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Append enough bytes to push the file's new total size
|
||||
# (110 bytes) well past the 50-byte quota → 507. The
|
||||
# ingested blob is discarded before commit — the file
|
||||
# must come back completely unchanged.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
X-Update-Range: append
|
||||
Content-Type: text/plain
|
||||
`this-is-a-100-byte-ish-payload-that-blows-past-the-fifty-byte-quota-set-for-this-throwaway-user-abc`
|
||||
|
||||
HTTP 507
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body == "0123456789"
|
||||
header "ETag" contains {{quota_probe_etag}}
|
||||
|
||||
|
||||
# Cleanup Part B.
|
||||
DELETE {{base_url}}/webdav/patch-quota-probe.txt
|
||||
Authorization: Bearer {{quota_owner_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{quota_owner_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Part C — direct_put_max_bytes bounds the EDIT, not the whole file
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# `OXICLOUD_DIRECT_PUT_MAX_BYTES` (4 MiB) can't be exceeded by a
|
||||
# direct PUT, so a file bigger than the cap must be seeded through
|
||||
# the chunk-agnostic multipart upload endpoint instead. Reuses the
|
||||
# 5 MiB all-zero fixture `run.sh` already generates for the chunk/
|
||||
# direct-PUT cap tests.
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Resolve the home folder id, seed a 5 MiB file in it.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
home_folder_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{home_folder_id}}
|
||||
file: file,fixtures/chunk-over-cap-5mb.bin; application/octet-stream
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
big_file_name: jsonpath "$.name"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — A SMALL mid-file edit succeeds even though the file's
|
||||
# total size (5 MiB) is already over the 4 MiB cap.
|
||||
# Pre-fix, the cap comparison counted prefix+suffix+edit
|
||||
# against the raw cap and would have wrongly 413'd any
|
||||
# edit on a file this size; post-fix only the edit span
|
||||
# itself is bounded.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=100-104
|
||||
Content-Type: application/octet-stream
|
||||
`PATCH`
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "PATCH"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — An edit whose OWN body meets/exceeds the cap still
|
||||
# 413s — the cap still bites real over-cap edits, this
|
||||
# isn't a blanket bypass. Replaces the ENTIRE file (no
|
||||
# prefix/suffix at all) with a 5 MiB body.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
X-Update-Range: bytes=0-5242879
|
||||
Content-Type: application/octet-stream
|
||||
file,fixtures/chunk-over-cap-5mb.bin;
|
||||
|
||||
HTTP 413
|
||||
|
||||
|
||||
# Cleanup Part C.
|
||||
DELETE {{base_url}}/webdav/{{big_file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
Reference in New Issue
Block a user