3c31695579
Adds the foundation for external file mounts: admin-configured backends (raw host filesystem in v1; sftp/webdav/… as future provider kinds) surfaced as a folder inside a user's drive. Mount contents are virtual/live-passthrough — read straight from the backend, never stored in storage.files — and are a deliberately separate, limited storage type (no dedup/sharing/trash/search). The feature is dark by default (OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false). P1 scope (this PR): data model, the pluggable provider abstraction, and the read-only REST surface (mount listing + download). Read-write (P2), WebDAV/NextCloud path resolution (P3), and the admin UI (P4) follow. Core model - Mount root = a real storage.folders row; authorization for everything inside collapses onto that folder UUID (ltree-ancestry grant cascade). - Children are virtual, addressed by ext:<mount_id>:<base64url(node_id)> where node_id is provider-owned and opaque to the rest of the system. - A lock-free (arc-swap) MountRegistry maps mount-root UUID -> provider; a thin MountRouter::classify() is the single cheap hook handlers call before parsing an id as a UUID. With no mounts configured it always returns Regular, so existing code paths are unchanged. Added - migrations/20260805000000_external_mounts.sql (storage.external_mounts, kind + config JSONB) - domain/services/external_mount_id (id envelope + virtual etags) - application/ports/external_mount_ports (ExternalMountProvider, MountProviderFactory, repo port) - infrastructure local_fs_mount_provider (tokio::fs, symlink-escape-safe) + factory - application MountRegistry + MountRouter, pg ExternalMountRepository - DI wiring (AppState.mount_router), FeaturesConfig.enable_external_mounts - listing branch (FolderService::list_mount_dir_with_perms + folder_handler) and download branch (FileRetrievalService stat/open mount methods + file_handler) Authorization stays in the service layer (authz.require(Resource::Folder(mount_id))); handlers only classify. Cross-backend operations are out of scope for P1. Tests: 529 unit tests + 5 testcontainers integration tests (real Postgres 17), including end-to-end authorization (owner allowed, stranger denied). Line coverage of the new modules is 84–100% (cargo-llvm-cov). Known gap: file_handler::download_mount_file (HTTP glue) needs a full-app test (P4).
119 lines
4.2 KiB
Rust
119 lines
4.2 KiB
Rust
//! Shared testcontainers harness for external-mount integration tests.
|
|
//!
|
|
//! Compiled only under `#[cfg(all(test, integration_tests))]` (the
|
|
//! `testcontainers` dev-dependency is linked into test targets only). Each
|
|
//! `fresh_db()` spins up an ephemeral Postgres, applies every migration, and
|
|
//! returns a pool — so the DB-backed tests are self-contained and need only
|
|
//! docker, not the external `spawn-db.sh` compose harness.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use sqlx::PgPool;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use testcontainers_modules::postgres::Postgres;
|
|
use testcontainers_modules::testcontainers::runners::AsyncRunner;
|
|
use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt};
|
|
use uuid::Uuid;
|
|
|
|
use crate::domain::repositories::drive_repository::DriveRepository;
|
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
|
use crate::infrastructure::repositories::pg::{DrivePgRepository, FolderDbRepository};
|
|
|
|
/// Postgres image tag — must match production (PG13+): the schema uses
|
|
/// `CREATE OR REPLACE TRIGGER` (PG14+) and the `pg_trgm` / `ltree` contrib
|
|
/// extensions. The `testcontainers` module default (`11-alpine`) is too old.
|
|
pub const PG_IMAGE_TAG: &str = "17-alpine";
|
|
|
|
/// A provisioned mount: a user with a personal drive, a child folder serving as
|
|
/// the mount root, and (when created via [`provision_mount`]) an
|
|
/// `external_mounts` row.
|
|
pub struct Provisioned {
|
|
pub owner_id: Uuid,
|
|
pub drive_id: Uuid,
|
|
pub mount_folder_id: Uuid,
|
|
}
|
|
|
|
/// Bring up an ephemeral Postgres, apply every migration, return a pool. Keep
|
|
/// the returned container handle alive for the test's duration.
|
|
pub async fn fresh_db() -> (ContainerAsync<Postgres>, Arc<PgPool>) {
|
|
let container = Postgres::default()
|
|
.with_tag(PG_IMAGE_TAG)
|
|
.start()
|
|
.await
|
|
.expect("start postgres testcontainer (is docker running?)");
|
|
let port = container
|
|
.get_host_port_ipv4(5432)
|
|
.await
|
|
.expect("container port");
|
|
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(4)
|
|
.connect(&url)
|
|
.await
|
|
.expect("connect to ephemeral postgres");
|
|
sqlx::migrate!().run(&pool).await.expect("apply migrations");
|
|
(container, Arc::new(pool))
|
|
}
|
|
|
|
/// Insert a minimal `user` role account, returning its id.
|
|
pub async fn make_user(pool: &PgPool, name: &str) -> Uuid {
|
|
sqlx::query_scalar::<_, Uuid>(
|
|
"INSERT INTO auth.users (username, email, password_hash, role)
|
|
VALUES ($1, $2, 'x', 'user') RETURNING id",
|
|
)
|
|
.bind(name)
|
|
.bind(format!("{name}@example.test"))
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("insert user")
|
|
}
|
|
|
|
/// Provision user → personal drive → a child folder named `folder_name` that
|
|
/// will act as the mount root. Does NOT insert an `external_mounts` row.
|
|
pub async fn provision_folder(
|
|
pool: &Arc<PgPool>,
|
|
user_name: &str,
|
|
folder_name: &str,
|
|
) -> Provisioned {
|
|
let owner_id = make_user(pool, user_name).await;
|
|
let drive_repo = DrivePgRepository::new(pool.clone());
|
|
let drive = drive_repo
|
|
.create_personal_drive_atomic(owner_id, None)
|
|
.await
|
|
.expect("create personal drive");
|
|
let root_folder_id = drive.drive.root_folder_id;
|
|
|
|
let folder_repo = FolderDbRepository::new(pool.clone());
|
|
let folder = folder_repo
|
|
.create_folder(
|
|
folder_name.to_string(),
|
|
Some(root_folder_id.to_string()),
|
|
owner_id,
|
|
)
|
|
.await
|
|
.expect("create mount-root folder");
|
|
let mount_folder_id = Uuid::parse_str(folder.id()).expect("uuid");
|
|
|
|
Provisioned {
|
|
owner_id,
|
|
drive_id: drive.drive.id,
|
|
mount_folder_id,
|
|
}
|
|
}
|
|
|
|
/// Insert an `external_mounts` row for `mount_folder_id` with a `local_fs`
|
|
/// provider pointed at `host_path`.
|
|
pub async fn insert_mount(pool: &PgPool, p: &Provisioned, host_path: &str) {
|
|
sqlx::query(
|
|
"INSERT INTO storage.external_mounts
|
|
(mount_folder_id, kind, config, name, owner_id, read_only)
|
|
VALUES ($1, 'local_fs', $2, 'Media', $3, false)",
|
|
)
|
|
.bind(p.mount_folder_id)
|
|
.bind(serde_json::json!({ "path": host_path }))
|
|
.bind(p.owner_id)
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert external mount");
|
|
}
|