From acf2311ceaae51623d6d27d004918da7d5764a76 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 29 Jun 2026 21:10:26 +0200 Subject: [PATCH 1/5] test(oidc): ensure that static-dist is built to validate tests --- tests/oidc/oidc.hurl | 23 ++++++++++++----------- tests/oidc/run.sh | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index c8ee6ee2..47e7b428 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -109,24 +109,23 @@ header "Location" contains "redirect_uri=" # {frontend_url}/login?oidc_code=… # # With `location: true` Hurl follows the whole chain and -# lands on the SPA login URL. The test server config -# (server-with-oidc.env) points `OXICLOUD_STATIC_PATH` -# at ./static — which is the legacy vanilla frontend, NOT -# static-dist/ — so /login returns 404. That 404 is the -# test signal: it proves we landed AT /login (i.e. the -# d1bbe8ba contract held). The URL we end at is the -# actual assertion. +# lands on the SPA login URL. The SvelteKit SPA serves +# `/login` from `static-dist/login.html` with 200 — this +# is the production contract. The runner (`tests/oidc/run.sh`) +# builds `static-dist/` before launching the server so +# local and CI both see the production behaviour. Without +# that build the route would 404 via the ServeDir fallback. # # A pre-d1bbe8ba server would have redirected to -# `http://localhost:8087/?oidc_code=…` instead — same -# 404, but the `landed_at` assertion would catch it. +# `http://localhost:8087/?oidc_code=…` instead — the +# `landed_at` regex below catches that regardless. # ───────────────────────────────────────────────────────────── GET {{idp_url}} [Options] location: true location-trusted: true -HTTP 404 +HTTP 200 [Captures] landed_at: url oidc_code: url regex "oidc_code=([a-f0-9]+)" @@ -289,7 +288,9 @@ GET {{relogin_idp_url}} location: true location-trusted: true -HTTP 404 +# Same contract as Step 4 — the SPA serves /login with 200 (the +# runner ensures static-dist/ is built before the server starts). +HTTP 200 [Captures] relogin_oidc_code: url regex "oidc_code=([a-f0-9]+)" [Asserts] diff --git a/tests/oidc/run.sh b/tests/oidc/run.sh index 407e5228..0a72f972 100755 --- a/tests/oidc/run.sh +++ b/tests/oidc/run.sh @@ -143,6 +143,21 @@ set +a source "$COMMON/wipe-storage.sh" wipe_storage "$OXICLOUD_STORAGE_PATH" +# ── 3.5. Ensure the SPA is built (static-dist/) ──────────────────────────── +# The OIDC suite's Step 4 + Step 9 walk the full redirect chain and assert +# they land on `/login?oidc_code=…` with HTTP 200 — the production contract, +# where the container ships `static-dist/login.html`. Without that bundle +# `resolve_static_path` falls back to `OXICLOUD_STATIC_PATH=./static`, which +# was removed in commit 54639d46 — so ServeDir 404s the route and Step 4 +# fails. Build here so local + CI both exercise the production layout. +DIST_DIR="$REPO_ROOT/static-dist" +if [[ ! -f "$DIST_DIR/login.html" ]]; then + log "Building SvelteKit SPA (static-dist/login.html missing)..." + (cd "$REPO_ROOT/frontend" \ + && npm ci --silent --no-audit --no-fund \ + && npm run build) || die "Frontend build failed; static-dist/ is required for the OIDC tests" +fi + # ── 4. Start OxiCloud server with OIDC enabled ───────────────────────────── BUILD_TARGET="${BUILD_TARGET:-debug}" OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" From ee92d365b938defdc141c20a747769519cf644ff Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 29 Jun 2026 21:11:40 +0200 Subject: [PATCH 2/5] feat(drive): ensure drive_id updated on file|folder moved to another drive --- ...000000_cascade_drive_id_on_folder_move.sql | 73 ++++ .../services/drive_management_service.rs | 57 +-- .../services/file_management_service.rs | 35 +- src/application/services/folder_service.rs | 24 +- .../repositories/pg/folder_db_repository.rs | 31 +- tests/api/cross_drive_move.hurl | 325 ++++++++++++++++++ tests/api/run.sh | 3 +- 7 files changed, 506 insertions(+), 42 deletions(-) create mode 100644 migrations/20260807000000_cascade_drive_id_on_folder_move.sql create mode 100644 tests/api/cross_drive_move.hurl diff --git a/migrations/20260807000000_cascade_drive_id_on_folder_move.sql b/migrations/20260807000000_cascade_drive_id_on_folder_move.sql new file mode 100644 index 00000000..428e95c7 --- /dev/null +++ b/migrations/20260807000000_cascade_drive_id_on_folder_move.sql @@ -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(); diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index 4900cdfb..b1cad79c 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -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(()) } diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index ecd18695..8a1a8a82 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -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( diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 1ee4c0c2..63ea2ddf 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -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)) } diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index a682d939..ade9105c 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -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) diff --git a/tests/api/cross_drive_move.hurl b/tests/api/cross_drive_move.hurl new file mode 100644 index 00000000..0c91caee --- /dev/null +++ b/tests/api/cross_drive_move.hurl @@ -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 diff --git a/tests/api/run.sh b/tests/api/run.sh index b33f51e1..7042b47f 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -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" From e81b297f68160f2cd35602abf2247eea83f68738 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 29 Jun 2026 21:19:51 +0200 Subject: [PATCH 3/5] feat(drive): can move|copy to other drives --- frontend/src/lib/components/MoveDialog.svelte | 119 +++++++++++++++++- .../src/lib/components/MoveDialog.test.ts | 37 +++++- 2 files changed, 145 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/MoveDialog.svelte b/frontend/src/lib/components/MoveDialog.svelte index 3b7fa193..d9bfe240 100644 --- a/frontend/src/lib/components/MoveDialog.svelte +++ b/frontend/src/lib/components/MoveDialog.svelte @@ -3,13 +3,28 @@ import { listFolder, moveFolder } from '$lib/api/endpoints/folders'; import { moveFile } from '$lib/api/endpoints/files'; import { copyFiles, copyFolders } from '$lib/api/endpoints/batch'; - import type { FolderItem } from '$lib/api/types'; + import type { Drive, DriveRole, FolderItem } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; import { t } from '$lib/i18n/index.svelte'; - import { session } from '$lib/stores/session.svelte'; + import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; import { ui } from '$lib/stores/ui.svelte'; + // A drive accepts new items only if the caller can Create on its root. + // Owner / Editor / Contributor cover that; Commenter + Viewer cannot. + const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const; + function isWritable(d: Drive): boolean { + return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role); + } + + // Default-personal first, then secondary personals, then shared; within + // a group, alphabetical. Mirrors DrivePicker so the sidebar and this + // dialog rank drives identically. + function driveRank(d: Drive): number { + if (d.default_for_user) return 0; + return d.kind === 'personal' ? 1 : 2; + } + interface Target { id: string; name: string; @@ -34,9 +49,21 @@ let crumbs = $state>([]); let folders = $state([]); let currentId = $state(null); + let selectedDriveId = $state(null); let loading = $state(false); let working = $state(false); + const writableDrives = $derived( + [...drivesStore.drives].filter(isWritable).sort((a, b) => { + const r = driveRank(a) - driveRank(b); + return r !== 0 ? r : a.name.localeCompare(b.name); + }) + ); + + // The chip strip only earns its vertical space when there's a real + // choice. One writable drive → identical to the single-drive UI. + const showDriveSwitcher = $derived(writableDrives.length > 1); + async function loadInto(id: string) { loading = true; try { @@ -50,10 +77,23 @@ } async function init() { - const home = await session.loadHomeFolder(); - if (!home) return; - crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }]; - await loadInto(home); + await drivesStore.load(); + const home = drivesStore.findDefault(); + // Prefer the user's home drive when it's writable (covers the + // common case: moving stuff around inside Personal). Otherwise + // fall back to the first writable drive, sorted as above. + const start = home && isWritable(home) ? home : writableDrives[0]; + if (!start) return; + selectedDriveId = start.id; + crumbs = [{ id: start.root_folder_id, name: start.name }]; + await loadInto(start.root_folder_id); + } + + async function switchDrive(d: Drive) { + if (d.id === selectedDriveId) return; + selectedDriveId = d.id; + crumbs = [{ id: d.root_folder_id, name: d.name }]; + await loadInto(d.root_folder_id); } function enter(f: FolderItem) { @@ -124,6 +164,30 @@
+ {#if showDriveSwitcher} +
+ {#each writableDrives as d (d.id)} + + {/each} +
+ {/if} +