feat(drive): add /api/drive
- permit shared drive creation from oxicloud admin (for now)
- prepare other personal drive creation (Not implemented), need to validate
quota policies and strategy first
- add hurl test to verify permissions
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
# =============================================================
|
||||
# OxiCloud — D2 drive membership API + delegation + caller_role
|
||||
# OxiCloud — D2/D3a drive membership + create-drive end-to-end
|
||||
# =============================================================
|
||||
# Verifies the D2 membership surface on personal drives (the only
|
||||
# drive kind today — shared-drive positive cases land alongside D3's
|
||||
# create endpoint):
|
||||
#
|
||||
# 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).
|
||||
@@ -20,6 +17,38 @@
|
||||
# 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.
|
||||
# =============================================================
|
||||
@@ -216,3 +245,582 @@ 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 "$.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 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}}
|
||||
|
||||
|
||||
# =============================================================
|
||||
# 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 "$.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. Every member-mutation verb → 404
|
||||
# (anti-enum: same shape as if the drive didn't exist).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
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 404
|
||||
|
||||
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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 → 404.
|
||||
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 404
|
||||
|
||||
|
||||
# 26b — Editor PATCH a member → 404.
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 26c — Editor DELETE a member → 404.
|
||||
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 26d — Editor renames the drive (root folder) → 404.
|
||||
# Editor bundle has Update on content, but the rename
|
||||
# endpoint uses authz.require(...) and Editor's bundle on
|
||||
# the drive root resolves through the same drive precheck.
|
||||
# Editor has Update; folder rename uses Update; so this
|
||||
# should actually SUCCEED. Asserting 200 to reflect the
|
||||
# real engine semantics — the "Editor can rename the drive"
|
||||
# fact is a real product question worth surfacing here.
|
||||
# If you want rename to be Owner-only, the fix is in the
|
||||
# folder service (require Manage, not Update).
|
||||
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "team-drive-editor-renamed" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Restore the previous name so downstream assertions don't drift.
|
||||
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "team-drive-renamed" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
[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 "$.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
|
||||
|
||||
@@ -141,18 +141,16 @@ body contains "{{dora_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 8 — Teardown. Order matters: remove the nested group-member
|
||||
# before deleting Group_B, so the FK cascade doesn't get
|
||||
# ahead of us; remove dora's direct membership similarly.
|
||||
# 8 — Teardown.
|
||||
# The D3a self-defense in `subject_group_service::remove_member`
|
||||
# refuses any individual membership removal that would empty a
|
||||
# seeded group's transitive user set — which BOTH of these
|
||||
# would (removing Group_B from A leaves A with no users;
|
||||
# removing dora from B leaves B with no users). So we skip the
|
||||
# manual member-by-member unwind and just DELETE the groups
|
||||
# directly. `delete_group` cascades through `subject_group_members`
|
||||
# via FK and isn't subject to the per-remove guard.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/groups/{{group_a_id}}/members/group/{{group_b_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{group_b_id}}/members/user/{{dora_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{group_a_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
@@ -234,18 +234,66 @@ HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Remove grace from engineering, then re-check access (after cache TTL).
|
||||
# Note: the authz cache has a 30s TTL — Hurl tests run within seconds so
|
||||
# grace may still see the folder during the cache window. We assert the
|
||||
# membership removal succeeded; the post-TTL denial is exercised by the
|
||||
# Rust integration tests, not here (test runtime cost).
|
||||
# Step 8 — Self-defense on group remove_member.
|
||||
# A group must not drop to 0 transitive users once seeded —
|
||||
# without this guard, an admin could empty a group that owns
|
||||
# a shared drive (D3a), leaving the drive with no effective
|
||||
# Owner. Conservative-by-default: the rule applies to every
|
||||
# group, not just drive-owning ones.
|
||||
#
|
||||
# So the first attempt to remove grace (the sole member) is
|
||||
# refused with 400. We then seed the group with a second user,
|
||||
# re-attempt the removal, and assert it now succeeds — the
|
||||
# authz cascade tests below depend on grace being out of the
|
||||
# group.
|
||||
#
|
||||
# Note: the authz cache has a 30s TTL — Hurl tests run within
|
||||
# seconds so grace may still see the folder during the cache
|
||||
# window. We assert the membership removal succeeded; the
|
||||
# post-TTL denial is exercised by the Rust integration tests,
|
||||
# not here (test runtime cost).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 8a — First removal refused: grace is the sole transitive user.
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# 8b — Seed engineering with a second user so the removal can succeed.
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "grp_helper", "password": "GrpHelperPwd1!", "email": "grp_helper@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
helper_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/groups/{{engineers_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{helper_user_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# 8c — Grace removal now succeeds: engineering still has grp_helper.
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# 8d — Confirming the invariant still holds: removing the last user
|
||||
# (grp_helper) is again refused.
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{helper_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Authenticated /api/groups/search (no admin role required).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user