Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
584 lines
20 KiB
Rust
584 lines
20 KiB
Rust
//! Service integration tests with Vault dependencies
|
|
//!
|
|
//! This module tests how services integrate with Vault:
|
|
//! - Service startup with Vault dependencies
|
|
//! - Certificate provisioning during service initialization
|
|
//! - Inter-service mTLS communication with Vault certificates
|
|
//! - Configuration hot-reload with Vault-backed secrets
|
|
//! - Service health monitoring with Vault integration
|
|
//! - Graceful degradation when Vault is unavailable
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::process::Command as AsyncCommand;
|
|
use tokio::sync::RwLock;
|
|
use tokio::time::{sleep, timeout};
|
|
use tracing::{debug, info, warn, error};
|
|
|
|
use crate::{VaultTestConfig, VaultTestResults};
|
|
|
|
/// Service status information
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ServiceStatus {
|
|
NotStarted,
|
|
Starting,
|
|
Healthy,
|
|
Unhealthy,
|
|
Stopped,
|
|
}
|
|
|
|
/// Service information for testing
|
|
#[derive(Debug, Clone)]
|
|
pub struct ServiceInfo {
|
|
pub name: String,
|
|
pub container_name: String,
|
|
pub health_check_url: Option<String>,
|
|
pub expected_startup_time: Duration,
|
|
pub requires_vault: bool,
|
|
pub status: ServiceStatus,
|
|
}
|
|
|
|
impl ServiceInfo {
|
|
pub fn new(name: &str, requires_vault: bool) -> Self {
|
|
Self {
|
|
name: name.to_string(),
|
|
container_name: format!("foxhunt-{}-test", name),
|
|
health_check_url: Self::get_health_url(name),
|
|
expected_startup_time: Duration::from_secs(30),
|
|
requires_vault,
|
|
status: ServiceStatus::NotStarted,
|
|
}
|
|
}
|
|
|
|
fn get_health_url(service: &str) -> Option<String> {
|
|
match service {
|
|
"tli-service" => Some("http://localhost:3000/health".to_string()),
|
|
"trading-service" => Some("http://localhost:50052/health".to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Service integration test coordinator
|
|
pub struct ServiceIntegrationTester {
|
|
config: VaultTestConfig,
|
|
services: Vec<ServiceInfo>,
|
|
}
|
|
|
|
impl ServiceIntegrationTester {
|
|
pub fn new(config: VaultTestConfig) -> Self {
|
|
let services = vec![
|
|
ServiceInfo::new("tli-service", true),
|
|
ServiceInfo::new("trading-service", true),
|
|
];
|
|
|
|
Self { config, services }
|
|
}
|
|
|
|
/// Test service startup sequence with Vault dependencies
|
|
pub async fn test_service_startup_sequence(
|
|
&mut self,
|
|
results: &Arc<RwLock<VaultTestResults>>,
|
|
) -> Result<()> {
|
|
info!("Testing service startup sequence with Vault dependencies");
|
|
|
|
let overall_start = Instant::now();
|
|
|
|
// Test 1: Start services in dependency order
|
|
for service_info in &mut self.services {
|
|
if service_info.requires_vault {
|
|
info!("Starting Vault-dependent service: {}", service_info.name);
|
|
|
|
let startup_start = Instant::now();
|
|
self.start_service(&service_info.name).await?;
|
|
|
|
// Wait for service to become healthy
|
|
let health_result = self.wait_for_service_health(
|
|
service_info,
|
|
service_info.expected_startup_time,
|
|
).await;
|
|
|
|
match health_result {
|
|
Ok(_) => {
|
|
let startup_duration = startup_start.elapsed();
|
|
service_info.status = ServiceStatus::Healthy;
|
|
|
|
let mut test_results = results.write().await;
|
|
test_results.add_success(
|
|
&format!("{}_startup", service_info.name),
|
|
startup_duration,
|
|
);
|
|
|
|
info!("Service {} started successfully in {:?}",
|
|
service_info.name, startup_duration);
|
|
}
|
|
Err(e) => {
|
|
service_info.status = ServiceStatus::Unhealthy;
|
|
|
|
let mut test_results = results.write().await;
|
|
test_results.add_failure(
|
|
&format!("{}_startup", service_info.name),
|
|
e.to_string(),
|
|
);
|
|
|
|
warn!("Service {} failed to start: {}", service_info.name, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let total_startup_time = overall_start.elapsed();
|
|
|
|
{
|
|
let mut test_results = results.write().await;
|
|
test_results.add_success("service_startup_sequence", total_startup_time);
|
|
}
|
|
|
|
info!("Service startup sequence completed in {:?}", total_startup_time);
|
|
Ok(())
|
|
}
|
|
|
|
/// Test certificate provisioning during service startup
|
|
pub async fn test_certificate_provisioning(
|
|
&self,
|
|
results: &Arc<RwLock<VaultTestResults>>,
|
|
) -> Result<()> {
|
|
info!("Testing certificate provisioning during service startup");
|
|
|
|
let test_start = Instant::now();
|
|
|
|
// Check if services have provisioned certificates from Vault
|
|
for service_info in &self.services {
|
|
if service_info.status == ServiceStatus::Healthy && service_info.requires_vault {
|
|
// Check service logs for certificate provisioning
|
|
let logs = self.get_service_logs(&service_info.name).await?;
|
|
|
|
if logs.contains("Certificate obtained from Vault") ||
|
|
logs.contains("Successfully connected to Vault") {
|
|
info!("Service {} successfully provisioned certificates", service_info.name);
|
|
} else {
|
|
warn!("Service {} may not have provisioned certificates from Vault",
|
|
service_info.name);
|
|
}
|
|
|
|
// Check if certificate files exist in the container
|
|
self.verify_certificate_files(&service_info.container_name).await?;
|
|
}
|
|
}
|
|
|
|
let provisioning_duration = test_start.elapsed();
|
|
|
|
{
|
|
let mut test_results = results.write().await;
|
|
test_results.add_success("certificate_provisioning", provisioning_duration);
|
|
}
|
|
|
|
info!("Certificate provisioning test completed in {:?}", provisioning_duration);
|
|
Ok(())
|
|
}
|
|
|
|
/// Test inter-service mTLS communication
|
|
pub async fn test_inter_service_mtls(
|
|
&self,
|
|
results: &Arc<RwLock<VaultTestResults>>,
|
|
) -> Result<()> {
|
|
info!("Testing inter-service mTLS communication");
|
|
|
|
let test_start = Instant::now();
|
|
|
|
// Find healthy services for communication testing
|
|
let healthy_services: Vec<_> = self.services.iter()
|
|
.filter(|s| s.status == ServiceStatus::Healthy)
|
|
.collect();
|
|
|
|
if healthy_services.len() < 2 {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_failure(
|
|
"inter_service_mtls",
|
|
"Not enough healthy services for mTLS testing".to_string(),
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
// Test TLI -> Trading Service communication
|
|
let mtls_result = self.test_grpc_communication().await;
|
|
|
|
match mtls_result {
|
|
Ok(comm_duration) => {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_success("inter_service_mtls", comm_duration);
|
|
test_results.performance.tls_handshake_time = Some(comm_duration);
|
|
|
|
info!("Inter-service mTLS communication successful");
|
|
}
|
|
Err(e) => {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_failure("inter_service_mtls", e.to_string());
|
|
|
|
warn!("Inter-service mTLS communication failed: {}", e);
|
|
}
|
|
}
|
|
|
|
let total_duration = test_start.elapsed();
|
|
info!("Inter-service mTLS test completed in {:?}", total_duration);
|
|
Ok(())
|
|
}
|
|
|
|
/// Test configuration hot-reload with Vault secrets
|
|
pub async fn test_configuration_hot_reload(
|
|
&self,
|
|
results: &Arc<RwLock<VaultTestResults>>,
|
|
) -> Result<()> {
|
|
info!("Testing configuration hot-reload with Vault secrets");
|
|
|
|
let test_start = Instant::now();
|
|
|
|
// Update a configuration value in PostgreSQL (simulating config change)
|
|
self.update_test_configuration().await?;
|
|
|
|
// Wait for services to pick up the change
|
|
sleep(Duration::from_secs(5)).await;
|
|
|
|
// Check service logs for configuration reload
|
|
let mut reload_detected = false;
|
|
|
|
for service_info in &self.services {
|
|
if service_info.status == ServiceStatus::Healthy {
|
|
let logs = self.get_service_logs(&service_info.name).await?;
|
|
|
|
if logs.contains("Configuration reloaded") ||
|
|
logs.contains("Hot reload triggered") {
|
|
reload_detected = true;
|
|
info!("Service {} detected configuration hot-reload", service_info.name);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let reload_duration = test_start.elapsed();
|
|
|
|
if reload_detected {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_success("configuration_hot_reload", reload_duration);
|
|
} else {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_failure(
|
|
"configuration_hot_reload",
|
|
"No services detected configuration hot-reload".to_string(),
|
|
);
|
|
}
|
|
|
|
info!("Configuration hot-reload test completed in {:?}", reload_duration);
|
|
Ok(())
|
|
}
|
|
|
|
/// Test graceful degradation when Vault is unavailable
|
|
pub async fn test_vault_unavailable_degradation(
|
|
&self,
|
|
results: &Arc<RwLock<VaultTestResults>>,
|
|
) -> Result<()> {
|
|
info!("Testing graceful degradation when Vault is unavailable");
|
|
|
|
let test_start = Instant::now();
|
|
|
|
// Stop Vault service temporarily
|
|
self.stop_vault_service().await?;
|
|
|
|
// Wait for services to detect Vault unavailability
|
|
sleep(Duration::from_secs(10)).await;
|
|
|
|
// Check that services continue running and use cached certificates
|
|
let mut services_degraded_gracefully = 0;
|
|
|
|
for service_info in &self.services {
|
|
if service_info.status == ServiceStatus::Healthy && service_info.requires_vault {
|
|
let service_still_healthy = self.check_service_health(&service_info.name).await.is_ok();
|
|
|
|
if service_still_healthy {
|
|
services_degraded_gracefully += 1;
|
|
info!("Service {} degraded gracefully without Vault", service_info.name);
|
|
} else {
|
|
warn!("Service {} failed when Vault became unavailable", service_info.name);
|
|
}
|
|
|
|
// Check logs for circuit breaker activation
|
|
let logs = self.get_service_logs(&service_info.name).await?;
|
|
if logs.contains("Circuit breaker opened") ||
|
|
logs.contains("Using cached certificates") {
|
|
info!("Service {} activated circuit breaker for Vault", service_info.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart Vault service
|
|
self.start_vault_service().await?;
|
|
|
|
let degradation_duration = test_start.elapsed();
|
|
|
|
if services_degraded_gracefully > 0 {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_success("vault_unavailable_degradation", degradation_duration);
|
|
} else {
|
|
let mut test_results = results.write().await;
|
|
test_results.add_failure(
|
|
"vault_unavailable_degradation",
|
|
"No services degraded gracefully".to_string(),
|
|
);
|
|
}
|
|
|
|
info!("Vault unavailability degradation test completed in {:?}", degradation_duration);
|
|
Ok(())
|
|
}
|
|
|
|
/// Start a specific service
|
|
async fn start_service(&self, service_name: &str) -> Result<()> {
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", "tests/e2e/vault_integration/docker-compose.vault.yml",
|
|
"-p", &self.config.compose_project,
|
|
"up", "-d", service_name
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to start service: {}", service_name))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!(
|
|
"Failed to start service {}: {}", service_name, stderr
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Wait for service to become healthy
|
|
async fn wait_for_service_health(
|
|
&self,
|
|
service_info: &ServiceInfo,
|
|
timeout_duration: Duration,
|
|
) -> Result<()> {
|
|
let start_time = Instant::now();
|
|
|
|
while start_time.elapsed() < timeout_duration {
|
|
match self.check_service_health(&service_info.name).await {
|
|
Ok(_) => return Ok(()),
|
|
Err(_) => {
|
|
sleep(Duration::from_secs(2)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(anyhow::anyhow!(
|
|
"Service {} did not become healthy within {:?}",
|
|
service_info.name, timeout_duration
|
|
))
|
|
}
|
|
|
|
/// Check if a service is healthy
|
|
async fn check_service_health(&self, service_name: &str) -> Result<()> {
|
|
// Check Docker container health
|
|
let container_name = format!("foxhunt-{}-test", service_name);
|
|
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args(["inspect", "--format", "{{.State.Health.Status}}", &container_name]);
|
|
|
|
let output = cmd.output().await?;
|
|
let status = String::from_utf8_lossy(&output.stdout).trim().to_lowercase();
|
|
|
|
if status == "healthy" {
|
|
Ok(())
|
|
} else if status == "unhealthy" {
|
|
Err(anyhow::anyhow!("Service {} is unhealthy", service_name))
|
|
} else {
|
|
Err(anyhow::anyhow!("Service {} health status unknown: {}", service_name, status))
|
|
}
|
|
}
|
|
|
|
/// Get service logs
|
|
async fn get_service_logs(&self, service_name: &str) -> Result<String> {
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", "tests/e2e/vault_integration/docker-compose.vault.yml",
|
|
"-p", &self.config.compose_project,
|
|
"logs", "--tail", "100", service_name
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.with_context(|| format!("Failed to get logs for service: {}", service_name))?;
|
|
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
}
|
|
|
|
/// Verify certificate files exist in container
|
|
async fn verify_certificate_files(&self, container_name: &str) -> Result<()> {
|
|
let cert_files = [
|
|
"/opt/foxhunt/certs/service.crt",
|
|
"/opt/foxhunt/certs/service.key",
|
|
"/opt/foxhunt/certs/ca.crt",
|
|
];
|
|
|
|
for cert_file in &cert_files {
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args(["exec", container_name, "test", "-f", cert_file]);
|
|
|
|
let output = cmd.output().await?;
|
|
|
|
if !output.status.success() {
|
|
return Err(anyhow::anyhow!(
|
|
"Certificate file {} not found in container {}", cert_file, container_name
|
|
));
|
|
}
|
|
}
|
|
|
|
info!("All certificate files verified in container: {}", container_name);
|
|
Ok(())
|
|
}
|
|
|
|
/// Test gRPC communication between services
|
|
async fn test_grpc_communication(&self) -> Result<Duration> {
|
|
let start = Instant::now();
|
|
|
|
// Simplified gRPC health check - in real implementation would use actual gRPC client
|
|
let mut cmd = AsyncCommand::new("curl");
|
|
cmd.args([
|
|
"-s", "-f", "--max-time", "5",
|
|
"http://localhost:50051/health" // TLI service health endpoint
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to perform gRPC health check")?;
|
|
|
|
if output.status.success() {
|
|
Ok(start.elapsed())
|
|
} else {
|
|
Err(anyhow::anyhow!("gRPC communication test failed"))
|
|
}
|
|
}
|
|
|
|
/// Update test configuration in PostgreSQL
|
|
async fn update_test_configuration(&self) -> Result<()> {
|
|
let mut cmd = AsyncCommand::new("docker");
|
|
cmd.args([
|
|
"exec", "foxhunt-postgres-test",
|
|
"psql", "-U", "foxhunt", "-d", "foxhunt_test",
|
|
"-c", "UPDATE configuration SET value = 'test_hot_reload_' || extract(epoch from now()) WHERE key = 'test_config';"
|
|
]);
|
|
|
|
let output = cmd.output().await
|
|
.context("Failed to update test configuration")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!("Failed to update configuration: {}", stderr));
|
|
}
|
|
|
|
// Trigger PostgreSQL NOTIFY for hot reload
|
|
let mut notify_cmd = AsyncCommand::new("docker");
|
|
notify_cmd.args([
|
|
"exec", "foxhunt-postgres-test",
|
|
"psql", "-U", "foxhunt", "-d", "foxhunt_test",
|
|
"-c", "NOTIFY config_update;"
|
|
]);
|
|
|
|
notify_cmd.output().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop Vault service for testing degradation
|
|
async fn stop_vault_service(&self) -> Result<()> {
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", "tests/e2e/vault_integration/docker-compose.vault.yml",
|
|
"-p", &self.config.compose_project,
|
|
"stop", "vault"
|
|
]);
|
|
|
|
cmd.output().await
|
|
.context("Failed to stop Vault service")?;
|
|
|
|
info!("Vault service stopped for degradation testing");
|
|
Ok(())
|
|
}
|
|
|
|
/// Start Vault service after testing
|
|
async fn start_vault_service(&self) -> Result<()> {
|
|
let mut cmd = AsyncCommand::new("docker-compose");
|
|
cmd.args([
|
|
"-f", "tests/e2e/vault_integration/docker-compose.vault.yml",
|
|
"-p", &self.config.compose_project,
|
|
"start", "vault"
|
|
]);
|
|
|
|
cmd.output().await
|
|
.context("Failed to start Vault service")?;
|
|
|
|
// Wait for Vault to become healthy again
|
|
sleep(Duration::from_secs(10)).await;
|
|
|
|
info!("Vault service restarted");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Run all service integration tests
|
|
pub async fn run_service_tests(
|
|
config: &VaultTestConfig,
|
|
results: &Arc<RwLock<VaultTestResults>>,
|
|
) -> Result<()> {
|
|
info!("Running service integration tests");
|
|
|
|
let mut tester = ServiceIntegrationTester::new(config.clone());
|
|
|
|
// Test 1: Service startup sequence
|
|
tester.test_service_startup_sequence(results).await?;
|
|
|
|
// Test 2: Certificate provisioning
|
|
tester.test_certificate_provisioning(results).await?;
|
|
|
|
// Test 3: Inter-service mTLS communication
|
|
tester.test_inter_service_mtls(results).await?;
|
|
|
|
// Test 4: Configuration hot-reload
|
|
tester.test_configuration_hot_reload(results).await?;
|
|
|
|
// Test 5: Graceful degradation
|
|
tester.test_vault_unavailable_degradation(results).await?;
|
|
|
|
info!("All service integration tests completed");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_service_info_creation() {
|
|
let service = ServiceInfo::new("tli-service", true);
|
|
assert_eq!(service.name, "tli-service");
|
|
assert_eq!(service.container_name, "foxhunt-tli-service-test");
|
|
assert!(service.requires_vault);
|
|
assert_eq!(service.status, ServiceStatus::NotStarted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_health_url_mapping() {
|
|
let tli_service = ServiceInfo::new("tli-service", true);
|
|
assert!(tli_service.health_check_url.is_some());
|
|
|
|
let unknown_service = ServiceInfo::new("unknown", false);
|
|
assert!(unknown_service.health_check_url.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_service_integration_tester_creation() {
|
|
let config = VaultTestConfig::default();
|
|
let tester = ServiceIntegrationTester::new(config);
|
|
|
|
assert_eq!(tester.services.len(), 2);
|
|
assert!(tester.services.iter().all(|s| s.requires_vault));
|
|
}
|
|
} |