diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl new file mode 100644 index 00000000..97587f88 --- /dev/null +++ b/tests/api/drives_membership.hurl @@ -0,0 +1,218 @@ +# ============================================================= +# OxiCloud — D2 drive membership API + delegation + caller_role +# ============================================================= +# Verifies the D2 membership surface on personal drives (the only +# drive kind today — shared-drive positive cases land alongside D3's +# create endpoint): +# +# 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. +# +# 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.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 "$.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 diff --git a/tests/api/run.sh b/tests/api/run.sh index ebe7450f..d0639366 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -152,6 +152,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/groups_effective_members.hurl" \ "$API_DIR/grants_nested_groups.hurl" \ "$API_DIR/drives_foundation.hurl" \ + "$API_DIR/drives_membership.hurl" \ "$API_DIR/external_users.hurl" \ "$API_DIR/search_basic.hurl" \ "$API_DIR/nc_second_user_setup.hurl" \