Merge origin/main into webdav-litmus-compliance

This commit is contained in:
M.Schmidt
2026-07-12 22:25:12 +02:00
189 changed files with 11105 additions and 5655 deletions
+282
View File
@@ -0,0 +1,282 @@
# =============================================================
# OxiCloud – CalDAV + Round-3 AuthZ end-to-end scenario
# =============================================================
# Verifies the full CalDAV surface post-Round-3:
#
# * MKCALENDAR / PROPFIND / DELETE against `/caldav/*` all
# route through `CalendarService`, which enforces
# `authz.require` on every method.
# * Cross-user access uses the 404 anti-enum shape (was 403
# in the bespoke `check_calendar_access` era).
# * Sharing goes through the generic `POST /api/grants` with
# `resource.type = "calendar"` — a first-class ReBAC
# resource variant added in Round 3 Phase 1.
# * A shared calendar shows up in the recipient's PROPFIND
# listing while the grant is live and disappears again
# after revoke.
#
# The `calendar_id` is server-assigned at MKCALENDAR time and
# surfaces in the PROPFIND response as `/caldav/<uuid>/`. We
# extract it with a regex on the response body — the fresh CI
# database (`tests/webdav/run.sh` spawns a private Postgres)
# guarantees admin has zero pre-existing calendars, so the
# first-match regex is unambiguous.
#
# CalDAV auth is JWT via the same middleware the REST API uses
# (`/caldav/*` and `/carddav/*` are both wrapped in
# `auth_middleware + require_internal_user_layer` in main.rs).
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Alice (admin) logs in.
# ─────────────────────────────────────────────────────────────
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"
# ─────────────────────────────────────────────────────────────
# Step 2 – MKCALENDAR: create a fresh calendar for the test.
# Empty body → the CalDAV handler derives the display name
# from the last path segment ("round3-cal" here). The response
# is 201 with an empty body — CalDAV convention. The
# server-assigned UUID is captured in Step 3 via PROPFIND.
# ─────────────────────────────────────────────────────────────
MKCALENDAR {{base_url}}/caldav/round3-cal/
Authorization: Bearer {{alice_token}}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 3 – Alice PROPFIND at Depth 1 lists her calendars.
# The response is a `<D:multistatus>` — each calendar surfaces
# as `<D:href>/caldav/<uuid>/</D:href>`. Regex-capture the
# UUID (first `/caldav/<uuid>/` in the body — the root href
# is `/caldav/` alone, no UUID, so it can't match).
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/caldav/
Authorization: Bearer {{alice_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname/>
<D:resourcetype/>
</D:prop>
</D:propfind>
```
HTTP 207
[Captures]
calendar_id: body regex "/caldav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/"
# ─────────────────────────────────────────────────────────────
# Step 4 – Provision Bob. Idempotent: `HTTP *` accepts 201
# on the first run and 409 on subsequent ones. Login is the
# actual precondition.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"username": "caldav_bob",
"password": "CaldavBobPassword1!",
"email": "caldav_bob@example.com",
"role": "user"
}
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "caldav_bob",
"password": "CaldavBobPassword1!"
}
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 5 – Cross-user PROPFIND. Bob has no grant on Alice's
# calendar; his listing does NOT include the calendar's UUID.
# (Bob's OWN response body will list his lifecycle-provisioned
# calendars — none of them collide with Alice's UUID.)
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/caldav/
Authorization: Bearer {{bob_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
body not contains "{{calendar_id}}"
# ─────────────────────────────────────────────────────────────
# Step 6 – Cross-user direct PROPFIND on Alice's calendar
# → 404. `authz.require(Read)` denies with `NotFound` for
# anti-enumeration parity with files/folders/drives.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/caldav/{{calendar_id}}/
Authorization: Bearer {{bob_token}}
Depth: 0
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/></D:prop>
</D:propfind>
```
HTTP *
[Asserts]
status >= 400
status < 500
# ─────────────────────────────────────────────────────────────
# Step 7 – Alice shares the calendar with Bob as Viewer via
# the generic ReBAC grant endpoint. `resource.type = "calendar"`
# is a first-class variant post-Round-3.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "calendar", "id": "{{calendar_id}}" },
"role": "viewer"
}
HTTP 201
[Captures]
share_grant_id: jsonpath "$.grants[0].id"
[Asserts]
jsonpath "$.grants[0].role" == "viewer"
jsonpath "$.grants[0].resource.type" == "calendar"
jsonpath "$.grants[0].resource.id" == "{{calendar_id}}"
# ─────────────────────────────────────────────────────────────
# Step 8 – Bob PROPFIND now includes Alice's calendar. The
# `list_my_calendars` service method reads
# `authz.list_incoming_grants(user)` and unions across
# owned + shared, replacing the pre-Round-3 owner-only query.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/caldav/
Authorization: Bearer {{bob_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
body contains "{{calendar_id}}"
# ─────────────────────────────────────────────────────────────
# Step 8b – Unified list-on-resource: Alice queries
# `GET /api/grants?resource_type=calendar&resource_id=…`. The
# handler requires `Share` on the resource (Alice's Owner grant
# satisfies it) and returns the raw `role_grants` rows including
# the Owner self-grant. Confirms `ResourceTypeDto::Calendar` is
# admitted at the query-string boundary.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants?resource_type=calendar&resource_id={{calendar_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$[*].subject.id" contains "{{bob_user_id}}"
jsonpath "$[*].subject.id" contains "{{alice_user_id}}"
jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].role" == "viewer"
jsonpath "$[?(@.subject.id == '{{alice_user_id}}')].role" == "owner"
jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "calendar"
# ─────────────────────────────────────────────────────────────
# Step 8c – Viewer Bob is denied on the unified list endpoint —
# `Share` is required, Viewer's bundle excludes it → 404
# anti-enum shape (same treatment as any other resource type).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants?resource_type=calendar&resource_id={{calendar_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 9 – Alice revokes the grant. `DELETE /api/grants/{id}`
# maps to a single `role_grants` row delete.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/grants/{{share_grant_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 10 – Bob PROPFIND no longer includes Alice's calendar.
# The role_grants row is gone, so `list_incoming_grants` won't
# surface it and `list_my_calendars` collapses back to Bob's
# own.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/caldav/
Authorization: Bearer {{bob_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
body not contains "{{calendar_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11 – Cleanup: Alice deletes the calendar. The service
# runs `authz.require(Delete)` (owner passes via the seeded
# Owner grant), then `revoke_all_for_resource` wipes any
# remaining grants on the calendar in case a share slipped
# through.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/caldav/{{calendar_id}}/
Authorization: Bearer {{alice_token}}
HTTP *
[Asserts]
status >= 200
status < 300
+342
View File
@@ -24,6 +24,7 @@ Content-Type: application/json
HTTP 200
[Captures]
token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer"
@@ -276,3 +277,344 @@ Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
# ═════════════════════════════════════════════════════════════
# Round 3 — CardDAV/AddressBook AuthZ regression
# ═════════════════════════════════════════════════════════════
# Post-Round-3, address-book access + sharing routes through
# `AuthorizationEngine` and `storage.role_grants`. The dedicated
# `carddav.address_book_shares` table stopped being consulted;
# the generic `POST /api/grants` endpoint accepts
# `resource.type = "address_book"` as a first-class ReBAC
# resource.
#
# Coverage:
# 15. Fresh book owned by admin (Alice).
# 16. Non-member user (Bob) doesn't see the book.
# 17. Bob's direct GET on the book → 404 (anti-enum, was 403
# pre-Round-3).
# 18. Alice shares with Bob as Viewer via `POST /api/grants`.
# 19. Bob's listing includes the book with is_readonly=true.
# 20. Viewer role's bundle has no Create — Bob's contact
# write → 404 (anti-enum).
# 21. Alice revokes via `DELETE /api/grants/{id}`.
# 22. Bob no longer sees the book.
# 23. Cleanup.
# =============================================================
# Step 15 — Alice creates a fresh book for the share regression.
POST {{base_url}}/api/address-books
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "Round3 Share Book",
"description": "Book for the multi-user share regression",
"is_public": false
}
HTTP 201
[Captures]
share_book_id: jsonpath "$.id"
# Step 16 — Provision Bob. Idempotent: accept 201 on first run,
# 409 on subsequent runs; login is the actual precondition.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{token}}
Content-Type: application/json
{
"username": "carddav_bob",
"password": "CarddavBobPassword1!",
"email": "carddav_bob@example.com",
"role": "user"
}
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "carddav_bob",
"password": "CarddavBobPassword1!"
}
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id"
# Step 17 — Bob's book listing does NOT include Alice's book.
GET {{base_url}}/api/address-books
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains {{share_book_id}}
# Step 18a — Direct GET on Alice's book: 404 (anti-enum).
GET {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
HTTP 404
# Step 18b — Contact-write into Alice's book: 404. Bob has no
# grant, so authz.require(Create) rejects with NotFound.
# Body is minimal on purpose — the endpoint's wire DTO
# (`CreateContactRequest`) marks every collection field
# `#[serde(default)]`, so `full_name` alone deserialises
# fine and lets the request reach the authz gate. Any
# body-side 422 here would mask the AuthZ regression the
# step is meant to verify.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"full_name": "Sneaky Insert"
}
HTTP 404
# Step 19 — Alice shares the book with Bob as Viewer via the
# generic ReBAC grant endpoint. `resource.type = "address_book"`
# is a first-class variant post-Round-3.
POST {{base_url}}/api/grants
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "viewer"
}
HTTP 201
[Captures]
share_grant_id: jsonpath "$.grants[0].id"
[Asserts]
jsonpath "$.grants[0].role" == "viewer"
jsonpath "$.grants[0].resource.type" == "address_book"
jsonpath "$.grants[0].resource.id" == "{{share_book_id}}"
# Step 20 — Bob's listing now includes the book, marked readonly
# because he's not the owner.
GET {{base_url}}/api/address-books
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[?(@.id == '{{share_book_id}}')].is_readonly" == true
# Step 21 — Viewer bundle has no Create permission — Bob's
# contact write still 404s. Same minimal-body reasoning as
# Step 18b: keep the request valid at the wire layer so any
# rejection has to come from the AuthZ engine.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"full_name": "Viewer Cannot Write"
}
HTTP 404
# Step 21b — Unified list-on-resource: Alice queries
# `GET /api/grants?resource_type=address_book&resource_id=…`.
# `Share` is required (Alice's Owner grant satisfies it) and the
# response includes the Owner self-grant that the per-domain
# UI hides. Confirms `ResourceTypeDto::AddressBook` is admitted
# at the query-string boundary.
GET {{base_url}}/api/grants?resource_type=address_book&resource_id={{share_book_id}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$[*].subject.id" contains "{{bob_user_id}}"
jsonpath "$[*].subject.id" contains "{{admin_user_id}}"
jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].role" == "viewer"
jsonpath "$[?(@.subject.id == '{{admin_user_id}}')].role" == "owner"
jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "address_book"
# Step 21c — Viewer Bob is denied on the unified list endpoint —
# `Share` isn't in the Viewer bundle → 404 anti-enum shape.
GET {{base_url}}/api/grants?resource_type=address_book&resource_id={{share_book_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# Step 22 — Alice revokes the grant.
DELETE {{base_url}}/api/grants/{{share_grant_id}}
Authorization: Bearer {{token}}
HTTP 204
# Step 23 — Bob's listing no longer includes the book.
GET {{base_url}}/api/address-books
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains {{share_book_id}}
# Step 24 — Cleanup: Alice deletes the book.
DELETE {{base_url}}/api/address-books/{{share_book_id}}
Authorization: Bearer {{token}}
HTTP 204
# ═════════════════════════════════════════════════════════════
# Round 3 — CardDAV protocol coverage
# ═════════════════════════════════════════════════════════════
# Verifies the CardDAV surface end-to-end:
#
# * MKCOL creates an address book via the CardDAV protocol
# (`ContactService::create_address_book` seeds an Owner
# role_grant on the caller so the engine's cache warms).
# * PROPFIND lists it in the caller's address-book home.
# * A non-member's PROPFIND doesn't include the book.
# * `POST /api/grants` with `resource.type = "address_book"`
# grants Read to the non-member.
# * The recipient's PROPFIND now includes the book.
# * Revoke → book vanishes.
# * DELETE cleans up.
#
# Book UUID is server-assigned at MKCOL time and appears in the
# PROPFIND multistatus as `<D:href>/carddav/<uuid>/</D:href>`.
# Regex-capture is unambiguous only if admin has zero
# pre-existing CardDAV books — true on the CI DB (fresh from
# `tests/webdav/run.sh`'s private Postgres), false in a
# populated dev DB.
# =============================================================
# Step 25 — Alice creates a fresh book via CardDAV MKCOL.
# Empty body — `handle_mkcol` derives the display name from the
# path's last segment.
MKCOL {{base_url}}/carddav/round3-carddav-book/
Authorization: Bearer {{token}}
HTTP 201
# Step 26 — Alice PROPFIND at Depth 1 lists her books. Capture
# the server-assigned UUID with a regex on the `<D:href>` value.
PROPFIND {{base_url}}/carddav/
Authorization: Bearer {{token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname/>
<D:resourcetype/>
</D:prop>
</D:propfind>
```
HTTP 207
[Captures]
carddav_book_id: body regex "/carddav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/"
# Step 27 — Bob PROPFIND: the book UUID is NOT in his response.
# (Bob's lifecycle-provisioned books, if any, get their own
# UUIDs — no collision.)
PROPFIND {{base_url}}/carddav/
Authorization: Bearer {{bob_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
body not contains "{{carddav_book_id}}"
# Step 28 — Alice shares the book with Bob as Viewer via the
# generic ReBAC grant endpoint (same wire format as the
# calendar test, only the resource type differs).
POST {{base_url}}/api/grants
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{carddav_book_id}}" },
"role": "viewer"
}
HTTP 201
[Captures]
carddav_grant_id: jsonpath "$.grants[0].id"
# Step 29 — Bob PROPFIND now includes the shared book. The
# CardDAV handler routes through the same
# `list_user_address_books` as the REST API, so the shared
# book flows in via the role_grants union.
PROPFIND {{base_url}}/carddav/
Authorization: Bearer {{bob_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
body contains "{{carddav_book_id}}"
# Step 30 — Alice revokes the grant.
DELETE {{base_url}}/api/grants/{{carddav_grant_id}}
Authorization: Bearer {{token}}
HTTP 204
# Step 31 — Bob PROPFIND no longer includes the book.
PROPFIND {{base_url}}/carddav/
Authorization: Bearer {{bob_token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
HTTP 207
[Asserts]
body not contains "{{carddav_book_id}}"
# Step 32 — Cleanup: Alice deletes the book via CardDAV DELETE.
DELETE {{base_url}}/carddav/{{carddav_book_id}}/
Authorization: Bearer {{token}}
HTTP *
[Asserts]
status >= 200
status < 300
+168
View File
@@ -471,6 +471,67 @@ Content-Type: application/json
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 10c — partial-merge regression guard.
#
# The `PATCH /api/drives/{id}/policies` handler documents that
# omitting a field means "leave it alone", not "set it to false".
# Prior implementation round-tripped the wire body through the
# typed `DrivePolicies` struct (which has `#[serde(default)]`, so
# every omitted field defaults to `false`) and then serialised the
# whole struct into the JSONB `||` merge — silently clobbering
# every unmentioned flag back to `false`. This step exercises
# multi-flag interaction so that regression can't creep back:
#
# 1. Set `forbid_sharing = true`, assert the bag.
# 2. In a SEPARATE PATCH, set only `forbid_public_links = true`.
# 3. Assert `forbid_sharing` STILL reads `true` in the response
# — proving the merge honoured "leave omitted keys alone".
#
# Reset both back to false at the end so the shared-drive steps
# below start from a clean state.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"forbid_sharing": true
}
HTTP 200
[Asserts]
jsonpath "$.forbid_sharing" == true
jsonpath "$.forbid_public_links" == false
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"forbid_public_links": true
}
HTTP 200
[Asserts]
# The load-bearing assertion — `forbid_sharing` must NOT have been
# clobbered by the omitted-key regression.
jsonpath "$.forbid_sharing" == true
jsonpath "$.forbid_public_links" == true
# Reset both.
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"forbid_sharing": false,
"forbid_public_links": false
}
HTTP 200
[Asserts]
jsonpath "$.forbid_sharing" == false
jsonpath "$.forbid_public_links" == false
# ─────────────────────────────────────────────────────────────
# Step 11 — `forbid_external_sharing` on a SHARED drive, via
# `POST /api/drives/{id}/members`.
@@ -818,6 +879,113 @@ Content-Type: application/json
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 11d — `include_in_photo_index` scope opt-in (§15).
#
# Default personal drives are seeded with the flag = true by the
# `PersonalDriveLifecycleHook` + backfill migration
# (20260901000000_default_personal_photo_music_flags.sql). Non-
# default drives (shared, secondary personals) start opted-out
# and only surface in `/api/photos` after an admin flips the
# flag on via PATCH.
#
# Coverage:
# a. Upload a PNG into dp_owner's default Personal drive →
# surfaces in `/api/photos` (default-personal auto-opted in).
# b. Upload a PNG into the shared drive → does NOT surface
# (flag omitted).
# c. Admin flips `include_in_photo_index=true` on the shared
# drive → the shared-drive PNG surfaces in `/api/photos`.
#
# `/api/photos` returns a flat array of PhotoDto — each carries
# the file's `id`. Assertions use `jsonpath "$[*].id" contains
# "…"` to sidestep the single-match filter quirks
# (feedback_hurl_jsonpath_filter_empty).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{owner_token}}
[MultipartFormData]
folder_id: {{personal_root_id}}
file: file,fixtures/blue-image.png; image/png
HTTP 201
[Captures]
personal_photo_id: jsonpath "$.id"
# Baseline — personal-drive photo is visible in the timeline.
GET {{base_url}}/api/photos
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains "{{personal_photo_id}}"
# Upload a PNG into the SHARED drive's root. dp_owner is Owner
# on the shared drive from earlier steps, so Create passes.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{owner_token}}
[MultipartFormData]
folder_id: {{shared_root_id}}
file: file,fixtures/red-image.png; image/png
HTTP 201
[Captures]
shared_photo_id: jsonpath "$.id"
# Shared drive is NOT opted-in yet — the shared photo must be
# absent from `/api/photos`. The personal photo stays visible.
GET {{base_url}}/api/photos
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains "{{shared_photo_id}}"
jsonpath "$[*].id" contains "{{personal_photo_id}}"
# Flip `include_in_photo_index=true` on the shared drive.
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"include_in_photo_index": true
}
HTTP 200
[Asserts]
jsonpath "$.include_in_photo_index" == true
# Shared-drive photo now surfaces in `/api/photos`. Personal
# photo remains visible — no regression on the always-in-scope
# default drive.
GET {{base_url}}/api/photos
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains "{{shared_photo_id}}"
jsonpath "$[*].id" contains "{{personal_photo_id}}"
# Cleanup — both photos so the shared-drive delete below finds
# an empty drive. The personal-drive photo cascade-deletes with
# dp_owner in Step 12; we still remove it here so the delete
# path is exercised explicitly (deletes don't affect the flag).
DELETE {{base_url}}/api/files/{{shared_photo_id}}
Authorization: Bearer {{owner_token}}
HTTP 204
DELETE {{base_url}}/api/files/{{personal_photo_id}}
Authorization: Bearer {{owner_token}}
HTTP 204
# Cleanup the shared drive: empty (no content was added) → delete
# via DELETE /api/drives/{id}. dp_owner is Owner so the call
# carries Manage; the per-drive empty-before-delete guard passes
+91
View File
@@ -302,6 +302,97 @@ jsonpath "$.blobs_deleted" exists
jsonpath "$.bytes_freed" exists
# ─────────────────────────────────────────────────────────────
# Step 11 — Pre-flight quota gate on MOVE and COPY.
#
# Silent gap before 2026-07-06:
# `move_file_with_perms` / `move_folder_with_perms`
# / `copy_file_with_perms` / `copy_folder_tree_with_perms`
# never called `check_drive_quota` on the destination.
# A user could bypass a tight drive's cap by uploading
# to their unlimited personal drive first and MOVE-ing
# (or COPY-ing) into the tight drive afterwards.
#
# Fix landed in the service layer, so both REST + WebDAV +
# NC WebDAV surfaces got the check for free. This step
# locks in the 507 shape on the REST path:
#
# a) MOVE a 5 MiB file from unlimited → tight → 507.
# b) COPY a 5 MiB file from unlimited → tight → 507.
# c) Sanity — same MOVE targeted at unlimited still 200.
# ─────────────────────────────────────────────────────────────
# Capture the 5 MiB file id currently living in the unlimited drive
# (uploaded at Step 7). We'll try to relocate it into the 100-byte
# tight drive.
GET {{base_url}}/api/files?folder_id={{unlimited_root_id}}
Authorization: Bearer {{owner_token}}
HTTP 200
[Captures]
big_file_id: jsonpath "$[0].id"
# 11a — MOVE 5 MiB file into the tight (100-byte quota) drive.
# Refused at the service pre-check: 5_242_880 + 32 > 100.
PUT {{base_url}}/api/files/{{big_file_id}}/move
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"folder_id": "{{tight_root_id}}"
}
HTTP 507
# 11b — COPY same file into tight drive. Same refusal shape as MOVE
# — COPY creates a NEW file row that counts against
# `drives.used_bytes` even when blob dedup means no new bytes
# hit the store. Batch endpoint lives under `/api/batch/…`,
# not `/api/files/…`.
POST {{base_url}}/api/batch/files/copy
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"file_ids": ["{{big_file_id}}"],
"target_folder_id": "{{tight_root_id}}"
}
# Batch envelope: 200 all-ok, 206 partial, 400 all-failed. Our
# single-item batch has one quota-refused item → 400 with the
# failure in the `.failed[]` array (per `BatchOperationResponse`).
HTTP 400
[Asserts]
jsonpath "$.stats.failed" == 1
jsonpath "$.stats.successful" == 0
jsonpath "$.failed[0].id" == "{{big_file_id}}"
jsonpath "$.failed[0].error" exists
# 11c — Sanity: the file MOVE isn't universally broken. Targeting
# the unlimited drive's own root succeeds (it's already
# there, but MOVE is idempotent for same-parent — service
# returns 200 without re-doing storage work).
PUT {{base_url}}/api/files/{{big_file_id}}/move
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"folder_id": "{{unlimited_root_id}}"
}
HTTP 200
# `used_bytes` on the tight drive is unchanged — the two refused
# operations above never wrote anything.
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32
# No cleanup tail here — `tests/api/storage_cleanup_check.sh` enumerates
# every drive via `GET /api/admin/drives` and drains+deletes any that
# isn't admin's default. This keeps individual Hurl tests focused on
+74 -3
View File
@@ -511,6 +511,26 @@ HTTP 200
jsonpath "$[*].id" contains {{team_drive_id}}
# ─────────────────────────────────────────────────────────────
# Step 21b — Upload gate by role (post-Drive AuthZ audit Round 2).
# Bob is Editor on team_drive; `POST /api/files/upload`
# targeting team_root_folder_id should succeed. This is
# the REST-side counterpart of the WebDAV/NC PUT chain
# hardened by `update_file_streaming_with_perms`. If
# this fails, the whole role-bundle → Permission::Create
# wiring is broken.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{bob_token}}
[MultipartFormData]
folder_id: {{team_root_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
bob_editor_upload_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 22 — Higher role wins: Bob now ALSO gets a Viewer direct
# grant (would lower his bundle). The collapsed caller_role
@@ -537,6 +557,50 @@ HTTP 200
jsonpath "$[*].id" contains {{team_drive_id}}
# ─────────────────────────────────────────────────────────────
# Step 22b — Viewer CANNOT upload into a shared drive.
# Post-Drive AuthZ audit Round 2: the create branch of
# `update_file_streaming_with_perms` requires
# `Permission::Create` on the parent folder — bundled
# with `owner`/`editor`/`contributor` role_grants only,
# NOT with `viewer`. `POST /api/files/upload` shares the
# same `save_file_with_blob` gate, so a Viewer probe
# must land 404 (anti-enum: same shape as no-such-folder)
# + `authz.denied` audit line. Also verify the batch /
# overwrite paths refuse — the whole chain from
# drive-membership to file write is exercised here.
# ─────────────────────────────────────────────────────────────
# 22b.i — Fresh file: 404.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{bob_token}}
[MultipartFormData]
folder_id: {{team_root_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
# 22b.ii — Overwrite attempt on the Editor-era upload: still 404.
# `save_file_with_blob` catches the duplicate name at the
# `Create`-permission check before the upsert races (which
# would otherwise 409). The audit shape stays 404.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{bob_token}}
[MultipartFormData]
folder_id: {{team_root_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
# 22b.iii — Alice's Editor-era file is untouched.
GET {{base_url}}/api/files/{{bob_editor_upload_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
# =============================================================
# Per-role mutation matrix — what every role can / can't do
# =============================================================
@@ -845,15 +909,22 @@ HTTP 409
# 30c — Clear the lingering content (the Editor-created folder from
# Step 27). Delete via the regular folder endpoint so the row
# lands in trash, not the live tree; `is_empty` excludes
# trashed rows so a populated trash bin is allowed.
# Step 27 and the Editor-era file from Step 21b). Delete via
# the regular endpoints so rows land in trash, not the live
# tree; `is_empty` excludes trashed rows so a populated trash
# bin is allowed.
DELETE {{base_url}}/api/folders/{{editor_created_folder_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
DELETE {{base_url}}/api/files/{{bob_editor_upload_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# 30d — Owner on an empty drive → 204.
DELETE {{base_url}}/api/drives/{{team_drive_id}}
Authorization: Bearer {{alice_token}}
+74
View File
@@ -159,3 +159,77 @@ Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
# Step 12 — Cross-tenant regression (post-Drive AuthZ audit,
# Round 1 HIGH). Before this fix, `POST /api/favorites/…`
# accepted any UUID and enrolled it; the listing endpoint
# then JOINed back to storage.files/folders and returned
# name/mime/size/drive_id for anything the caller had
# managed to add — an information oracle over the whole
# tenant. Now the write path calls `authz.require(Read, …)`
# per item; a caller with no grant gets 404 (anti-enum)
# + `authz.denied` audit line. See
# `docs/plan/authz_audit/rest_storage.md`.
# ─────────────────────────────────────────────────────────────
# Create a second, unprivileged user. Idempotent: `HTTP *` accepts
# either 201 (first run) or 409 (subsequent runs). The login below
# is the actual precondition — if it succeeds we know the user
# exists with the expected password.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{token}}
Content-Type: application/json
{ "username": "fav_mallory", "password": "FavMalloryPassword1!", "email": "fav_mallory@example.com", "role": "user" }
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "fav_mallory", "password": "FavMalloryPassword1!" }
HTTP 200
[Captures]
mallory_token: jsonpath "$.access_token"
# Step 12a — Single-add on admin's file: 404 (anti-enum shape).
POST {{base_url}}/api/favorites/file/{{file_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 12b — Single-add on admin's folder: 404.
POST {{base_url}}/api/favorites/folder/{{test1_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 12c — Batch: must fail wholesale on the first denial. A partial
# success would still leak "which items are valid" — the same
# oracle we're closing.
POST {{base_url}}/api/favorites/batch
Authorization: Bearer {{mallory_token}}
Content-Type: application/json
{
"items": [
{ "item_id": "{{file_id}}", "item_type": "file" },
{ "item_id": "{{test1_id}}", "item_type": "folder" }
]
}
HTTP 404
# Step 12d — Mallory's favorites list is EMPTY — no partial success
# slipped through.
GET {{base_url}}/api/favorites/resources
Authorization: Bearer {{mallory_token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0
+408
View File
@@ -0,0 +1,408 @@
# =============================================================
# OxiCloud – Music (playlist) + Round-3 AuthZ end-to-end scenario
# =============================================================
# Verifies the full playlist REST surface post-Round-3:
#
# * `POST /api/playlists` seeds an Owner grant on
# `Resource::Playlist(uuid)` so the caller can see it via the
# unified engine (list, get) on the very next request.
# * `GET /api/playlists` returns the union of owned + shared
# playlists via `authz.list_incoming_grants`; the pre-Round-3
# owner-only + separate shared query pair is gone.
# * Cross-user reads (`GET /api/playlists/{id}`) return the 404
# anti-enum shape (was 403 in the bespoke
# `user_has_access` era).
# * Sharing works through BOTH surfaces post-migration:
# - Generic `POST /api/grants` with `resource.type = "playlist"`
# (first-class ReBAC variant added in this PR)
# - Legacy `POST /api/playlists/{id}/share` (bool `can_write`)
# still routes through the same `role_grants` table via
# `authz.set_role`, so both flows converge on the unified
# engine.
# * `GET /api/playlists/{id}/shares` reads `list_grants_on_resource`
# and hides the Owner self-grant.
# * Revoke through either surface drops the playlist from the
# recipient's listing.
# * Viewer role blocks writes: `Update`/`Delete`/`Share` all 404 for
# a Viewer, matching the anti-enum shape.
#
# The `playlist_id` is captured from the POST response body. Fresh CI
# database via `tests/api/run.sh`, so admin has no prior playlists —
# the JSONPath capture from `GET /api/playlists` is unambiguous.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Alice (admin) logs in.
# ─────────────────────────────────────────────────────────────
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"
# ─────────────────────────────────────────────────────────────
# Step 2 – Alice creates a playlist. The response body carries the
# server-assigned UUID and `owner_id == alice_user_id`. The service
# also seeds an Owner role_grant on `Resource::Playlist(uuid)` —
# proven by Step 4 which lists playlists via
# `authz.list_incoming_grants` and expects this one to surface.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/playlists
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"name": "round3-playlist",
"description": "Music AuthZ migration coverage"
}
HTTP 201
[Captures]
playlist_id: jsonpath "$.id"
[Asserts]
jsonpath "$.name" == "round3-playlist"
jsonpath "$.owner_id" == "{{alice_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 3 – Alice GETs the playlist she just created. This is the
# fast-path validation of the Owner grant seeded at create time:
# without it, `authz.require(Read)` would return NotFound and this
# would 404.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 4 – Alice lists playlists — hers appears exactly once.
# The service reads `list_incoming_grants(Alice)` and filters to
# `Resource::Playlist`, so this exercises the same code path as
# CalDAV's `list_my_calendars`.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 5 – Provision Bob. Idempotent: `HTTP *` accepts 201 first
# run, 409 subsequent runs. Login is the real precondition.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"username": "music_bob",
"password": "MusicBobPassword1!",
"email": "music_bob@example.com",
"role": "user"
}
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "music_bob",
"password": "MusicBobPassword1!"
}
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
bob_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 6 – Cross-user GET on Alice's playlist → 404. Before Round 3
# this was the bespoke `user_has_access` denial which returned 403;
# post-migration `authz.require(Read)` denies with `NotFound` for
# anti-enumeration parity with files/folders/drives.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 7 – Bob's playlist listing does NOT include Alice's. The
# `list_incoming_grants(Bob)` call sees no grant on that playlist,
# so nothing surfaces.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 8 – Alice shares the playlist with Bob as Viewer via the
# generic ReBAC grant endpoint. `resource.type = "playlist"` is a
# first-class variant added by this PR; before Round 3, this
# request would 400 (Unsupported resource type).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "playlist", "id": "{{playlist_id}}" },
"role": "viewer"
}
HTTP 201
[Captures]
share_grant_id: jsonpath "$.grants[0].id"
[Asserts]
jsonpath "$.grants[0].role" == "viewer"
jsonpath "$.grants[0].resource.type" == "playlist"
jsonpath "$.grants[0].resource.id" == "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 9 – Bob GET now succeeds. `authz.require(Read)` sees the
# Viewer role_grant row and grants access.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 10 – Bob's listing now surfaces Alice's playlist — proving
# the owned + shared union in `list_playlists`.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists?include_shared=true
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11 – Bob cannot rename the playlist. Viewer's bundle is
# Read-only (no Update), so `require_playlist_perm(Update)` denies
# with the 404 anti-enum shape.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "name": "hijacked" }
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 12 – Bob cannot delete the playlist. Viewer's bundle
# excludes Delete → 404.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 13 – Bob cannot re-share the playlist. Viewer's bundle
# excludes Share → 404 on the legacy /share endpoint (which now
# routes through `authz.require(Share)`).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/playlists/{{playlist_id}}/share
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "user_id": "{{alice_user_id}}", "can_write": true }
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 14 – Alice lists shares via the legacy endpoint. The
# service reads `list_grants_on_resource` and drops the Owner
# self-grant, so exactly one row surfaces: Bob as Viewer
# (can_write=false).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}/shares
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$[*].user_id" contains "{{bob_user_id}}"
jsonpath "$[?(@.user_id == '{{bob_user_id}}')].can_write" == false
jsonpath "$[*].user_id" not contains "{{alice_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 14b – Same query, unified endpoint. `GET /api/grants?
# resource_type=playlist&resource_id=…` requires `Share` on the
# resource (same gate as the legacy /shares endpoint) and returns
# the raw `role_grants` rows — including the Owner self-grant that
# the legacy DTO hides. Confirms `ResourceTypeDto::Playlist` is
# admitted at the wire boundary and that both surfaces read the
# same underlying data.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants?resource_type=playlist&resource_id={{playlist_id}}
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$[*].subject.id" contains "{{bob_user_id}}"
jsonpath "$[*].subject.id" contains "{{alice_user_id}}"
jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].role" == "viewer"
jsonpath "$[?(@.subject.id == '{{alice_user_id}}')].role" == "owner"
jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "playlist"
# ─────────────────────────────────────────────────────────────
# Step 14c – Bob (Viewer only) is denied on the unified list
# endpoint: `Share` is required, Viewer's bundle excludes it →
# 404 anti-enum shape.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants?resource_type=playlist&resource_id={{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 15 – Alice revokes the ReBAC grant. `DELETE /api/grants/{id}`
# deletes the single `role_grants` row keyed by grant_id.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/grants/{{share_grant_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 16 – Bob's GET goes back to 404, and his listing drops the
# playlist. The `role_grants` row is gone → `list_incoming_grants`
# doesn't surface it, `require(Read)` denies.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
GET {{base_url}}/api/playlists?include_shared=true
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$..id" not contains "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 17 – Alice re-shares Bob as Editor via the LEGACY endpoint.
# `can_write=true` maps to `Role::Editor` inside
# `music_service::share_playlist` — proving the legacy surface
# and `/api/grants` now converge on the same `role_grants` table.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/playlists/{{playlist_id}}/share
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "user_id": "{{bob_user_id}}", "can_write": true }
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 18 – Editor CAN update (Editor's bundle includes Update).
# Confirms the can_write=true → Editor mapping actually takes
# effect at the engine level.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "description": "renamed by editor bob" }
HTTP 200
[Asserts]
jsonpath "$.description" == "renamed by editor bob"
# ─────────────────────────────────────────────────────────────
# Step 19 – Editor still cannot Share (Share stays Owner-only).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/playlists/{{playlist_id}}/share
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "user_id": "{{alice_user_id}}", "can_write": false }
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 20 – `/shares` now reports Bob as Editor (can_write=true).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}/shares
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$[*].user_id" contains "{{bob_user_id}}"
jsonpath "$[?(@.user_id == '{{bob_user_id}}')].can_write" == true
# ─────────────────────────────────────────────────────────────
# Step 21 – Alice removes the legacy-endpoint share.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/playlists/{{playlist_id}}/share/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 22 – Post-remove listing is empty (Owner self-grant is
# still hidden).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}/shares
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$..user_id" not contains "{{bob_user_id}}"
# ─────────────────────────────────────────────────────────────
# Step 23 – Cleanup: Alice deletes the playlist. The service
# runs `authz.require(Delete)` (owner passes via the seeded Owner
# grant), then `revoke_all_for_resource` wipes any stray grants.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 24 – GET returns 404 after delete (nothing to enum).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{alice_token}}
HTTP 404
+79
View File
@@ -274,6 +274,85 @@ status >= 400
status < 500
# ─────────────────────────────────────────────────────────────
# 14b — Viewer-laundering regression (post-Drive AuthZ audit,
# Round 1 HIGH). Before the fix, `POST /api/shares` checked
# only "does the item exist" — any authenticated user who
# could name the UUID could mint a public Viewer link,
# laundering read access into a permanent anonymous URL
# that survived their own grant revocation. Now the
# service calls `authz.require(Share, resource)` before
# minting the token; a caller without `Share`
# (Viewer/Commenter/Contributor/no-grant-at-all) gets 404
# (anti-enum) + `authz.denied` audit line. See
# `docs/plan/authz_audit/admin_membership.md`.
#
# We test the strongest form: an unrelated user with no
# grant at all. The intermediate case (Viewer with Read
# but not Share) is covered by the same code path — Share
# is bundled only with owner/editor role_grants.
# ─────────────────────────────────────────────────────────────
# Create/lookup the attacker. Idempotent: `HTTP *` accepts either
# 201 (first run) or 409 (subsequent runs). Login below is the real
# precondition.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "sh_mallory", "password": "ShMalloryPassword1!", "email": "sh_mallory@example.com", "role": "user" }
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "sh_mallory", "password": "ShMalloryPassword1!" }
HTTP 200
[Captures]
mallory_token: jsonpath "$.access_token"
# Step 14b.i — Mallory tries to mint a public share on admin's
# folder: 404 (anti-enum). No token appears in the
# response body.
POST {{base_url}}/api/shares
Authorization: Bearer {{mallory_token}}
Content-Type: application/json
{
"item_id": "{{share_folder_id}}",
"item_name": "public-share-test",
"item_type": "folder"
}
HTTP 404
# Step 14b.ii — Same attempt on admin's file: 404.
POST {{base_url}}/api/shares
Authorization: Bearer {{mallory_token}}
Content-Type: application/json
{
"item_id": "{{shared_file_id}}",
"item_name": "hello.txt",
"item_type": "file"
}
HTTP 404
# Step 14b.iii — Mallory has no shares — no partial success slipped
# through. (`GET /api/shares` returns only shares the
# caller created; response is paginated.)
GET {{base_url}}/api/shares
Authorization: Bearer {{mallory_token}}
HTTP 200
[Asserts]
jsonpath "$.items" isCollection
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
# 15 — Teardown: revoke the password share + the direct
# file-share, then delete the folder.
+64
View File
@@ -187,3 +187,67 @@ DELETE {{base_url}}/api/recent/clear
Authorization: Bearer {{token}}
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 10 — Cross-tenant regression (post-Drive AuthZ audit,
# Round 1 HIGH). Before this fix, `POST /api/recent/…`
# accepted any UUID and the listing endpoint JOINed back
# to storage.files/folders (name/mime/size/drive_id) — a
# metadata oracle over the whole tenant. Now the write
# path calls `authz.require(Read, …)`; unauthorised
# callers get 404 (anti-enum) + `authz.denied` audit line.
# See `docs/plan/authz_audit/rest_storage.md`.
# ─────────────────────────────────────────────────────────────
# Re-discover a folder id so the attacker has TWO targets to probe
# (file + folder). Same test1 folder as favorites.hurl.
GET {{base_url}}/api/folders/{{home_folder_id}}/resources?resource_types=folder
Authorization: Bearer {{token}}
HTTP 200
[Captures]
test1_id: jsonpath "$.items[0].resource.id"
# Create/lookup the attacker. Idempotent: `HTTP *` accepts either
# 201 (first run) or 409 (subsequent runs). Login below is the real
# precondition.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{token}}
Content-Type: application/json
{ "username": "rec_mallory", "password": "RecMalloryPassword1!", "email": "rec_mallory@example.com", "role": "user" }
HTTP *
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "rec_mallory", "password": "RecMalloryPassword1!" }
HTTP 200
[Captures]
mallory_token: jsonpath "$.access_token"
# Step 10a — Record admin's file into mallory's recent: 404.
POST {{base_url}}/api/recent/file/{{file_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 10b — Same for admin's folder: 404.
POST {{base_url}}/api/recent/folder/{{test1_id}}
Authorization: Bearer {{mallory_token}}
HTTP 404
# Step 10c — Mallory's recent list stays empty.
GET {{base_url}}/api/recent/resources
Authorization: Bearer {{mallory_token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0
+23 -1
View File
@@ -38,17 +38,34 @@ wait_for_http() {
SERVER_PID=""
WOPI_MOCK_PID=""
cleanup() {
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud server (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [[ -n "$WOPI_MOCK_PID" ]]; then
log "Stopping WOPI mock discovery (pid $WOPI_MOCK_PID)..."
kill "$WOPI_MOCK_PID" 2>/dev/null || true
wait "$WOPI_MOCK_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh"
}
trap cleanup EXIT
# ── 0. WOPI mock discovery ────────────────────────────────────────────────────
# Serves the static discovery.xml `OXICLOUD_WOPI_DISCOVERY_URL`
# points at (server.env pins port 9100). Started BEFORE OxiCloud so
# the server's cache-fill on first WOPI request finds it. The mock
# is stdlib-only Python (no deps) — see the file header for what it
# returns and why it's cheap.
log "Starting WOPI mock discovery on port 9100..."
node "$COMMON/wopi_mock_discovery.js" > /tmp/wopi-mock-discovery.log 2>&1 &
WOPI_MOCK_PID=$!
# ── 1. Start postgres ─────────────────────────────────────────────────────────
bash "$COMMON/spawn-db.sh"
@@ -144,6 +161,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/batch_folder_copy.hurl" \
"$API_DIR/dedup_blob_cleanup.hurl" \
"$API_DIR/contacts.hurl" \
"$API_DIR/calendar.hurl" \
"$API_DIR/playlists.hurl" \
"$API_DIR/public_shares.hurl" \
"$API_DIR/permissions.hurl" \
"$API_DIR/grants.hurl" \
@@ -170,7 +189,10 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/webdav_dead_properties.hurl" \
"$API_DIR/nc_webdav_dead_properties.hurl" \
"$API_DIR/webdav_protected_properties.hurl" \
"$API_DIR/webdav_nested_move_cascade.hurl"
"$API_DIR/webdav_drive_root.hurl" \
"$API_DIR/webdav_permissions.hurl" \
"$API_DIR/webdav_nested_move_cascade.hurl" \
"$API_DIR/wopi_authz.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+6 -1
View File
@@ -150,9 +150,14 @@ while IFS= read -r drive_id; do
while IFS= read -r file_id; do
[[ -z "$file_id" ]] && continue
curl -sf -X DELETE -H "$AUTH" "$base_url/api/files/$file_id" >/dev/null
HTTP_STATUS=$(curl -s -H "$AUTH" -o /tmp/del.json -w '%{http_code}' \
-X DELETE "$base_url/api/files/$file_id")
if [[ "$HTTP_STATUS" != "204" ]]; then
log "FILE DELETE FAILED: file=$file_id drive=$drive_id ($DRIVE_NAME) status=$HTTP_STATUS body=$(cat /tmp/del.json)"
fi
done < <(echo "$CONTENTS" | jq -r '.items[] | select(.resource_type == "file") | .resource.id')
# Empty the drive's per-drive trash so D3b's "drive must be empty"
# guard passes on the delete. `/api/trash/drive/{id}` is the
# Owner-only per-drive empty (admin is Owner now via the grant
+229
View File
@@ -0,0 +1,229 @@
# =============================================================
# OxiCloud — WebDAV drive-root URL scheme
# =============================================================
# Exercises the native WebDAV URL scheme documented in
# `src/interfaces/api/handlers/webdav_handler.rs::resolve_webdav_scope`:
#
# Default deployment (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`):
# * `/webdav/` → default drive's contents
# * `/webdav/@drive/` → drive listing (per-drive
# virtual folders)
# * `/webdav/@drive/<uuid>/…` → explicit drive by UUID
# * `/webdav/@drive/<name>/…` → explicit drive by name
#
# Coverage:
# 1. Login, capture JWT
# 2. Resolve caller's default drive (id + display name)
# 3. Create a magic folder under the home root via REST
# 4. PROPFIND `/webdav/` — Depth: 1 lists the magic folder as
# an immediate child of the default drive. This is the
# user-visible bug fix: pre-refactor, `/webdav/` returned a
# drive listing instead of the default drive's contents.
# 5. PROPFIND `/webdav/@drive/` — Depth: 1 lists each drive as
# a virtual child (at least the caller's default is present).
# 6. PROPFIND `/webdav/@drive/<uuid>/` — descends into the
# selected drive by UUID; magic folder appears here too.
# 7. PROPFIND `/webdav/@drive/<name>/` — same via display name.
# 8. Cleanup: DELETE the magic folder via REST.
#
# The magic folder name embeds a run-scoped marker so parallel
# `hurl --jobs N` runs don't step on each other and repeat runs
# against a shared DB don't collide.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Login, capture JWT
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Resolve caller's default drive (id + display name).
# `GET /api/drives` returns rows in a stable order:
# the caller's default personal drive first, then by
# display name. See `DriveRepository::list_readable_by`.
# `default_for_user` on the DTO is present-only for
# default rows (`Option<Uuid>` with `skip_serializing_if`),
# so `$[0]` — combined with the stable order — is the
# default drive for a fresh admin account.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/drives
Authorization: Bearer {{token}}
HTTP 200
[Captures]
default_drive_id: jsonpath "$[0].id"
default_drive_name: jsonpath "$[0].name"
# ─────────────────────────────────────────────────────────────
# Step 3 — Resolve the caller's home root folder id.
# A default personal drive has exactly one root folder
# (the drive-root itself). We need its id to create the
# magic folder as its child.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{token}}
HTTP 200
[Captures]
home_folder_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 4 — Create a magic folder under the home root via REST.
# The name is deterministic-yet-unique so PROPFIND
# assertions below can find it by exact string match,
# and parallel test runs can't collide.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hurl-drive-root-magic-marker",
"parent_id": "{{home_folder_id}}"
}
HTTP 201
[Captures]
magic_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 5 — PROPFIND on `/webdav/` (bare root). The default
# deployment maps this to the caller's DEFAULT drive
# contents, so Depth: 1 must include the magic folder.
#
# Pre-refactor this returned a drive listing instead —
# the exact regression that broke back-compat with
# pre-multi-drive WebDAV clients.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 6 — PROPFIND on `/webdav/@drive/`. This is the explicit
# drive picker — Depth: 1 returns one virtual child
# per drive the caller has Read on. The default drive
# must appear (by its display name).
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists
# ─────────────────────────────────────────────────────────────
# Step 7 — PROPFIND on `/webdav/@drive/<uuid>/`. The explicit
# by-UUID selector — descends INTO the chosen drive.
# Depth: 1 lists that drive's top-level children —
# the magic folder must be one of them.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/{{default_drive_id}}/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 8 — PROPFIND on `/webdav/@drive/<name>/`. The explicit
# by-name selector — same result as the UUID form.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/{{default_drive_name}}/
Authorization: Bearer {{token}}
Depth: 1
HTTP 207
[Asserts]
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
# ─────────────────────────────────────────────────────────────
# Step 9 — Reject MKCOL at `/webdav/@drive/` (bare pseudo-root).
# The drive-listing target has no writable parent
# folder — 405 Method Not Allowed. This guard prevents
# a client from silently succeeding at "creating a
# drive by MKCOL" (the drive-create surface is
# `POST /api/drives`, not WebDAV).
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/
Authorization: Bearer {{token}}
HTTP 405
# ─────────────────────────────────────────────────────────────
# Step 10 — Reject MKCOL at `/webdav/@drive/<not-a-drive>`.
# `<not-a-drive>` gets interpreted as a drive selector;
# no drive with that name/UUID exists → 404. Sits
# adjacent to Step 9 so any future maintainer touching
# the pseudo-root rejection sees BOTH shapes at once
# (bare listing = 405, unknown selector = 404).
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/hurl-not-a-real-drive
Authorization: Bearer {{token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 11 — Reject PUT at `/webdav/@drive/<not-a-drive>/x.txt`.
# Same rejection shape as MKCOL — trying to write a
# file into a non-existent drive.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/hurl-not-a-real-drive/probe.txt
Authorization: Bearer {{token}}
Content-Type: text/plain
```
probe
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 11b — Reject PUT at `/webdav/@drive/test.txt`. The URL
# segment immediately after `@drive/` is ALWAYS a
# drive selector — never a filename. A caller that
# bookmarks a file URL under `@drive` with a name
# that doesn't match any drive must get 404, not
# silently create a file at the drive-listing level.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/test.txt
Authorization: Bearer {{token}}
Content-Type: text/plain
```
probe
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup: DELETE the magic folder via REST so
# subsequent test runs / other hurl files don't see
# our marker.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{magic_folder_id}}
Authorization: Bearer {{token}}
HTTP 204
+301
View File
@@ -0,0 +1,301 @@
# =============================================================
# OxiCloud — WebDAV per-role permissions + cross-drive MOVE policy
# =============================================================
# End-to-end coverage for the two WebDAV authz axes exposed by the
# `@drive` URL scheme:
#
# 1. Per-role gates through the drive-scope resolver: a Viewer on a
# shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An
# Editor can. AuthZ denials return `NotFound` (anti-enum), so
# a probing caller can't tell a genuinely-missing folder from
# one they simply lack Create on.
#
# 2. Drive policy `forbid_cross_drive_move` gates MOVE at the
# SOURCE drive (see `DrivePolicies::refuse_cross_drive_move`
# in `src/domain/entities/drive.rs`) — even a fully-authorised
# Editor can't move content OUT of a drive whose owner has
# forbidden cross-drive movement. Rejection is 405
# (`ErrorKind::UnsupportedOperation` → `METHOD_NOT_ALLOWED`).
#
# Assumes the default `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` config —
# runs alongside the other tests in `tests/api/run.sh`. Uses the
# `@drive/<uuid>` selector so the paths don't collide with any
# drive-name-collision oddities.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Login as admin (bootstrapped by `setup.hurl`).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Create a fresh user "webdav_bob" via the admin
# endpoint, log him in.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"username": "webdav_bob",
"password": "WebdavBobPassword1!",
"email": "webdav_bob@example.com",
"role": "user"
}
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "webdav_bob", "password": "WebdavBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# Capture Bob's default personal drive id — used by the cross-drive
# MOVE scenario. Bob is not a member of any shared drive yet, so his
# `/api/drives` listing has exactly one entry (his own default).
GET {{base_url}}/api/drives
Authorization: Bearer {{bob_token}}
HTTP 200
[Captures]
bob_personal_drive_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Admin creates a shared drive owned by admin.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "webdav-perm-shared",
"owner": { "type": "user", "id": "{{admin_user_id}}" }
}
HTTP 201
[Captures]
shared_drive_id: jsonpath "$.id"
shared_root_id: jsonpath "$.root_folder_id"
# ─────────────────────────────────────────────────────────────
# Step 4 — Grant Bob VIEWER on the shared drive via /api/grants.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
"role": "viewer"
}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 5 — Bob (VIEWER) CAN PROPFIND the shared drive root.
# Depth 0 to keep the assertion minimal; a 207 with the
# drive's own href suffices as "Bob has Read".
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/
Authorization: Bearer {{bob_token}}
Depth: 0
HTTP 207
# ─────────────────────────────────────────────────────────────
# Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive.
# `authz.require(Create, Folder)` denial returns
# `DomainError::not_found` (anti-enum), which maps to 404.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 7 — Bob (VIEWER) CANNOT PUT a file.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt
Authorization: Bearer {{bob_token}}
Content-Type: text/plain
```
viewer should not upload
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 8 — Admin creates a probe folder in the shared drive so
# the Editor-can-rename step below has a real target.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{admin_token}}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder.
# MOVE requires Update on the source, which Viewer
# doesn't have. Same anti-enum 404 shape.
# ─────────────────────────────────────────────────────────────
MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{bob_token}}
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 10 — Promote Bob from VIEWER to EDITOR.
# `PATCH /api/drives/{id}/members/{subject-type}/{id}`
# mutates the role in-place.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{bob_user_id}}
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "role": "editor" }
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 11 — Bob (EDITOR) CAN MKCOL a new folder.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder
Authorization: Bearer {{bob_token}}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 12 — Bob (EDITOR) CAN PUT a file.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder/hello.txt
Authorization: Bearer {{bob_token}}
Content-Type: text/plain
```
editor uploaded content
```
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 13 — Bob (EDITOR) CAN MOVE (rename) the probe folder.
# ─────────────────────────────────────────────────────────────
MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{bob_token}}
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 14 — Bob puts a file in his OWN personal drive as the
# source for the cross-drive MOVE test below.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/xdrive-probe.txt
Authorization: Bearer {{bob_token}}
Content-Type: text/plain
```
cross-drive probe payload
```
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 15 — Admin flips `forbid_cross_drive_move` ON for Bob's
# PERSONAL drive. The policy sits on the SOURCE drive
# per `DrivePolicies::refuse_cross_drive_move`; only
# OxiCloud-admin can PATCH policies.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "forbid_cross_drive_move": true }
HTTP 200
[Asserts]
jsonpath "$.forbid_cross_drive_move" == true
# ─────────────────────────────────────────────────────────────
# Step 16 — Bob tries to MOVE `xdrive-probe.txt` from his
# PERSONAL drive to the SHARED drive. Blocked at the
# service layer by the policy — `OperationNotSupported`
# maps to 405 Method Not Allowed.
# ─────────────────────────────────────────────────────────────
MOVE {{base_url}}/webdav/xdrive-probe.txt
Authorization: Bearer {{bob_token}}
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt
HTTP 405
# ─────────────────────────────────────────────────────────────
# Step 17 — Admin flips the policy OFF.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "forbid_cross_drive_move": false }
HTTP 200
[Asserts]
jsonpath "$.forbid_cross_drive_move" == false
# ─────────────────────────────────────────────────────────────
# Step 18 — Bob retries the same MOVE. Now the policy is off,
# Bob has Update on source (his own personal drive) +
# Create on dest parent (Editor on shared drive), so
# the move succeeds. 201 on rename/move to a new URL,
# per `handle_move`'s existing convention.
# ─────────────────────────────────────────────────────────────
MOVE {{base_url}}/webdav/xdrive-probe.txt
Authorization: Bearer {{bob_token}}
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 19 — Verify the destination now exists and the source
# is gone. Both PROPFINDs use Bob's token to also
# re-confirm the AuthZ gates on the destination side.
# ─────────────────────────────────────────────────────────────
PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt
Authorization: Bearer {{bob_token}}
Depth: 0
HTTP 207
PROPFIND {{base_url}}/webdav/xdrive-probe.txt
Authorization: Bearer {{bob_token}}
Depth: 0
HTTP 404
+377
View File
@@ -0,0 +1,377 @@
# =============================================================
# OxiCloud — WOPI authorization at token redemption
# =============================================================
# Regression coverage for the WOPI verb-handler bypass documented in
# memory note `wopi-authz-bypass`. Two bugs closed:
#
# 1. Verb handlers (check_file_info, get_file, put_file,
# file_operations, host_page) previously did NOT call
# `AuthorizationEngine::require` at redemption. A grant
# revoked between mint-time and request-time silently kept
# working until the token TTL expired.
#
# 2. The mint helper decided `can_write` from the client's
# `requested_action` string (`!= "view"` → write). A Viewer
# clicking "Edit in Collabora" received a write-capable
# token because the string was "edit".
#
# The fix wires `authz.require` on every verb and derives
# `can_write` from the caller's actual Update permission. This
# suite hits both paths through the real HTTP surface.
#
# Note on infra:
# * `OXICLOUD_WOPI_ENABLED=true` in tests/common/server.env
# * `OXICLOUD_WOPI_SECRET` pinned so the tokens the server mints
# round-trip verify-able through the suite
# * WOPI discovery served by `tests/common/wopi_mock_discovery.py`
# started by run.sh — mock URL points at a black-hole editor
# so we only assert on OxiCloud's own responses
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Login as admin (owner) and 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 Bob user (Viewer under test) via admin API
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"username": "wopi-bob",
"password": "WopiBobPassword1!",
"email": "wopi-bob@example.com",
"role": "user"
}
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "wopi-bob", "password": "WopiBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 3 — Alice uploads a plain-text file the WOPI verbs will
# target. `text/plain` is in the mock discovery XML so
# `/api/wopi/editor-url` resolves to a real (black-hole)
# editor URL — the endpoint returns 200 with an
# access_token we can then poke at the verbs.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{alice_token}}
[MultipartFormData]
folder_id: {{alice_home_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
file_id: jsonpath "$.id"
[Asserts]
jsonpath "$.mime_type" == "text/plain"
# ─────────────────────────────────────────────────────────────
# Step 4 — Alice mints an editor-URL for her own file with
# `action=edit`. Owner has Update → can_write=true.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
Authorization: Bearer {{alice_token}}
HTTP 200
[Captures]
alice_edit_token: jsonpath "$.access_token"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.editor_url" contains "edit"
# ─────────────────────────────────────────────────────────────
# Step 5 — CheckFileInfo with the owner's edit token. Verb
# re-checks Read → allowed. `user_can_write=true`
# reflects real Update.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_edit_token}}
HTTP 200
[Asserts]
jsonpath "$.UserId" == "{{alice_user_id}}"
jsonpath "$.UserCanWrite" == true
jsonpath "$.SupportsUpdate" == true
# ─────────────────────────────────────────────────────────────
# Step 6 — GetFile with the owner's edit token. Verb re-checks
# Read → 200 with body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}}
HTTP 200
[Asserts]
body contains "Hello"
# ─────────────────────────────────────────────────────────────
# Step 7 — PutFile with the owner's edit token. Verb re-checks
# Update → 200. The owner overwrites her own file.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}}
Content-Type: application/octet-stream
```
owner overwrite via WOPI PutFile
```
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 8 — Alice explicitly requests view mode. Even the owner
# gets `can_write=false` — the token respects the
# client's downgrade so Collabora can open a doc
# "read-only for co-browsing".
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=view
Authorization: Bearer {{alice_token}}
HTTP 200
[Captures]
alice_view_token: jsonpath "$.access_token"
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_view_token}}
HTTP 200
[Asserts]
# Owner explicitly requested view — supports_update flips off.
jsonpath "$.UserCanWrite" == false
jsonpath "$.SupportsUpdate" == false
# View token trying to write → 401 (token's can_write bit says no
# before the authz.require ever runs).
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_view_token}}
Content-Type: application/octet-stream
```
owner trying to write with view token
```
HTTP 401
# ─────────────────────────────────────────────────────────────
# Step 9 — SECURITY: Bob has NO grant on Alice's file. Requests
# an edit-URL. The mint helper's Read gate fires → 404
# (anti-enum). This is the pre-fix behaviour holding
# — mint-time Read was already enforced via
# get_file_with_perms.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 10 — Alice grants Bob the Viewer role on the file.
# Capture the grant id off the POST response so Step
# 13's revoke doesn't need to LIST + filter (the LIST
# endpoint returns a bare JSON array, not
# `.grants[?...]`, and Hurl's single-match filter
# capture behaviour is quirky — see memory note
# `feedback_hurl_jsonpath_filter_empty`).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "file", "id": "{{file_id}}" },
"role": "viewer"
}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 11 — SECURITY: Bob (Viewer) requests an EDIT token. Fix
# #12: mint helper derives `can_write` from real
# Update permission, not from the requested_action
# string. Bob has Read but not Update → token is
# minted with `can_write=false` even though he asked
# for "edit".
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
Authorization: Bearer {{bob_token}}
HTTP 200
[Captures]
bob_forged_edit_token: jsonpath "$.access_token"
# CheckFileInfo with Bob's "edit" token shows UserCanWrite=false
# because the token's can_write bit was scrubbed at mint. Prior
# to the fix this was `true` — a Viewer editing Alice's file.
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_forged_edit_token}}
HTTP 200
[Asserts]
jsonpath "$.UserId" == "{{bob_user_id}}"
jsonpath "$.UserCanWrite" == false
jsonpath "$.SupportsUpdate" == false
# Bob attempting PutFile with his "edit" token → 401. The
# token's own can_write=false is the outer gate; even if the
# token had somehow been forged with can_write=true, the
# redemption-time authz.require(Update) would return 404.
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}}
Content-Type: application/octet-stream
```
Bob trying to write as Viewer
```
HTTP 401
# Bob CAN read (his Read grant is real).
GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}}
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 12 — SECURITY: promote Bob to Editor. Now he legitimately
# holds Update, so an edit token becomes truly write-
# capable.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "file", "id": "{{file_id}}" },
"role": "editor"
}
# The engine's `ON CONFLICT UPDATE` collapses one role row per
# (subject, resource), so this Editor grant REPLACES the Viewer
# grant from Step 10 rather than stacking. Bob now holds
# Editor alone; revoking it in Step 13 leaves him with no
# grants at all.
HTTP 201
[Captures]
bob_grant_id: jsonpath "$.grants[0].id"
GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit
Authorization: Bearer {{bob_token}}
HTTP 200
[Captures]
bob_real_edit_token: jsonpath "$.access_token"
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}}
HTTP 200
[Asserts]
# Bob is a real Editor now → can_write flips to true.
jsonpath "$.UserCanWrite" == true
jsonpath "$.SupportsUpdate" == true
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}}
Content-Type: application/octet-stream
```
Bob as Editor legitimately writes
```
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 13 — SECURITY: revoke Bob's grant AFTER his edit token was
# minted. The token stays cryptographically valid until
# TTL, but every subsequent verb call must hit the
# authorization engine and reject.
#
# This is the CORE bug the memory note describes: prior
# to the fix Bob's PutFile still succeeded here because
# the verb handlers trusted the token in isolation.
#
# The Editor grant from Step 12 REPLACED the Viewer
# grant from Step 10 (engine's ON CONFLICT UPDATE —
# one role row per subject/resource). So revoking the
# Editor grant leaves Bob with no grants at all; every
# verb — Read AND Update — must refuse.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/grants/{{bob_grant_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# CheckFileInfo — no Read → 404. Prior to the fix the verb
# handler trusted the token and returned 200 with the file's
# metadata.
GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}}
HTTP 404
# GetFile — no Read → 404. Prior to the fix Bob could still
# download the file content until the token TTL expired.
GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}}
HTTP 404
# PutFile — no Update → 404 (verb-side require_wopi_perm), OR
# 401 if the token's own `!claims.can_write` gate happened to
# fire first. The important assertion is "not 200" — a revoked
# grant must never let the caller through.
POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}}
Content-Type: application/octet-stream
```
Bob post-revoke tries to write
```
HTTP 404
# ─────────────────────────────────────────────────────────────
# Cleanup — delete the test file so subsequent Hurl files don't
# see it. Bob user stays; other tests may reuse the `wopi-bob`
# username, but the grants that made this test meaningful are
# gone.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{file_id}}
Authorization: Bearer {{alice_token}}
HTTP 204