Files
Oxicloud/tests/api/role_grants.hurl
T
Edouard Vanbelle 72129af0bd refactor(role): use grant only
- remove permission centric mode
    - finalize migration drop all tables with permissions
    - ensure roles are ENUM (owner is always displayed first)
2026-06-18 02:47:06 +02:00

396 lines
15 KiB
Plaintext

# =============================================================
# OxiCloud — D-Prep: role_grants dual-write + new wire format
# =============================================================
# Pins the D-Prep refactor behaviours that don't fit naturally into
# the existing `grants.hurl` (which is API-shape-focused). Specifically:
#
# 1. Wire-format role names:
# - "owner" is accepted on POST and emitted on response
# - "admin" is REJECTED with 422 (compat alias retired in the
# cleanup PR — see Step 6a)
#
# 2. Role-keyed write proof: granting a role and then exercising a
# permission from its bundle works → proves the row landed in
# `storage.role_grants` and the engine read path expands the
# bundle correctly (see `folder_cascade_grant_exists`).
#
# 3. Atomic role updates via PUT /api/grants/role — the role flips
# in a single SQL update (no DELETE+INSERT race window).
#
# 4. Clean revoke: DELETE /api/grants/{id} clears role_grants too.
#
# Self-contained: creates its own users, folders, and files so it
# can run in any position relative to other test files.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — admin login + home folder lookup
# ─────────────────────────────────────────────────────────────
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"
GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
admin_home_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Create two fresh test users (renee, sam) so this file
# doesn't depend on cross-file fixtures. Use the
# legacy-compat path that POSTs to /api/admin/users.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "renee", "password": "ReneePassword1!", "email": "renee@example.com", "role": "user" }
HTTP 201
[Captures]
renee_user_id: jsonpath "$.id"
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "sam", "password": "SamPassword1!", "email": "sam@example.com", "role": "user" }
HTTP 201
[Captures]
sam_user_id: jsonpath "$.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "renee", "password": "ReneePassword1!" }
HTTP 200
[Captures]
renee_token: jsonpath "$.access_token"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "sam", "password": "SamPassword1!" }
HTTP 200
[Captures]
sam_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 3 — admin creates a folder "role-grants-test" + a file
# inside it to use as the authz target throughout the file.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "role-grants-test", "parent_id": "{{admin_home_id}}" }
HTTP 201
[Captures]
test_folder_id: jsonpath "$.id"
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{test_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
test_file_id: jsonpath "$.id"
# Rename immediately so subsequent throwaway uploads of `hello.txt`
# to the same folder don't 409. Each throwaway upload below applies
# the same pattern (upload → rename → use) to keep the namespace
# clean for the next one.
PUT {{base_url}}/api/files/{{test_file_id}}/rename
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "step3-anchor.txt" }
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 4 — Grant renee role="owner" on the folder. New wire format.
# Response should echo back the canonical name.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{renee_user_id}}" },
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
"role": "owner"
}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 5 — Dual-write proof: renee (now Owner of the folder) can
# DELETE a file inside it. Owner's bundle includes Delete;
# the engine reads from role_grants → if dual-write didn't
# land the row, the cascade query returns empty and the
# delete is refused.
#
# We upload + delete a throwaway file to avoid removing the
# test_file we'll need for later steps.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{test_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
throwaway_file_id: jsonpath "$.id"
# Rename so subsequent uploads in this folder don't 409 on "hello.txt".
PUT {{base_url}}/api/files/{{throwaway_file_id}}/rename
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "renee-throwaway.txt" }
HTTP 200
DELETE {{base_url}}/api/files/{{throwaway_file_id}}
Authorization: Bearer {{renee_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 6a — Reject the legacy "admin" string. The cleanup PR
# retired the `#[serde(alias = "admin")]` compat shim on
# `RoleDto::Owner`; the deserialiser now refuses it with 422.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{sam_user_id}}" },
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
"role": "admin"
}
HTTP 422
# ─────────────────────────────────────────────────────────────
# Step 6b — Grant sam Owner with the canonical role string.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/grants
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{sam_user_id}}" },
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
"role": "owner"
}
HTTP 201
[Captures]
# The single role-keyed Grant returned in `.grants[0]` is the
# `storage.role_grants` row id. Step 10's revoke uses it to
# `clear_role` and wipe the row.
sam_grant_id: jsonpath "$.grants[0].id"
# Sam should now have Owner-equivalent access — Delete works.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{test_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
sam_throwaway_id: jsonpath "$.id"
PUT {{base_url}}/api/files/{{sam_throwaway_id}}/rename
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "sam-throwaway.txt" }
HTTP 200
DELETE {{base_url}}/api/files/{{sam_throwaway_id}}
Authorization: Bearer {{sam_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 7 — Atomic role update: demote renee from Owner to Viewer
# via PUT /api/grants/role. Single SQL UPDATE on
# role_grants — no DELETE+INSERT race.
#
# After: renee can still Read but should be refused Delete.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{renee_user_id}}" },
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
"role": "viewer"
}
HTTP 200
# Renee can still read the file (Viewer's bundle includes Read).
GET {{base_url}}/api/files/{{test_file_id}}
Authorization: Bearer {{renee_token}}
HTTP 200
# Renee CANNOT delete — Viewer's bundle excludes Delete; the
# folder_cascade_grant_exists query for Delete returns empty.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{test_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
post_demote_file_id: jsonpath "$.id"
PUT {{base_url}}/api/files/{{post_demote_file_id}}/rename
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "post-demote.txt" }
HTTP 200
DELETE {{base_url}}/api/files/{{post_demote_file_id}}
Authorization: Bearer {{renee_token}}
HTTP *
[Asserts]
status >= 400
status < 500
# ─────────────────────────────────────────────────────────────
# Step 8 — Promote renee back to Editor (one role change, atomic)
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{renee_user_id}}" },
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
"role": "editor"
}
HTTP 200
# Editor's bundle includes Update — renaming a file should work.
PUT {{base_url}}/api/files/{{post_demote_file_id}}/rename
Authorization: Bearer {{renee_token}}
Content-Type: application/json
{ "name": "renamed-by-renee.txt" }
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 9 — My Shares response shape: the outgoing-resources endpoint
# must emit role strings from the new roster ("viewer" /
# "editor" / "owner"), never the legacy "admin".
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants/outgoing/resources
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
# The response body MUST contain "owner" (sam's role) and "editor"
# (renee's current role after the promote). It MUST NOT contain the
# legacy "admin" role string for any grant emitted by the server.
body contains "\"role\":\"owner\""
body contains "\"role\":\"editor\""
body not contains "\"role\":\"admin\""
# ─────────────────────────────────────────────────────────────
# Step 10 — Revoke: removing sam's grant. `engine.revoke()` DELETEs
# the single `storage.role_grants` row by id.
#
# After: sam's Delete attempt should be refused (proof
# the role_grants row is gone — the cascade query for
# Delete returns empty because sam has no row pointing
# at this folder).
# ─────────────────────────────────────────────────────────────
# sam_grant_id was captured at Step 6b from the create response.
DELETE {{base_url}}/api/grants/{{sam_grant_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
# Post-revoke: sam can no longer Delete in this folder.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{test_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
post_revoke_file_id: jsonpath "$.id"
PUT {{base_url}}/api/files/{{post_revoke_file_id}}/rename
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "post-revoke.txt" }
HTTP 200
DELETE {{base_url}}/api/files/{{post_revoke_file_id}}
Authorization: Bearer {{sam_token}}
HTTP *
[Asserts]
status >= 400
status < 500
# ─────────────────────────────────────────────────────────────
# Step 11 — Teardown: clean up users + folder so this file
# leaves no residue for the storage_cleanup_check.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{test_folder_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/admin/users/{{renee_user_id}}
Authorization: Bearer {{admin_token}}
HTTP 200
DELETE {{base_url}}/api/admin/users/{{sam_user_id}}
Authorization: Bearer {{admin_token}}
HTTP 200