961 lines
39 KiB
Plaintext
961 lines
39 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — D2/D3a drive membership + create-drive end-to-end
|
|
# =============================================================
|
|
# D2 coverage (steps 1-12, personal-drive-only world):
|
|
# 1. `GET /api/drives` exposes `caller_role` on every row.
|
|
# 2. `GET /api/drives/{id}/members` lists role grants on a drive
|
|
# (one Owner row for the lifecycle-hook-provisioned default).
|
|
# 3. Personal-drive guard refuses every membership mutation
|
|
# (POST / PATCH / DELETE on `/api/drives/{id}/members*`) with
|
|
# 405 — personal drives are single-user single-owner.
|
|
# 4. The same guard fires on the generic `/api/grants` write paths
|
|
# (POST / PUT / DELETE) when `resource.type='drive'` and the
|
|
# drive is personal — verifies the `DriveManagementService`
|
|
# delegation that closes the bypass.
|
|
# 5. Drive grants surface in `GET /api/grants/incoming/resources`
|
|
# with `resource_types=drive` filter (previously hard-skipped).
|
|
# 6. Anti-enum: an unrelated user gets the same `404` for a drive
|
|
# they can't read, whether or not it exists.
|
|
#
|
|
# D3a coverage (steps 13-22, unlocked by `POST /api/drives`):
|
|
# - admin-only authz gate on create
|
|
# - kind=personal returns 501 (placeholder for the future PR)
|
|
# - Owner subject = user → single Owner shared drive
|
|
# - Owner subject = group with members → group-mediated Owner
|
|
# - Owner subject = empty group → 400 (no orphan Owner)
|
|
# - Token subject refused
|
|
# - Last-owner protection on the new shared drive
|
|
# - Group-mediated Owner: caller_role resolves the strongest role
|
|
# (MIN over direct + group grants)
|
|
# - Editor cascade through the drive precheck (Bob gets Editor on
|
|
# a shared drive via the membership API and sees the drive)
|
|
# - Role demotion: PATCH Bob from Editor to Viewer reflects in his
|
|
# listing
|
|
#
|
|
# Per-role mutation matrix coverage (steps 23-29):
|
|
# - Owner CAN rename the drive (positive symmetry)
|
|
# - Owner CAN edit owners / editors / viewers (grant Owner, promote
|
|
# and demote across all role boundaries)
|
|
# - Viewer CANNOT POST / PATCH / DELETE members → 404
|
|
# - Editor CANNOT POST / PATCH / DELETE members → 404
|
|
# - Editor CAN modify drive content (positive role-bundle check)
|
|
# - Viewer CAN read drive content (positive role-bundle check)
|
|
# - Non-member sees 404 on every member-mutation verb AND on
|
|
# GET /members (anti-enum: no existence leak)
|
|
#
|
|
# **Known gap** surfaced by Step 26d: today's folder rename uses
|
|
# `Permission::Update`, which is in Editor's bundle. The plan
|
|
# (`drive.md §6`) says drive rename should be Owner-only. If/when
|
|
# tightening: change the folder service to require `Manage` (or a
|
|
# new `RenameDrive` permission) for folders that are drive roots.
|
|
#
|
|
# Self-contained: provisions its own users so it can run after
|
|
# drives_foundation without aliasing state.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 1 — admin login
|
|
# ─────────────────────────────────────────────────────────────
|
|
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.full.user.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Admin's GET /api/drives now includes `caller_role`.
|
|
# Personal-drive owner role is seeded by
|
|
# PersonalDriveLifecycleHook on user creation.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[0].kind" == "personal"
|
|
jsonpath "$[0].default_for_user" == "{{admin_user_id}}"
|
|
jsonpath "$[0].caller_role" == "owner"
|
|
|
|
[Captures]
|
|
admin_drive_id: jsonpath "$[0].id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — GET /api/drives/{id}/members returns the lifecycle-
|
|
# seeded Owner row and only that.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives/{{admin_drive_id}}/members
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].subject.type" == "user"
|
|
jsonpath "$[0].subject.id" == "{{admin_user_id}}"
|
|
jsonpath "$[0].resource.type" == "drive"
|
|
jsonpath "$[0].resource.id" == "{{admin_drive_id}}"
|
|
jsonpath "$[0].role" == "owner"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Create a fresh user (mbr_alice) so we have a second
|
|
# subject the personal-drive guard can refuse on.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "username": "mbr_alice", "password": "MbrAlicePassword1!", "email": "mbr_alice@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
alice_user_id: jsonpath "$.user.id"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "mbr_alice", "password": "MbrAlicePassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
alice_token: jsonpath "$.access_token"
|
|
|
|
# Alice's GET /api/drives surfaces her own default with caller_role=owner.
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].caller_role" == "owner"
|
|
jsonpath "$[0].default_for_user" == "{{alice_user_id}}"
|
|
|
|
[Captures]
|
|
alice_drive_id: jsonpath "$[0].id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Personal-drive guard via the dedicated endpoint:
|
|
# POST /api/drives/{id}/members refuses with 405.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives/{{admin_drive_id}}/members
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 405
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — PATCH on a personal drive's members → 405 (even for
|
|
# the owner row itself; personal drives' membership
|
|
# is structurally immutable).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/api/drives/{{admin_drive_id}}/members/user/{{admin_user_id}}
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "role": "editor" }
|
|
|
|
HTTP 405
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — DELETE on a personal drive's owner → 405. (Verifies
|
|
# the guard fires BEFORE the last-owner check; the order
|
|
# matters for the right error.)
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/drives/{{admin_drive_id}}/members/user/{{admin_user_id}}
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 405
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Generic /api/grants delegation. POST /api/grants with
|
|
# resource.type='drive' on a personal drive must hit the
|
|
# same guard, otherwise the membership rules are bypassable.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/grants
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"resource": { "type": "drive", "id": "{{admin_drive_id}}" },
|
|
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 405
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 9 — PUT /api/grants/role (silent admin update) on a drive
|
|
# resource — also delegated. Same guard fires.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"resource": { "type": "drive", "id": "{{admin_drive_id}}" },
|
|
"subject": { "type": "user", "id": "{{admin_user_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 405
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 10 — Drive grants in /api/grants/incoming/resources.
|
|
# Default UI calls pass `resource_types=file,folder` so
|
|
# drives don't appear; explicit `resource_types=drive`
|
|
# must surface the admin's Owner grant on their own drive.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/incoming/resources?resource_types=drive
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.items" count >= 1
|
|
jsonpath "$.items[0].resource_type" == "drive"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 11 — Anti-enum: Alice queries the members of admin's
|
|
# personal drive. She has no Read on it → 404, same
|
|
# shape as "drive doesn't exist". Operators see the
|
|
# real reason in the audit log; she sees nothing.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives/{{admin_drive_id}}/members
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 12 — The same anti-enum shape for a UUID that doesn't
|
|
# exist at all. Indistinguishable from step 11 to the
|
|
# caller — the canonical no-leak response.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives/00000000-0000-0000-0000-000000000000/members
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# =============================================================
|
|
# D3a — POST /api/drives (create shared drive)
|
|
# =============================================================
|
|
# Below covers the create-shared-drive endpoint + the role-bundle
|
|
# tests that were deferred until shared-drive creation was wirable:
|
|
#
|
|
# - admin-only authz gate
|
|
# - kind=personal returns 501 (placeholder)
|
|
# - Owner subject = user → single Owner shared drive
|
|
# - Owner subject = group with members → group-mediated Owner
|
|
# - Owner subject = empty group → 400 (no orphan Owner)
|
|
# - Token subject refused
|
|
# - Editor cascade: drive Owner can mutate content in the new drive
|
|
# - Last-owner protection on removal
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 13 — Non-admin caller refused with 403.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "should-not-exist",
|
|
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
|
}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 14 — kind=personal returns 501 (wire-shape placeholder).
|
|
# The body is accepted as valid JSON; the rejection is
|
|
# explicit at the service layer.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "personal",
|
|
"name": "side-private",
|
|
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
|
}
|
|
|
|
HTTP 501
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 15 — Create a shared drive with a single user owner
|
|
# (Alice). The new drive lands with kind=shared,
|
|
# default_for_user=NULL, and Alice as the sole Owner.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "alice-shared",
|
|
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
|
}
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
jsonpath "$.kind" == "shared"
|
|
jsonpath "$.name" == "alice-shared"
|
|
jsonpath "$.default_for_user" not exists
|
|
jsonpath "$.used_bytes" == 0
|
|
|
|
[Captures]
|
|
alice_shared_drive_id: jsonpath "$.id"
|
|
|
|
|
|
# Alice now sees TWO drives — her default Personal + the new shared.
|
|
# Caller_role on the shared drive is "owner" (her user grant).
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 2
|
|
jsonpath "$[*].id" contains {{alice_shared_drive_id}}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 16 — Last-owner protection: Alice is the sole Owner of
|
|
# the new shared drive. Removing her grant must refuse.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 400
|
|
|
|
|
|
# And the demotion form: PATCH her role to editor → same refusal.
|
|
PATCH {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "role": "editor" }
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 17 — Empty group is refused (would orphan the drive's Owner).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/groups
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "name": "empty-grp-for-drive" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
empty_group_id: jsonpath "$.id"
|
|
|
|
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "should-not-exist",
|
|
"owner": { "type": "group", "id": "{{empty_group_id}}" }
|
|
}
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 18 — Empty name is refused (basic validation).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": " ",
|
|
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
|
}
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 19 — Token subject is refused (drives can't be owned by
|
|
# share-link tokens).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "should-not-exist",
|
|
"owner": { "type": "token", "id": "00000000-0000-0000-0000-000000000099" }
|
|
}
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 20 — Group-mediated Owner: create a group, add Alice, then
|
|
# create a shared drive with the group as Owner. Alice
|
|
# should see the new drive in her listing with
|
|
# caller_role="owner" (resolved through the group).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/groups
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "name": "drive-grp-with-alice" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
alice_group_id: jsonpath "$.id"
|
|
|
|
|
|
POST {{base_url}}/api/groups/{{alice_group_id}}/members
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "user_id": "{{alice_user_id}}" }
|
|
|
|
HTTP 201
|
|
|
|
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "team-drive",
|
|
"owner": { "type": "group", "id": "{{alice_group_id}}" }
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
team_drive_id: jsonpath "$.id"
|
|
team_root_folder_id: jsonpath "$.root_folder_id"
|
|
|
|
|
|
# Alice's drive listing now includes the team drive with caller_role=owner.
|
|
# `MIN(role)` over (direct grants + group-mediated grants) resolves Owner.
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[*].id" contains {{team_drive_id}}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 21 — Editor cascade through the drive precheck. Add a fresh
|
|
# user (mbr_bob) as Editor on the team drive; he should
|
|
# be able to read the drive root and create folders in it
|
|
# via the drive's Editor permission bundle, without any
|
|
# per-folder grant.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "username": "mbr_bob", "password": "MbrBobPassword1!", "email": "mbr_bob@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
bob_user_id: jsonpath "$.user.id"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "mbr_bob", "password": "MbrBobPassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
bob_token: jsonpath "$.access_token"
|
|
|
|
|
|
# Bob has no role on the team drive → drive doesn't appear in his listing.
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[*].id" not contains {{team_drive_id}}
|
|
|
|
|
|
# Admin (well — Alice as drive Owner; admin would also work) grants Bob Editor.
|
|
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 201
|
|
|
|
|
|
# Bob now sees the drive with caller_role=editor.
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
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
|
|
# must remain Editor (the stronger of his two grants).
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Demote Bob to Viewer via PATCH — first ensure he was editor before
|
|
# (already confirmed via the GET above).
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "role": "viewer" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.role" == "viewer"
|
|
|
|
|
|
# Bob's listing now reflects the demotion.
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
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. Bob has Read on the
|
|
# drive (viewer role cascades) → graduated denial returns
|
|
# 403 (see [[project_authz_require_graduated_denial]]).
|
|
# Also verify the batch / overwrite paths refuse — the
|
|
# whole chain from drive-membership to file write is
|
|
# exercised here.
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
# 22b.i — Fresh file: 403.
|
|
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 403
|
|
|
|
|
|
# 22b.ii — Overwrite attempt on the Editor-era upload: still 403.
|
|
# `save_file_with_blob` catches the duplicate name at the
|
|
# `Create`-permission check before the upsert races (which
|
|
# would otherwise 409).
|
|
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 403
|
|
|
|
|
|
# 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
|
|
# =============================================================
|
|
# Setup state at this point:
|
|
# - team_drive owners: alice (via alice_group) — sole Owner role grant
|
|
# - team_drive Viewer: bob (user grant after Step 22 demotion)
|
|
#
|
|
# Steps 23-29 cover the per-role authorization matrix on member
|
|
# management + drive rename + content R/W. Anti-enum: every refusal
|
|
# returns 404 (not 403) so an unauthorised caller can't enumerate the
|
|
# difference between "drive doesn't exist" and "you can't manage it".
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 23 — Owner CAN rename the drive.
|
|
# Drive name lives on its root folder per drive.md §6,
|
|
# renamed via PUT /api/folders/<root_folder_id>/rename.
|
|
# Alice's Owner role (via her group) carries Update, so
|
|
# the engine drive precheck grants the rename.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "team-drive-renamed" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.name" == "team-drive-renamed"
|
|
|
|
|
|
# The new name surfaces on the drive listing too — drive.name is
|
|
# sourced from the root folder per DriveDto::From<DriveWithRootName>.
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Filter expressions in Hurl: `[?(...)]` collapses to a scalar when there's
|
|
# exactly one match — list-style predicates like `includes` / `contains` then
|
|
# fail with a type mismatch. So we assert string equality instead.
|
|
jsonpath "$[?(@.id=='{{team_drive_id}}')].name" == "team-drive-renamed"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 24 — Owner CAN edit owners, editors, and viewers.
|
|
# Grant Carol Owner, promote Bob to Owner, then demote
|
|
# Bob back to Viewer (the role he needs for Step 25).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "username": "mbr_carol", "password": "MbrCarolPassword1!", "email": "mbr_carol@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
carol_user_id: jsonpath "$.user.id"
|
|
|
|
|
|
# 24a — Owner grants Carol Owner role (Owner-creates-Owner).
|
|
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{carol_user_id}}" },
|
|
"role": "owner"
|
|
}
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
jsonpath "$.role" == "owner"
|
|
|
|
|
|
# 24b — Owner promotes Bob (Viewer) to Owner.
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "role": "owner" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.role" == "owner"
|
|
|
|
|
|
# 24c — Owner demotes Bob back to Viewer (last-owner protection
|
|
# allows it: Carol + Alice-via-group remain as Owners).
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "role": "viewer" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.role" == "viewer"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 25 — Viewer CANNOT edit drive members.
|
|
# Bob is Viewer (has Read on the drive) → graduated denial
|
|
# returns 403 on every member-mutation verb.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 403
|
|
|
|
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{ "role": "viewer" }
|
|
|
|
HTTP 403
|
|
|
|
|
|
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 26 — Editor CANNOT edit drive members + CANNOT rename
|
|
# the drive (rename = PUT on the drive's root folder).
|
|
# Promote Bob to Editor first (Owner-driven).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "role": "editor" }
|
|
|
|
HTTP 200
|
|
|
|
|
|
# 26a — Editor POST /api/drives/{id}/members → 403 (Editor has Read).
|
|
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# 26b — Editor PATCH a member → 403.
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{ "role": "viewer" }
|
|
|
|
HTTP 403
|
|
|
|
|
|
# 26c — Editor DELETE a member → 403.
|
|
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# 26d — Editor renames the drive (root folder) → 403.
|
|
# Folder rename normally requires `Permission::Update` (which
|
|
# Editor has on every folder in the drive via the engine's drive
|
|
# precheck). The folder service promotes the requirement to
|
|
# `Permission::Manage` when the target folder has `parent_id IS
|
|
# NULL` — i.e. it's a drive root — so the drive-rename surface is
|
|
# Owner-only per drive.md §6, without changing the public folder
|
|
# endpoint shape. Editor has Read → graduated denial → 403.
|
|
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{ "name": "team-drive-editor-renamed" }
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 27 — Editor CAN modify content in the drive (positive).
|
|
# Confirms the Editor bundle isn't accidentally too
|
|
# restrictive — they can create folders under the drive
|
|
# root via the drive precheck.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{bob_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "editor-created-folder",
|
|
"parent_id": "{{team_root_folder_id}}"
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
editor_created_folder_id: jsonpath "$.id"
|
|
[Asserts]
|
|
jsonpath "$.name" == "editor-created-folder"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 28 — Viewer CAN read content in the drive (positive).
|
|
# Demote Bob back to Viewer, then confirm he can still
|
|
# list the drive's root folder.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "role": "viewer" }
|
|
|
|
HTTP 200
|
|
|
|
|
|
GET {{base_url}}/api/folders/{{team_root_folder_id}}
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 29 — A user with NO role on the drive cannot edit members.
|
|
# Provision a fresh user (mbr_dave) with no grants on
|
|
# the team drive; every member-mutation verb → 404.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "username": "mbr_dave", "password": "MbrDavePassword1!", "email": "mbr_dave@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
dave_user_id: jsonpath "$.user.id"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "mbr_dave", "password": "MbrDavePassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
dave_token: jsonpath "$.access_token"
|
|
|
|
|
|
# 29a — Non-member POST → 404 (the drive itself appears not to exist).
|
|
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# 29b — Non-member PATCH → 404.
|
|
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "role": "viewer" }
|
|
|
|
HTTP 404
|
|
|
|
|
|
# 29c — Non-member DELETE → 404.
|
|
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# 29d — Non-member GET members → 404 too (anti-enum: no member-list leak).
|
|
GET {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 30 — Drive delete (D3b).
|
|
# - Non-Owner → 403 (Bob is Viewer post-Step 28, has Read).
|
|
# - Owner on non-empty drive → 409 (the editor-created-folder
|
|
# from Step 27 is still live).
|
|
# - Owner after the folder is trashed → 204.
|
|
# Personal-drive refusal (default_for_user IS NOT NULL) is
|
|
# covered separately — `mbr_dave` keeps his default drive,
|
|
# we exercise its 405 below.
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
# 30a — Viewer (Bob) cannot delete the drive → 403 (has Read).
|
|
DELETE {{base_url}}/api/drives/{{team_drive_id}}
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# 30b — Owner (Alice) on a non-empty drive → 409 with the canonical
|
|
# "drive_not_empty" reason in the audit log.
|
|
DELETE {{base_url}}/api/drives/{{team_drive_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 409
|
|
|
|
|
|
# 30c — Clear the lingering content (the Editor-created folder from
|
|
# 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}}
|
|
|
|
HTTP 204
|
|
|
|
|
|
# 30e — Drive is gone; subsequent reads return 404.
|
|
GET {{base_url}}/api/drives/{{team_drive_id}}/members
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# 30f — Default Personal drive — Dave's home — cannot be deleted.
|
|
# Look up the drive id via the picker listing. Dave is a fresh
|
|
# user and only has his default personal drive, so `$[0].id`
|
|
# is unambiguous. (Avoiding the `[?(...)]` filter — Hurl
|
|
# collapses single-match results to a scalar, which breaks
|
|
# `nth` / list-style assertions; see memory.)
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
dave_default_drive_id: jsonpath "$[0].id"
|
|
[Asserts]
|
|
jsonpath "$[0].default_for_user" == "{{dave_user_id}}"
|
|
|
|
DELETE {{base_url}}/api/drives/{{dave_default_drive_id}}
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 405
|