Files
foxhunt/services/integration_tests/tests/common/auth_helpers.rs
jgrusewski 581d066007 🧪 Wave 149 Phase 5-6: Serial Test Isolation (Agents 414-415)
**Issue**: Non-deterministic test failures (53-57% pass rate)
**Root Cause #4**: Test environment pollution from std::env::remove_var()

## Investigation Results

### Agent 414: Root Cause Discovery
- **Analysis**: Proved JWT secrets matched byte-for-byte between services
- **Pattern**: Individual tests passed, parallel execution failed
- **Discovery**: 14 tests permanently removed JWT_SECRET from process environment
- **Impact**: Non-deterministic failures due to test execution order

## Fixes Applied

### Agent 415: Test Isolation with serial_test
- **Locations**:
  - services/integration_tests/tests/common/auth_helpers.rs:499 (1 test)
  - services/trading_service/tests/auth_security_tests.rs (12 tests)
- **Fix**: Added `#[serial_test::serial]` attribute to all 14 polluting tests
- **Dependencies**: serial_test = "3.0" (already in Cargo.toml)
- **Verification**: Stack traces confirmed serial_code_lock mutex execution

## Technical Discovery
**Key Insight**: Rust runs tests in parallel with non-deterministic ordering.
Tests that modify global state (env vars, static data, singletons) MUST use
serial_test isolation to prevent cross-contamination.

## Test Results
- Before Phase 5-6: 53-57% (non-deterministic)
- After Phase 5-6: 14-15/23 (61-65%, deterministic)
- Improvement: Eliminated randomness, stable pass rate

## Why This Was Difficult
1. Failures appeared random (different results each run)
2. 14 different tests could cause pollution
3. Required proving secrets matched to rule out other causes
4. Test execution order randomized by Rust test framework

## Files Modified
- services/integration_tests/tests/common/auth_helpers.rs (+1 attribute)
- services/trading_service/tests/auth_security_tests.rs (+12 attributes)

Total instances fixed: 14/14 (100%)

Co-authored-by: Wave 149 Agent 414 (Root Cause Analysis)
Co-authored-by: Wave 149 Agent 415 (Serial Test Fix)
2025-10-12 19:58:11 +02:00

523 lines
17 KiB
Rust

