346 lines
12 KiB
Plaintext
346 lines
12 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — D6 cross-drive move + drive_id cascade
|
|
# =============================================================
|
|
# Run:
|
|
# hurl --variables-file tests/api/test.env --file-root tests \
|
|
# --test tests/api/cross_drive_move.hurl
|
|
#
|
|
# Verifies:
|
|
# 1. File moved across drives lands in the destination drive's
|
|
# subtree AND the file row's `drive_id` syncs to the
|
|
# destination (observed via the per-drive quota sweep:
|
|
# source `used_bytes` drops, target rises).
|
|
# 2. Folder moved across drives ALSO syncs `drive_id` on every
|
|
# descendant — the cascade trigger added by migration
|
|
# `20260807000000_cascade_drive_id_on_folder_move.sql` is
|
|
# the load-bearing piece. Verified by moving a folder with
|
|
# a file inside and watching the destination drive's
|
|
# `used_bytes` jump by the descendant's size (not 0).
|
|
#
|
|
# Sweep convergence: `/api/admin/jobs/usage_reconcile/trigger` is the
|
|
# deterministic synchronisation point — it recomputes every
|
|
# drive's cached `used_bytes` from `SUM(file.size) WHERE
|
|
# drive_id = d.id`. If the file/folder move didn't update
|
|
# `drive_id`, the sweep would re-attribute size to the WRONG
|
|
# drive (or none), and the assertion below would fail.
|
|
#
|
|
# `forbid_cross_drive_move` policy refusal is covered by
|
|
# `tests/api/drive_policies.hurl` Step 11b — this scenario uses
|
|
# the policy OFF (the default) to exercise the happy path.
|
|
#
|
|
# Self-contained: provisions `dm_owner` and a fresh shared drive
|
|
# so it can run alongside the rest of the suite.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 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"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Provision `dm_owner`.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"username": "dm_owner",
|
|
"password": "DmOwnerPwd1!",
|
|
"email": "dm_owner@example.com",
|
|
"role": "user"
|
|
}
|
|
|
|
HTTP 201
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "dm_owner", "password": "DmOwnerPwd1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
owner_token: jsonpath "$.access_token"
|
|
owner_user_id: jsonpath "$.user.full.user.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Capture the user's default Personal drive + root.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/folders
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
personal_root_id: jsonpath "$[0].id"
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
personal_drive_id: jsonpath "$[0].id"
|
|
[Asserts]
|
|
jsonpath "$[0].kind" == "personal"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Admin creates a shared drive owned by dm_owner.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "dm-shared",
|
|
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
shared_drive_id: jsonpath "$.id"
|
|
shared_root_id: jsonpath "$.root_folder_id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Upload hello.txt (32 B) into the personal drive root.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{owner_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{personal_root_id}}
|
|
file: file,fixtures/hello.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
file_id: jsonpath "$.id"
|
|
|
|
|
|
# Baseline used_bytes after the upload settles. Trigger-sweep is
|
|
# the deterministic sync point — but only after the spawn'd hook
|
|
# has had a chance to land (bug_trigger_sweep_vs_spawn_hook_race.md).
|
|
POST {{base_url}}/api/admin/jobs/usage_reconcile/trigger
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
|
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Move hello.txt across drives → shared root.
|
|
#
|
|
# Observable behaviour: after the sweep, the source drive's
|
|
# used_bytes drops to 0 and the destination's rises to 32. The
|
|
# only way this happens is if `storage.files.drive_id` was
|
|
# updated on the move (the sweep recomputes from
|
|
# `SUM(size) WHERE drive_id = d.id`). The move_file SQL already
|
|
# syncs drive_id from the destination — this asserts it still
|
|
# does post-D6.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/files/{{file_id}}/move
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"folder_id": "{{shared_root_id}}"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
POST {{base_url}}/api/admin/jobs/usage_reconcile/trigger
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 0
|
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
|
|
|
|
|
# Confirm the file is now visible under the shared drive's root
|
|
# (cross-drive Read is fine — dm_owner is Owner on both).
|
|
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.items[?(@.resource.id=='{{file_id}}')].resource_type" == "file"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Folder move across drives, with a child file inside.
|
|
# The cascade trigger MUST propagate the new drive_id
|
|
# to the moved folder AND every descendant (folder +
|
|
# file). Verified by moving the folder, then sweeping —
|
|
# if the trigger doesn't fire, the descendant file's
|
|
# drive_id stays at the source drive and the sweep
|
|
# attributes its size to the wrong drive.
|
|
#
|
|
# First, move hello.txt back to the personal drive so the
|
|
# baseline for the next case is clean (and so the source-drive
|
|
# `used_bytes` reflects only what we're about to nest below).
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/files/{{file_id}}/move
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"folder_id": "{{personal_root_id}}"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# Create a folder under personal root, with hello-copy.txt inside.
|
|
POST {{base_url}}/api/folders
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{ "name": "dm-subtree", "parent_id": "{{personal_root_id}}" }
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
subtree_id: jsonpath "$.id"
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{owner_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{subtree_id}}
|
|
file: file,fixtures/hello-copy.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
nested_file_id: jsonpath "$.id"
|
|
|
|
|
|
# Baseline post-creation. Personal holds both hello.txt (32 B) +
|
|
# nested hello-copy.txt (32 B) = 64. Shared is empty.
|
|
#
|
|
# `[Options] delay: 200ms` is the workaround for the
|
|
# trigger-sweep-vs-spawn-hook race documented in
|
|
# bug_trigger_sweep_vs_spawn_hook_race.md: the upload responds 201 as
|
|
# soon as the row is written, but the storage-usage delta hook is
|
|
# tokio::spawn'd — without the delay, trigger-sweep can run while the
|
|
# hook from THIS upload (or a prior move) is still in flight, the
|
|
# sweep then recomputes from stale numbers, the late hook adds its
|
|
# delta on top, and used_bytes ends up too high by exactly one file's
|
|
# size. Symptom: expected 64, got 96 (one extra hook landed late).
|
|
# Real fix is await'ing the hook inline server-side; until then this
|
|
# delay deflakes the test.
|
|
POST {{base_url}}/api/admin/jobs/usage_reconcile/trigger
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 64
|
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
|
|
|
|
|
|
# Move the SUBTREE FOLDER (with its nested file) into the shared
|
|
# drive's root.
|
|
PUT {{base_url}}/api/folders/{{subtree_id}}/move
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"parent_id": "{{shared_root_id}}"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# The load-bearing assertion. After sweep:
|
|
# personal: hello.txt remains (32)
|
|
# shared: nested hello-copy.txt now charged here (32)
|
|
# Anything other than (32, 32) means the descendant file's
|
|
# drive_id wasn't cascaded by the trigger.
|
|
POST {{base_url}}/api/admin/jobs/usage_reconcile/trigger
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
|
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
|
|
|
|
|
# Folder is visible in shared's listing.
|
|
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.items[?(@.resource.id=='{{subtree_id}}')].resource_type" == "folder"
|
|
|
|
|
|
# Descendant file is still inside the moved subtree (subtree
|
|
# integrity preserved). Drive_id sync is invisible at this
|
|
# endpoint, but the used_bytes assertion above already
|
|
# established it.
|
|
GET {{base_url}}/api/folders/{{subtree_id}}/resources?limit=50
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.items[?(@.resource.id=='{{nested_file_id}}')].resource_type" == "file"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Cleanup. Move both files back to the personal drive's
|
|
# root + delete the subtree folder + delete the shared
|
|
# drive (must be empty), then the test user.
|
|
# ─────────────────────────────────────────────────────────────
|
|
PUT {{base_url}}/api/files/{{nested_file_id}}/move
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"folder_id": "{{personal_root_id}}"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
DELETE {{base_url}}/api/folders/{{subtree_id}}
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 204
|
|
|
|
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|