diff --git a/apps/nxmesh-agent/src/cli/mod.rs b/apps/nxmesh-agent/src/cli/mod.rs index 676c83b..ad41011 100644 --- a/apps/nxmesh-agent/src/cli/mod.rs +++ b/apps/nxmesh-agent/src/cli/mod.rs @@ -79,3 +79,84 @@ pub enum Commands { ca_cert: Option, }, } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Commands}; + + #[test] + fn parses_serve_flag_without_subcommand() { + let parsed = Cli::try_parse_from(["nxmesh-agent", "--serve"]); + assert!(parsed.is_ok()); + + let parsed = parsed.ok(); + assert!(parsed.is_some()); + let parsed = parsed.unwrap_or_else(|| unreachable!()); + + assert!(parsed.serve); + assert!(parsed.command.is_none()); + } + + #[test] + fn parses_import_certs_with_zip_defaults() { + let parsed = Cli::try_parse_from(["nxmesh-agent", "import-certs", "bundle.zip"]); + assert!(parsed.is_ok()); + + let parsed = parsed.ok(); + assert!(parsed.is_some()); + let parsed = parsed.unwrap_or_else(|| unreachable!()); + + match parsed.command { + Some(Commands::ImportCerts { + zip, + cert_name, + key_name, + ca_name, + cert, + key, + ca_cert, + }) => { + assert_eq!(zip.as_deref(), Some("bundle.zip")); + assert_eq!(cert_name.as_deref(), Some("cert.pem")); + assert_eq!(key_name.as_deref(), Some("key.pem")); + assert_eq!(ca_name.as_deref(), Some("ca.pem")); + assert!(cert.is_none()); + assert!(key.is_none()); + assert!(ca_cert.is_none()); + } + _ => unreachable!(), + } + } + + #[test] + fn rejects_import_certs_with_separate_files() { + let parsed = Cli::try_parse_from([ + "nxmesh-agent", + "import-certs", + "--cert", + "agent.crt", + "--key", + "agent.key", + "--ca-cert", + "ca.crt", + ]); + assert!(parsed.is_err()); + } + + #[test] + fn rejects_conflicting_zip_and_separate_inputs() { + let parsed = Cli::try_parse_from([ + "nxmesh-agent", + "import-certs", + "bundle.zip", + "--cert", + "agent.crt", + "--key", + "agent.key", + ]); + + assert!(parsed.is_err()); + } +} diff --git a/apps/nxmesh-agent/src/config/settings.rs b/apps/nxmesh-agent/src/config/settings.rs index 2a579c1..11c419a 100644 --- a/apps/nxmesh-agent/src/config/settings.rs +++ b/apps/nxmesh-agent/src/config/settings.rs @@ -318,6 +318,14 @@ where #[cfg(test)] mod tests { + use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + }; + + use tempfile::TempDir; + use super::*; #[test] @@ -330,4 +338,223 @@ mod tests { assert_send_sync::(); assert_send_sync::(); } + + fn write_file(path: &Path) { + let result = fs::write(path, b"content"); + assert!(result.is_ok()); + } + + fn create_exec_file(path: &Path) { + write_file(path); + let metadata = fs::metadata(path); + assert!(metadata.is_ok()); + let metadata = metadata.ok(); + assert!(metadata.is_some()); + let metadata = metadata.unwrap_or_else(|| unreachable!()); + + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let result = fs::set_permissions(path, perms); + assert!(result.is_ok()); + } + + fn create_non_exec_file(path: &Path) { + write_file(path); + let metadata = fs::metadata(path); + assert!(metadata.is_ok()); + let metadata = metadata.ok(); + assert!(metadata.is_some()); + let metadata = metadata.unwrap_or_else(|| unreachable!()); + + let mut perms = metadata.permissions(); + perms.set_mode(0o644); + let result = fs::set_permissions(path, perms); + assert!(result.is_ok()); + } + + fn valid_tls_raw_paths(temp_dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + let ca_path = temp_dir.path().join("ca.pem"); + let cert_path = temp_dir.path().join("cert.pem"); + let key_path = temp_dir.path().join("key.pem"); + + write_file(&ca_path); + write_file(&cert_path); + write_file(&key_path); + + (ca_path, cert_path, key_path) + } + + #[test] + fn tls_raw_path_validate_succeeds_when_all_files_exist() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let (ca_path, cert_path, key_path) = valid_tls_raw_paths(&temp_dir); + let settings = TLSSettings::RawPath { + ca_path: ca_path.to_string_lossy().to_string(), + cert_path: cert_path.to_string_lossy().to_string(), + key_path: key_path.to_string_lossy().to_string(), + }; + + assert!(settings.validate().is_ok()); + } + + #[test] + fn tls_raw_path_validate_fails_when_ca_missing() { + let settings = TLSSettings::RawPath { + ca_path: "/tmp/does-not-exist-ca.pem".into(), + cert_path: "/tmp/does-not-exist-cert.pem".into(), + key_path: "/tmp/does-not-exist-key.pem".into(), + }; + + let result = settings.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("CA file not found")); + } + + #[test] + fn tls_zip_path_validate_fails_when_zip_missing() { + let settings = TLSSettings::ZipPath { + cert_zip_path: "/tmp/missing-certs.zip".into(), + }; + + let result = settings.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("Certificate zip file not found")); + } + + #[test] + fn grpc_validate_fails_when_connection_string_empty() { + let settings = GrpcSettings { + connection_string: "".into(), + m_auth: MAuthSettings::Tls(TLSSettings::ZipPath { + cert_zip_path: "/tmp/does-not-exist.zip".into(), + }), + cors: None, + }; + + let result = settings.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("gRPC connection string cannot be empty")); + } + + #[test] + fn nginx_validate_succeeds_for_valid_paths_and_commands() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let nginx_binary = temp_dir.path().join("nginx"); + let nginx_config = temp_dir.path().join("nginx.conf"); + + create_exec_file(&nginx_binary); + write_file(&nginx_config); + + let nginx = NginxSettings { + nginx_config_path: nginx_config.to_string_lossy().to_string(), + nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), + override_nginx_reload_command: default_nginx_reload_command(), + override_nginx_test_command: default_nginx_test_command(), + nginx_reload_timeout_seconds: 30, + nginx_test_timeout_seconds: 30, + }; + + assert!(nginx.validate().is_ok()); + } + + #[test] + fn nginx_validate_fails_for_non_executable_binary() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let nginx_binary = temp_dir.path().join("nginx"); + let nginx_config = temp_dir.path().join("nginx.conf"); + + create_non_exec_file(&nginx_binary); + write_file(&nginx_config); + + let nginx = NginxSettings { + nginx_config_path: nginx_config.to_string_lossy().to_string(), + nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), + override_nginx_reload_command: default_nginx_reload_command(), + override_nginx_test_command: default_nginx_test_command(), + nginx_reload_timeout_seconds: 30, + nginx_test_timeout_seconds: 30, + }; + + let result = nginx.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("Nginx binary is not executable")); + } + + #[test] + fn nginx_validate_fails_when_reload_command_lacks_template() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let nginx_binary = temp_dir.path().join("nginx"); + let nginx_config = temp_dir.path().join("nginx.conf"); + + create_exec_file(&nginx_binary); + write_file(&nginx_config); + + let nginx = NginxSettings { + nginx_config_path: nginx_config.to_string_lossy().to_string(), + nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), + override_nginx_reload_command: vec!["nginx".into(), "-s".into(), "reload".into()], + override_nginx_test_command: default_nginx_test_command(), + nginx_reload_timeout_seconds: 30, + nginx_test_timeout_seconds: 30, + }; + + let result = nginx.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("Nginx reload command must contain the binary path template")); + } + + #[test] + fn level_filter_round_trip_serialization() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + level: LevelFilter, + } + + let original = Wrapper { + level: LevelFilter::DEBUG, + }; + + let encoded = serde_json::to_string(&original); + assert!(encoded.is_ok()); + let encoded = encoded.ok(); + assert!(encoded.is_some()); + let encoded = encoded.unwrap_or_else(|| unreachable!()); + assert!(encoded.to_lowercase().contains("debug")); + + let decoded = serde_json::from_str::(&encoded); + assert!(decoded.is_ok()); + let decoded = decoded.ok(); + assert!(decoded.is_some()); + let decoded = decoded.unwrap_or_else(|| unreachable!()); + assert_eq!(decoded.level, LevelFilter::DEBUG); + } } diff --git a/apps/nxmesh-agent/src/connector/master/mod.rs b/apps/nxmesh-agent/src/connector/master/mod.rs index da408b2..4e72f1b 100644 --- a/apps/nxmesh-agent/src/connector/master/mod.rs +++ b/apps/nxmesh-agent/src/connector/master/mod.rs @@ -4,7 +4,8 @@ use tokio::sync::Mutex; pub mod ssh; -pub type AgentClient = nxmesh_proto::agent_service_client::AgentServiceClient; +pub type AgentClient = + nxmesh_proto::agent_service_client::AgentServiceClient; #[async_trait::async_trait] pub trait MasterConnectorTrait: Send + Sync { @@ -38,3 +39,105 @@ impl MasterConnectorTrait for MasterConnector { self.connector.get_client() } } + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + use tokio::sync::Mutex; + + use crate::config::settings::{ + GrpcSettings, LogSettings, MAuthSettings, Settings, TLSSettings, + }; + + use super::{AgentClient, MasterConnector, MasterConnectorTrait}; + + struct FakeConnector { + called: Arc, + fail: bool, + client: Arc>, + } + + #[async_trait::async_trait] + impl MasterConnectorTrait for FakeConnector { + async fn connect( + &mut self, + _settings: &Settings, + ) -> Result<(), Box> { + self.called.store(true, Ordering::SeqCst); + if self.fail { + return Err("connector failed".into()); + } + Ok(()) + } + + fn get_client(&self) -> Arc> { + self.client.clone() + } + } + + fn test_settings() -> Settings { + Settings { + grpc: GrpcSettings { + connection_string: "https://localhost:50051".to_string(), + m_auth: MAuthSettings::Tls(TLSSettings::ZipPath { + cert_zip_path: "/tmp/certs.zip".to_string(), + }), + cors: None, + }, + log: LogSettings::default(), + nginx: None, + } + } + + fn test_client() -> Arc> { + let channel = + tonic::transport::Channel::from_static("http://127.0.0.1:50051").connect_lazy(); + Arc::new(Mutex::new(AgentClient::new(channel))) + } + + #[tokio::test] + async fn master_connector_delegates_connect_successfully() { + let called = Arc::new(AtomicBool::new(false)); + let fake = FakeConnector { + called: called.clone(), + fail: false, + client: test_client(), + }; + let mut master = MasterConnector::new(Box::new(fake)); + + let result = master.connect(&test_settings()).await; + assert!(result.is_ok()); + assert!(called.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn master_connector_propagates_connect_errors() { + let fake = FakeConnector { + called: Arc::new(AtomicBool::new(false)), + fail: true, + client: test_client(), + }; + let mut master = MasterConnector::new(Box::new(fake)); + + let result = master.connect(&test_settings()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn master_connector_returns_underlying_client() { + let shared_client = test_client(); + let fake = FakeConnector { + called: Arc::new(AtomicBool::new(false)), + fail: false, + client: shared_client.clone(), + }; + let master = MasterConnector::new(Box::new(fake)); + + let client = master.get_client(); + assert!(Arc::ptr_eq(&client, &shared_client)); + } +} diff --git a/apps/nxmesh-agent/src/connector/master/ssh.rs b/apps/nxmesh-agent/src/connector/master/ssh.rs index 2414bf4..3245609 100644 --- a/apps/nxmesh-agent/src/connector/master/ssh.rs +++ b/apps/nxmesh-agent/src/connector/master/ssh.rs @@ -6,7 +6,7 @@ use nxmesh_proto::agent_service_client::AgentServiceClient; use tonic::transport::{Certificate, ClientTlsConfig, Identity}; use tracing::warn; -use crate::config::settings::{self, MAuthSettings, TLSSettings}; +use crate::config::settings::{MAuthSettings, TLSSettings}; use super::{AgentClient, MasterConnectorTrait}; @@ -130,3 +130,151 @@ impl MasterConnectorTrait for SshMasterConnector { self.client.clone() } } + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{ + fs::{self, File}, + io::Write, + path::Path, + }; + + use tempfile::TempDir; + + use crate::config::settings::{MAuthSettings, TLSSettings}; + + use super::SshMasterConnector; + + const CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----\n"; + const KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----\nAQ==\n-----END PRIVATE KEY-----\n"; + const CA_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----\n"; + + fn create_zip_with_entries( + dir: &TempDir, + file_name: &str, + entries: &[(&str, &[u8])], + ) -> Result> { + let zip_path = dir.path().join(file_name); + let file = File::create(&zip_path)?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o600); + + for (entry_name, contents) in entries { + zip.start_file(entry_name, options)?; + zip.write_all(contents)?; + } + + zip.finish()?; + Ok(zip_path.to_string_lossy().to_string()) + } + + fn write_file( + path: &Path, + contents: &[u8], + ) -> Result<(), Box> { + fs::write(path, contents)?; + Ok(()) + } + + #[tokio::test] + async fn extract_certificate_succeeds_with_expected_files() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let zip_path = create_zip_with_entries( + &temp_dir, + "certs.zip", + &[ + ("cert.pem", CERT_PEM), + ("key.pem", KEY_PEM), + ("ca.pem", CA_PEM), + ("ignored.txt", b"ignored"), + ], + ) + .expect("failed to create zip"); + + let (ca, cert, key) = SshMasterConnector::extract_certificate(&zip_path) + .await + .expect("expected cert extraction to succeed"); + + assert_eq!(ca, CA_PEM); + assert_eq!(cert, CERT_PEM); + assert_eq!(key, KEY_PEM); + } + + #[tokio::test] + async fn extract_certificate_fails_when_required_files_are_missing() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let zip_path = create_zip_with_entries( + &temp_dir, + "missing-key.zip", + &[("cert.pem", CERT_PEM), ("ca.pem", CA_PEM)], + ) + .expect("failed to create zip"); + + let err = SshMasterConnector::extract_certificate(&zip_path) + .await + .expect_err("expected extraction to fail when key.pem is missing"); + + assert!( + err.to_string() + .contains("Certificate zip must contain cert.pem, key.pem and ca.pem") + ); + } + + #[tokio::test] + async fn generate_tls_config_succeeds_for_raw_paths() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let cert_path = temp_dir.path().join("cert.pem"); + let key_path = temp_dir.path().join("key.pem"); + let ca_path = temp_dir.path().join("ca.pem"); + + write_file(&cert_path, CERT_PEM).expect("failed to write cert.pem"); + write_file(&key_path, KEY_PEM).expect("failed to write key.pem"); + write_file(&ca_path, CA_PEM).expect("failed to write ca.pem"); + + let settings = MAuthSettings::Tls(TLSSettings::RawPath { + ca_path: ca_path.to_string_lossy().to_string(), + cert_path: cert_path.to_string_lossy().to_string(), + key_path: key_path.to_string_lossy().to_string(), + }); + + let result = SshMasterConnector::generate_tls_config(&settings).await; + assert!(result.is_ok(), "expected raw path TLS config to succeed"); + } + + #[tokio::test] + async fn generate_tls_config_succeeds_for_zip_path() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let zip_path = create_zip_with_entries( + &temp_dir, + "certs.zip", + &[ + ("cert.pem", CERT_PEM), + ("key.pem", KEY_PEM), + ("ca.pem", CA_PEM), + ], + ) + .expect("failed to create zip"); + + let settings = MAuthSettings::Tls(TLSSettings::ZipPath { + cert_zip_path: zip_path, + }); + + let result = SshMasterConnector::generate_tls_config(&settings).await; + assert!(result.is_ok(), "expected zip path TLS config to succeed"); + } + + #[tokio::test] + async fn generate_tls_config_fails_for_missing_raw_files() { + let settings = MAuthSettings::Tls(TLSSettings::RawPath { + ca_path: "/tmp/non-existent-ca.pem".to_string(), + cert_path: "/tmp/non-existent-cert.pem".to_string(), + key_path: "/tmp/non-existent-key.pem".to_string(), + }); + + let result = SshMasterConnector::generate_tls_config(&settings).await; + assert!(result.is_err(), "expected raw path TLS config to fail"); + } +} diff --git a/apps/nxmesh-agent/src/connector/mod.rs b/apps/nxmesh-agent/src/connector/mod.rs index 0d22726..d9873fa 100644 --- a/apps/nxmesh-agent/src/connector/mod.rs +++ b/apps/nxmesh-agent/src/connector/mod.rs @@ -1 +1 @@ -pub mod master; \ No newline at end of file +pub mod master;