//! JWT Authentication Helper Module for E2E Tests
//!
//! Provides reusable authentication utilities for integration and E2E tests.
//! This module centralizes JWT token generation and authenticated client creation
//! to ensure consistency across test suites.
//!
//! Features:
//! - Valid JWT token generation matching API Gateway validation
//! - Authenticated gRPC client creation with proper metadata
//! - Support for both MFA-enabled and MFA-disabled scenarios
//! - Configurable user roles and permissions
//! - Environment-based JWT secret management
//!
//! Usage:
//! ```rust
//! use common::auth_helpers::{create_test_jwt, create_authenticated_trading_client};
//!
//! #[tokio::test]
//! async fn test_authenticated_request() -> Result<()> {
//! let mut client = create_authenticated_trading_client().await?;
//! // Use client for authenticated gRPC calls
//! Ok(())
//! }
//! ```
/// Initialize test environment by loading .env file
/// This runs BEFORE module initialization, ensuring JWT_SECRET is available
#[ctor::ctor]
fn init_test_env() {
// Load .env file at module initialization time
let _ = dotenvy::dotenv();
// Verify JWT_SECRET is available
if std::env::var("JWT_SECRET").is_err() {
eprintln!("WARNING: JWT_SECRET not found in .env file");
}
}
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use tonic::{metadata::MetadataValue, Request, Status};
use uuid::Uuid;
/// Default API Gateway address for testing
///
/// Port 50051 = API Gateway external port (0.0.0.0:50051)
pub const DEFAULT_API_GATEWAY_ADDR: &str = "http://localhost:50051";
/// Default test user ID
pub const DEFAULT_TEST_USER_ID: &str = "test_trader_001";
/// Default test user role
pub const DEFAULT_TEST_ROLE: &str = "trader";
/// JWT claims structure (matches API Gateway JwtClaims)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestJwtClaims {
/// JWT ID for revocation tracking (MANDATORY for API Gateway)
pub jti: String,
/// Subject (user ID)
pub sub: String,
/// Issued at timestamp
pub iat: u64,
/// Expiration timestamp
pub exp: u64,
/// Issuer (must match API Gateway validation)
pub iss: String,
/// Audience (must match API Gateway validation)
pub aud: String,
/// User roles
pub roles: Vec<String>,
/// Additional permissions
pub permissions: Vec<String>,
/// Token type: "access" or "refresh"
#[serde(default = "default_token_type")]
pub token_type: String,
/// Session ID for tracking related tokens
pub session_id: Option<String>,
}
fn default_token_type() -> String {
"access".to_string()
}
/// Authentication configuration for test tokens
#[derive(Debug, Clone)]
pub struct TestAuthConfig {
/// User ID for the test token
pub user_id: String,
/// User roles (e.g., "trader", "admin", "viewer")
pub roles: Vec<String>,
/// User permissions (e.g., "trading.submit", "api.access")
pub permissions: Vec<String>,
/// Token expiration duration (default: 1 hour)
pub expiry_duration: Duration,
/// Whether MFA is enabled for this user
pub mfa_enabled: bool,
/// Whether MFA has been verified (only relevant if mfa_enabled = true)
pub mfa_verified: bool,
}
impl Default for TestAuthConfig {
fn default() -> Self {
Self {
user_id: DEFAULT_TEST_USER_ID.to_string(),
roles: vec![DEFAULT_TEST_ROLE.to_string()],
permissions: vec![
"api.access".to_string(),
"trading.submit".to_string(),
"trading.view".to_string(),
"trading.cancel".to_string(),
],
expiry_duration: Duration::hours(1),
mfa_enabled: false,
mfa_verified: false,
}
}
}
impl TestAuthConfig {
/// Create a new test auth config with default trader permissions
pub fn trader() -> Self {
Self::default()
}
/// Create a test auth config for an admin user
pub fn admin() -> Self {
Self {
user_id: "test_admin_001".to_string(),
roles: vec!["admin".to_string()],
permissions: vec![
"api.access".to_string(),
"trading.*".to_string(),
"admin.*".to_string(),
],
expiry_duration: Duration::hours(1),
mfa_enabled: true,
mfa_verified: true,
}
}
/// Create a test auth config for a viewer (read-only) user
pub fn viewer() -> Self {
Self {
user_id: "test_viewer_001".to_string(),
roles: vec!["viewer".to_string()],
permissions: vec![
"api.access".to_string(),
"trading.view".to_string(),
],
expiry_duration: Duration::hours(1),
mfa_enabled: false,
mfa_verified: false,
}
}
/// Create a test auth config with MFA enabled
pub fn with_mfa_enabled(mut self) -> Self {
self.mfa_enabled = true;
self.mfa_verified = true; // Assume verified for tests unless explicitly set
self
}
/// Create a test auth config with MFA not verified (for testing MFA flows)
///
/// # Errors
/// Returns error if the operation fails
pub fn with_mfa_unverified(mut self) -> Self {
self.mfa_enabled = true;
self.mfa_verified = false;
self
}
/// Set custom user ID
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = user_id.into();
self
}
/// Set custom roles
pub fn with_roles(mut self, roles: Vec<String>) -> Self {
self.roles = roles;
self
}
/// Set custom permissions
pub fn with_permissions(mut self, permissions: Vec<String>) -> Self {
self.permissions = permissions;
self
}
/// Set custom expiry duration
pub fn with_expiry(mut self, duration: Duration) -> Self {
self.expiry_duration = duration;
self
}
}
/// Get JWT secret from environment (REQUIRED)
///
/// WAVE 130: Fail-fast pattern - JWT_SECRET MUST be set in .env file
///
/// This prevents configuration drift and ensures single source of truth
///
/// # Panics
///
/// Panics if JWT_SECRET environment variable is not set
///
/// # Setup
/// 1. Copy .env.example to .env
/// 2. Set JWT_SECRET in .env file
/// 3. Load .env before running tests (e.g., `source .env` or use dotenv)
pub fn get_test_jwt_secret() -> String {
// WAVE 149 Agent 411: Trim whitespace to match API Gateway behavior
std::env::var("JWT_SECRET")
.expect(
"FATAL: JWT_SECRET must be set in .env file for E2E tests\n\
\n\
Setup:\n\
1. Copy .env.example to .env\n\
2. Set JWT_SECRET in .env file\n\
3. Run: export $(cat .env | xargs)\n\
\n\
Example: JWT_SECRET=OvFLDUbIDak3CSCi5t6zKfsAp65cjTOJ85q9YE+TFY8b361DGg1gSTra2rW6mps3cWrRGQ/NXRA5uftUpMldvOaEHMMgfBs4JjVODDElREdvUFm0EttD1A==",
)
.trim()
.to_string()
}
/// Get test user ID (consistent across tests)
pub fn get_test_user_id() -> String {
DEFAULT_TEST_USER_ID.to_string()
}
/// Get API Gateway address from environment or use default
pub fn get_api_gateway_addr() -> String {
std::env::var("API_GATEWAY_ADDR").unwrap_or_else(|_| DEFAULT_API_GATEWAY_ADDR.to_string())
}
/// Generate a valid JWT token for testing
///
/// This function creates a JWT token that will pass API Gateway validation:
/// - Issuer: "foxhunt-trading" (matches JwtConfig)
///
/// - Audience: "trading-api" (matches JwtConfig)
/// - Algorithm: HS256 (matches API Gateway)
///
/// - Contains required claims: jti, sub, iat, exp, roles, permissions
///
/// # Arguments
/// * `config` - Authentication configuration (user ID, roles, permissions, etc.)
///
/// # Returns
/// * `Result<String>` - JWT token string ready to use in Authorization header
///
/// # Example
/// ```rust
/// let config = TestAuthConfig::trader();
/// let token = create_test_jwt(config)?;
/// assert!(!token.is_empty());
/// ```
pub fn create_test_jwt(config: TestAuthConfig) -> Result<String> {
let now = Utc::now();
let claims = TestJwtClaims {
jti: Uuid::new_v4().to_string(), // Required for revocation tracking
sub: config.user_id,
iat: now.timestamp() as u64,
exp: (now + config.expiry_duration).timestamp() as u64,
iss: "foxhunt-api-gateway".to_string(), // MUST match API Gateway JwtConfig
aud: "foxhunt-services".to_string(), // MUST match API Gateway JwtConfig
roles: config.roles,
permissions: config.permissions,
token_type: "access".to_string(),
session_id: Some(Uuid::new_v4().to_string()),
};
let jwt_secret = get_test_jwt_secret();
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(jwt_secret.as_ref()),
)
.context("Failed to encode JWT token")?;
Ok(token)
}
/// Generate a valid JWT token with default trader configuration
///
/// Convenience function for the most common test case.
///
/// # Example
/// ```rust
/// let token = create_default_test_jwt()?;
/// ```
pub fn create_default_test_jwt() -> Result<String> {
create_test_jwt(TestAuthConfig::default())
}
/// Generate an expired JWT token for testing token expiry handling
///
/// # Example
/// ```rust
/// let expired_token = create_expired_test_jwt()?;
/// // This token will fail API Gateway validation due to expiry
/// ```
pub fn create_expired_test_jwt() -> Result<String> {
let config = TestAuthConfig::default().with_expiry(Duration::seconds(-3600));
create_test_jwt(config)
}
/// Generate a JWT token with invalid issuer for testing validation
///
/// # Example
/// ```rust
/// let invalid_token = create_invalid_issuer_jwt()?;
/// // This token will fail API Gateway validation due to wrong issuer
/// ```
pub fn create_invalid_issuer_jwt() -> Result<String> {
let now = Utc::now();
let claims = TestJwtClaims {
jti: Uuid::new_v4().to_string(),
sub: DEFAULT_TEST_USER_ID.to_string(),
iat: now.timestamp() as u64,
exp: (now + Duration::hours(1)).timestamp() as u64,
iss: "wrong-issuer".to_string(), // Invalid issuer
aud: "foxhunt-services".to_string(),
roles: vec![DEFAULT_TEST_ROLE.to_string()],
permissions: vec!["api.access".to_string()],
token_type: "access".to_string(),
session_id: Some(Uuid::new_v4().to_string()),
};
let jwt_secret = get_test_jwt_secret();
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(jwt_secret.as_ref()),
)
.context("Failed to encode JWT token")?;
Ok(token)
}
/// Create an authentication interceptor function
///
/// This function creates an interceptor closure that automatically injects
/// JWT token and user context into request metadata.
///
/// # Arguments
/// * `config` - Authentication configuration
///
/// # Returns
/// * Interceptor function that can be used with `Client::with_interceptor`
///
/// # Example
/// ```rust
/// use trading::trading_service_client::TradingServiceClient;
///
/// let interceptor = create_auth_interceptor(TestAuthConfig::trader())?;
/// let channel = Channel::from_static("http://localhost:50051").connect().await?;
/// let mut client = TradingServiceClient::with_interceptor(channel, interceptor);
/// ```
///
/// # Errors
/// Returns error if the operation fails
pub fn create_auth_interceptor(
config: TestAuthConfig,
) -> Result<impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone> {
let token = create_test_jwt(config.clone())?;
let user_id = config.user_id.clone();
let roles_str = config.roles.join(",");
let interceptor = move |mut req: Request<()>| -> Result<Request<()>, Status> {
// Inject JWT token in Authorization header
let token_value = format!("Bearer {}", token);
let metadata_value = MetadataValue::try_from(token_value)
.map_err(|e| Status::invalid_argument(format!("Invalid token format: {}", e)))?;
req.metadata_mut().insert("authorization", metadata_value);
// Inject user context metadata (expected by some services)
let user_id_value = MetadataValue::try_from(user_id.as_str())
.map_err(|e| Status::invalid_argument(format!("Invalid user_id: {}", e)))?;
req.metadata_mut().insert("x-user-id", user_id_value);
let role_value = MetadataValue::try_from(roles_str.as_str())
.map_err(|e| Status::invalid_argument(format!("Invalid role: {}", e)))?;
req.metadata_mut().insert("x-user-role", role_value);
Ok(req)
};
Ok(interceptor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_test_jwt_default() {
let token = create_default_test_jwt().expect("Should create JWT token");
assert!(!token.is_empty());
assert!(token.contains(".")); // JWT has 3 parts separated by dots
}
#[test]
fn test_create_test_jwt_trader() {
let config = TestAuthConfig::trader();
let token = create_test_jwt(config).expect("Should create JWT token");
assert!(!token.is_empty());
}
#[test]
fn test_create_test_jwt_admin() {
let config = TestAuthConfig::admin();
let token = create_test_jwt(config).expect("Should create JWT token");
assert!(!token.is_empty());
}
#[test]
fn test_create_test_jwt_viewer() {
let config = TestAuthConfig::viewer();
let token = create_test_jwt(config).expect("Should create JWT token");
assert!(!token.is_empty());
}
#[test]
fn test_create_expired_jwt() {
let token = create_expired_test_jwt().expect("Should create expired JWT token");
assert!(!token.is_empty());
// Decode to verify it's expired
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = false; // Disable expiry check to decode
validation.set_issuer(&["foxhunt-api-gateway"]);
validation.set_audience(&["foxhunt-services"]);
let jwt_secret = get_test_jwt_secret();
let token_data = decode::<TestJwtClaims>(
&token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&validation,
)
.expect("Should decode token");
let now = Utc::now().timestamp() as u64;
assert!(token_data.claims.exp < now, "Token should be expired");
}
#[test]
fn test_create_invalid_issuer_jwt() {
let token = create_invalid_issuer_jwt().expect("Should create JWT token");
assert!(!token.is_empty());
// Decode to verify wrong issuer
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = false;
validation.set_issuer(&["foxhunt-trading"]);
let jwt_secret = get_test_jwt_secret();
let result = decode::<TestJwtClaims>(
&token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&validation,
);
assert!(result.is_err(), "Should fail validation due to wrong issuer");
}
#[test]
fn test_auth_config_builder() {
let config = TestAuthConfig::trader()
.with_user_id("custom_user_123")
.with_roles(vec!["custom_role".to_string()])
.with_permissions(vec!["custom.permission".to_string()])
.with_expiry(Duration::minutes(30))
.with_mfa_enabled();
assert_eq!(config.user_id, "custom_user_123");
assert_eq!(config.roles, vec!["custom_role"]);
assert_eq!(config.permissions, vec!["custom.permission"]);
assert_eq!(config.expiry_duration, Duration::minutes(30));
assert!(config.mfa_enabled);
assert!(config.mfa_verified);
}
#[test]
fn test_get_test_user_id() {
let user_id = get_test_user_id();
assert_eq!(user_id, DEFAULT_TEST_USER_ID);
}
#[test]
#[serial_test::serial] // WAVE 149 Agent 415: Prevent test pollution from remove_var
#[should_panic(expected = "JWT_SECRET must be set")]
fn test_get_test_jwt_secret_fails_without_env() {
// Clear JWT_SECRET to test fail-fast behavior
std::env::remove_var("JWT_SECRET");
let _secret = get_test_jwt_secret();
}
#[test]
fn test_get_test_jwt_secret_with_env() {
// Set JWT_SECRET to test success path
std::env::set_var("JWT_SECRET", "test_secret_at_least_64_chars_long_for_security_xxxxxxxxxxxxxxxxxxxxxxxxx");
let secret = get_test_jwt_secret();
assert!(!secret.is_empty());
assert!(secret.len() >= 64); // Should be high-entropy
}
#[test]
fn test_get_api_gateway_addr() {
let addr = get_api_gateway_addr();
assert!(!addr.is_empty());
assert!(addr.starts_with("http://"));
}
}