d9987782c4
- provide is_shared and is_favorite information in DTO, information propagated as badge/buttons per items
regarding performances I try to be minimalis on SQL to prevent any perf regression
- remove old set of sharedids and favoriteids (was not functionnal anymore)
- fix date picker (no past selection) in grant
- fix contextmenu close on /files section
1449 lines
54 KiB
Plaintext
1449 lines
54 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — ReBAC grant management (POST/DELETE/GET /api/grants)
|
|
# =============================================================
|
|
# Exercises cross-user grants, cascading, roles, revoke, lifecycle
|
|
# cleanup. Uses ONLY endpoints that route through the
|
|
# AuthorizationEngine — handler-layer inline checks (e.g.
|
|
# GET /api/folders/{id}) are scheduled for cleanup separately.
|
|
#
|
|
# Runs AFTER permissions.hurl (bob already exists). Self-contained
|
|
# resources (unique names) so it doesn't depend on prior state.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 1 — Login as admin (Alice), capture token + home folder.
|
|
# `alice_user_id` is captured for the D0 §14 provenance assertions
|
|
# that compare `created_by` / `updated_by` on resources Alice owns.
|
|
# ─────────────────────────────────────────────────────────────
|
|
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.id"
|
|
|
|
GET {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
alice_home_id: jsonpath "$[0].id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Create two test users specific to this file (dave + eve).
|
|
# Avoids cross-file dependencies on bob from permissions.hurl
|
|
# and gives us their user_id directly from the create response.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "username": "dave", "password": "DavePassword1!", "email": "dave@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
dave_user_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "username": "eve", "password": "EvePassword1!", "email": "eve@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
eve_user_id: jsonpath "$.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Login dave and eve.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "dave", "password": "DavePassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
dave_token: jsonpath "$.access_token"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "eve", "password": "EvePassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
eve_token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Alice creates a folder "grant-shared" + a child "grant-child".
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "grant-shared", "parent_id": "{{alice_home_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
shared_folder_id: jsonpath "$.id"
|
|
[Asserts]
|
|
# D0 §14 provenance — Alice creates, so both fields stamp Alice.
|
|
jsonpath "$.created_by" == "{{alice_user_id}}"
|
|
jsonpath "$.updated_by" == "{{alice_user_id}}"
|
|
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "grant-child", "parent_id": "{{shared_folder_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
child_folder_id: jsonpath "$.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Without any grant, bob cannot rename Alice's folder.
|
|
# PUT /api/folders/{id}/rename goes through the engine →
|
|
# 404 (anti-enumeration).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "bob-tried" }
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Alice grants Bob the Viewer role. Server expands → [read].
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/grants
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 201
|
|
# Cleanup PR: one role row per (subject, resource). `CreateGrantResponseDto`
|
|
# wraps a single role-keyed Grant in `.grants[0]`.
|
|
[Asserts]
|
|
jsonpath "$.grants" count == 1
|
|
jsonpath "$.grants[0].role" == "viewer"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Viewer cannot rename (no Update grant). Dave has Read
|
|
# (viewer role) → graduated denial returns 403.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "bob-tried-again" }
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Bob's incoming grants list contains the new grant.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].role" == "viewer"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 9 — Promote Bob to Owner (covers comment, create, update, share,
|
|
# delete, manage). PUT /api/grants/role replaces the role in one
|
|
# UPSERT against `storage.role_grants`.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
|
"role": "owner"
|
|
}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].role" == "owner"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 10 — Bob can now rename (Owner includes update).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "renamed-by-bob-as-admin" }
|
|
|
|
HTTP 200
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 11 — Cascading: Bob can also rename the CHILD folder, because
|
|
# his Update grant on the parent cascades via ltree to the
|
|
# child resource — even though no direct grant on the child.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{child_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "renamed-child-via-cascade" }
|
|
|
|
HTTP 200
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 12 — Bob re-shares to Carol (he has Share via Owner).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/grants
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{eve_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
eve_grant_id: jsonpath "$.grants[0].id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 13 — Carol can see the grant in her incoming list.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{eve_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].role" == "viewer"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 14 — Bob's outgoing grants list contains the grant to Carol.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/outgoing
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{eve_grant_id}}')].id" == "{{eve_grant_id}}"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 15 — Demote Bob to Viewer; he loses update/share/etc.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].role" == "viewer"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 16 — Demoted Bob (now Viewer) can no longer rename. Read
|
|
# is still granted → graduated denial returns 403.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "bob-tried-after-demote" }
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 17 — Lifecycle: Alice deletes the folder. The DB trigger
|
|
# trg_cleanup_role_grants_folder removes both bob's and
|
|
# carol's grants automatically (also for the cascade-deleted
|
|
# child).
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/folders/{{child_folder_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/folders/{{shared_folder_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/trash/empty
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 18 — After permanent delete, Bob's incoming list no longer
|
|
# contains the deleted folder's grant.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Every user carries three self-owned Owner grants provisioned by
|
|
# the lifecycle hooks:
|
|
# * personal drive (PersonalDriveLifecycleHook, D0)
|
|
# * default calendar (DefaultCalendarLifecycleHook, #545)
|
|
# * default address book (DefaultAddressBookLifecycleHook, #545)
|
|
# The pre-lifecycle-hook assertion here was "no grants at all"
|
|
# (count == 0). D0 shifted it to "exactly the drive Owner grant"
|
|
# (count == 1). Adding the CalDAV/CardDAV defaults shifts it again
|
|
# to count == 3. Body-contains checks for each resource type are
|
|
# ordering-agnostic (the incoming feed doesn't guarantee stable
|
|
# ordering across resource types) and mirror the pattern used by
|
|
# default_caldav_carddav.hurl.
|
|
jsonpath "$" count == 3
|
|
body contains "\"type\":\"drive\""
|
|
body contains "\"type\":\"calendar\""
|
|
body contains "\"type\":\"address_book\""
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 19 — Same for Carol.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{eve_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# See Step 18 for the invariant rationale (three self-owned Owner
|
|
# grants per user from the lifecycle hooks).
|
|
jsonpath "$" count == 3
|
|
body contains "\"type\":\"drive\""
|
|
body contains "\"type\":\"calendar\""
|
|
body contains "\"type\":\"address_book\""
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# PHASE 2 — Comprehensive permission coverage with fresh user "adam".
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Exercises every engine-aware endpoint at each permission tier:
|
|
#
|
|
# no grant → all read/write/delete operations return 404
|
|
# Viewer → read endpoints OK, modify/delete endpoints return 404
|
|
# Editor → update + create + thumbnail-push OK, delete still 404
|
|
# Admin → everything including delete
|
|
#
|
|
# Endpoints in scope (all routed through the AuthorizationEngine):
|
|
# Folders: /contents · /contents/paginated · /listing · /download (zip)
|
|
# · POST / · PUT /{id}/rename · PUT /{id}/move · DELETE /{id}
|
|
# Files: GET / · GET /{id} (download)
|
|
# · GET /{id}/metadata · GET /{id}/thumbnail/{size}
|
|
# · PUT /{id}/thumbnail/{size} (push, Update)
|
|
# · PUT /{id}/rename · PUT /{id}/move · DELETE /{id}
|
|
# · POST /upload (via folder require_permission)
|
|
#
|
|
# Listing endpoints that are still owner-scoped (GET /api/folders root,
|
|
# GET /api/folders/paginated) are NOT covered here — they don't
|
|
# reflect grants today and are tracked as separate cleanup work.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 20 — Create user adam (fresh, no relationship to alice's tree).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "username": "adam", "password": "AdamPassword1!", "email": "adam@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
adam_user_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "adam", "password": "AdamPassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
adam_token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 21 — Alice creates a fresh shareable folder, sub-folder, and
|
|
# uploads a JPEG (which the server auto-thumbnails).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "perm-test-folder", "parent_id": "{{alice_home_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
perm_folder_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "perm-test-child", "parent_id": "{{perm_folder_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
perm_child_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{alice_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{perm_folder_id}}
|
|
file: file,fixtures/oxicloud-logo.jpg; image/jpeg
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
perm_file_id: jsonpath "$.id"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 2A — Adam has NO grant. Every engine-aware endpoint denies.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
# ── Folder reads ─────────────────────────────────────────────
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources?resource_types=folder
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/download
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
# ── File reads ───────────────────────────────────────────────
|
|
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
GET {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
GET {{base_url}}/api/files/{{perm_file_id}}/metadata
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
# ── Folder mutations ─────────────────────────────────────────
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-attack", "parent_id": "{{perm_folder_id}}" }
|
|
|
|
HTTP 404
|
|
|
|
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-rename-attempt" }
|
|
|
|
HTTP 404
|
|
|
|
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
# ── File mutations ───────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{adam_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{perm_folder_id}}
|
|
file: file,fixtures/hello.txt; text/plain
|
|
|
|
HTTP 404
|
|
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-file-rename" }
|
|
|
|
HTTP 404
|
|
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: image/png
|
|
file,fixtures/blue-image.png;
|
|
|
|
HTTP 404
|
|
|
|
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
# ── Chunked upload: cannot start session in alice's folder ──
|
|
# create_upload_impl pre-checks Permission::Create via require_permission.
|
|
POST {{base_url}}/api/uploads
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"filename": "adam-chunked-attack.mp4",
|
|
"folder_id": "{{perm_folder_id}}",
|
|
"content_type": "video/mp4",
|
|
"total_size": 2760653,
|
|
"chunk_size": 3000000
|
|
}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 2B — Alice grants adam Viewer. Read OK, mutate/delete denied.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
POST {{base_url}}/api/grants
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{adam_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 201
|
|
|
|
# ── Read endpoints now succeed ──────────────────────────────
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources?resource_types=folder
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.items" count == 1
|
|
jsonpath "$.items[0].resource.id" == "{{perm_child_id}}"
|
|
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/download
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
header "Content-Type" contains "zip"
|
|
|
|
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].id" == "{{perm_file_id}}"
|
|
|
|
GET {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/files/{{perm_file_id}}/metadata
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
header "Content-Type" startsWith "image/"
|
|
|
|
# ── Cascading: child folder also readable via parent's grant ─
|
|
GET {{base_url}}/api/folders/{{perm_child_id}}/resources?resource_types=folder
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
|
|
# ── Mutations still denied (Viewer has no Update/Create/Delete).
|
|
# Viewer has Read → graduated denial returns 403 (not 404
|
|
# anti-enum, which is reserved for Phase 2A above where Adam
|
|
# had no Read at all).
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-attack-2", "parent_id": "{{perm_folder_id}}" }
|
|
|
|
HTTP 403
|
|
|
|
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-rename-as-viewer" }
|
|
|
|
HTTP 403
|
|
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-file-rename-as-viewer" }
|
|
|
|
HTTP 403
|
|
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: image/png
|
|
file,fixtures/blue-image.png;
|
|
|
|
HTTP 403
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{adam_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{perm_folder_id}}
|
|
file: file,fixtures/hello.txt; text/plain
|
|
|
|
HTTP 403
|
|
|
|
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 403
|
|
|
|
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 403
|
|
|
|
# ── Viewer cannot start a chunked upload (no Create grant) ──
|
|
POST {{base_url}}/api/uploads
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"filename": "viewer-chunked-attempt.mp4",
|
|
"folder_id": "{{perm_folder_id}}",
|
|
"content_type": "video/mp4",
|
|
"total_size": 2760653,
|
|
"chunk_size": 3000000
|
|
}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 2C — Promote adam to Editor (read + comment + create + update).
|
|
# Create + Update endpoints now succeed; Delete still denied.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{adam_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
# ── Update succeeds ─────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "renamed-by-adam-as-editor" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# D0 §14 provenance — folder counterpart of the file rename below.
|
|
# Adam (Editor) mutates Alice's folder; `updated_by` becomes Adam,
|
|
# `created_by` stays Alice.
|
|
jsonpath "$.created_by" == "{{alice_user_id}}"
|
|
jsonpath "$.updated_by" == "{{adam_user_id}}"
|
|
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-renamed-logo.jpg" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# D0 §14 provenance — Adam (an Editor, not the owner) mutates the
|
|
# file, so `updated_by` switches to Adam's id while `created_by`
|
|
# stays Alice (the original uploader). This is the canonical
|
|
# cross-user provenance check: distinguishes "who first put this
|
|
# here" from "who last touched it" and proves the mutator's id
|
|
# overrides the row's `user_id` (pre-D0 they were silently the
|
|
# same; post-D0 they can diverge once a non-owner mutates).
|
|
jsonpath "$.created_by" == "{{alice_user_id}}"
|
|
jsonpath "$.updated_by" == "{{adam_user_id}}"
|
|
|
|
# ── D0 §14 provenance survives on the LISTING endpoint too ──
|
|
# The rename-response asserts above cover the mutation DTO, but
|
|
# /api/folders/{id}/resources has its own DTO-build path that
|
|
# used to hardcode created_by/updated_by = None (silent bug —
|
|
# owner column rendered "—" on /files for everyone). Hit the
|
|
# listing and re-assert both the untouched folder (both = alice)
|
|
# AND the Adam-renamed file (created_by=alice, updated_by=adam)
|
|
# on the same page — two shapes, one round-trip.
|
|
#
|
|
# Fixed indices are safe because at this point perm_folder_id
|
|
# holds exactly two rows and the default order_by=name puts
|
|
# 'perm-test-child' (folder) at [0] and 'adam-renamed-logo.jpg'
|
|
# (file) at [1]. Anything appended to this folder later in the
|
|
# scenario would break these indices — hence the assertion runs
|
|
# BEFORE the subsequent thumbnail/create/upload steps.
|
|
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.items" count == 2
|
|
# [0] — untouched folder inherits Alice on both fields.
|
|
jsonpath "$.items[0].resource.name" == "perm-test-child"
|
|
jsonpath "$.items[0].resource.created_by" == "{{alice_user_id}}"
|
|
jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}"
|
|
# [1] — file Adam renamed. created_by stays alice (original
|
|
# uploader), updated_by is adam (last mutator). Canonical
|
|
# listing-side cross-user split.
|
|
jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg"
|
|
jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}"
|
|
jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}"
|
|
# Caller-flag contract on the listing endpoint. Neither row
|
|
# favorited by Alice → is_favorite = false. The `perm_folder_id`
|
|
# tree carries a role_grant on the FOLDER (Adam as Editor), not
|
|
# on `perm-test-child` or `adam-renamed-logo.jpg` — so both
|
|
# child rows should carry is_shared = false; the grant on the
|
|
# parent doesn't cascade to per-child EXISTS.
|
|
jsonpath "$.items[0].resource.is_favorite" == false
|
|
jsonpath "$.items[0].resource.is_shared" == false
|
|
jsonpath "$.items[1].resource.is_favorite" == false
|
|
jsonpath "$.items[1].resource.is_shared" == false
|
|
|
|
# ── Thumbnail push (Update) succeeds ────────────────────────
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: image/png
|
|
file,fixtures/blue-image.png;
|
|
|
|
HTTP 201
|
|
|
|
# ── Create succeeds ─────────────────────────────────────────
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-created-child", "parent_id": "{{perm_folder_id}}" }
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
# D0 §14 provenance — Adam (Editor on Alice's folder) creates a
|
|
# child folder inside it. Both `created_by` and `updated_by` stamp
|
|
# Adam: he's the original author AND the last toucher of this
|
|
# fresh row. The parent's owner (Alice) doesn't appear anywhere on
|
|
# the new row's provenance — content authored in a shared scope
|
|
# belongs to its author.
|
|
jsonpath "$.created_by" == "{{adam_user_id}}"
|
|
jsonpath "$.updated_by" == "{{adam_user_id}}"
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{adam_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{perm_folder_id}}
|
|
file: file,fixtures/hello.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
# Same shape for a file upload: Adam authored, Adam touched last.
|
|
jsonpath "$.created_by" == "{{adam_user_id}}"
|
|
jsonpath "$.updated_by" == "{{adam_user_id}}"
|
|
|
|
# ── Chunked upload full lifecycle as Editor ─────────────────
|
|
# 1. Open session (server pre-checks Create on folder)
|
|
POST {{base_url}}/api/uploads
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"filename": "adam-chunked-video.mp4",
|
|
"folder_id": "{{perm_folder_id}}",
|
|
"content_type": "video/mp4",
|
|
"total_size": 2760653,
|
|
"chunk_size": 3000000
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
adam_upload_id: jsonpath "$.upload_id"
|
|
|
|
# 2. Send the single chunk (chunk_size > total_size → 1 chunk).
|
|
PATCH {{base_url}}/api/uploads/{{adam_upload_id}}?chunk_index=0
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/octet-stream
|
|
file,fixtures/free_video_over_1MB.mp4;
|
|
|
|
HTTP 200
|
|
|
|
# 3. Status query: dave (different user) cannot peek at adam's session.
|
|
HEAD {{base_url}}/api/uploads/{{adam_upload_id}}
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 404
|
|
|
|
# 4. Cancel attempt by a different user is rejected.
|
|
DELETE {{base_url}}/api/uploads/{{adam_upload_id}}
|
|
Authorization: Bearer {{dave_token}}
|
|
|
|
HTTP 404
|
|
|
|
# 5. Adam completes the upload — file is created in alice's folder.
|
|
POST {{base_url}}/api/uploads/{{adam_upload_id}}/complete
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
adam_chunked_file_id: jsonpath "$.file_id"
|
|
|
|
# 6. The new file is visible in the folder listing (caller-of-listing is alice).
|
|
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{adam_chunked_file_id}}')].name" == "adam-chunked-video.mp4"
|
|
|
|
# 7. A second session that adam cancels before completing — cleanup path.
|
|
POST {{base_url}}/api/uploads
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"filename": "adam-cancelled.mp4",
|
|
"folder_id": "{{perm_folder_id}}",
|
|
"content_type": "video/mp4",
|
|
"total_size": 2760653,
|
|
"chunk_size": 3000000
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
adam_cancel_id: jsonpath "$.upload_id"
|
|
|
|
DELETE {{base_url}}/api/uploads/{{adam_cancel_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 204
|
|
|
|
# ── Regression pin for AuthZ audit #17 (2026-07-12). ─────────
|
|
# The chunked-upload `complete` handler used to call plain
|
|
# `upload_file_streaming` at finalize — no `_with_perms` check.
|
|
# A grant revoked between session-open and finalize stayed
|
|
# effective until the last chunk landed (up to 24h JWT TTL,
|
|
# forever with app-passwords). Fix: swap to
|
|
# `upload_file_streaming_with_perms` so `authz.require(Create,
|
|
# Folder)` re-runs at complete time.
|
|
#
|
|
# Sequence:
|
|
# 1. Adam (Editor) opens a session — pre-check passes.
|
|
# 2. Adam PATCHes the single chunk (chunk upload is unauth'd,
|
|
# always allowed).
|
|
# 3. Alice DEMOTES Adam to Viewer (Viewer bundle has Read but
|
|
# no Create).
|
|
# 4. Adam POST /complete → 403 (pre-fix: 201 + file created).
|
|
# 5. Cleanup: cancel the orphaned session + re-promote Adam
|
|
# to Editor so the following steps aren't disturbed.
|
|
|
|
# 1 — Open session while Editor.
|
|
POST {{base_url}}/api/uploads
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"filename": "audit17-post-revoke.mp4",
|
|
"folder_id": "{{perm_folder_id}}",
|
|
"content_type": "video/mp4",
|
|
"total_size": 2760653,
|
|
"chunk_size": 3000000
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
audit17_upload_id: jsonpath "$.upload_id"
|
|
|
|
|
|
# 2 — Send the single chunk (session pre-authorised).
|
|
PATCH {{base_url}}/api/uploads/{{audit17_upload_id}}?chunk_index=0
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/octet-stream
|
|
file,fixtures/free_video_over_1MB.mp4;
|
|
|
|
HTTP 200
|
|
|
|
|
|
# 3 — Alice demotes Adam Editor → Viewer (Create removed).
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{adam_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# 4 — Finalize now fails: engine re-checks Create at complete
|
|
# time. Adam still has Read (viewer role) → graduated denial
|
|
# returns 403; pre-fix returned 201 with a phantom file.
|
|
POST {{base_url}}/api/uploads/{{audit17_upload_id}}/complete
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# 5a — The session is orphaned (chunks on disk, no completion).
|
|
# Cancel it as Adam (still owns the session, so the `_with_perms`
|
|
# gate on DELETE-session lets him through).
|
|
DELETE {{base_url}}/api/uploads/{{audit17_upload_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 204
|
|
|
|
|
|
# 5b — Restore Adam to Editor so subsequent steps behave as
|
|
# before this regression pin was inserted.
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{adam_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# ── Delete still denied (Editor excludes Delete). Editor has
|
|
# Read → graduated denial returns 403.
|
|
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 403
|
|
|
|
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 2D — Promote adam to Owner (full bundle, incl. delete). Delete OK.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{adam_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
|
"role": "owner"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 204
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 2E — Lifecycle cleanup. Alice (still the owner) trashes &
|
|
# empties; the trigger removes all role_grants rows.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/trash/empty
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
|
|
# Adam's incoming list holds only his three self-owned Owner grants
|
|
# (drive + calendar + address_book — provisioned by the lifecycle
|
|
# hooks). No inbound grants from other users.
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# See Step 18 above for the full invariant rationale — three
|
|
# self-owned Owner grants per user (drive + calendar +
|
|
# address_book). Body-contains rather than positional check
|
|
# because the incoming feed doesn't guarantee stable ordering
|
|
# across resource types.
|
|
jsonpath "$" count == 3
|
|
body contains "\"type\":\"drive\""
|
|
body contains "\"type\":\"calendar\""
|
|
body contains "\"type\":\"address_book\""
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# PHASE 3 — Batch operations (/api/batch/*)
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Every batch endpoint passes caller_id through to the batch service,
|
|
# which delegates per-item to engine-aware *_with_perms methods. The
|
|
# handler aggregates results: 200 (all OK), 206 (mixed), 400 (all failed).
|
|
#
|
|
# Endpoints exercised:
|
|
# POST /api/batch/files/get · /api/batch/files/move
|
|
# POST /api/batch/files/copy · /api/batch/files/delete
|
|
# POST /api/batch/folders/get · /api/batch/folders/create
|
|
# POST /api/batch/folders/move · /api/batch/folders/copy
|
|
# POST /api/batch/folders/delete · /api/batch/trash
|
|
# POST /api/batch/download · GET /api/batch/download (querystring)
|
|
#
|
|
# Fresh user "frank" — no grants from earlier phases.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step P3.1 — Create frank and login.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "username": "frank", "password": "FrankPassword1!", "email": "frank@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
frank_user_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "frank", "password": "FrankPassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
frank_token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step P3.2 — Alice creates a batch-test folder with 2 sub-folders
|
|
# and 2 files (all owned by alice).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "batch-test", "parent_id": "{{alice_home_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
batch_root_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "batch-sub-A", "parent_id": "{{batch_root_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
batch_sub_a_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "batch-sub-B", "parent_id": "{{batch_root_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
batch_sub_b_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{alice_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{batch_root_id}}
|
|
file: file,fixtures/red-image.png; image/png
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
batch_file_1_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{alice_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{batch_root_id}}
|
|
file: file,fixtures/green-image.png; image/png
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
batch_file_2_id: jsonpath "$.id"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 3A — frank has NO grant. Every batch op returns 400 (all failed).
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
# Files — get
|
|
POST {{base_url}}/api/batch/files/get
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}", "{{batch_file_2_id}}"] }
|
|
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 0
|
|
jsonpath "$.stats.failed" == 2
|
|
|
|
# Files — move
|
|
POST {{base_url}}/api/batch/files/move
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" }
|
|
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.failed" == 1
|
|
|
|
# Files — copy
|
|
POST {{base_url}}/api/batch/files/copy
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" }
|
|
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.failed" == 1
|
|
|
|
# Files — delete
|
|
POST {{base_url}}/api/batch/files/delete
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"] }
|
|
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.failed" == 1
|
|
|
|
# Folders — get
|
|
POST {{base_url}}/api/batch/folders/get
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}", "{{batch_sub_b_id}}"] }
|
|
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.failed" == 2
|
|
|
|
# Folders — create child (no Create on batch_root)
|
|
POST {{base_url}}/api/batch/folders/create
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folders": [{ "name": "frank-attack", "parent_id": "{{batch_root_id}}" }] }
|
|
|
|
HTTP 400
|
|
|
|
# Folders — move
|
|
POST {{base_url}}/api/batch/folders/move
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_sub_b_id}}" }
|
|
|
|
HTTP 400
|
|
|
|
# Folders — copy
|
|
POST {{base_url}}/api/batch/folders/copy
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_sub_b_id}}" }
|
|
|
|
HTTP 400
|
|
|
|
# Folders — delete
|
|
POST {{base_url}}/api/batch/folders/delete
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}"], "recursive": false }
|
|
|
|
HTTP 400
|
|
|
|
# Trash (mixed)
|
|
POST {{base_url}}/api/batch/trash
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "folder_ids": ["{{batch_sub_a_id}}"] }
|
|
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.failed" == 2
|
|
|
|
# Download POST — engine rejects each item; batch service tracks
|
|
# `items_added` and bails out with NotFound when none were authorized.
|
|
POST {{base_url}}/api/batch/download
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "folder_ids": [] }
|
|
|
|
HTTP 404
|
|
|
|
# Download GET (querystring variant) — same behavior
|
|
GET {{base_url}}/api/batch/download?file_ids={{batch_file_1_id}}
|
|
Authorization: Bearer {{frank_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 3B — Alice grants frank Viewer. Read endpoints succeed;
|
|
# mutating batch ops still all-fail.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
POST {{base_url}}/api/grants
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{frank_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{batch_root_id}}" },
|
|
"role": "viewer"
|
|
}
|
|
|
|
HTTP 201
|
|
|
|
# get_files succeeds (Read cascades to all descendants)
|
|
POST {{base_url}}/api/batch/files/get
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}", "{{batch_file_2_id}}"] }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 2
|
|
jsonpath "$.stats.failed" == 0
|
|
|
|
# get_folders succeeds
|
|
POST {{base_url}}/api/batch/folders/get
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}", "{{batch_sub_b_id}}"] }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 2
|
|
|
|
# Download POST as Viewer — succeeds (Read sufficient)
|
|
POST {{base_url}}/api/batch/download
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}", "{{batch_file_2_id}}"], "folder_ids": [] }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
header "Content-Type" == "application/zip"
|
|
|
|
# Download GET — same
|
|
GET {{base_url}}/api/batch/download?file_ids={{batch_file_1_id}},{{batch_file_2_id}}
|
|
Authorization: Bearer {{frank_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
header "Content-Type" == "application/zip"
|
|
|
|
# Mutations still rejected
|
|
POST {{base_url}}/api/batch/files/move
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" }
|
|
|
|
HTTP 400
|
|
|
|
POST {{base_url}}/api/batch/files/delete
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"] }
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 3C — Promote frank to Editor (read + comment + create + update).
|
|
# Move + copy + create succeed; delete still fails.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{frank_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{batch_root_id}}" },
|
|
"role": "editor"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
# Batch folder create (Create on parent)
|
|
POST {{base_url}}/api/batch/folders/create
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"folders": [
|
|
{ "name": "frank-batch-1", "parent_id": "{{batch_root_id}}" },
|
|
{ "name": "frank-batch-2", "parent_id": "{{batch_root_id}}" }
|
|
]
|
|
}
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 2
|
|
|
|
# Batch file move (Update on file + Create on target)
|
|
POST {{base_url}}/api/batch/files/move
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 1
|
|
|
|
# Batch file copy (Read on src + Create on dst)
|
|
POST {{base_url}}/api/batch/files/copy
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_2_id}}"], "target_folder_id": "{{batch_sub_b_id}}" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 1
|
|
|
|
# Batch folder move
|
|
POST {{base_url}}/api/batch/folders/move
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_sub_b_id}}" }
|
|
|
|
HTTP 200
|
|
|
|
# Batch folder copy — copy sub_a (now nested inside sub_b after the
|
|
# move above) back to batch_root. Avoids name collision with the
|
|
# existing sub_b at the root.
|
|
POST {{base_url}}/api/batch/folders/copy
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_root_id}}" }
|
|
|
|
HTTP 200
|
|
|
|
# Batch delete still denied (Editor excludes Delete)
|
|
POST {{base_url}}/api/batch/files/delete
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_2_id}}"] }
|
|
|
|
HTTP 400
|
|
|
|
POST {{base_url}}/api/batch/folders/delete
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_b_id}}"], "recursive": true }
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 3D — Promote frank to Admin. Delete + trash succeed.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
PUT {{base_url}}/api/grants/role
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"subject": { "type": "user", "id": "{{frank_user_id}}" },
|
|
"resource": { "type": "folder", "id": "{{batch_root_id}}" },
|
|
"role": "owner"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
# Frank (Owner role includes Delete) trashes batch_file_2 — item goes to
|
|
# Alice's trash because file.user_id is unchanged (Alice is still owner).
|
|
POST {{base_url}}/api/batch/trash
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_2_id}}"], "folder_ids": [] }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 1
|
|
|
|
# Alice trashes batch_file_1 (which frank moved into batch_sub_a in Phase 3C).
|
|
POST {{base_url}}/api/batch/trash
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "file_ids": ["{{batch_file_1_id}}"], "folder_ids": [] }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.stats.successful" == 1
|
|
|
|
# Batch permanent-delete a folder
|
|
POST {{base_url}}/api/batch/folders/delete
|
|
Authorization: Bearer {{frank_token}}
|
|
Content-Type: application/json
|
|
{ "folder_ids": ["{{batch_sub_b_id}}"], "recursive": true }
|
|
|
|
HTTP 200
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# Phase 3E — Lifecycle cleanup. Alice deletes the batch-test root.
|
|
# Trigger removes all of frank's grants.
|
|
# ════════════════════════════════════════════════════════════════════
|
|
DELETE {{base_url}}/api/folders/{{batch_root_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/trash/empty
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{frank_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# See Step 18 above for the full invariant rationale — three
|
|
# self-owned Owner grants per user (drive + calendar +
|
|
# address_book) from the lifecycle hooks. Body-contains rather
|
|
# than positional check because the incoming feed doesn't
|
|
# guarantee stable ordering across resource types.
|
|
jsonpath "$" count == 3
|
|
body contains "\"type\":\"drive\""
|
|
body contains "\"type\":\"calendar\""
|
|
body contains "\"type\":\"address_book\""
|