Files
Oxicloud/tests/api/nc_webdav_patch_consistency.hurl
T
2026-08-21 23:56:25 +02:00

546 lines
21 KiB
Plaintext

# =============================================================
# 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.full.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 "$.user.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 "$.user.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 "$.user.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 "$.user.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