e3823ce470
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
1305 lines
48 KiB
Plaintext
1305 lines
48 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).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "bob-tried-again" }
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 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 can no longer rename.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
|
Authorization: Bearer {{dave_token}}
|
|
Content-Type: application/json
|
|
{ "name": "bob-tried-after-demote" }
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 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]
|
|
# Post-D0 every user carries an incoming Owner grant on their own
|
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
|
# assertion was "no grants at all" (count == 0); the post-D0
|
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
|
# when nothing matches, so a `count == 0` over a negative filter
|
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].resource.type" == "drive"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 19 — Same for Carol.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{eve_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Post-D0 every user carries an incoming Owner grant on their own
|
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
|
# assertion was "no grants at all" (count == 0); the post-D0
|
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
|
# when nothing matches, so a `count == 0` over a negative filter
|
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].resource.type" == "drive"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 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) ─
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-attack-2", "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-as-viewer" }
|
|
|
|
HTTP 404
|
|
|
|
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
|
Authorization: Bearer {{adam_token}}
|
|
Content-Type: application/json
|
|
{ "name": "adam-file-rename-as-viewer" }
|
|
|
|
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
|
|
|
|
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
|
|
|
|
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
# ── 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 404
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 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}}"
|
|
|
|
# ── 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
|
|
|
|
# ── Delete still denied (Editor excludes Delete) ────────────
|
|
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 404
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 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 is empty.
|
|
GET {{base_url}}/api/grants/incoming
|
|
Authorization: Bearer {{adam_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Post-D0 every user carries an incoming Owner grant on their own
|
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
|
# assertion was "no grants at all" (count == 0); the post-D0
|
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
|
# when nothing matches, so a `count == 0` over a negative filter
|
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].resource.type" == "drive"
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# 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]
|
|
# Post-D0 every user carries an incoming Owner grant on their own
|
|
# personal drive (provisioned by the lifecycle hook). The pre-D0
|
|
# assertion was "no grants at all" (count == 0); the post-D0
|
|
# equivalent is "exactly the self-drive grant remains" (count == 1).
|
|
# Hurl's JSONPath filter returns "no value" — not an empty array —
|
|
# when nothing matches, so a `count == 0` over a negative filter
|
|
# fails to evaluate; the positive-count form sidesteps that quirk.
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].resource.type" == "drive"
|