//! Docker-based SMB test servers for integration testing. //! //! Provides [`TestServers`] for starting Samba containers on demand, //! with factory methods that return connected [`SmbClient`] instances. //! Enable the `testing` feature flag to use this module. //! //! # Three-layer testing model //! //! **Layer 1: Rust integration tests** -- Use [`TestServers`] to get //! pre-connected clients in `#[tokio::test]` functions. //! //! **Layer 2: E2E tests** -- Use [`write_compose_files`] to extract //! embedded Docker infrastructure, then run `docker compose up` from //! your test framework (Playwright, Cypress, etc.). //! //! **Layer 3: Manual QA** -- Extract compose files once, run containers //! manually, browse virtual servers in your app during development. //! //! # Example //! //! ```rust,no_run //! use std::sync::LazyLock; //! use smb2::testing::TestServers; //! //! static SERVERS: LazyLock = LazyLock::new(|| { //! TestServers::start_blocking().unwrap() //! }); //! //! # async fn example() { //! let mut guest = SERVERS.guest_client().await.unwrap(); //! let shares = guest.list_shares().await.unwrap(); //! # } //! ``` use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use log::{debug, info}; use crate::client::{ClientConfig, SmbClient}; // ── Error type ────────────────────────────────────────────────────────── /// Errors from the test infrastructure (Docker, process, health checks). /// /// Separate from [`crate::Error`] because these are test-setup failures, /// not protocol errors. #[derive(Debug)] pub enum Error { /// Docker compose command failed. Docker(std::io::Error), /// Container didn't pass health check in time. HealthCheckTimeout { /// Name of the container that timed out. container: String, }, /// Requested a client for a container that isn't running. ContainerNotStarted { /// Name of the container that was requested. container: String, /// Suggestion for how to fix this. hint: String, }, /// SMB connection or operation failed. Smb(crate::Error), /// Failed to write embedded files to disk. Io(std::io::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Error::Docker(e) => write!(f, "docker command failed: {e}"), Error::HealthCheckTimeout { container } => { write!(f, "health check timed out for container: {container}") } Error::ContainerNotStarted { container, hint } => { write!(f, "container not started: {container} ({hint})") } Error::Smb(e) => write!(f, "smb connection failed: {e}"), Error::Io(e) => write!(f, "failed to write compose files: {e}"), } } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Error::Docker(e) | Error::Io(e) => Some(e), Error::Smb(e) => Some(e), _ => None, } } } /// Result type for test infrastructure operations. pub type Result = std::result::Result; // ── Port constants ────────────────────────────────────────────────────── const DEFAULT_GUEST_PORT: u16 = 10480; const DEFAULT_AUTH_PORT: u16 = 10481; const DEFAULT_BOTH_PORT: u16 = 10482; const DEFAULT_50SHARES_PORT: u16 = 10483; const DEFAULT_UNICODE_PORT: u16 = 10484; const DEFAULT_LONGNAMES_PORT: u16 = 10485; const DEFAULT_DEEPNEST_PORT: u16 = 10486; const DEFAULT_MANYFILES_PORT: u16 = 10487; const DEFAULT_READONLY_PORT: u16 = 10488; const DEFAULT_WINDOWS_PORT: u16 = 10489; const DEFAULT_SYNOLOGY_PORT: u16 = 10490; const DEFAULT_LINUX_PORT: u16 = 10491; const DEFAULT_FLAKY_PORT: u16 = 10492; const DEFAULT_SLOW_PORT: u16 = 10493; const DEFAULT_MAXREADSIZE_PORT: u16 = 10494; /// Resolve a port from an environment variable, falling back to a default. fn port(env_var: &str, default: u16) -> u16 { std::env::var(env_var) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(default) } /// Port for the guest-access container. pub fn guest_port() -> u16 { port("SMB_CONSUMER_GUEST_PORT", DEFAULT_GUEST_PORT) } /// Port for the auth-required container. pub fn auth_port() -> u16 { port("SMB_CONSUMER_AUTH_PORT", DEFAULT_AUTH_PORT) } /// Port for the mixed auth container. pub fn both_port() -> u16 { port("SMB_CONSUMER_BOTH_PORT", DEFAULT_BOTH_PORT) } /// Port for the 50-shares container. pub fn many_shares_port() -> u16 { port("SMB_CONSUMER_50SHARES_PORT", DEFAULT_50SHARES_PORT) } /// Port for the unicode container. pub fn unicode_port() -> u16 { port("SMB_CONSUMER_UNICODE_PORT", DEFAULT_UNICODE_PORT) } /// Port for the long-names container. pub fn longnames_port() -> u16 { port("SMB_CONSUMER_LONGNAMES_PORT", DEFAULT_LONGNAMES_PORT) } /// Port for the deep-nesting container. pub fn deepnest_port() -> u16 { port("SMB_CONSUMER_DEEPNEST_PORT", DEFAULT_DEEPNEST_PORT) } /// Port for the many-files container. pub fn manyfiles_port() -> u16 { port("SMB_CONSUMER_MANYFILES_PORT", DEFAULT_MANYFILES_PORT) } /// Port for the read-only container. pub fn readonly_port() -> u16 { port("SMB_CONSUMER_READONLY_PORT", DEFAULT_READONLY_PORT) } /// Port for the Windows-like container. pub fn windows_port() -> u16 { port("SMB_CONSUMER_WINDOWS_PORT", DEFAULT_WINDOWS_PORT) } /// Port for the Synology-like container. pub fn synology_port() -> u16 { port("SMB_CONSUMER_SYNOLOGY_PORT", DEFAULT_SYNOLOGY_PORT) } /// Port for the Linux container. pub fn linux_port() -> u16 { port("SMB_CONSUMER_LINUX_PORT", DEFAULT_LINUX_PORT) } /// Port for the flaky container. pub fn flaky_port() -> u16 { port("SMB_CONSUMER_FLAKY_PORT", DEFAULT_FLAKY_PORT) } /// Port for the slow container. pub fn slow_port() -> u16 { port("SMB_CONSUMER_SLOW_PORT", DEFAULT_SLOW_PORT) } /// Port for the max-read-size container. /// /// The server enforces `smb2 max read = 65536` and `smb2 max write = 65536`, /// so every transfer larger than 64 KB is chunked. Consumers can target this /// fixture to exercise the streaming write/read fallback paths without /// needing the internal-fixture `smb-maxreadsize` container. pub fn maxreadsize_port() -> u16 { port("SMB_CONSUMER_MAXREADSIZE_PORT", DEFAULT_MAXREADSIZE_PORT) } // ── Embedded files ────────────────────────────────────────────────────── // docker-compose.yml const COMPOSE_YML: &str = include_str!("../../tests/docker/consumer/docker-compose.yml"); // smb-consumer-guest const GUEST_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-guest/Dockerfile"); const GUEST_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-guest/smb.conf"); // smb-consumer-auth const AUTH_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-auth/Dockerfile"); const AUTH_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-auth/smb.conf"); // smb-consumer-both const BOTH_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-both/Dockerfile"); const BOTH_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-both/smb.conf"); // smb-consumer-50shares const SHARES50_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-50shares/Dockerfile"); const SHARES50_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-50shares/smb.conf"); const SHARES50_GENERATE_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-50shares/generate-conf.sh"); // smb-consumer-unicode const UNICODE_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-unicode/Dockerfile"); const UNICODE_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-unicode/smb.conf"); const UNICODE_POPULATE: &str = include_str!("../../tests/docker/consumer/smb-consumer-unicode/populate.sh"); // smb-consumer-longnames const LONGNAMES_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-longnames/Dockerfile"); const LONGNAMES_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-longnames/smb.conf"); const LONGNAMES_POPULATE: &str = include_str!("../../tests/docker/consumer/smb-consumer-longnames/populate.sh"); // smb-consumer-deepnest const DEEPNEST_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-deepnest/Dockerfile"); const DEEPNEST_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-deepnest/smb.conf"); const DEEPNEST_POPULATE: &str = include_str!("../../tests/docker/consumer/smb-consumer-deepnest/populate.sh"); // smb-consumer-manyfiles const MANYFILES_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-manyfiles/Dockerfile"); const MANYFILES_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-manyfiles/smb.conf"); // smb-consumer-readonly const READONLY_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-readonly/Dockerfile"); const READONLY_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-readonly/smb.conf"); // smb-consumer-windows const WINDOWS_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-windows/Dockerfile"); const WINDOWS_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-windows/smb.conf"); // smb-consumer-synology const SYNOLOGY_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-synology/Dockerfile"); const SYNOLOGY_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-synology/smb.conf"); // smb-consumer-linux const LINUX_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-linux/Dockerfile"); const LINUX_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-linux/smb.conf"); // smb-consumer-flaky const FLAKY_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-flaky/Dockerfile"); const FLAKY_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-flaky/smb.conf"); const FLAKY_CYCLE: &str = include_str!("../../tests/docker/consumer/smb-consumer-flaky/cycle.sh"); // smb-consumer-slow const SLOW_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-slow/Dockerfile"); const SLOW_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-slow/smb.conf"); const SLOW_ENTRYPOINT: &str = include_str!("../../tests/docker/consumer/smb-consumer-slow/entrypoint.sh"); // smb-consumer-maxreadsize const MAXREADSIZE_DOCKERFILE: &str = include_str!("../../tests/docker/consumer/smb-consumer-maxreadsize/Dockerfile"); const MAXREADSIZE_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-maxreadsize/smb.conf"); // ── Embedded file manifest ────────────────────────────────────────────── /// A file to write into the compose directory. struct EmbeddedFile { /// Path relative to the compose directory root. relative_path: &'static str, /// File contents. contents: &'static str, /// Whether the file should be executable (shell scripts). executable: bool, } /// All files needed to reproduce the consumer Docker infrastructure. fn embedded_files() -> Vec { vec![ EmbeddedFile { relative_path: "docker-compose.yml", contents: COMPOSE_YML, executable: false, }, // guest EmbeddedFile { relative_path: "smb-consumer-guest/Dockerfile", contents: GUEST_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-guest/smb.conf", contents: GUEST_SMB_CONF, executable: false, }, // auth EmbeddedFile { relative_path: "smb-consumer-auth/Dockerfile", contents: AUTH_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-auth/smb.conf", contents: AUTH_SMB_CONF, executable: false, }, // both EmbeddedFile { relative_path: "smb-consumer-both/Dockerfile", contents: BOTH_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-both/smb.conf", contents: BOTH_SMB_CONF, executable: false, }, // 50shares EmbeddedFile { relative_path: "smb-consumer-50shares/Dockerfile", contents: SHARES50_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-50shares/smb.conf", contents: SHARES50_SMB_CONF, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-50shares/generate-conf.sh", contents: SHARES50_GENERATE_CONF, executable: true, }, // unicode EmbeddedFile { relative_path: "smb-consumer-unicode/Dockerfile", contents: UNICODE_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-unicode/smb.conf", contents: UNICODE_SMB_CONF, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-unicode/populate.sh", contents: UNICODE_POPULATE, executable: true, }, // longnames EmbeddedFile { relative_path: "smb-consumer-longnames/Dockerfile", contents: LONGNAMES_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-longnames/smb.conf", contents: LONGNAMES_SMB_CONF, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-longnames/populate.sh", contents: LONGNAMES_POPULATE, executable: true, }, // deepnest EmbeddedFile { relative_path: "smb-consumer-deepnest/Dockerfile", contents: DEEPNEST_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-deepnest/smb.conf", contents: DEEPNEST_SMB_CONF, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-deepnest/populate.sh", contents: DEEPNEST_POPULATE, executable: true, }, // manyfiles EmbeddedFile { relative_path: "smb-consumer-manyfiles/Dockerfile", contents: MANYFILES_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-manyfiles/smb.conf", contents: MANYFILES_SMB_CONF, executable: false, }, // readonly EmbeddedFile { relative_path: "smb-consumer-readonly/Dockerfile", contents: READONLY_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-readonly/smb.conf", contents: READONLY_SMB_CONF, executable: false, }, // windows EmbeddedFile { relative_path: "smb-consumer-windows/Dockerfile", contents: WINDOWS_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-windows/smb.conf", contents: WINDOWS_SMB_CONF, executable: false, }, // synology EmbeddedFile { relative_path: "smb-consumer-synology/Dockerfile", contents: SYNOLOGY_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-synology/smb.conf", contents: SYNOLOGY_SMB_CONF, executable: false, }, // linux EmbeddedFile { relative_path: "smb-consumer-linux/Dockerfile", contents: LINUX_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-linux/smb.conf", contents: LINUX_SMB_CONF, executable: false, }, // flaky EmbeddedFile { relative_path: "smb-consumer-flaky/Dockerfile", contents: FLAKY_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-flaky/smb.conf", contents: FLAKY_SMB_CONF, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-flaky/cycle.sh", contents: FLAKY_CYCLE, executable: true, }, // slow EmbeddedFile { relative_path: "smb-consumer-slow/Dockerfile", contents: SLOW_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-slow/smb.conf", contents: SLOW_SMB_CONF, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-slow/entrypoint.sh", contents: SLOW_ENTRYPOINT, executable: true, }, // maxreadsize EmbeddedFile { relative_path: "smb-consumer-maxreadsize/Dockerfile", contents: MAXREADSIZE_DOCKERFILE, executable: false, }, EmbeddedFile { relative_path: "smb-consumer-maxreadsize/smb.conf", contents: MAXREADSIZE_SMB_CONF, executable: false, }, ] } // ── File writing ──────────────────────────────────────────────────────── /// Write all embedded Docker files to the given directory. /// /// Creates the directory structure Docker Compose expects: /// /// ```text /// / /// docker-compose.yml /// smb-consumer-guest/ /// Dockerfile /// smb.conf /// smb-consumer-auth/ /// Dockerfile /// smb.conf /// ... /// ``` /// /// Use this for Layer 2 (E2E tests) or Layer 3 (manual QA) where you /// run `docker compose up` outside of Rust. pub fn write_compose_files(dir: &Path) -> Result<()> { let files = embedded_files(); for file in &files { let path = dir.join(file.relative_path); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(Error::Io)?; } fs::write(&path, file.contents).map_err(Error::Io)?; #[cfg(unix)] if file.executable { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o755); fs::set_permissions(&path, perms).map_err(Error::Io)?; } } debug!("wrote {} embedded files to {}", files.len(), dir.display()); Ok(()) } // ── Profile ───────────────────────────────────────────────────────────── /// Which containers to start. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Profile { /// Guest + auth only (fast startup). Minimal, /// All 14 containers. All, } impl Profile { /// Service names for `docker compose up`. fn services(self) -> &'static [&'static str] { match self { Profile::Minimal => &["smb-consumer-guest", "smb-consumer-auth"], Profile::All => &[ "smb-consumer-guest", "smb-consumer-auth", "smb-consumer-both", "smb-consumer-50shares", "smb-consumer-unicode", "smb-consumer-longnames", "smb-consumer-deepnest", "smb-consumer-manyfiles", "smb-consumer-readonly", "smb-consumer-windows", "smb-consumer-synology", "smb-consumer-linux", "smb-consumer-flaky", "smb-consumer-slow", ], } } } // ── TestServers ───────────────────────────────────────────────────────── /// Docker-based SMB test servers for integration testing. /// /// Starts Samba containers on construction, stops on drop. Each server /// type has a factory method returning a connected [`SmbClient`]. /// /// Consumers can also skip `TestServers` entirely and use the compose /// files directly for E2E or manual testing via [`write_compose_files`]. pub struct TestServers { compose_dir: PathBuf, profile: Profile, } impl TestServers { /// Start the minimal set: guest + auth containers. /// /// This is the fastest option (~2 seconds). Use [`start_all`](Self::start_all) /// if you need all 14 containers. pub async fn start() -> Result { let servers = Self::prepare(Profile::Minimal)?; servers.compose_up()?; servers.wait_healthy()?; Ok(servers) } /// Start all 14 consumer containers. pub async fn start_all() -> Result { let servers = Self::prepare(Profile::All)?; servers.compose_up()?; servers.wait_healthy()?; Ok(servers) } /// Blocking version of [`start_all`](Self::start_all) for use in /// [`LazyLock`](std::sync::LazyLock) statics. /// /// # Example /// /// ```rust,no_run /// use std::sync::LazyLock; /// use smb2::testing::TestServers; /// /// static SERVERS: LazyLock = LazyLock::new(|| { /// TestServers::start_blocking().unwrap() /// }); /// ``` pub fn start_blocking() -> Result { let servers = Self::prepare(Profile::All)?; servers.compose_up()?; servers.wait_healthy()?; Ok(servers) } /// Guest-access server. No credentials needed. pub async fn guest_client(&self) -> Result { self.require_service("smb-consumer-guest")?; let addr = format!("127.0.0.1:{}", guest_port()); connect_guest(&addr).await } /// Auth-required server. Needs username and password. pub async fn auth_client(&self, user: &str, pass: &str) -> Result { self.require_service("smb-consumer-auth")?; let addr = format!("127.0.0.1:{}", auth_port()); connect_auth(&addr, user, pass).await } /// Mixed server, guest connection. Can access the "public" share only. pub async fn both_client(&self) -> Result { self.require_service("smb-consumer-both")?; let addr = format!("127.0.0.1:{}", both_port()); connect_guest(&addr).await } /// Mixed server, authenticated connection. Can access both "public" /// and "private" shares. pub async fn both_client_auth(&self, user: &str, pass: &str) -> Result { self.require_service("smb-consumer-both")?; let addr = format!("127.0.0.1:{}", both_port()); connect_auth(&addr, user, pass).await } /// Read-only server. Writes return errors. pub async fn readonly_client(&self) -> Result { self.require_service("smb-consumer-readonly")?; let addr = format!("127.0.0.1:{}", readonly_port()); connect_guest(&addr).await } /// Server with 50 shares for testing share enumeration at scale. pub async fn many_shares_client(&self) -> Result { self.require_service("smb-consumer-50shares")?; let addr = format!("127.0.0.1:{}", many_shares_port()); connect_guest(&addr).await } /// Server with unicode share and file names (CJK, emoji, accented characters). pub async fn unicode_client(&self) -> Result { self.require_service("smb-consumer-unicode")?; let addr = format!("127.0.0.1:{}", unicode_port()); connect_guest(&addr).await } /// Server with 200+ character filenames. Tests path truncation. pub async fn longnames_client(&self) -> Result { self.require_service("smb-consumer-longnames")?; let addr = format!("127.0.0.1:{}", longnames_port()); connect_guest(&addr).await } /// Server with 50-level deep directory tree. Tests navigation overflow. pub async fn deepnest_client(&self) -> Result { self.require_service("smb-consumer-deepnest")?; let addr = format!("127.0.0.1:{}", deepnest_port()); connect_guest(&addr).await } /// Server with 10,000+ files in one directory. pub async fn many_files_client(&self) -> Result { self.require_service("smb-consumer-manyfiles")?; let addr = format!("127.0.0.1:{}", manyfiles_port()); connect_guest(&addr).await } /// Windows-like server (server string in smb.conf). Tests OS detection. pub async fn windows_client(&self) -> Result { self.require_service("smb-consumer-windows")?; let addr = format!("127.0.0.1:{}", windows_port()); connect_guest(&addr).await } /// Synology-like server (server string in smb.conf). Tests NAS-specific UI. pub async fn synology_client(&self) -> Result { self.require_service("smb-consumer-synology")?; let addr = format!("127.0.0.1:{}", synology_port()); connect_guest(&addr).await } /// Generic Linux Samba server. Most common real-world server type. pub async fn linux_client(&self) -> Result { self.require_service("smb-consumer-linux")?; let addr = format!("127.0.0.1:{}", linux_port()); connect_guest(&addr).await } /// Flaky server (5 seconds up, 5 seconds down). Tests error recovery UI. pub async fn flaky_client(&self) -> Result { self.require_service("smb-consumer-flaky")?; let addr = format!("127.0.0.1:{}", flaky_port()); connect_guest(&addr).await } /// Slow server (200ms latency). Tests loading states and timeouts. pub async fn slow_client(&self) -> Result { self.require_service("smb-consumer-slow")?; let addr = format!("127.0.0.1:{}", slow_port()); connect_guest(&addr).await } // ── Internal helpers ──────────────────────────────────────────── /// Create a temp directory, write embedded files, return the struct. fn prepare(profile: Profile) -> Result { let compose_dir = std::env::temp_dir().join(format!("smb2-testing-{}", std::process::id())); write_compose_files(&compose_dir)?; info!("prepared compose files in {}", compose_dir.display()); Ok(Self { compose_dir, profile, }) } /// Run `docker compose up` for the selected profile. fn compose_up(&self) -> Result<()> { let services = self.profile.services(); info!("starting {} container(s)", services.len()); let mut cmd = Command::new("docker"); cmd.arg("compose") .arg("-f") .arg(self.compose_dir.join("docker-compose.yml")) .arg("up") .arg("-d") .arg("--build"); for svc in services { cmd.arg(svc); } debug!("running: {:?}", cmd); let output = cmd.output().map_err(Error::Docker)?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); debug!("docker compose up stderr: {stderr}"); return Err(Error::Docker(std::io::Error::other(format!( "docker compose up failed: {stderr}" )))); } Ok(()) } /// Wait for all started containers to pass Docker health checks. fn wait_healthy(&self) -> Result<()> { let services = self.profile.services(); let timeout = Duration::from_secs(30); let poll_interval = Duration::from_millis(500); let start = std::time::Instant::now(); for service in services { // Skip health check for flaky container (it intentionally cycles). if *service == "smb-consumer-flaky" { debug!("skipping health check for {service} (intentionally flaky)"); continue; } loop { if start.elapsed() > timeout { return Err(Error::HealthCheckTimeout { container: service.to_string(), }); } let output = Command::new("docker") .arg("compose") .arg("-f") .arg(self.compose_dir.join("docker-compose.yml")) .arg("ps") .arg("--format") .arg("{{.Health}}") .arg(service) .output() .map_err(Error::Docker)?; let status = String::from_utf8_lossy(&output.stdout) .trim() .to_lowercase(); if status.contains("healthy") { debug!("{service} is healthy"); break; } debug!("{service} health: {status:?}, waiting..."); std::thread::sleep(poll_interval); } } info!("all containers healthy"); Ok(()) } /// Check that a service is part of the current profile. fn require_service(&self, service: &str) -> Result<()> { if self.profile.services().contains(&service) { Ok(()) } else { Err(Error::ContainerNotStarted { container: service.to_string(), hint: "call start_all() to start all containers".to_string(), }) } } /// Run `docker compose down` (best-effort). fn compose_down(&self) { debug!("stopping containers in {}", self.compose_dir.display()); let result = Command::new("docker") .arg("compose") .arg("-f") .arg(self.compose_dir.join("docker-compose.yml")) .arg("down") .arg("--timeout") .arg("5") .output(); match result { Ok(output) if output.status.success() => { info!("containers stopped"); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); debug!("docker compose down stderr: {stderr}"); } Err(e) => { debug!("failed to run docker compose down: {e}"); } } } /// Clean up the temp directory (best-effort). fn cleanup_dir(&self) { if self.compose_dir.exists() { if let Err(e) = fs::remove_dir_all(&self.compose_dir) { debug!("failed to clean up {}: {e}", self.compose_dir.display()); } } } } impl Drop for TestServers { fn drop(&mut self) { self.compose_down(); self.cleanup_dir(); } } // ── Connection helpers ────────────────────────────────────────────────── async fn connect_guest(addr: &str) -> Result { SmbClient::connect(ClientConfig { addr: addr.to_string(), timeout: Duration::from_secs(10), username: String::new(), password: String::new(), domain: String::new(), auto_reconnect: false, compression: true, dfs_enabled: false, dfs_target_overrides: std::collections::HashMap::new(), }) .await .map_err(Error::Smb) } async fn connect_auth(addr: &str, user: &str, pass: &str) -> Result { SmbClient::connect(ClientConfig { addr: addr.to_string(), timeout: Duration::from_secs(10), username: user.to_string(), password: pass.to_string(), domain: String::new(), auto_reconnect: false, compression: true, dfs_enabled: false, dfs_target_overrides: std::collections::HashMap::new(), }) .await .map_err(Error::Smb) } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; // ── Port resolution ───────────────────────────────────────────── #[test] fn port_returns_default_when_env_unset() { // Use a unique env var name that won't collide with real env. let val = port("SMB2_TEST_NONEXISTENT_PORT_12345", 9999); assert_eq!(val, 9999); } #[test] fn port_returns_env_value_when_set() { let key = "SMB2_TEST_PORT_OVERRIDE_CHECK"; std::env::set_var(key, "12345"); let val = port(key, 9999); std::env::remove_var(key); assert_eq!(val, 12345); } #[test] fn port_returns_default_for_non_numeric_env() { let key = "SMB2_TEST_PORT_BAD_VALUE"; std::env::set_var(key, "not_a_number"); let val = port(key, 7777); std::env::remove_var(key); assert_eq!(val, 7777); } #[test] fn port_returns_default_for_empty_env() { let key = "SMB2_TEST_PORT_EMPTY"; std::env::set_var(key, ""); let val = port(key, 5555); std::env::remove_var(key); assert_eq!(val, 5555); } // ── Default port values ───────────────────────────────────────── #[test] fn default_ports_are_in_consumer_range() { let ports = [ DEFAULT_GUEST_PORT, DEFAULT_AUTH_PORT, DEFAULT_BOTH_PORT, DEFAULT_50SHARES_PORT, DEFAULT_UNICODE_PORT, DEFAULT_LONGNAMES_PORT, DEFAULT_DEEPNEST_PORT, DEFAULT_MANYFILES_PORT, DEFAULT_READONLY_PORT, DEFAULT_WINDOWS_PORT, DEFAULT_SYNOLOGY_PORT, DEFAULT_LINUX_PORT, DEFAULT_FLAKY_PORT, DEFAULT_SLOW_PORT, ]; for p in ports { assert!( (10480..=10493).contains(&p), "port {p} outside expected range 10480-10493" ); } } #[test] fn default_ports_are_unique() { let ports = [ DEFAULT_GUEST_PORT, DEFAULT_AUTH_PORT, DEFAULT_BOTH_PORT, DEFAULT_50SHARES_PORT, DEFAULT_UNICODE_PORT, DEFAULT_LONGNAMES_PORT, DEFAULT_DEEPNEST_PORT, DEFAULT_MANYFILES_PORT, DEFAULT_READONLY_PORT, DEFAULT_WINDOWS_PORT, DEFAULT_SYNOLOGY_PORT, DEFAULT_LINUX_PORT, DEFAULT_FLAKY_PORT, DEFAULT_SLOW_PORT, ]; let mut seen = std::collections::HashSet::new(); for p in ports { assert!(seen.insert(p), "duplicate port: {p}"); } } // ── Error formatting ──────────────────────────────────────────── #[test] fn error_display_docker() { let err = Error::Docker(std::io::Error::new( std::io::ErrorKind::NotFound, "docker not found", )); let msg = err.to_string(); assert!(msg.contains("docker command failed"), "got: {msg}"); assert!(msg.contains("docker not found"), "got: {msg}"); } #[test] fn error_display_health_check_timeout() { let err = Error::HealthCheckTimeout { container: "smb-consumer-guest".to_string(), }; let msg = err.to_string(); assert!(msg.contains("health check timed out"), "got: {msg}"); assert!(msg.contains("smb-consumer-guest"), "got: {msg}"); } #[test] fn error_display_container_not_started() { let err = Error::ContainerNotStarted { container: "smb-consumer-unicode".to_string(), hint: "call start_all()".to_string(), }; let msg = err.to_string(); assert!(msg.contains("container not started"), "got: {msg}"); assert!(msg.contains("smb-consumer-unicode"), "got: {msg}"); assert!(msg.contains("start_all()"), "got: {msg}"); } #[test] fn error_display_io() { let err = Error::Io(std::io::Error::new( std::io::ErrorKind::PermissionDenied, "permission denied", )); let msg = err.to_string(); assert!(msg.contains("write compose files"), "got: {msg}"); } #[test] fn error_debug_is_implemented() { let err = Error::HealthCheckTimeout { container: "test".to_string(), }; // Just verify Debug doesn't panic. let _ = format!("{err:?}"); } // ── write_compose_files ───────────────────────────────────────── #[test] fn write_compose_files_creates_expected_structure() { let dir = std::env::temp_dir().join(format!("smb2-test-write-{}", std::process::id())); // Clean up from any previous run. let _ = fs::remove_dir_all(&dir); write_compose_files(&dir).unwrap(); // Verify top-level compose file. assert!(dir.join("docker-compose.yml").exists()); // Verify all 15 container directories exist with Dockerfiles. let containers = [ "smb-consumer-guest", "smb-consumer-auth", "smb-consumer-both", "smb-consumer-50shares", "smb-consumer-unicode", "smb-consumer-longnames", "smb-consumer-deepnest", "smb-consumer-manyfiles", "smb-consumer-readonly", "smb-consumer-windows", "smb-consumer-synology", "smb-consumer-linux", "smb-consumer-flaky", "smb-consumer-slow", "smb-consumer-maxreadsize", ]; for name in containers { let dockerfile = dir.join(name).join("Dockerfile"); assert!(dockerfile.exists(), "missing Dockerfile for {name}"); let smb_conf = dir.join(name).join("smb.conf"); assert!(smb_conf.exists(), "missing smb.conf for {name}"); } // Verify extra scripts exist. assert!(dir.join("smb-consumer-50shares/generate-conf.sh").exists()); assert!(dir.join("smb-consumer-unicode/populate.sh").exists()); assert!(dir.join("smb-consumer-longnames/populate.sh").exists()); assert!(dir.join("smb-consumer-deepnest/populate.sh").exists()); assert!(dir.join("smb-consumer-flaky/cycle.sh").exists()); assert!(dir.join("smb-consumer-slow/entrypoint.sh").exists()); // Clean up. let _ = fs::remove_dir_all(&dir); } #[test] fn write_compose_files_content_matches_embedded() { let dir = std::env::temp_dir().join(format!("smb2-test-content-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); write_compose_files(&dir).unwrap(); let compose = fs::read_to_string(dir.join("docker-compose.yml")).unwrap(); assert!( compose.contains("smb-consumer-guest"), "compose file should reference guest service" ); assert!( compose.contains("10480"), "compose file should contain default guest port" ); let guest_conf = fs::read_to_string(dir.join("smb-consumer-guest/smb.conf")).unwrap(); assert!( guest_conf.contains("[public]"), "guest smb.conf should have [public] share" ); let _ = fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] fn write_compose_files_scripts_are_executable() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("smb2-test-exec-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); write_compose_files(&dir).unwrap(); let scripts = [ "smb-consumer-50shares/generate-conf.sh", "smb-consumer-unicode/populate.sh", "smb-consumer-longnames/populate.sh", "smb-consumer-deepnest/populate.sh", "smb-consumer-flaky/cycle.sh", "smb-consumer-slow/entrypoint.sh", ]; for script in scripts { let path = dir.join(script); let mode = fs::metadata(&path).unwrap().permissions().mode(); assert!( mode & 0o111 != 0, "{script} should be executable (mode: {mode:#o})" ); } let _ = fs::remove_dir_all(&dir); } // ── Profile / require_service ─────────────────────────────────── #[test] fn minimal_profile_includes_guest_and_auth() { let services = Profile::Minimal.services(); assert!(services.contains(&"smb-consumer-guest")); assert!(services.contains(&"smb-consumer-auth")); assert_eq!(services.len(), 2); } #[test] fn all_profile_includes_14_services() { let services = Profile::All.services(); assert_eq!(services.len(), 14); } #[test] fn require_service_ok_for_minimal_profile() { let servers = TestServers { compose_dir: PathBuf::from("/tmp/fake"), profile: Profile::Minimal, }; assert!(servers.require_service("smb-consumer-guest").is_ok()); assert!(servers.require_service("smb-consumer-auth").is_ok()); } #[test] fn require_service_fails_for_non_minimal_container() { let servers = TestServers { compose_dir: PathBuf::from("/tmp/fake"), profile: Profile::Minimal, }; let err = servers.require_service("smb-consumer-unicode").unwrap_err(); match err { Error::ContainerNotStarted { container, hint } => { assert_eq!(container, "smb-consumer-unicode"); assert!(hint.contains("start_all()")); } other => panic!("expected ContainerNotStarted, got: {other:?}"), } } #[test] fn require_service_ok_for_all_profile() { let servers = TestServers { compose_dir: PathBuf::from("/tmp/fake"), profile: Profile::All, }; // Should succeed for every container. for svc in Profile::All.services() { assert!( servers.require_service(svc).is_ok(), "require_service failed for {svc}" ); } } // ── Embedded file count ───────────────────────────────────────── #[test] fn embedded_files_count() { let files = embedded_files(); // 1 compose + 14 containers * (Dockerfile + smb.conf) = 29 // + 6 extra scripts = 35 assert_eq!(files.len(), 35, "expected 35 embedded files"); } #[test] fn embedded_files_no_empty_contents() { for file in embedded_files() { assert!( !file.contents.is_empty(), "embedded file {} has empty contents", file.relative_path ); } } }