411 lines
21 KiB
Plaintext
411 lines
21 KiB
Plaintext
# =============================================================
|
||
# 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.full.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.full.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). Bob has Read → graduated denial returns
|
||
# 403 (see [[project_authz_require_graduated_denial]]).
|
||
# ─────────────────────────────────────────────────────────────
|
||
PUT {{base_url}}/api/playlists/{{playlist_id}}
|
||
Authorization: Bearer {{bob_token}}
|
||
Content-Type: application/json
|
||
{ "name": "hijacked" }
|
||
|
||
HTTP 403
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 12 – Bob cannot delete the playlist. Viewer's bundle
|
||
# excludes Delete → 403 (Read granted).
|
||
# ─────────────────────────────────────────────────────────────
|
||
DELETE {{base_url}}/api/playlists/{{playlist_id}}
|
||
Authorization: Bearer {{bob_token}}
|
||
|
||
HTTP 403
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 13 – Bob cannot re-share the playlist. Viewer's bundle
|
||
# excludes Share → 403 (Read granted). The legacy /share endpoint
|
||
# 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 403
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# 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.
|
||
# Bob has Read → graduated denial returns 403 (see
|
||
# [[project_authz_require_graduated_denial]]).
|
||
# ─────────────────────────────────────────────────────────────
|
||
GET {{base_url}}/api/grants?resource_type=playlist&resource_id={{playlist_id}}
|
||
Authorization: Bearer {{bob_token}}
|
||
|
||
HTTP 403
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# 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).
|
||
# Bob has Read (Editor bundle) → graduated denial returns 403.
|
||
# ─────────────────────────────────────────────────────────────
|
||
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 403
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# 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
|