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)
|
.set_role(caller_id, subject, role, resource, expires_at)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if caller_is_admin {
|
// 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!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
event = "drive_membership.set_via_admin",
|
event = "drive.member_added",
|
||||||
drive_id = %drive_id,
|
drive_id = %drive_id,
|
||||||
subject_type = subject.type_str(),
|
subject_type = subject.type_str(),
|
||||||
subject_id = %subject.id(),
|
subject_id = %subject.id(),
|
||||||
role = role.as_str(),
|
role = role.as_str(),
|
||||||
|
via_admin = caller_is_admin,
|
||||||
by = %caller_id,
|
by = %caller_id,
|
||||||
"👮🏻♂️ admin set drive member role bypassing Manage check",
|
expires_at = ?expires_at,
|
||||||
|
"🤝 drive member added",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
Ok(grant)
|
Ok(grant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,17 +312,21 @@ impl DriveManagementService {
|
|||||||
|
|
||||||
self.authz.clear_role(subject, resource).await?;
|
self.authz.clear_role(subject, resource).await?;
|
||||||
|
|
||||||
if caller_is_admin {
|
// 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!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
event = "drive_membership.removed_via_admin",
|
event = "drive.member_removed",
|
||||||
drive_id = %drive_id,
|
drive_id = %drive_id,
|
||||||
subject_type = subject.type_str(),
|
subject_type = subject.type_str(),
|
||||||
subject_id = %subject.id(),
|
subject_id = %subject.id(),
|
||||||
|
via_admin = caller_is_admin,
|
||||||
by = %caller_id,
|
by = %caller_id,
|
||||||
"👮🏻♂️ admin removed drive member bypassing Manage check",
|
"👋 drive member removed",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,13 +302,13 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// D5 `forbid_cross_drive_move`: refuse when the destination
|
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives` audit
|
||||||
// folder belongs to a different drive than the source file and
|
// share the same src/dst drive_id lookup: the gate refuses
|
||||||
// the source drive's policy is on. Silently skipped if the
|
// before the move; the audit fires after a successful move
|
||||||
// drive repo isn't wired (stub builders) or the move target is
|
// when the two drives differ. Silently skipped if the drive
|
||||||
// None (root namespace — same-drive semantics). Source policy
|
// repo isn't wired (stub builders) or the move target is None
|
||||||
// is canonical per §8: the drive that owns the content
|
// (root namespace — same-drive semantics).
|
||||||
// controls outbound moves.
|
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||||
if let Some(drive_repo) = &self.drive_repo
|
if let Some(drive_repo) = &self.drive_repo
|
||||||
&& let Some(target_folder_id) = folder_id.as_deref()
|
&& let Some(target_folder_id) = folder_id.as_deref()
|
||||||
{
|
{
|
||||||
@@ -338,10 +338,29 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
dst_drive_id,
|
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(
|
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)
|
// 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
|
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives`
|
||||||
// different drives and the source drive's policy is on.
|
// 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
|
// Skipped for parent_id=None (root namespace, same-drive
|
||||||
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
||||||
// same shape as `move_file_with_perms`.
|
// same shape as `move_file_with_perms`.
|
||||||
|
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||||
if let Some(drive_repo) = &self.drive_repo
|
if let Some(drive_repo) = &self.drive_repo
|
||||||
&& let Some(parent_id) = &dto.parent_id
|
&& let Some(parent_id) = &dto.parent_id
|
||||||
{
|
{
|
||||||
@@ -592,6 +594,7 @@ impl FolderUseCase for FolderService {
|
|||||||
dst_drive_id,
|
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))
|
Ok(FolderDto::from(folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -657,17 +657,32 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
||||||
//
|
//
|
||||||
// §14: `updated_by = $3` (caller_id), 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", || {
|
let row = retry_on_deadlock("folders.move", || {
|
||||||
sqlx::query_as::<_, FolderRow>(
|
sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
WITH dest AS (
|
||||||
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3
|
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
)
|
||||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
UPDATE storage.folders f
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
SET parent_id = $1::uuid,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
|
||||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
updated_at = NOW(),
|
||||||
created_by, updated_by
|
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)
|
.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/trash_per_drive.hurl" \
|
||||||
"$API_DIR/drive_quota.hurl" \
|
"$API_DIR/drive_quota.hurl" \
|
||||||
"$API_DIR/user_envelope_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"
|
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user