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
|
||||
@@ -75,9 +75,11 @@ BEGIN
|
||||
VALUES ('personal', admin_id, NULL)
|
||||
RETURNING id INTO drive_id;
|
||||
|
||||
-- Post-D7: `storage.folders.user_id` dropped. Ownership lives on the
|
||||
-- drive-Owner role_grant below; provenance in `created_by`/`updated_by`.
|
||||
INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
VALUES ('Personal', NULL, admin_id, drive_id, admin_id, admin_id)
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
VALUES ('Personal', NULL, drive_id, admin_id, admin_id)
|
||||
RETURNING id INTO folder_id;
|
||||
|
||||
UPDATE storage.drives SET root_folder_id = folder_id WHERE id = drive_id;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Shared test-server environment variables.
|
||||
# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node).
|
||||
# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here —
|
||||
# each test suite sets those to avoid port/directory conflicts.
|
||||
|
||||
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_STATIC_PATH=./static
|
||||
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
|
||||
OXICLOUD_ENABLE_AUTH=true
|
||||
OXICLOUD_ENABLE_TRASH=true
|
||||
OXICLOUD_ENABLE_SEARCH=true
|
||||
OXICLOUD_ENABLE_FILE_SHARING=true
|
||||
OXICLOUD_ENABLE_MUSIC=true
|
||||
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||
OXICLOUD_WOPI_ENABLED=true
|
||||
# Fixed secret so the Hurl WOPI test can hand-craft valid access
|
||||
# tokens with a known signing key. Prod deployments MUST override
|
||||
# this to a random per-deployment value.
|
||||
OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod
|
||||
# Discovery URL points at a black hole — VERB endpoints don't need
|
||||
# discovery, and the WOPI Hurl suite deliberately does NOT touch
|
||||
# `/api/wopi/editor-url` (the only path that would fetch it), so
|
||||
# an unreachable URL keeps startup fast and hermetic.
|
||||
OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml
|
||||
OXICLOUD_WOPI_TOKEN_TTL_SECS=3600
|
||||
OXICLOUD_OIDC_ENABLED=false
|
||||
|
||||
OXICLOUD_NEXTCLOUD_ENABLED=true
|
||||
|
||||
# Test-only sweep triggers (`/api/admin/internal/trigger-sweep`,
|
||||
# `/api/admin/internal/trigger-gc`). Off by default in production;
|
||||
# the Hurl suite needs them to assert post-delete quota convergence
|
||||
# without waiting out the 600 s reconciliation tick.
|
||||
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
|
||||
|
||||
RUST_LOG="warn,audit=info,sqlx::migrate=info"
|
||||
#RUST_LOG="warn,audit=info,oxicloud::quota=debug"
|
||||
#RUST_LOG=debug
|
||||
#RUST_LOG=info
|
||||
|
||||
# Per-chunk upload cap, exercised by chunked_upload_cap.hurl.
|
||||
# 4 MiB: lets the existing grants.hurl single-chunk test (2.76 MB) pass
|
||||
# under the cap, while the cap test sends a 5 MiB fixture to trigger 413.
|
||||
OXICLOUD_CHUNK_MAX_BYTES=4194304
|
||||
|
||||
# Direct-PUT (non-chunked) cap, exercised by chunked_upload_cap.hurl.
|
||||
# 4 MiB: same threshold as the chunked cap so the existing 5 MiB
|
||||
# fixture (chunk-over-cap-5mb.bin) can prove BOTH caps with one
|
||||
# generated file. All existing direct-PUT tests
|
||||
# (test_dedup_webdav_multichunk.sh = 2.76 MB, _ref_count = ~66 KB,
|
||||
# _nextcloud_put_blake3 = 32 B) stay safely under this cap.
|
||||
OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304
|
||||
|
||||
# grow up limits for tests
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
|
||||
|
||||
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
|
||||
# outbound message in-process so external_users.hurl can retrieve the
|
||||
# invitation body and follow the magic-link URL. The `SMTP_FROM` value
|
||||
# is required so the mock can build a valid Message; host/port are
|
||||
# irrelevant in mock mode but kept set for completeness.
|
||||
OXICLOUD_SMTP_MOCK=true
|
||||
OXICLOUD_SMTP_HOST=localhost
|
||||
OXICLOUD_SMTP_PORT=25
|
||||
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
|
||||
OXICLOUD_SMTP_TLS=none
|
||||
OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
||||
|
||||
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
|
||||
# exercise the cap behaviour with a small, deterministic request count.
|
||||
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
||||
OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3
|
||||
OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2
|
||||
OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50
|
||||
|
||||
# permits IP spoofing for tests
|
||||
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
|
||||
|
||||
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
|
||||
|
||||
# /webdav/ will points directly to list of drives
|
||||
OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""
|
||||
+11
-1
@@ -13,7 +13,17 @@ OXICLOUD_ENABLE_SEARCH=true
|
||||
OXICLOUD_ENABLE_FILE_SHARING=true
|
||||
OXICLOUD_ENABLE_MUSIC=true
|
||||
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||
OXICLOUD_WOPI_ENABLED=false
|
||||
OXICLOUD_WOPI_ENABLED=true
|
||||
# Fixed secret so the Hurl WOPI test can hand-craft valid access
|
||||
# tokens with a known signing key. Prod deployments MUST override
|
||||
# this to a random per-deployment value.
|
||||
OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod
|
||||
# Discovery URL points at a black hole — VERB endpoints don't need
|
||||
# discovery, and the WOPI Hurl suite deliberately does NOT touch
|
||||
# `/api/wopi/editor-url` (the only path that would fetch it), so
|
||||
# an unreachable URL keeps startup fast and hermetic.
|
||||
OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml
|
||||
OXICLOUD_WOPI_TOKEN_TTL_SECS=3600
|
||||
OXICLOUD_OIDC_ENABLED=false
|
||||
|
||||
OXICLOUD_NEXTCLOUD_ENABLED=true
|
||||
|
||||
@@ -34,9 +34,10 @@ wipe_storage() {
|
||||
fi
|
||||
|
||||
# Sanity check: must end in tests/<name>/storage where <name> is
|
||||
# lowercase alphanumeric. Stops `rm -rf` from ever running against
|
||||
# an unexpected expansion of a callerʼs path.
|
||||
if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then
|
||||
# lowercase alphanumeric (hyphens allowed so multi-word runner names
|
||||
# like `webdav-drive-root` pass). Stops `rm -rf` from ever running
|
||||
# against an unexpected expansion of a caller's path.
|
||||
if [[ ! "$path" =~ /tests/[a-z0-9][a-z0-9-]*/storage$ ]]; then
|
||||
echo "[wipe_storage] ERROR: '$path' does not match .../tests/<name>/storage — refusing to wipe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal mock WOPI discovery server for the Hurl WOPI suite.
|
||||
//
|
||||
// Serves a valid RFC-shaped discovery XML on `GET /discovery.xml` so
|
||||
// `OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:<port>/discovery.xml`
|
||||
// resolves to a real editor URL when `/api/wopi/editor-url` fetches it.
|
||||
//
|
||||
// The `urlsrc` we hand back points at a black-hole host so no real
|
||||
// editor process needs to be running — the Hurl suite only asserts on
|
||||
// OxiCloud's own responses (token contents, HTTP status codes,
|
||||
// headers). The mock exists purely to let `get_editor_url` succeed
|
||||
// end-to-end so we can exercise the mint-time authz path (Viewer-
|
||||
// clicks-Edit gets a read-only token).
|
||||
//
|
||||
// Node stdlib only — matches the tooling used by tests/oidc/fake_idp
|
||||
// (both are stdlib-free apart from `node-oidc-provider` on that side).
|
||||
// No package.json, no npm install, no extra dependency for the api
|
||||
// test suite. Started + reaped by `tests/api/run.sh`. Port comes from
|
||||
// `WOPI_MOCK_PORT` env var (default 9100).
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const DISCOVERY_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<wopi-discovery>
|
||||
<net-zone name="external-http">
|
||||
<!-- text/plain lets the txt files the Hurl suite uploads round-trip. -->
|
||||
<app name="text/plain" favIconUrl="http://mock-editor.invalid/favicon.ico">
|
||||
<action name="edit" ext="txt" urlsrc="http://mock-editor.invalid/edit?"/>
|
||||
<action name="view" ext="txt" urlsrc="http://mock-editor.invalid/view?"/>
|
||||
</app>
|
||||
<!-- One office extension so tests can also exercise the docx path
|
||||
if they need to. -->
|
||||
<app name="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
|
||||
<action name="edit" ext="docx" urlsrc="http://mock-editor.invalid/edit?"/>
|
||||
<action name="view" ext="docx" urlsrc="http://mock-editor.invalid/view?"/>
|
||||
</app>
|
||||
</net-zone>
|
||||
<proof-key oldvalue="" oldmodulus="" oldexponent=""
|
||||
value="" modulus="" exponent=""/>
|
||||
</wopi-discovery>
|
||||
`;
|
||||
|
||||
const port = Number(process.env.WOPI_MOCK_PORT || 9100);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/discovery.xml') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(DISCOVERY_XML),
|
||||
});
|
||||
res.end(DISCOVERY_XML);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
// SIGTERM from `kill` in run.sh cleanup — exit quietly so the test
|
||||
// runner's tail-of-log stays clean.
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(sig, () => server.close(() => process.exit(0)));
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`wopi-mock-discovery listening on 127.0.0.1:${port}`);
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,186 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV drive-root URL scheme, `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` variant
|
||||
# =============================================================
|
||||
# Companion to `webdav_drive_root.hurl`. That file exercises the
|
||||
# default config (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`); this
|
||||
# one exercises the empty-string config where `/webdav/` IS the
|
||||
# drive listing and there's no default-drive shortcut.
|
||||
#
|
||||
# Server env for this test: `tests/common/server-webdav-drive-root.env`
|
||||
# sets `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`. This file assumes that
|
||||
# config is active — it is NOT part of the standard `run.sh`
|
||||
# invocation (which starts the default-config server).
|
||||
#
|
||||
# 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/` — drive listing (default drive
|
||||
# appears as a virtual child under its display name).
|
||||
# 5. PROPFIND `/webdav/<uuid>/` — descend into a drive by
|
||||
# UUID. Magic folder appears.
|
||||
# 6. PROPFIND `/webdav/<name>/` — descend into a drive by
|
||||
# display name. Magic folder appears.
|
||||
# 7. `/webdav/@drive/` returns 404 in this mode — the sigil
|
||||
# has no reserved meaning when `webdav_drive_listing_prefix=""`.
|
||||
# A drive genuinely named `@drive` would resolve here; the
|
||||
# 404 comes from "no such drive," not the sigil.
|
||||
# 8. Cleanup: DELETE the magic folder via REST.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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 {{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.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
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.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-drive-root-empty-magic-marker",
|
||||
"parent_id": "{{home_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
magic_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — PROPFIND on `/webdav/` (bare root). With
|
||||
# `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` this IS the drive
|
||||
# listing — the default drive appears as a virtual
|
||||
# child under its display name. The magic folder does
|
||||
# NOT appear here (it lives one level deeper).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists
|
||||
# Magic folder is one level deeper — must NOT show up at root.
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" not exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PROPFIND on `/webdav/<uuid>/`. Descends into the
|
||||
# default drive; magic folder is a top-level child.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/{{default_drive_id}}/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PROPFIND on `/webdav/<name>/`. Same descent via
|
||||
# display name.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/{{default_drive_name}}/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — `/webdav/@drive/` has no reserved meaning in the
|
||||
# empty-config mode. `@drive` is treated as a plain
|
||||
# drive selector; no drive by that name → 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Reject MKCOL at `/webdav/` (bare pseudo-root).
|
||||
# In the empty-config mode `/webdav/` IS the drive
|
||||
# listing — there's no writable parent, so 405
|
||||
# Method Not Allowed. This guard prevents a client
|
||||
# from creating something at "root" that shadows a
|
||||
# drive name.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 405
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Reject MKCOL at `/webdav/<not-a-drive>`. The first
|
||||
# URL segment is the drive selector in this config;
|
||||
# an unknown selector yields 404. A client cannot
|
||||
# "create a drive" via MKCOL — the drive-create
|
||||
# surface is `POST /api/drives`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/hurl-not-a-real-drive
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Reject PUT at `/webdav/<not-a-drive>/x.txt`. Same
|
||||
# rejection shape as MKCOL.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/hurl-not-a-real-drive/probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
probe
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Cleanup: DELETE the magic folder via REST.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{magic_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# WebDAV drive-root URL-scheme variant runner.
|
||||
#
|
||||
# Exercises `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` — the config where the
|
||||
# WebDAV `@drive` path segment is disabled and `/webdav/` IS the
|
||||
# drive listing. `tests/api/webdav_drive_root.hurl` covers the
|
||||
# default `"@drive"` config in the main API run; this runner
|
||||
# starts a separately-configured server to cover the empty-string
|
||||
# case, mirroring the OIDC runner's shape.
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash tests/webdav-drive-root/run.sh
|
||||
#
|
||||
# Prerequisites: docker, cargo, hurl ≥ 4.0
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMMON="$REPO_ROOT/tests/common"
|
||||
TEST_DIR="$REPO_ROOT/tests/webdav-drive-root"
|
||||
|
||||
# shellcheck source=test.env
|
||||
source "$TEST_DIR/test.env"
|
||||
|
||||
SERVER_PORT="${base_url##*:}"
|
||||
|
||||
log() { echo "[webdav-drive-root] $*"; }
|
||||
die() { echo "[webdav-drive-root] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1" timeout="${2:-60}"
|
||||
local deadline=$(( $(date +%s) + timeout ))
|
||||
until curl -sf "$url" >/dev/null 2>&1; do
|
||||
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# ── Teardown (always runs on exit) ────────────────────────────────────────────
|
||||
|
||||
SERVER_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
|
||||
bash "$COMMON/stop-db.sh"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── 1. Start postgres ─────────────────────────────────────────────────────────
|
||||
|
||||
bash "$COMMON/spawn-db.sh"
|
||||
|
||||
# ── 2. Load the drive-root-variant server env + port ──────────────────────────
|
||||
|
||||
set -a
|
||||
# shellcheck source=../common/server-webdav-drive-root.env
|
||||
source "$COMMON/server-webdav-drive-root.env"
|
||||
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav-drive-root/storage"
|
||||
set +a
|
||||
|
||||
# shellcheck source=../common/wipe-storage.sh
|
||||
source "$COMMON/wipe-storage.sh"
|
||||
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
# ── 3. Start OxiCloud server with the drive-root-variant config ───────────────
|
||||
|
||||
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||
|
||||
if [[ ! -x "$OXICLOUD_BIN" ]]; then
|
||||
log "Building OxiCloud server ($BUILD_TARGET)..."
|
||||
case "$BUILD_TARGET" in
|
||||
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
|
||||
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
|
||||
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
log "Starting OxiCloud server with WEBDAV_DRIVE_LISTING_PREFIX='' on port $SERVER_PORT..."
|
||||
"$OXICLOUD_BIN" --config "$COMMON/server-webdav-drive-root.env" &
|
||||
SERVER_PID=$!
|
||||
log "Waiting for server at $base_url..."
|
||||
wait_for_http "$base_url/ready" 120
|
||||
log "Server is ready."
|
||||
|
||||
# ── 4. Run Hurl tests ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# `setup.hurl` from the shared api/ suite bootstraps the initial admin
|
||||
# account via `POST /api/setup` — the endpoint locks after the first
|
||||
# admin exists, so it's a one-shot idempotency-by-server-state seed.
|
||||
# We reuse the file rather than duplicating the setup body so credential
|
||||
# / schema changes in the api tests automatically flow here.
|
||||
|
||||
log "Running Hurl tests..."
|
||||
hurl --variables-file "$TEST_DIR/test.env" \
|
||||
--file-root "$REPO_ROOT/tests" \
|
||||
--test --jobs 1 \
|
||||
"$REPO_ROOT/tests/api/setup.hurl" \
|
||||
"$TEST_DIR/drive_root_empty_config.hurl"
|
||||
|
||||
log "webdav-drive-root tests passed."
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test credentials for the WebDAV drive-root variant runner — NOT real secrets.
|
||||
# Runs on a separate port from tests/api and tests/webdav so a
|
||||
# `just api-test` chain doesn't collide when the previous runner's
|
||||
# teardown is still in progress.
|
||||
base_url=http://localhost:8089
|
||||
username=admin
|
||||
email=admin@example.com
|
||||
# gitguardian:ignore
|
||||
password=TestPassword1!
|
||||
@@ -323,7 +323,20 @@ grep -q 'g8-doomed' <<< "$BODY" \
|
||||
|| fail "K1: g8-doomed.txt not in trashbin PROPFIND"
|
||||
grep -q '<nc:trashbin-original-location>' <<< "$BODY" \
|
||||
|| fail "K1: trashbin response missing <nc:trashbin-original-location>"
|
||||
pass "K1: trashbin shows g8-doomed.txt with original-location"
|
||||
|
||||
# Post-D3 (secondary/shared drive support): the `original-location`
|
||||
# value is drive-relative — the emitter strips the drive-root segment
|
||||
# from the internal `storage.folders.path` (`"Personal/g8-doomed.txt"`
|
||||
# for a file at the default drive root) so NC clients see
|
||||
# `"g8-doomed.txt"` regardless of what the drive's root is named.
|
||||
# Regression guard: the pre-D3 code hardcoded `strip_prefix("Personal/")`
|
||||
# — a bug that would silently break secondary drives. Assert the
|
||||
# stripped shape (no leading `Personal/`, no leading `/`, no drive
|
||||
# segment).
|
||||
grep -q '<nc:trashbin-original-location>g8-doomed\.txt</nc:trashbin-original-location>' <<< "$BODY" \
|
||||
|| fail "K1: original-location not drive-relative (expected 'g8-doomed.txt', got: $(grep -o '<nc:trashbin-original-location>[^<]*</nc:trashbin-original-location>' <<< "$BODY"))"
|
||||
|
||||
pass "K1: trashbin shows g8-doomed.txt with drive-relative original-location"
|
||||
|
||||
# Extract the trashed item id (last segment of the href).
|
||||
# Trashbin hrefs are `/remote.php/dav/trashbin/{user}/trash/{uuid}`
|
||||
|
||||
Reference in New Issue
Block a user