feat(drive): ensure drive_id updated on file|folder moved to another drive
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
-- D6: cross-drive folder moves must propagate `drive_id` to the moved
|
||||
-- folder's subtree (descendant folders + files), not just `lpath`.
|
||||
--
|
||||
-- Today's `cascade_folder_path()` trigger only rewrites `path` + `lpath`
|
||||
-- on descendants — it leaves `drive_id` untouched. That worked when
|
||||
-- moves were intra-drive (drive_id never changed), but after D5 the
|
||||
-- `forbid_cross_drive_move` policy gate exposed the gap: a successful
|
||||
-- cross-drive move (gate off OR not yet enforced) leaves the subtree
|
||||
-- in an inconsistent state — lpath rooted in drive B but `drive_id`
|
||||
-- column still drive A on every descendant row. Any drive-id-scoped
|
||||
-- query then returns the wrong drive's content.
|
||||
--
|
||||
-- The fix is to extend the cascade trigger so a change in the parent
|
||||
-- folder's `drive_id` (the only thing that changes drive_id during a
|
||||
-- move) cascades to every descendant folder + every descendant file.
|
||||
-- Files cascade too because `storage.files.drive_id` is the canonical
|
||||
-- per-file drive-membership signal (D0 dual-write).
|
||||
--
|
||||
-- Migration is idempotent via `CREATE OR REPLACE FUNCTION`.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cascade_folder_path()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF pg_trigger_depth() > 1 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.path IS DISTINCT FROM NEW.path OR OLD.lpath IS DISTINCT FROM NEW.lpath THEN
|
||||
-- Single batch update: rewrite path/lpath for every descendant
|
||||
-- folder at once via the GiST lpath index.
|
||||
UPDATE storage.folders
|
||||
SET path = NEW.path || substr(path, length(OLD.path) + 1),
|
||||
lpath = NEW.lpath || subpath(lpath, nlevel(OLD.lpath))
|
||||
WHERE lpath <@ OLD.lpath
|
||||
AND id != NEW.id;
|
||||
END IF;
|
||||
|
||||
-- D6: cascade `drive_id` to every descendant folder + file when the
|
||||
-- moved row's drive_id has changed (cross-drive move). The GiST
|
||||
-- index covers the folder predicate; `storage.files.drive_id` is
|
||||
-- updated through the folder→file FK relation since files only
|
||||
-- carry `folder_id` directly (drive_id is a denormalised dual-write).
|
||||
--
|
||||
-- Triggered on the column-list `AFTER UPDATE OF path, lpath, drive_id`
|
||||
-- registration below — so this branch only runs when the explicit
|
||||
-- move statement on the moved row sets `drive_id` to a new value.
|
||||
-- The descendant batch UPDATE that fires from the path/lpath branch
|
||||
-- above doesn't touch drive_id, so the trigger doesn't recurse on
|
||||
-- the per-descendant rewrite.
|
||||
IF OLD.drive_id IS DISTINCT FROM NEW.drive_id THEN
|
||||
UPDATE storage.folders
|
||||
SET drive_id = NEW.drive_id
|
||||
WHERE lpath <@ NEW.lpath
|
||||
AND drive_id = OLD.drive_id;
|
||||
|
||||
UPDATE storage.files f
|
||||
SET drive_id = NEW.drive_id
|
||||
FROM storage.folders fo
|
||||
WHERE f.folder_id = fo.id
|
||||
AND fo.lpath <@ NEW.lpath
|
||||
AND f.drive_id = OLD.drive_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Re-register the trigger with `drive_id` added to the column list so the
|
||||
-- trigger fires when a move sets a new drive_id on the moved row. (CREATE
|
||||
-- OR REPLACE TRIGGER replaces the same name in place; no DROP needed.)
|
||||
CREATE OR REPLACE TRIGGER trg_folders_cascade_path
|
||||
AFTER UPDATE OF path, lpath, drive_id ON storage.folders
|
||||
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
|
||||
@@ -249,18 +249,25 @@ impl DriveManagementService {
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await?;
|
||||
|
||||
if caller_is_admin {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.set_via_admin",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
role = role.as_str(),
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ admin set drive member role bypassing Manage check",
|
||||
);
|
||||
}
|
||||
// D6 §11: canonical `drive.member_added` audit event covers
|
||||
// every successful membership write (add + role-refresh, since
|
||||
// the underlying `set_role` is UPSERT — distinguishing the two
|
||||
// would require an additional read and bring no extra ops
|
||||
// value). `via_admin` carries the bypass signal that used to
|
||||
// live in a separate `drive_membership.set_via_admin` event;
|
||||
// log aggregators now have one canonical name per operation.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.member_added",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
role = role.as_str(),
|
||||
via_admin = caller_is_admin,
|
||||
by = %caller_id,
|
||||
expires_at = ?expires_at,
|
||||
"🤝 drive member added",
|
||||
);
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
@@ -305,17 +312,21 @@ impl DriveManagementService {
|
||||
|
||||
self.authz.clear_role(subject, resource).await?;
|
||||
|
||||
if caller_is_admin {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.removed_via_admin",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ admin removed drive member bypassing Manage check",
|
||||
);
|
||||
}
|
||||
// D6 §11: canonical `drive.member_removed` audit event covers
|
||||
// every successful removal (owner-driven or admin bypass).
|
||||
// `via_admin` replaces the separate
|
||||
// `drive_membership.removed_via_admin` event — single name,
|
||||
// one boolean field for the bypass signal.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.member_removed",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
via_admin = caller_is_admin,
|
||||
by = %caller_id,
|
||||
"👋 drive member removed",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -302,13 +302,13 @@ impl FileManagementUseCase for FileManagementService {
|
||||
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// D5 `forbid_cross_drive_move`: refuse when the destination
|
||||
// folder belongs to a different drive than the source file and
|
||||
// the source drive's policy is on. Silently skipped if the
|
||||
// drive repo isn't wired (stub builders) or the move target is
|
||||
// None (root namespace — same-drive semantics). Source policy
|
||||
// is canonical per §8: the drive that owns the content
|
||||
// controls outbound moves.
|
||||
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives` audit
|
||||
// share the same src/dst drive_id lookup: the gate refuses
|
||||
// before the move; the audit fires after a successful move
|
||||
// when the two drives differ. Silently skipped if the drive
|
||||
// repo isn't wired (stub builders) or the move target is None
|
||||
// (root namespace — same-drive semantics).
|
||||
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||
if let Some(drive_repo) = &self.drive_repo
|
||||
&& let Some(target_folder_id) = folder_id.as_deref()
|
||||
{
|
||||
@@ -338,10 +338,29 @@ impl FileManagementUseCase for FileManagementService {
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
self.move_file(file_id, folder_id, caller_id).await
|
||||
let dto = self.move_file(file_id, folder_id, caller_id).await?;
|
||||
|
||||
// D6 §11 audit: emit only when the move actually crossed a
|
||||
// drive boundary. Same-drive moves are too noisy to audit at
|
||||
// info — operators care about the cross-drive case for
|
||||
// exfiltration / quota tracking.
|
||||
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "resource.moved_between_drives",
|
||||
resource_type = "file",
|
||||
resource_id = %dto.id,
|
||||
src_drive_id = %src_drive_id,
|
||||
dst_drive_id = %dst_drive_id,
|
||||
by = %caller_id,
|
||||
"📦 file moved between drives",
|
||||
);
|
||||
}
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
async fn copy_file_with_perms(
|
||||
|
||||
@@ -558,11 +558,13 @@ impl FolderUseCase for FolderService {
|
||||
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
|
||||
}
|
||||
|
||||
// D5 `forbid_cross_drive_move`: refuse when src and dst sit in
|
||||
// different drives and the source drive's policy is on.
|
||||
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives`
|
||||
// audit share the same src/dst lookup. Gate before the move,
|
||||
// audit after a successful move when the two drives differ.
|
||||
// Skipped for parent_id=None (root namespace, same-drive
|
||||
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
||||
// same shape as `move_file_with_perms`.
|
||||
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||
if let Some(drive_repo) = &self.drive_repo
|
||||
&& let Some(parent_id) = &dto.parent_id
|
||||
{
|
||||
@@ -592,6 +594,7 @@ impl FolderUseCase for FolderService {
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +610,23 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// D6 audit: only emit when the move crossed a drive boundary.
|
||||
// The cascade trigger has already propagated drive_id to the
|
||||
// subtree at this point (see migration
|
||||
// `20260807000000_cascade_drive_id_on_folder_move.sql`).
|
||||
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "resource.moved_between_drives",
|
||||
resource_type = "folder",
|
||||
resource_id = %folder.id(),
|
||||
src_drive_id = %src_drive_id,
|
||||
dst_drive_id = %dst_drive_id,
|
||||
by = %caller_id,
|
||||
"📦 folder moved between drives",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
|
||||
@@ -657,17 +657,32 @@ impl FolderRepository for FolderDbRepository {
|
||||
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
||||
//
|
||||
// §14: `updated_by = $3` (caller_id), see rename_folder.
|
||||
//
|
||||
// D6: also sync `drive_id` from the destination parent on
|
||||
// cross-drive moves. The CTE-derived `dest.drive_id` is
|
||||
// assigned via COALESCE so a root-level move (no destination —
|
||||
// `new_parent_id = NULL`) keeps the existing drive_id, mirroring
|
||||
// the file move path. The cascade trigger
|
||||
// (`cascade_folder_path`) then propagates the new drive_id to
|
||||
// every descendant folder + file in the subtree — see
|
||||
// `migrations/20260807000000_cascade_drive_id_on_folder_move.sql`.
|
||||
let row = retry_on_deadlock("folders.move", || {
|
||||
sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
WITH dest AS (
|
||||
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
|
||||
)
|
||||
UPDATE storage.folders f
|
||||
SET parent_id = $1::uuid,
|
||||
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
|
||||
updated_at = NOW(),
|
||||
updated_by = $3
|
||||
WHERE f.id = $2::uuid AND NOT f.is_trashed
|
||||
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
|
||||
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
|
||||
f.created_by, f.updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(new_parent_id)
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
# =============================================================
|
||||
# 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/internal/trigger-sweep` 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 — without it the fire-and-forget
|
||||
# delta hook may not yet have landed in the row when we read it.
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
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/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
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.
|
||||
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
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/internal/trigger-sweep
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
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
|
||||
+2
-1
@@ -164,7 +164,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/trash_per_drive.hurl" \
|
||||
"$API_DIR/drive_quota.hurl" \
|
||||
"$API_DIR/user_envelope_quota.hurl" \
|
||||
"$API_DIR/drive_policies.hurl"
|
||||
"$API_DIR/drive_policies.hurl" \
|
||||
"$API_DIR/cross_drive_move.hurl"
|
||||
|
||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user