fix(drive): fix drive_id on copy to other drive

- fix alsot drive_id exposition
This commit is contained in:
Edouard Vanbelle
2026-06-29 21:55:47 +02:00
parent e81b297f68
commit 583af517c2
15 changed files with 593 additions and 27 deletions
@@ -0,0 +1,167 @@
-- ════════════════════════════════════════════════════════════════════════════
-- D6 — storage.copy_folder_tree cross-drive support
-- ════════════════════════════════════════════════════════════════════════════
-- D0/M5 (`20260802100004_copy_folder_tree_drive_id.sql`) introduced drive_id
-- into this function but pulled it from the SOURCE folder for every level —
-- a deliberate "intra-drive only" limitation called out in that migration's
-- header. After D6 landed cross-drive moves end-to-end (cascade trigger +
-- WITH dest CTE on file_move/folder_move) copies were the lone holdout: a
-- batch-copy of a folder tree into another drive left every new row with
-- the SOURCE's drive_id while parent_id pointed into the DESTINATION drive.
-- Net effect: the per-drive quota sweep (`SUM(size) WHERE drive_id = d.id`)
-- charged the SOURCE drive for size physically living under the dest tree.
--
-- The fix mirrors `copy_file` SQL in
-- `infrastructure/repositories/pg/file_blob_write_repository.rs::copy_file`
-- (the single-file copy path already gets drive_id from the destination via
-- a `dest_folder` CTE) — here we resolve the destination drive ONCE at the
-- top of the function and bind it for every level of folders + every file.
--
-- Provenance contract: `created_by` / `updated_by` on the copied rows STAY
-- as the source row's values. A copy is a duplicate, not a new authoring
-- event; preserving the original author across copies is the correct
-- semantic. Subsequent edits to the copy bump `updated_by` through the
-- normal write path. This makes the previously-deferred caller_id thread
-- (memory: project_copy_folder_tree_caller_id.md) unnecessary — drive_id
-- is the only field that needs the destination's perspective.
--
-- Preserved semantics from the prior body:
-- - level-by-level folder INSERTs so trg_folders_path can resolve
-- parent's path/lpath from rows inserted in the previous level.
-- - One batched file INSERT (zero-copy via blob hash) at the end.
-- - Returns the same shape: (new_root_id::text, folders_copied, files_copied).
-- - Error codes (P0002 missing source, 23505 duplicate name) unchanged.
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
p_source_id UUID,
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
DECLARE
v_root_lpath ltree;
v_root_depth INT;
v_max_depth INT;
v_level INT;
v_folders BIGINT := 0;
v_files BIGINT := 0;
v_inserted BIGINT;
v_new_root UUID;
v_dest_drive_id UUID;
BEGIN
-- Validate source exists
SELECT fo.lpath, nlevel(fo.lpath)
INTO v_root_lpath, v_root_depth
FROM storage.folders fo
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
IF v_root_lpath IS NULL THEN
RAISE EXCEPTION 'Source folder not found: %', p_source_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
-- Resolve the destination drive_id ONCE up front. The whole copied
-- subtree lands in this drive; pulling it per-row from `fo.drive_id`
-- (the previous body) was the cross-drive bug.
--
-- When p_target_parent_id is NULL the caller asked for "copy to
-- root" — there is no global root in the multi-drive world, so we
-- preserve the source's drive_id (legacy behaviour, defensive).
-- Real API call sites always pass a concrete target folder.
IF p_target_parent_id IS NULL THEN
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_source_id;
ELSE
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
IF v_dest_drive_id IS NULL THEN
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
END IF;
-- Temp mapping: every folder in the subtree → new UUID
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_map;
INSERT INTO _copy_map(old_id)
SELECT fo.id
FROM storage.folders fo
WHERE NOT fo.is_trashed
AND fo.lpath <@ v_root_lpath;
-- Remember new root ID
SELECT cm.new_id INTO v_new_root
FROM _copy_map cm WHERE cm.old_id = p_source_id;
-- Max depth for level iteration
SELECT MAX(nlevel(fo.lpath))
INTO v_max_depth
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id;
-- ── Insert folders level by level ──
-- Each level is a separate INSERT so the BEFORE INSERT trigger
-- (trg_folders_path) can resolve the parent's path/lpath from rows
-- inserted in the previous level. drive_id is the destination's
-- (resolved once above); user_id + provenance preserved from source.
FOR v_level IN v_root_depth .. v_max_depth LOOP
INSERT INTO storage.folders(
id, name, parent_id, user_id,
drive_id, created_by, updated_by
)
SELECT cm.new_id,
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
THEN p_dest_name ELSE fo.name END,
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
ELSE pm.new_id END,
fo.user_id,
v_dest_drive_id,
fo.created_by,
fo.updated_by
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
WHERE NOT fo.is_trashed
AND nlevel(fo.lpath) = v_level;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_folders := v_folders + v_inserted;
END LOOP;
-- ── Batch copy all files (zero-copy: same blob_hash) ──
-- drive_id from destination; everything else (user_id, created_by,
-- updated_by) preserved from source so authorship survives the copy.
INSERT INTO storage.files(
name, folder_id, user_id, blob_hash, size, mime_type,
media_sort_date, drive_id, created_by, updated_by
)
SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type,
f.media_sort_date, v_dest_drive_id, f.created_by, f.updated_by
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
WHERE NOT f.is_trashed;
GET DIAGNOSTICS v_files = ROW_COUNT;
-- ── Batch increment blob ref_counts ──
IF v_files > 0 THEN
UPDATE storage.blobs b
SET ref_count = ref_count + hc.cnt
FROM (
SELECT f.blob_hash, COUNT(*)::int AS cnt
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.new_id
WHERE NOT f.is_trashed
GROUP BY f.blob_hash
) hc
WHERE b.hash = hc.blob_hash;
END IF;
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
END;
$$ LANGUAGE plpgsql;
+5
View File
@@ -125,6 +125,11 @@ pub struct FavoriteResourceRow {
pub resource_created_at: DateTime<Utc>, pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, pub owner_id: Uuid,
/// Drive that owns this row. Surfaced on the favorites listing
/// so a UI can tell when a favorited item lives in a different
/// drive than the user's home (post-D6 cross-drive moves +
/// copies make this reachable).
pub drive_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for /// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Routes into `FileDto::content_hash` and feeds /// folder rows. Routes into `FileDto::content_hash` and feeds
/// `File::compute_etag` to populate `FileDto::etag`. /// `File::compute_etag` to populate `FileDto::etag`.
+6
View File
@@ -206,6 +206,12 @@ pub struct FolderResourceRow {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, pub owner_id: Uuid,
/// Drive that owns this row. Same column as
/// `storage.folders.drive_id` / `storage.files.drive_id`. Surfaced
/// on the listing so a UI can tell when a child lives in a
/// different drive than its parent (post-D6 cross-drive moves +
/// copies make this reachable).
pub drive_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for /// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Populates `FileDto::content_hash` + `FileDto::etag` /// folder rows. Populates `FileDto::content_hash` + `FileDto::etag`
/// on the REST `/api/folders/{id}/resources` listing so API /// on the REST `/api/folders/{id}/resources` listing so API
+5
View File
@@ -105,6 +105,11 @@ pub struct RecentResourceRow {
pub resource_created_at: DateTime<Utc>, pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, pub owner_id: Uuid,
/// Drive that owns this row. Surfaced on the recent listing
/// so a UI can tell when a recently-accessed item lives in a
/// different drive than the user's home (post-D6 cross-drive
/// moves + copies make this reachable).
pub drive_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for /// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so this listing's /// folder rows. Feeds `File::compute_etag` so this listing's
/// `etag` matches GET/HEAD/PROPFIND for the same file. /// `etag` matches GET/HEAD/PROPFIND for the same file.
+4
View File
@@ -155,6 +155,10 @@ pub struct SearchFolderResultDto {
pub path: String, pub path: String,
/// Parent folder ID /// Parent folder ID
pub parent_id: Option<String>, pub parent_id: Option<String>,
/// Drive that owns this folder. Same column as `storage.folders.drive_id`,
/// carried through so downstream callers (e.g. the NC search REPORT
/// handler) can populate `FolderDto::drive_id` without a fallback sentinel.
pub drive_id: uuid::Uuid,
/// Creation timestamp /// Creation timestamp
pub created_at: u64, pub created_at: u64,
/// Last modification timestamp /// Last modification timestamp
@@ -241,6 +241,7 @@ impl SearchService {
name: folder.name.clone(), name: folder.name.clone(),
path: folder.path.clone(), path: folder.path.clone(),
parent_id: folder.parent_id.clone(), parent_id: folder.parent_id.clone(),
drive_id: folder.drive_id,
created_at: folder.created_at, created_at: folder.created_at,
modified_at: folder.modified_at, modified_at: folder.modified_at,
is_root: folder.is_root, is_root: folder.is_root,
@@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
fld.created_at AS resource_created_at, fld.created_at AS resource_created_at,
fld.updated_at AS modified_at, fld.updated_at AS modified_at,
fld.user_id AS owner_id, fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash, NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner, (fld.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at, uf.created_at AS favorited_at,
@@ -333,6 +334,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.created_at AS resource_created_at, f.created_at AS resource_created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id AS owner_id, f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash, f.blob_hash,
(f.user_id = $1::uuid) AS is_owner, (f.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at, uf.created_at AS favorited_at,
@@ -504,7 +506,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
SELECT SELECT
r.resource_type, r.resource_id, r.name, r.parent_id, r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at, r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.is_owner, r.favorited_at, r.resource_path, r.owner_id, r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col} r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r FROM resources r
{user_join} {user_join}
@@ -576,6 +578,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"), resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"), modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"), owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(), blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false), is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"), favorited_at: row.get("favorited_at"),
@@ -1435,6 +1435,7 @@ impl FolderDbRepository {
f.created_at, f.created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id, f.user_id,
f.drive_id,
NULL::text AS blob_hash, NULL::text AS blob_hash,
LOWER(f.name) AS sort_str, LOWER(f.name) AS sort_str,
0::bigint AS type_order, 0::bigint AS type_order,
@@ -1454,6 +1455,7 @@ impl FolderDbRepository {
fm.created_at, fm.created_at,
fm.updated_at AS modified_at, fm.updated_at AS modified_at,
fm.user_id, fm.user_id,
fm.drive_id,
fm.blob_hash, fm.blob_hash,
LOWER(fm.name) AS sort_str, LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order, fm.category_order::bigint AS type_order,
@@ -1578,7 +1580,8 @@ impl FolderDbRepository {
let sql = format!( let sql = format!(
"WITH resources AS ({cte_inner}) \ "WITH resources AS ({cte_inner}) \
SELECT resource_type, id, name, folder_id, mime_type, size, \ SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, user_id, blob_hash, sort_str, type_order, folder_first \ created_at, modified_at, user_id, drive_id, blob_hash, \
sort_str, type_order, folder_first \
FROM resources \ FROM resources \
{where_clause} \ {where_clause} \
{order_clause} \ {order_clause} \
@@ -1586,7 +1589,7 @@ impl FolderDbRepository {
); );
// Row: (resource_type, id, name, folder_id, mime_type, size, // Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, user_id, blob_hash, // created_at, modified_at, user_id, drive_id, blob_hash,
// sort_str, type_order, folder_first) // sort_str, type_order, folder_first)
type Row = ( type Row = (
String, String,
@@ -1598,6 +1601,7 @@ impl FolderDbRepository {
chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>,
Uuid, Uuid,
Uuid,
Option<String>, Option<String>,
String, String,
i64, i64,
@@ -1629,10 +1633,11 @@ impl FolderDbRepository {
created_at: r.6, created_at: r.6,
modified_at: r.7, modified_at: r.7,
owner_id: r.8, owner_id: r.8,
blob_hash: r.9, drive_id: r.9,
sort_str: r.10, blob_hash: r.10,
type_order: r.11, sort_str: r.11,
folder_first: r.12, type_order: r.12,
folder_first: r.13,
}) })
.collect()) .collect())
} }
@@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
fld.created_at AS resource_created_at, fld.created_at AS resource_created_at,
fld.updated_at AS modified_at, fld.updated_at AS modified_at,
fld.user_id AS owner_id, fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash, NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner, (fld.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at, ur.accessed_at AS accessed_at,
@@ -240,6 +241,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.created_at AS resource_created_at, f.created_at AS resource_created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id AS owner_id, f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash, f.blob_hash,
(f.user_id = $1::uuid) AS is_owner, (f.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at, ur.accessed_at AS accessed_at,
@@ -409,7 +411,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
SELECT SELECT
r.resource_type, r.resource_id, r.name, r.parent_id, r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at, r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.is_owner, r.accessed_at, r.resource_path, r.owner_id, r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col} r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r FROM resources r
{user_join} {user_join}
@@ -485,6 +487,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"), resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"), modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"), owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(), blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false), is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"), accessed_at: row.get("accessed_at"),
@@ -216,11 +216,7 @@ pub async fn list_favorites_resources(
path, path,
parent_id: row.parent_id.map(|u| u.to_string()), parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
// Listing handler — drive_id is informational drive_id: row.drive_id,
// and the favorites row doesn't currently
// SELECT it. Path-based lookups never enter
// this code path.
drive_id: uuid::Uuid::nil(),
created_at: row.resource_created_at.timestamp() as u64, created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64,
is_root: false, is_root: false,
@@ -502,10 +502,7 @@ pub async fn list_folder_resources(
path: String::new(), // cleared — share recipients must not see hierarchy path: String::new(), // cleared — share recipients must not see hierarchy
parent_id: row.parent_id.map(|u| u.to_string()), parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
// Resources listing — drive_id is informational drive_id: row.drive_id,
// here; not selected by the underlying query.
// Path-based lookups never enter this code path.
drive_id: uuid::Uuid::nil(),
created_at: row.created_at.timestamp() as u64, created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64,
is_root: false, is_root: false,
@@ -247,11 +247,7 @@ pub async fn list_recent_resources(
path, path,
parent_id: row.parent_id.map(|u| u.to_string()), parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
// Listing handler — drive_id is informational drive_id: row.drive_id,
// and the recents row doesn't currently SELECT
// it. Path-based lookups never enter this code
// path.
drive_id: uuid::Uuid::nil(),
created_at: row.resource_created_at.timestamp() as u64, created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64,
is_root: false, is_root: false,
+1 -4
View File
@@ -361,10 +361,7 @@ fn folder_dto_from_search(
path: sr.path.clone(), path: sr.path.clone(),
parent_id: sr.parent_id.clone(), parent_id: sr.parent_id.clone(),
owner_id: None, owner_id: None,
// Search result — drive_id is informational. The search row drive_id: sr.drive_id,
// doesn't currently SELECT it, and path-based lookups never
// enter this code path.
drive_id: uuid::Uuid::nil(),
created_at: sr.created_at, created_at: sr.created_at,
modified_at: sr.modified_at, modified_at: sr.modified_at,
is_root: sr.is_root, is_root: sr.is_root,
+380
View File
@@ -0,0 +1,380 @@
# =============================================================
# OxiCloud — D6 cross-drive COPY + drive_id resolution
# =============================================================
# Run:
# hurl --variables-file tests/api/test.env --file-root tests \
# --test tests/api/cross_drive_copy.hurl
#
# Companion to `cross_drive_move.hurl`. The MOVE path was fixed
# in D6 via the WITH dest CTE + cascade trigger; the COPY path
# was the lone holdout, fixed by migration
# `20260808000000_copy_folder_tree_cross_drive.sql` which makes
# `storage.copy_folder_tree` resolve drive_id from the
# destination once, instead of pulling source's drive_id per row.
#
# Verifies:
# 1. Single-file batch copy across drives lands in the
# destination drive (source unchanged because copy ≠ move).
# Already-correct path via `copy_file` SQL — guarded here
# so a regression on the file path is caught.
# 2. Folder-tree batch copy across drives. Two layers of
# assertion:
# a) DIRECT — the copied folder's `drive_id` field reads
# as the destination drive (FolderDto exposes it).
# This is the load-bearing check for the migration.
# b) INDIRECT — per-drive sweep totals: source unchanged,
# destination grew by the descendant file's size.
# Pre-fix this would have left destination = 0 and
# the nested file's size mis-attributed to source.
# The folder + file INSERTs in the migration share the
# same `v_dest_drive_id` variable, so (a) passing implies
# file rows used the same value and (b) cross-checks it.
#
# Sweep convergence: `/api/admin/internal/trigger-sweep` is the
# deterministic synchronisation point — without it the
# fire-and-forget delta hook may not yet have updated the cached
# `used_bytes` when we read it.
# =============================================================
# ─────────────────────────────────────────────────────────────
# 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 `dc_owner`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"username": "dc_owner",
"password": "DcOwnerPwd1!",
"email": "dc_owner@example.com",
"role": "user"
}
HTTP 201
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "dc_owner", "password": "DcOwnerPwd1!" }
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 dc_owner.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "dc-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.
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 — Single-file batch COPY across drives.
#
# `copy_file` SQL already binds dest drive_id via the dest_folder
# CTE; this step guards that path so a regression is caught.
# After sweep: source keeps its 32 (copy ≠ move), dest gains 32.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/batch/files/copy
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"file_ids": ["{{file_id}}"],
"target_folder_id": "{{shared_root_id}}"
}
HTTP 200
[Captures]
shared_file_id: jsonpath "$.successful[0].id"
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
# Confirm the duplicate is visible under the shared drive's root.
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.items[?(@.resource_type=='file')].resource.name" contains "hello.txt"
# ─────────────────────────────────────────────────────────────
# Step 7 — Folder-tree COPY across drives, with a nested file.
# Pre-migration this was the broken path: source's
# drive_id leaked into every descendant of the copied
# subtree because `storage.copy_folder_tree` used
# `fo.drive_id` per row instead of resolving the
# destination drive once.
#
# Create a folder under personal root with hello-copy.txt inside,
# then batch-copy the whole subtree to the shared drive.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{ "name": "dc-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
# Add one more level of nesting so the cascade-through-levels in
# copy_folder_tree gets exercised — the level-by-level INSERT
# loop is where the previous body's bug compounded.
POST {{base_url}}/api/folders
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{ "name": "dc-subtree-inner", "parent_id": "{{subtree_id}}" }
HTTP 201
[Captures]
inner_id: jsonpath "$.id"
POST {{base_url}}/api/files/upload
Authorization: Bearer {{owner_token}}
[MultipartFormData]
folder_id: {{inner_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
# Baseline post-creation. Personal holds:
# - hello.txt at root (32 B)
# - hello-copy.txt nested in dc-subtree (32 B)
# - hello.txt nested in dc-subtree-inner (32 B)
# = 96 total. Shared still has the file-copy from Step 6 (32 B).
#
# Delay: the file-upload service fires the per-drive used_bytes
# delta via `tokio::spawn` (file_upload_service.rs ~372). With two
# uploads back-to-back the spawned hooks race the sweep: if the
# hook lands AFTER `trigger-sweep`'s recompute, the additive
# UPDATE clobbers the SUM with `used_bytes += delta`, doubling
# the file's size into the cached counter. 200 ms is well above
# the tokio task latency on any reasonable box; the deterministic
# fix would be intra-transaction hooks, deferred until D7.
POST {{base_url}}/api/admin/internal/trigger-sweep
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" == 96
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
# Copy the SUBTREE FOLDER (with its nested file + nested folder
# + nested-nested file) into the shared drive's root. The
# response's `new_root_folder_id` lets us follow up with a
# direct drive_id assertion on the copy.
POST {{base_url}}/api/batch/folders/copy
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"folder_ids": ["{{subtree_id}}"],
"target_folder_id": "{{shared_root_id}}"
}
HTTP 200
[Captures]
new_root_folder_id: jsonpath "$.successful[0].new_root_folder_id"
[Asserts]
jsonpath "$.successful[0].folders_copied" == 2
jsonpath "$.successful[0].files_copied" == 2
# ── (a) DIRECT drive_id assertion on the copied root. ──
# FolderDto exposes drive_id, so we can read it back end-to-end
# without touching SQL. Pre-fix this would equal personal_drive_id
# instead of shared_drive_id.
GET {{base_url}}/api/folders/{{new_root_folder_id}}
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.drive_id" == "{{shared_drive_id}}"
jsonpath "$.name" == "dc-subtree"
# ── (a') DIRECT drive_id assertion on the descendant folder. ──
# Walk into the copied root and verify its child folder also
# inherited the destination drive_id. This is the level-by-level
# loop's correctness guard — pre-fix the inner folder would have
# kept personal_drive_id and the cascade trigger doesn't fire on
# INSERT (it only handles UPDATE OF drive_id).
#
# The `/resources` listing now surfaces the real drive_id (the
# handler used to stub Uuid::nil because the row didn't project
# drive_id; the underlying query was extended alongside this
# migration to project f.drive_id / fm.drive_id). We can assert
# directly on the listing AND cross-check via GET /api/folders/{id}.
GET {{base_url}}/api/folders/{{new_root_folder_id}}/resources?limit=50
Authorization: Bearer {{owner_token}}
HTTP 200
[Captures]
# Single-match filter — Hurl unwraps to scalar; do NOT use `nth N`
# here (see feedback_hurl_jsonpath_filter_empty.md: filters with
# nth fail on a single-match result).
new_inner_id: jsonpath "$.items[?(@.resource_type=='folder')].resource.id"
[Asserts]
jsonpath "$.items[?(@.resource_type=='folder')].resource.drive_id" == "{{shared_drive_id}}"
jsonpath "$.items[?(@.resource_type=='file')].resource.name" == "hello-copy.txt"
GET {{base_url}}/api/folders/{{new_inner_id}}
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.drive_id" == "{{shared_drive_id}}"
jsonpath "$.name" == "dc-subtree-inner"
# ── (b) INDIRECT cross-check via per-drive sweep. ──
# Source unchanged (copy ≠ move): personal still 96.
# Destination grew by the two descendant files (32 + 32 = 64) +
# the Step 6 file copy (32) = 96. Anything other than (96, 96)
# would mean the file INSERT in copy_folder_tree used the wrong
# drive_id.
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" == 96
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 96
# ─────────────────────────────────────────────────────────────
# Step 8 — Cleanup. Drain the shared drive (it isn't covered by
# the user-delete cascade), delete the shared drive,
# drain the source subtree from the personal drive,
# then delete the test user.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{shared_file_id}}
Authorization: Bearer {{owner_token}}
HTTP 204
DELETE {{base_url}}/api/folders/{{new_root_folder_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/folders/{{subtree_id}}
Authorization: Bearer {{owner_token}}
HTTP 204
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
Authorization: Bearer {{admin_token}}
HTTP 200
+2 -1
View File
@@ -165,7 +165,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$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" "$API_DIR/cross_drive_move.hurl" \
"$API_DIR/cross_drive_copy.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh" #bash "$API_DIR/dedup_bulk_upload.sh"