Merge origin/main into webdav-litmus-compliance
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user