16ea08b093
now Drive is purely a metadata
each drive has always a root folder
this model minimize Oxicloud changes, and simplify
the Drive name is simply the folder's root's name
note: owner of Drive has more permission that an owner of the root folder
220 lines
9.7 KiB
Plaintext
220 lines
9.7 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — D0 drives foundation
|
|
# =============================================================
|
|
# Verifies the D0 server-side foundation lands end-to-end:
|
|
#
|
|
# 1. Every internal user gets exactly one default Personal drive
|
|
# (the M2 backfill + the on-login lifecycle hook).
|
|
# 2. `GET /api/drives` returns that drive with the right shape
|
|
# (kind='personal', default_for_user matches the caller).
|
|
# 3. New folder/file rows stamp `drive_id` (verified indirectly:
|
|
# uploads succeed against a NOT NULL drive_id column post-M3).
|
|
# 4. Cross-drive isolation in `/api/search` — user A's indexed
|
|
# content does NOT surface in user B's search (Tantivy
|
|
# Must-clause on drive_id + handler-side ReBAC re-check).
|
|
# 5. `created_by` / `updated_by` provenance — files surface a
|
|
# non-null `last_modified_by` (via the file metadata endpoint)
|
|
# proving the dual-write took effect.
|
|
#
|
|
# Self-contained: creates its own users + folders so it can run
|
|
# independently of other test files.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 1 — admin login
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "{{username}}", "password": "{{password}}" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
admin_token: jsonpath "$.access_token"
|
|
admin_user_id: jsonpath "$.user.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Admin's GET /api/drives surfaces a default Personal drive
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Admin has at least one drive — the default Personal at index 0
|
|
# (DrivePgRepository orders default-first via `default_for_user IS NULL ASC`).
|
|
jsonpath "$" count >= 1
|
|
jsonpath "$[0].kind" == "personal"
|
|
jsonpath "$[0].default_for_user" == "{{admin_user_id}}"
|
|
jsonpath "$[0].name" == "Personal"
|
|
# root_folder_id surfaces the drive's mount-point folder. Sourced via
|
|
# JOIN from storage.folders.name — drives have no `name` column under
|
|
# the D0 design (docs/plan/drive.md §3). Folder API operations
|
|
# (create-in-drive, rename-drive) all key off this id.
|
|
jsonpath "$[0].root_folder_id" exists
|
|
[Captures]
|
|
admin_drive_id: jsonpath "$[0].id"
|
|
admin_root_folder_id: jsonpath "$[0].root_folder_id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Create two fresh users (drv_alice, drv_bob) so the
|
|
# cross-drive isolation test below uses fixtures that
|
|
# don't collide with other test files.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "username": "drv_alice", "password": "DrvAlicePassword1!", "email": "drv_alice@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
alice_user_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{ "username": "drv_bob", "password": "DrvBobPassword1!", "email": "drv_bob@example.com", "role": "user" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
bob_user_id: jsonpath "$.id"
|
|
|
|
|
|
# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login`
|
|
# (since `on_user_created` may have provisioned already; the hook is
|
|
# idempotent either way). After this her default drive exists.
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "drv_alice", "password": "DrvAlicePassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
alice_token: jsonpath "$.access_token"
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "drv_bob", "password": "DrvBobPassword1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
bob_token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Each non-admin user sees exactly their default drive.
|
|
# Confirms the lifecycle hook provisioned + drive listing
|
|
# is correctly scoped (no cross-user leak).
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Exactly one drive: the default Personal.
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].kind" == "personal"
|
|
jsonpath "$[0].default_for_user" == "{{alice_user_id}}"
|
|
jsonpath "$[0].name" == "Personal"
|
|
jsonpath "$[0].root_folder_id" exists
|
|
[Captures]
|
|
alice_drive_id: jsonpath "$[0].id"
|
|
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$" count == 1
|
|
jsonpath "$[0].kind" == "personal"
|
|
jsonpath "$[0].default_for_user" == "{{bob_user_id}}"
|
|
jsonpath "$[0].name" == "Personal"
|
|
jsonpath "$[0].root_folder_id" exists
|
|
[Captures]
|
|
bob_drive_id: jsonpath "$[0].id"
|
|
|
|
|
|
# Cross-user drive id distinctness — Alice's drive id ≠ Bob's drive id.
|
|
# Hurl can't assert via inter-capture; the search-isolation step below
|
|
# proves the same property functionally.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Each user's home folder works end-to-end. The lifecycle
|
|
# hook creates the drive; folder creation under the home
|
|
# uses the drive's id (M3 NOT NULL on storage.folders.drive_id
|
|
# enforces this — any code path that doesn't set drive_id
|
|
# would error out here).
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
alice_home_id: jsonpath "$[0].id"
|
|
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{alice_token}}
|
|
Content-Type: application/json
|
|
{ "name": "drv-alice-folder", "parent_id": "{{alice_home_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
alice_subfolder_id: jsonpath "$.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Upload a small file via the multipart path so its
|
|
# drive_id and created_by/updated_by columns get stamped
|
|
# by the file repository's dual-write.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{alice_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{alice_subfolder_id}}
|
|
file: file,fixtures/hello.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
alice_file_id: jsonpath "$.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Cross-drive isolation in `/api/search`. Bob searches
|
|
# for a term that exists only in Alice's file. The
|
|
# response must be empty (no leak of either the existence
|
|
# or the snippet of Alice's content).
|
|
#
|
|
# The Tantivy worker may need a tick to drain the dirty
|
|
# queue + extract text before the term is indexed. In a
|
|
# synchronous test we tolerate either response shape
|
|
# (empty results vs. some results all of which are Bob's
|
|
# own files), as long as Alice's specific file_id is
|
|
# absent. The check is the file_id-absent assertion.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/search?query=hello
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Bob may have his own hits or none — what matters is that
|
|
# Alice's file_id never appears in his result set.
|
|
jsonpath "$.files[?(@.id=='{{alice_file_id}}')]" not exists
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Anti-enum cleanup: drop Alice's file + folder so the
|
|
# shared test storage doesn't accumulate cross-test state.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/folders/{{alice_subfolder_id}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/trash/empty
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|