feat: Add unit tests for CLI and MasterConnector, including certificate extraction and validation

This commit is contained in:
GW_MC
2026-03-21 03:32:09 +00:00
parent d7cbb2a2ce
commit eba30f557e
5 changed files with 562 additions and 3 deletions

View File

@@ -79,3 +79,84 @@ pub enum Commands {
ca_cert: Option<String>,
},
}
#[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());
}
}

View File

@@ -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::<LogSettings>();
assert_send_sync::<NginxSettings>();
}
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::<Wrapper>(&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);
}
}

View File

@@ -4,7 +4,8 @@ use tokio::sync::Mutex;
pub mod ssh;
pub type AgentClient = nxmesh_proto::agent_service_client::AgentServiceClient<tonic::transport::Channel>;
pub type AgentClient =
nxmesh_proto::agent_service_client::AgentServiceClient<tonic::transport::Channel>;
#[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<AtomicBool>,
fail: bool,
client: Arc<Mutex<AgentClient>>,
}
#[async_trait::async_trait]
impl MasterConnectorTrait for FakeConnector {
async fn connect(
&mut self,
_settings: &Settings,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.called.store(true, Ordering::SeqCst);
if self.fail {
return Err("connector failed".into());
}
Ok(())
}
fn get_client(&self) -> Arc<Mutex<AgentClient>> {
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<Mutex<AgentClient>> {
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));
}
}

View File

@@ -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<String, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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");
}
}