Files
Oxicloud/tests/api/cross_drive_move.hurl
T
Edouard Vanbelle f66f7fa31f feat(job-registry): remplace /api/admin/internal/trigger-*
remplace /api/admin/internal/trigger-* to /api/admin/jobs/{...}/trigger
remove OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS
2026-07-27 23:54:43 +02:00

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/storage_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.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/storage_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/storage_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/storage_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/storage_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