diff --git a/migrations/20260808000000_copy_folder_tree_cross_drive.sql b/migrations/20260808000000_copy_folder_tree_cross_drive.sql new file mode 100644 index 00000000..a79bd08f --- /dev/null +++ b/migrations/20260808000000_copy_folder_tree_cross_drive.sql @@ -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; diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 80852772..a592efae 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -125,6 +125,11 @@ pub struct FavoriteResourceRow { pub resource_created_at: DateTime, pub modified_at: DateTime, 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 /// folder rows. Routes into `FileDto::content_hash` and feeds /// `File::compute_etag` to populate `FileDto::etag`. diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 929215f4..bb827a33 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -206,6 +206,12 @@ pub struct FolderResourceRow { pub created_at: DateTime, pub modified_at: DateTime, 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 /// folder rows. Populates `FileDto::content_hash` + `FileDto::etag` /// on the REST `/api/folders/{id}/resources` listing so API diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 3eee591b..3bd6f019 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -105,6 +105,11 @@ pub struct RecentResourceRow { pub resource_created_at: DateTime, pub modified_at: DateTime, 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 /// folder rows. Feeds `File::compute_etag` so this listing's /// `etag` matches GET/HEAD/PROPFIND for the same file. diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index 7236f1f6..6c2530ed 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -155,6 +155,10 @@ pub struct SearchFolderResultDto { pub path: String, /// Parent folder ID pub parent_id: Option, + /// 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 pub created_at: u64, /// Last modification timestamp diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index 1b3b1262..e32810c3 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -241,6 +241,7 @@ impl SearchService { name: folder.name.clone(), path: folder.path.clone(), parent_id: folder.parent_id.clone(), + drive_id: folder.drive_id, created_at: folder.created_at, modified_at: folder.modified_at, is_root: folder.is_root, diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 3020722b..c932a6b8 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { fld.created_at AS resource_created_at, fld.updated_at AS modified_at, fld.user_id AS owner_id, + fld.drive_id AS drive_id, NULL::text AS blob_hash, (fld.user_id = $1::uuid) AS is_owner, uf.created_at AS favorited_at, @@ -333,6 +334,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { f.created_at AS resource_created_at, f.updated_at AS modified_at, f.user_id AS owner_id, + f.drive_id AS drive_id, f.blob_hash, (f.user_id = $1::uuid) AS is_owner, uf.created_at AS favorited_at, @@ -504,7 +506,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, 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} FROM resources r {user_join} @@ -576,6 +578,7 @@ LIMIT $6" resource_created_at: row.get("resource_created_at"), modified_at: row.get("modified_at"), owner_id: row.get("owner_id"), + drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), favorited_at: row.get("favorited_at"), diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index ade9105c..282056ce 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1435,6 +1435,7 @@ impl FolderDbRepository { f.created_at, f.updated_at AS modified_at, f.user_id, + f.drive_id, NULL::text AS blob_hash, LOWER(f.name) AS sort_str, 0::bigint AS type_order, @@ -1454,6 +1455,7 @@ impl FolderDbRepository { fm.created_at, fm.updated_at AS modified_at, fm.user_id, + fm.drive_id, fm.blob_hash, LOWER(fm.name) AS sort_str, fm.category_order::bigint AS type_order, @@ -1578,7 +1580,8 @@ impl FolderDbRepository { let sql = format!( "WITH resources AS ({cte_inner}) \ 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 \ {where_clause} \ {order_clause} \ @@ -1586,7 +1589,7 @@ impl FolderDbRepository { ); // 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) type Row = ( String, @@ -1598,6 +1601,7 @@ impl FolderDbRepository { chrono::DateTime, chrono::DateTime, Uuid, + Uuid, Option, String, i64, @@ -1629,10 +1633,11 @@ impl FolderDbRepository { created_at: r.6, modified_at: r.7, owner_id: r.8, - blob_hash: r.9, - sort_str: r.10, - type_order: r.11, - folder_first: r.12, + drive_id: r.9, + blob_hash: r.10, + sort_str: r.11, + type_order: r.12, + folder_first: r.13, }) .collect()) } diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 93e44c05..707a2d94 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { fld.created_at AS resource_created_at, fld.updated_at AS modified_at, fld.user_id AS owner_id, + fld.drive_id AS drive_id, NULL::text AS blob_hash, (fld.user_id = $1::uuid) AS is_owner, ur.accessed_at AS accessed_at, @@ -240,6 +241,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { f.created_at AS resource_created_at, f.updated_at AS modified_at, f.user_id AS owner_id, + f.drive_id AS drive_id, f.blob_hash, (f.user_id = $1::uuid) AS is_owner, ur.accessed_at AS accessed_at, @@ -409,7 +411,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, 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} FROM resources r {user_join} @@ -485,6 +487,7 @@ LIMIT $6" resource_created_at: row.get("resource_created_at"), modified_at: row.get("modified_at"), owner_id: row.get("owner_id"), + drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), accessed_at: row.get("accessed_at"), diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 0a4ebfec..4b379f71 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -216,11 +216,7 @@ pub async fn list_favorites_resources( path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), - // Listing handler — drive_id is informational - // and the favorites row doesn't currently - // SELECT it. Path-based lookups never enter - // this code path. - drive_id: uuid::Uuid::nil(), + drive_id: row.drive_id, created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 7dc304df..857a7d35 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -502,10 +502,7 @@ pub async fn list_folder_resources( path: String::new(), // cleared — share recipients must not see hierarchy parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), - // Resources listing — drive_id is informational - // here; not selected by the underlying query. - // Path-based lookups never enter this code path. - drive_id: uuid::Uuid::nil(), + drive_id: row.drive_id, created_at: row.created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 592fdbc6..e64f2d47 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -247,11 +247,7 @@ pub async fn list_recent_resources( path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), - // Listing handler — drive_id is informational - // and the recents row doesn't currently SELECT - // it. Path-based lookups never enter this code - // path. - drive_id: uuid::Uuid::nil(), + drive_id: row.drive_id, created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 01d37b05..a380d6c9 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -361,10 +361,7 @@ fn folder_dto_from_search( path: sr.path.clone(), parent_id: sr.parent_id.clone(), owner_id: None, - // Search result — drive_id is informational. The search row - // doesn't currently SELECT it, and path-based lookups never - // enter this code path. - drive_id: uuid::Uuid::nil(), + drive_id: sr.drive_id, created_at: sr.created_at, modified_at: sr.modified_at, is_root: sr.is_root, diff --git a/tests/api/cross_drive_copy.hurl b/tests/api/cross_drive_copy.hurl new file mode 100644 index 00000000..644fc608 --- /dev/null +++ b/tests/api/cross_drive_copy.hurl @@ -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 diff --git a/tests/api/run.sh b/tests/api/run.sh index 7042b47f..73f7a8b3 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -165,7 +165,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/drive_quota.hurl" \ "$API_DIR/user_envelope_quota.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"