- JWT issuer now foxhunt-api across all 16 files (services, tests, config, docker-compose) - Remove serde alias api_gateway_url from FxtConfig (no backwards compat) - Remove api_gateway CLI alias from e2e orchestrator - All services must deploy simultaneously for JWT validation to match Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
503 lines
17 KiB
Rust
503 lines
17 KiB
Rust
//! E2E Test Framework Core
|
|
//!
|
|
//! Provides the main E2ETestFramework struct that orchestrates all testing components
|
|
//! including service management, gRPC clients, database connections, and ML pipeline testing.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::time::Duration;
|
|
use tokio::time::sleep;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::proto::backtesting::backtesting_service_client::BacktestingServiceClient;
|
|
use crate::proto::config::config_service_client::ConfigServiceClient;
|
|
use crate::proto::trading::trading_service_client::TradingServiceClient;
|
|
use crate::{
|
|
database::TestDatabase, ml_pipeline::MLPipelineTestHarness, performance::PerformanceTracker,
|
|
services::ServiceManager,
|
|
};
|
|
use tonic::metadata::AsciiMetadataValue;
|
|
use tonic::service::{interceptor::InterceptedService, Interceptor};
|
|
use tonic::transport::Channel;
|
|
|
|
/// JWT Authentication Interceptor for E2E Tests
|
|
///
|
|
/// Automatically injects JWT token into all gRPC requests
|
|
#[derive(Clone)]
|
|
pub struct AuthInterceptor {
|
|
token: AsciiMetadataValue,
|
|
}
|
|
|
|
impl AuthInterceptor {
|
|
fn new(token: &str) -> Result<Self> {
|
|
let bearer_token = format!("Bearer {}", token);
|
|
let token = AsciiMetadataValue::try_from(bearer_token)
|
|
.context("Failed to create metadata value from token")?;
|
|
Ok(Self { token })
|
|
}
|
|
}
|
|
|
|
impl Interceptor for AuthInterceptor {
|
|
fn call(
|
|
&mut self,
|
|
mut request: tonic::Request<()>,
|
|
) -> Result<tonic::Request<()>, tonic::Status> {
|
|
request
|
|
.metadata_mut()
|
|
.insert("authorization", self.token.clone());
|
|
Ok(request)
|
|
}
|
|
}
|
|
|
|
/// Main E2E Test Framework
|
|
///
|
|
/// Provides centralized orchestration for all testing components:
|
|
/// - Service lifecycle management
|
|
///
|
|
/// - gRPC client connections (via API Gateway with JWT auth)
|
|
/// - Database testing harness
|
|
///
|
|
/// - ML pipeline testing
|
|
/// - Performance monitoring
|
|
///
|
|
/// - Test data management
|
|
#[derive(Debug)]
|
|
pub struct E2ETestFramework {
|
|
pub service_manager: ServiceManager,
|
|
pub database_harness: TestDatabase,
|
|
pub ml_pipeline: MLPipelineTestHarness,
|
|
pub performance_tracker: PerformanceTracker,
|
|
|
|
// gRPC clients (initialized on demand, connect via API Gateway with auth interceptor)
|
|
pub trading_client: Option<TradingServiceClient<InterceptedService<Channel, AuthInterceptor>>>,
|
|
pub backtesting_client:
|
|
Option<BacktestingServiceClient<InterceptedService<Channel, AuthInterceptor>>>,
|
|
pub config_client: Option<ConfigServiceClient<InterceptedService<Channel, AuthInterceptor>>>,
|
|
|
|
// Authentication token for E2E tests
|
|
pub auth_token: String,
|
|
|
|
// Framework state
|
|
pub services_started: bool,
|
|
pub test_session_id: String,
|
|
}
|
|
|
|
impl E2ETestFramework {
|
|
/// Generate a test JWT token for E2E authentication
|
|
///
|
|
/// Creates a valid JWT token with test user credentials.
|
|
///
|
|
/// This token is accepted by API Gateway for test scenarios.
|
|
fn generate_test_jwt_token() -> Result<String> {
|
|
// Load .env file if present (development mode)
|
|
// Silent failure allows CI/CD to override with environment variables
|
|
let _ = dotenvy::dotenv();
|
|
use chrono::Utc;
|
|
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct Claims {
|
|
sub: String, // user_id
|
|
exp: usize, // expiration
|
|
iat: usize, // issued at
|
|
iss: String, // issuer
|
|
aud: String, // audience
|
|
jti: String, // JWT ID
|
|
roles: Vec<String>,
|
|
permissions: Vec<String>,
|
|
}
|
|
|
|
let now = Utc::now().timestamp() as usize;
|
|
let claims = Claims {
|
|
sub: "e2e_test_user".to_string(),
|
|
exp: now + 3600, // 1 hour expiration
|
|
iat: now,
|
|
iss: "foxhunt-api".to_string(),
|
|
aud: "foxhunt-services".to_string(),
|
|
jti: uuid::Uuid::new_v4().to_string(),
|
|
roles: vec!["trader".to_string(), "admin".to_string()],
|
|
permissions: vec![
|
|
"api.access".to_string(),
|
|
"trading.submit".to_string(),
|
|
"trading.cancel".to_string(),
|
|
"backtesting.run".to_string(),
|
|
"ml.train".to_string(),
|
|
],
|
|
};
|
|
|
|
// Load JWT secret from environment (loaded from .env or CI/CD)
|
|
// CRITICAL: Must match API Gateway JWT_SECRET configuration
|
|
let secret = std::env::var("JWT_SECRET").context(
|
|
"JWT_SECRET not configured. Options:\n \
|
|
1. Create .env file with JWT_SECRET (development) - AUTOMATIC\n \
|
|
2. Export JWT_SECRET environment variable (CI/CD)\n \
|
|
3. Verify .env file exists in project root",
|
|
)?;
|
|
|
|
// Validate secret length (security requirement)
|
|
if secret.len() < 64 {
|
|
anyhow::bail!(
|
|
"JWT_SECRET must be at least 64 characters (current: {}). \n\
|
|
Generate a secure secret: openssl rand -base64 64",
|
|
secret.len()
|
|
);
|
|
}
|
|
|
|
let token = encode(
|
|
&Header::new(Algorithm::HS256),
|
|
&claims,
|
|
&EncodingKey::from_secret(secret.as_bytes()),
|
|
)
|
|
.context("Failed to encode JWT token")?;
|
|
|
|
Ok(token)
|
|
}
|
|
|
|
/// Create a new E2E test framework instance
|
|
///
|
|
/// This initializes all components but does not start services.
|
|
///
|
|
/// Call `start_services()` to begin service orchestration.
|
|
pub async fn new() -> Result<Self> {
|
|
info!("🔧 Initializing E2E Test Framework...");
|
|
|
|
let test_session_id = format!(
|
|
"test_session_{}",
|
|
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
|
);
|
|
debug!("Generated test session ID: {}", test_session_id);
|
|
|
|
// Generate JWT token for E2E testing
|
|
let auth_token =
|
|
Self::generate_test_jwt_token().context("Failed to generate test JWT token")?;
|
|
debug!("Generated E2E test JWT token");
|
|
|
|
// Initialize database harness
|
|
let database_harness = TestDatabase::new("postgresql://localhost/foxhunt_test".to_string());
|
|
|
|
// Initialize ML pipeline testing
|
|
let ml_pipeline = MLPipelineTestHarness::new()
|
|
.await
|
|
.context("Failed to initialize ML pipeline test harness")?;
|
|
|
|
// Initialize service manager
|
|
let service_manager = ServiceManager::new();
|
|
|
|
// Initialize performance tracker
|
|
let performance_tracker = PerformanceTracker::new(&test_session_id)
|
|
.context("Failed to initialize performance tracker")?;
|
|
|
|
info!("✅ E2E Test Framework initialized successfully");
|
|
|
|
Ok(Self {
|
|
service_manager,
|
|
database_harness,
|
|
ml_pipeline,
|
|
performance_tracker,
|
|
trading_client: None,
|
|
backtesting_client: None,
|
|
config_client: None,
|
|
auth_token,
|
|
services_started: false,
|
|
test_session_id,
|
|
})
|
|
}
|
|
|
|
/// Start all services needed for testing
|
|
pub async fn start_services(&mut self) -> Result<()> {
|
|
if self.services_started {
|
|
debug!("Services already started, skipping startup");
|
|
return Ok(());
|
|
}
|
|
|
|
info!("🚀 Starting services for E2E testing...");
|
|
|
|
// Start services in proper order
|
|
self.service_manager
|
|
.start_all_services()
|
|
.await
|
|
.context("Failed to start services")?;
|
|
|
|
// Wait for services to be ready
|
|
info!("⏳ Waiting for services to be ready...");
|
|
self.wait_for_services_ready()
|
|
.await
|
|
.context("Services failed to become ready")?;
|
|
|
|
self.services_started = true;
|
|
info!("✅ All services started and ready");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop all services and cleanup
|
|
pub async fn stop_services(&mut self) -> Result<()> {
|
|
if !self.services_started {
|
|
debug!("Services not started, skipping shutdown");
|
|
return Ok(());
|
|
}
|
|
|
|
info!("🛑 Stopping services...");
|
|
|
|
// Close client connections first
|
|
self.trading_client = None;
|
|
self.backtesting_client = None;
|
|
self.config_client = None;
|
|
|
|
// Stop services
|
|
self.service_manager
|
|
.stop_all_services()
|
|
.await
|
|
.context("Failed to stop services")?;
|
|
|
|
self.services_started = false;
|
|
info!("✅ All services stopped");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get Trading Service gRPC client (via API Gateway with JWT auth)
|
|
pub async fn get_trading_client(
|
|
&mut self,
|
|
) -> Result<&mut TradingServiceClient<InterceptedService<Channel, AuthInterceptor>>> {
|
|
if self.trading_client.is_none() {
|
|
info!("🔌 Connecting to Trading Service via API Gateway (port 50051)...");
|
|
|
|
// Create authenticated channel via interceptor
|
|
let channel = Channel::from_static("http://[::1]:50051")
|
|
.connect()
|
|
.await
|
|
.context("Failed to connect to API Gateway")?;
|
|
|
|
let interceptor = AuthInterceptor::new(&self.auth_token)
|
|
.context("Failed to create auth interceptor")?;
|
|
|
|
let client = TradingServiceClient::with_interceptor(channel, interceptor);
|
|
|
|
self.trading_client = Some(client);
|
|
info!("✅ Connected to Trading Service via API Gateway with JWT auth");
|
|
}
|
|
|
|
Ok(self.trading_client.as_mut().unwrap())
|
|
}
|
|
|
|
/// Get Backtesting Service gRPC client (via API Gateway with JWT auth)
|
|
pub async fn get_backtesting_client(
|
|
&mut self,
|
|
) -> Result<&mut BacktestingServiceClient<InterceptedService<Channel, AuthInterceptor>>> {
|
|
if self.backtesting_client.is_none() {
|
|
info!("🔌 Connecting to Backtesting Service via API Gateway (port 50051)...");
|
|
|
|
// Create authenticated channel via interceptor
|
|
let channel = Channel::from_static("http://[::1]:50051")
|
|
.connect()
|
|
.await
|
|
.context("Failed to connect to API Gateway")?;
|
|
|
|
let interceptor = AuthInterceptor::new(&self.auth_token)
|
|
.context("Failed to create auth interceptor")?;
|
|
|
|
let client = BacktestingServiceClient::with_interceptor(channel, interceptor);
|
|
|
|
self.backtesting_client = Some(client);
|
|
info!("✅ Connected to Backtesting Service via API Gateway with JWT auth");
|
|
}
|
|
|
|
Ok(self.backtesting_client.as_mut().unwrap())
|
|
}
|
|
|
|
/// Get Configuration Service client (via API Gateway with JWT auth)
|
|
pub async fn get_config_client(
|
|
&mut self,
|
|
) -> Result<&mut ConfigServiceClient<InterceptedService<Channel, AuthInterceptor>>> {
|
|
if self.config_client.is_none() {
|
|
info!("🔌 Connecting to Configuration Service via API Gateway (port 50051)...");
|
|
|
|
// Create authenticated channel via interceptor
|
|
let channel = Channel::from_static("http://[::1]:50051")
|
|
.connect()
|
|
.await
|
|
.context("Failed to connect to API Gateway")?;
|
|
|
|
let interceptor = AuthInterceptor::new(&self.auth_token)
|
|
.context("Failed to create auth interceptor")?;
|
|
|
|
let client = ConfigServiceClient::with_interceptor(channel, interceptor);
|
|
|
|
self.config_client = Some(client);
|
|
info!("✅ Connected to Configuration Service via API Gateway with JWT auth");
|
|
}
|
|
|
|
Ok(self.config_client.as_mut().unwrap())
|
|
}
|
|
|
|
/// Check health status of all services
|
|
pub async fn check_services_health(&self) -> Result<ServicesHealthStatus> {
|
|
info!("🩺 Checking services health...");
|
|
|
|
let mut status = ServicesHealthStatus {
|
|
trading_service: ServiceHealth::Unknown,
|
|
config_service: ServiceHealth::Unknown,
|
|
database: ServiceHealth::Unknown,
|
|
all_healthy: false,
|
|
};
|
|
|
|
// Check Trading Service
|
|
status.trading_service = match self.check_trading_service_health().await {
|
|
Ok(_) => ServiceHealth::Healthy,
|
|
Err(e) => {
|
|
warn!("Trading Service health check failed: {}", e);
|
|
ServiceHealth::Unhealthy
|
|
},
|
|
};
|
|
|
|
// Note: Backtesting Service health check removed - service not available yet
|
|
|
|
// Check Database (simplified for now)
|
|
status.database = ServiceHealth::Healthy;
|
|
|
|
// Configuration service is database-backed, so use database status
|
|
status.config_service = status.database.clone();
|
|
|
|
// All services are healthy if no service is unhealthy
|
|
status.all_healthy = ![
|
|
&status.trading_service,
|
|
&status.config_service,
|
|
&status.database,
|
|
]
|
|
.iter()
|
|
.any(|s| matches!(s, ServiceHealth::Unhealthy));
|
|
|
|
if status.all_healthy {
|
|
info!("✅ All services are healthy");
|
|
} else {
|
|
warn!("⚠️ Some services are not healthy: {:#?}", status);
|
|
}
|
|
|
|
Ok(status)
|
|
}
|
|
|
|
/// Wait for all services to be ready with retries
|
|
async fn wait_for_services_ready(&self) -> Result<()> {
|
|
let max_retries = 30; // 30 attempts
|
|
let retry_delay = Duration::from_secs(2); // 2 seconds between attempts
|
|
|
|
for attempt in 1..=max_retries {
|
|
debug!("Health check attempt {}/{}", attempt, max_retries);
|
|
|
|
let health = self
|
|
.check_services_health()
|
|
.await
|
|
.context("Failed to check services health")?;
|
|
|
|
if health.all_healthy {
|
|
info!("✅ All services are ready after {} attempts", attempt);
|
|
return Ok(());
|
|
}
|
|
|
|
if attempt < max_retries {
|
|
debug!("Some services not ready, retrying in {:?}...", retry_delay);
|
|
sleep(retry_delay).await;
|
|
}
|
|
}
|
|
|
|
Err(anyhow::anyhow!(
|
|
"Services failed to become ready after {} attempts",
|
|
max_retries
|
|
))
|
|
}
|
|
|
|
/// Check Trading Service health (via API Gateway)
|
|
async fn check_trading_service_health(&self) -> Result<()> {
|
|
// Simple TCP connection check to API Gateway
|
|
use tokio::net::TcpStream;
|
|
let _stream = TcpStream::connect("127.0.0.1:50051")
|
|
.await
|
|
.context("Could not connect to API Gateway port 50051")?;
|
|
Ok(())
|
|
}
|
|
|
|
// Note: Backtesting Service health check removed - service not available yet
|
|
|
|
/// Get the test session ID
|
|
pub fn get_test_session_id(&self) -> &str {
|
|
&self.test_session_id
|
|
}
|
|
|
|
/// Setup test database
|
|
pub async fn setup_test_database(&self) -> Result<()> {
|
|
self.database_harness.setup().await
|
|
}
|
|
|
|
/// Teardown test database
|
|
pub async fn teardown_test_database(&self) -> Result<()> {
|
|
self.database_harness.teardown().await
|
|
}
|
|
}
|
|
|
|
/// Service health status enumeration
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ServiceHealth {
|
|
Healthy,
|
|
Unhealthy,
|
|
Unknown,
|
|
}
|
|
|
|
/// Overall services health status
|
|
#[derive(Debug, Clone)]
|
|
pub struct ServicesHealthStatus {
|
|
pub trading_service: ServiceHealth,
|
|
pub config_service: ServiceHealth,
|
|
pub database: ServiceHealth,
|
|
pub all_healthy: bool,
|
|
}
|
|
|
|
impl ServicesHealthStatus {
|
|
/// Get a summary of health status as a string
|
|
pub fn summary(&self) -> String {
|
|
let services = [
|
|
("Trading", &self.trading_service),
|
|
("Config", &self.config_service),
|
|
("Database", &self.database),
|
|
];
|
|
|
|
let healthy_count = services
|
|
.iter()
|
|
.filter(|(_, health)| matches!(health, ServiceHealth::Healthy))
|
|
.count();
|
|
|
|
let total_count = services.len();
|
|
|
|
format!("{}/{} services healthy", healthy_count, total_count)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
#[ignore] // Requires running services (database, JWT, ML pipeline)
|
|
async fn test_framework_creation() {
|
|
let framework = E2ETestFramework::new().await;
|
|
assert!(framework.is_ok());
|
|
|
|
let framework = framework.unwrap();
|
|
assert!(!framework.services_started);
|
|
assert!(!framework.test_session_id.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_service_health_summary() {
|
|
let status = ServicesHealthStatus {
|
|
trading_service: ServiceHealth::Healthy,
|
|
config_service: ServiceHealth::Unhealthy,
|
|
database: ServiceHealth::Healthy,
|
|
all_healthy: false,
|
|
};
|
|
|
|
let summary = status.summary();
|
|
assert_eq!(summary, "2/3 services healthy");
|
|
}
|
|
}
|