Merge upstream/main into feat/external-file-mounts

Resolve conflicts between the external-file-mounts feature and upstream's
D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move
gates, resource-access hook, folder-cascade lifecycle hook).

Key resolutions:
- FolderService::new now takes (repo, authz, file_lifecycle, mount_router);
  all callers + DI updated.
- FileRetrievalService / FileManagementService keep both the mount_router
  and the new resource_access_hook / drive_repo / storage_usage wiring.
- list_files_batch_with_perms: adapt the mount branch from offset- to
  keyset (after_name) pagination, mirroring paginate_mount_entries.
- download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>`
  signature, retain the mount-download branch.
- Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by
  (the mount owner) — the fields the frontend now uses for owner display.
- admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab.
- Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and
  regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
+504 -29
View File
@@ -5,8 +5,10 @@ use crate::application::dtos::folder_dto::{
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::external_mount_ports::MountEntry;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
use crate::application::services::file_lifecycle_service::FileLifecycleService;
use crate::application::services::mount_dto::{
audit_mount_write, mount_entry_folder_dto, mount_folder_dto, mount_parent_id,
};
@@ -28,6 +30,22 @@ pub struct FolderService {
/// External-mount classifier. Lets folder operations branch a mount-root or
/// `ext:` id onto the provider instead of the PostgreSQL repositories.
mount_router: Arc<MountRouter>,
/// File lifecycle dispatcher. Carried so `delete_folder_with_perms`
/// can fire `on_file_deleted` for every file the PG cascade is about
/// to reap. Always present — the dispatcher itself is a no-op when
/// no hooks are registered, so callers don't need an Option branch.
file_lifecycle: Arc<FileLifecycleService>,
/// Drive repository — used by D5's `forbid_cross_drive_move` gate
/// on `move_folder_with_perms`. Optional so stubs / test factories
/// can build the service without wiring the full drive repo; in
/// that case the cross-drive move check is skipped (the policy is
/// silently off). Production DI wires it via `with_drive_repo`.
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
/// Storage-usage service — used to pre-check the destination
/// drive's `used_bytes + subtree_bytes ≤ quota_bytes` invariant
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
}
impl FolderService {
@@ -35,12 +53,16 @@ impl FolderService {
pub fn new(
folder_storage: Arc<FolderDbRepository>,
authz: Arc<PgAclEngine>,
file_lifecycle: Arc<FileLifecycleService>,
mount_router: Arc<MountRouter>,
) -> Self {
Self {
folder_storage,
authz,
mount_router,
file_lifecycle,
drive_repo: None,
storage_usage: None,
}
}
@@ -99,6 +121,31 @@ impl FolderService {
}
}
/// Wires the drive repository, enabling D5
/// `forbid_cross_drive_move` enforcement on
/// `move_folder_with_perms`. Without it, the gate is silently
/// skipped.
pub fn with_drive_repo(
mut self,
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
) -> Self {
self.drive_repo = Some(drive_repo);
self
}
/// Wires the storage-usage service so `move_folder_with_perms`
/// can pre-check the destination drive's quota on cross-drive
/// folder moves.
pub fn with_storage_usage(
mut self,
storage_usage: Arc<
crate::application::services::storage_usage_service::StorageUsageService,
>,
) -> Self {
self.storage_usage = Some(storage_usage);
self
}
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
/// query instead of one per id. Like `get_folder` it performs no
/// per-folder authorization — both current callers (ACL grant listing,
@@ -435,18 +482,18 @@ impl FolderUseCase for FolderService {
.await?;
return self.list_folders(parent_id).await;
}
// No parent → list the user's root folders.
// No parent → list the caller's readable root folders. The
// predicate scopes by drive-membership grants (post-PR-B),
// closing the pre-D7 gap where the legacy `user_id` filter
// surfaced admin-created folders that admin had no role on.
let folders = self
.folder_storage
.list_folders_by_owner(parent_id, caller_id)
.list_root_folders_for_caller(caller_id)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!(
"Failed to list folders for owner '{}' in parent {:?}: {}",
caller_id, parent_id, e
),
format!("Failed to list root folders for caller '{caller_id}': {e}"),
)
})?;
Ok(folders.into_iter().map(FolderDto::from).collect())
@@ -487,6 +534,62 @@ impl FolderUseCase for FolderService {
Ok(response)
}
/// Keyset-paged sub-folder listing (name order), caller-scoped.
///
/// AuthZ mirrors `list_folders_paginated_with_perms`: one
/// `authz.require(Read)` on the parent per batch; root scope goes
/// through the caller's drive-membership listing.
async fn list_folders_batch_with_perms(
&self,
parent_id: Option<&str>,
caller_id: Uuid,
after_name: Option<&str>,
limit: usize,
) -> Result<Vec<FolderDto>, DomainError> {
match parent_id {
Some(pid) => {
self.authz
.require(
Subject::User(caller_id),
Permission::Read,
Self::folder_resource(pid)?,
)
.await?;
let folders = self
.folder_storage
.list_folders_batch(parent_id, after_name, limit)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to batch-list folders in parent {pid}: {e}"),
)
})?;
Ok(folders.into_iter().map(FolderDto::from).collect())
}
None => {
// Root scope: one row per readable drive — a handful.
let mut all = self
.folder_storage
.list_root_folders_for_caller(caller_id)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to batch-list root folders for '{caller_id}': {e}"),
)
})?;
all.sort_by(|a, b| a.name().cmp(b.name()));
Ok(all
.into_iter()
.filter(|f| after_name.is_none_or(|a| f.name() > a))
.take(limit)
.map(FolderDto::from)
.collect())
}
}
}
/// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_paginated_with_perms(
&self,
@@ -534,24 +637,23 @@ impl FolderUseCase for FolderService {
return self.list_folders_paginated(parent_id, &pagination).await;
} else {
let (folders, total_items) = self
.folder_storage
.list_folders_by_owner_paginated(
parent_id,
owner_id,
pagination.offset(),
pagination.limit(),
true,
)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!(
"Failed to list folders for owner '{}' with pagination in parent {:?}: {}",
owner_id, parent_id, e
),
.folder_storage
.list_root_folders_for_caller_paginated(
owner_id,
pagination.offset(),
pagination.limit(),
true,
)
})?;
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!(
"Failed to list root folders for caller '{}' with pagination: {}",
owner_id, e
),
)
})?;
let total = total_items.unwrap_or(folders.len());
@@ -630,7 +732,7 @@ impl FolderUseCase for FolderService {
)
.await?;
let folder = self
let renamed = self
.folder_storage
.rename_folder(id, dto.name, caller_id)
.await
@@ -641,7 +743,26 @@ impl FolderUseCase for FolderService {
)
})?;
Ok(FolderDto::from(folder))
// Root folders double as the drive's display name (see the
// `required_perm` branch above and `drive_pg_repository.rs`
// `readable_cache` + `default_drive_cache` docs).
// `drives.name` is sourced from `folders.name` of the root
// folder, so a rename affects BOTH caches — every user's
// readable-drive list AND the per-user default-drive lookup.
// Both are 30 s TTL; without the invalidation, `GET /api/drives`
// returns the stale name for up to that window after a root
// rename. Surfaced by `tests/api/drives_membership.hurl`
// Step 23. Regression from commit `12dc648c` ("perf: round 4 —
// drive-selector cache") which added the caches without
// wiring the root-rename invalidation.
if folder.parent_id().is_none()
&& let Some(drive_repo) = &self.drive_repo
{
drive_repo.invalidate_readable_all();
drive_repo.invalidate_default_drive_all();
}
Ok(FolderDto::from(renamed))
}
/// Moves a folder to a new parent. Requires `Update` on the source and
@@ -712,6 +833,60 @@ impl FolderUseCase for FolderService {
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
}
// 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
{
let src_folder_uuid =
Uuid::parse_str(id).map_err(|_| DomainError::not_found("Folder", id))?;
let dst_folder_uuid = Uuid::parse_str(parent_id)
.map_err(|_| DomainError::not_found("Folder", parent_id.as_str()))?;
// Independent point reads — overlapped so the pre-move drive
// resolution pays one round-trip, not two (ROUND10, same shape
// as `move_file_with_perms`).
let (src_res, dst_res) = tokio::join!(
drive_repo.get_drive_id_and_policies_for_folder(src_folder_uuid),
drive_repo.drive_id_for_folder(dst_folder_uuid),
);
let (src_drive_id, src_policies) = src_res.map_err(|e| {
DomainError::internal_error("Drive", format!("source drive lookup: {e:?}"))
})?;
let dst_drive_id = dst_res.map_err(|e| {
DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}"))
})?;
if src_drive_id != dst_drive_id {
src_policies.refuse_cross_drive_move(
crate::domain::entities::drive::CrossDriveMoveGateContext {
caller_id,
resource_type: "folder",
resource_id: src_folder_uuid,
src_drive_id,
dst_drive_id,
},
)?;
// Destination drive quota: sum the moved subtree's
// non-trashed files and refuse if the destination
// couldn't hold them. Same 507 shape as the file
// path + upload path — DomainError::QuotaExceeded
// maps at the AppError boundary.
if let Some(storage_usage) = &self.storage_usage {
let subtree_bytes = storage_usage.folder_subtree_bytes(src_folder_uuid).await?;
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
storage_usage
.check_drive_quota(dst_drive_id, subtree_u64)
.await?;
}
}
cross_drive = Some((src_drive_id, dst_drive_id));
}
}
let parent_ref = dto.parent_id.as_deref();
let folder = self
.folder_storage
@@ -724,12 +899,46 @@ impl FolderUseCase for FolderService {
)
})?;
// Cross-drive move flushes the authz engine's `owner_cache`
// — every descendant's cached `Resource → drive_id` mapping
// just got stale via the cascade trigger, and we don't (yet)
// walk the subtree to invalidate individually. Small perf
// cost (single JOIN per resource touched over the next
// minute) versus a stale-authz bug where destination-drive
// Owner cascades don't apply to moved content.
if cross_drive.is_some() {
self.authz.invalidate_owner_cache_all().await;
}
// 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))
}
/// Deletes a folder after verifying the caller has `Delete` permission.
/// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants`
/// rows targeting the deleted folder automatically.
///
/// Enumerates the subtree's file ids BEFORE the bulk DELETE so
/// `on_file_deleted` fires per file the PG cascade is about to reap —
/// without this, file-id-keyed lifecycle data (e.g. `ext-{file_id}.jpg`
/// video thumbnails, moka cache entries) leaks past the cascade.
/// Same shape `clear_trash_in` uses (`trash_service.rs:804-846`).
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
// External mount: delete on the provider (permanent — mounts have no
// trash). The mount root is a real folder row and is not deletable here.
@@ -758,12 +967,28 @@ impl FolderUseCase for FolderService {
)
.await?;
// Snapshot the file ids BEFORE the bulk DELETE — the rows are gone
// afterward. Failure to enumerate is non-fatal (logged in the repo
// method); the delete proceeds and only file-id-keyed cleanup is
// skipped (blob-keyed thumbnails still get reaped by GC).
let cascaded_file_ids = self
.folder_storage
.list_file_ids_in_subtree(id)
.await
.unwrap_or_default();
self.folder_storage.delete_folder(id).await.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to delete folder with ID: {}: {}", id, e),
)
})
})?;
for file_id in &cascaded_file_ids {
self.file_lifecycle.on_file_deleted(file_id);
}
Ok(())
}
}
@@ -1102,9 +1327,22 @@ impl PersonalDriveLifecycleHook {
// parent_id=NULL, drive_id pinned) + drives.root_folder_id
// wire-up + Owner role_grant. Single SQL statement, atomic
// against server crash mid-sequence (docs/plan/drive.md §3).
//
// `quota_bytes = None` (NULL in the DB) is the invariant for
// every personal drive per plan §7: the cap for a user's
// personal storage lives on `auth.users.storage_quota_bytes`
// (the user envelope), not on the drive row. Passing
// `Some(user.storage_quota_bytes())` here previously baked
// the user quota into `drives.quota_bytes` and — combined
// with the "0 = unlimited" convention on the user check but
// "0 = literal zero" convention on the drive check — turned
// "unlimited user" into "0-byte drive" (see #595). The
// migration `20260916000000_null_personal_drive_quota.sql`
// heals existing rows and adds a CHECK constraint pinning
// this invariant at the schema layer.
let drive_with_name = self
.drive_repo
.create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes()))
.create_personal_drive_atomic(user.id(), None)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -1142,6 +1380,17 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook {
self.provision_if_needed(user).await
}
/// External → internal upgrade. `on_user_created` fired at signup
/// with `is_external=true` and short-circuited in
/// `provision_if_needed`. The user is now internal — same helper
/// runs, but this time the `is_external` guard passes through and
/// the atomic CTE creates their default drive + root folder +
/// owner grant. Idempotent by construction: a rerun after a partial
/// failure hits the `find_default_for_user` short-circuit.
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
self.provision_if_needed(user).await
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
// Drives don't react to logout. Explicit no-op per the
// "no defaults" convention.
@@ -1415,6 +1664,7 @@ mod mount_authz_integration {
let fs = FolderService::new(
Arc::new(FolderDbRepository::new(pool.clone())),
acl(pool),
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
router,
);
(fs, p.mount_folder_id.to_string(), p.owner_id)
@@ -1610,6 +1860,7 @@ mod mount_authz_integration {
let folder_service = FolderService::new(
Arc::new(FolderDbRepository::new(pool.clone())),
acl(&pool),
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
router.clone(),
);
let retrieval = FileRetrievalService::new_with_authz_for_test(
@@ -1659,7 +1910,7 @@ mod mount_authz_integration {
// PROPFIND Depth:1 file loop: list files of the mount root.
let files = retrieval
.list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, 0, 100)
.list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, None, 100)
.await
.expect("list mount files");
assert_eq!(
@@ -1673,7 +1924,6 @@ mod mount_authz_integration {
.get_file_by_path(&format!("{}/sub/b.txt", root.path), p.drive_id)
.await
.expect("nested file");
use futures::TryStreamExt as _;
let content: Vec<u8> = Box::into_pin(retrieval.get_file_stream(&nested.id).await.unwrap())
.map_ok(|b| b.to_vec())
.try_concat()
@@ -1745,6 +1995,7 @@ mod mount_authz_integration {
let folder_service = FolderService::new(
Arc::new(FolderDbRepository::new(pool.clone())),
acl(&pool),
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
router.clone(),
);
@@ -1834,3 +2085,227 @@ mod mount_authz_integration {
assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound);
}
}
// ────────────────────────────────────────────────────────────────────────────
// Integration test — verifies the folder-cascade hook fix lands `on_file_deleted`
// for every file the PG cascade reaps when a folder is permanently deleted.
//
// Background: `delete_folder_with_perms` issues a bulk SQL DELETE that the PG
// `ON DELETE CASCADE` fans out to descendant folders + files. Without
// service-layer enumeration, file-id-keyed lifecycle data (thumbnails keyed
// on `ext-{file_id}.jpg`, moka cache entries, future per-file metadata)
// silently leaks. See [[bug-folder-cascade-hooks-missing]] in agent memory.
//
// How to run:
// bash tests/common/spawn-db.sh
// RUSTFLAGS='--cfg integration_tests' cargo test \
// -p oxicloud --lib folder_service::cascade_hook_integration_tests
// ────────────────────────────────────────────────────────────────────────────
#[cfg(integration_tests)]
#[allow(dead_code)]
mod cascade_hook_integration_tests {
use super::*;
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::services::dedup_service::DedupService;
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
use crate::integration_test_support::{ensure_clean_test_db, test_db_url};
use sqlx::Row;
use sqlx::postgres::PgPoolOptions;
use std::sync::Mutex;
use tempfile::TempDir;
/// Records every `on_file_deleted` call so the test can assert the
/// exact set of file ids the cascade fired hooks for. Other lifecycle
/// methods are no-ops — this fix only touches the deletion path.
#[derive(Default)]
struct RecordingHook {
deleted: Mutex<Vec<String>>,
}
impl FileLifecycleHook for RecordingHook {
fn on_file_created(
&self,
_file_id: &str,
_blob_hash: &str,
_content_type: &str,
_is_new_blob: bool,
) {
}
fn on_file_copied(
&self,
_file_id: &str,
_blob_hash: &str,
_content_type: &str,
_source_file_id: &str,
) {
}
fn on_file_updated(&self, _file_id: &str, _blob_hash: &str, _content_type: &str) {}
fn on_file_deleted(&self, file_id: &str) {
self.deleted.lock().unwrap().push(file_id.to_string());
}
}
async fn test_pool() -> Arc<sqlx::PgPool> {
let pool = PgPoolOptions::new()
.max_connections(4)
.connect(&test_db_url())
.await
.expect("connect to test DB — run tests/common/spawn-db.sh first");
ensure_clean_test_db(&pool).await;
Arc::new(pool)
}
/// Returns `(user_id, drive_id, drive_root_folder_id)` — same default
/// Personal drive every internal user gets post-D0 (provisioned by
/// `PersonalDriveLifecycleHook`).
async fn seed_user(pool: &sqlx::PgPool) -> (Uuid, Uuid, Uuid) {
sqlx::query(
"SELECT u.id AS user_id, d.id AS drive_id, d.root_folder_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1",
)
.fetch_one(pool)
.await
.map(|r| {
(
r.get::<Uuid, _>("user_id"),
r.get::<Uuid, _>("drive_id"),
r.get::<Uuid, _>("root_folder_id"),
)
})
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
}
/// Build a real `PgAclEngine` against the test pool so
/// `delete_folder_with_perms` can actually evaluate Owner — the user
/// from `seed_user` owns the default drive, so `Permission::Delete`
/// on its descendants resolves through the Owner short-circuit.
async fn build_authz(
pool: Arc<sqlx::PgPool>,
dir: &TempDir,
folder_repo: Arc<FolderDbRepository>,
) -> Arc<PgAclEngine> {
let backend = Arc::new(LocalBlobBackend::new(&dir.path().join("blobs")));
backend.initialize().await.expect("init backend");
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
let file_repo = Arc::new(FileBlobReadRepository::new(
pool.clone(),
dedup,
folder_repo.clone(),
));
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
}
/// Seed a file row under `folder_id`. `blob_hash` is just a string —
/// `storage.files.blob_hash` is VARCHAR(64) without a FK, so no blob
/// row is required. The cascade decrement trigger no-ops when the
/// hash is unknown.
async fn seed_file_under(
pool: &sqlx::PgPool,
user_id: Uuid,
drive_id: Uuid,
folder_id: Uuid,
label: &str,
) -> Uuid {
let blob_hash = blake3::hash(format!("cascade-{label}-{}", Uuid::new_v4()).as_bytes())
.to_hex()
.to_string();
// Post-D7: `user_id` omitted — the column is nullable and
// provenance flows through `created_by` / `updated_by`.
sqlx::query_scalar(
"INSERT INTO storage.files
(name, drive_id, folder_id, blob_hash, size, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $6)
RETURNING id",
)
.bind(format!(
"rust-test-cascade-{label}-{}",
&Uuid::new_v4().to_string()[..8]
))
.bind(drive_id)
.bind(folder_id)
.bind(&blob_hash)
.bind(42i64)
.bind(user_id)
.fetch_one(pool)
.await
.expect("seed file row")
}
#[tokio::test]
async fn delete_folder_with_perms_fires_hook_for_cascaded_files() {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let (user_id, drive_id, drive_root) = seed_user(&pool).await;
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
let authz = build_authz(pool.clone(), &dir, folder_repo.clone()).await;
let recorder: Arc<RecordingHook> = Arc::new(RecordingHook::default());
let fls = Arc::new(
crate::application::services::file_lifecycle_service::FileLifecycleService::new()
.with_hook(recorder.clone() as Arc<dyn FileLifecycleHook>),
);
let service = FolderService::new(
folder_repo.clone(),
authz,
fls,
Arc::new(MountRouter::new(Arc::new(
crate::application::services::mount_registry::MountRegistry::empty(),
))),
);
// Build parent/child via the production create path — it stamps
// provenance and computes paths the same way as live uploads.
let parent = folder_repo
.create_folder(
format!(
"rust-test-cascade-parent-{}",
&Uuid::new_v4().to_string()[..8]
),
Some(drive_root.to_string()),
user_id,
)
.await
.expect("create parent");
let child = folder_repo
.create_folder(
format!(
"rust-test-cascade-child-{}",
&Uuid::new_v4().to_string()[..8]
),
Some(parent.id().to_string()),
user_id,
)
.await
.expect("create child");
let child_uuid = Uuid::parse_str(child.id()).expect("child uuid");
// Two files: one directly under the parent, one nested under
// child. The cascade should reap both; the hook must fire for both.
let parent_uuid = Uuid::parse_str(parent.id()).expect("parent uuid");
let direct_file = seed_file_under(&pool, user_id, drive_id, parent_uuid, "direct").await;
let nested_file = seed_file_under(&pool, user_id, drive_id, child_uuid, "nested").await;
// Act — the production code path under test.
service
.delete_folder_with_perms(parent.id(), user_id)
.await
.expect("delete_folder_with_perms");
// Assert — every cascaded file id appears in the hook record.
let captured = recorder.deleted.lock().unwrap().clone();
assert!(
captured.contains(&direct_file.to_string()),
"expected on_file_deleted for direct-child file {direct_file}, got {captured:?}"
);
assert!(
captured.contains(&nested_file.to_string()),
"expected on_file_deleted for nested file {nested_file}, got {captured:?}"
);
}
}