- 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>
365 lines
12 KiB
Rust
365 lines
12 KiB
Rust
//! Authentication Flow Smoke Tests
|
|
//!
|
|
//! Tests to validate JWT authentication, session management, and authorization.
|
|
|
|
use super::common::*;
|
|
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, Algorithm};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct Claims {
|
|
sub: String,
|
|
iss: String,
|
|
aud: String,
|
|
exp: u64,
|
|
iat: u64,
|
|
roles: Vec<String>,
|
|
}
|
|
|
|
fn get_jwt_secret() -> String {
|
|
std::env::var("JWT_SECRET")
|
|
.unwrap_or_else(|_| "dev_secret_key_change_in_production".to_string())
|
|
}
|
|
|
|
fn create_test_token() -> Result<String, Box<dyn std::error::Error>> {
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
|
|
|
let claims = Claims {
|
|
sub: "test_user".to_string(),
|
|
iss: "foxhunt-api".to_string(),
|
|
aud: "foxhunt-services".to_string(),
|
|
iat: now,
|
|
exp: now + 3600, // Valid for 1 hour
|
|
roles: vec!["trader".to_string()],
|
|
};
|
|
|
|
let token = encode(
|
|
&Header::new(Algorithm::HS256),
|
|
&claims,
|
|
&EncodingKey::from_secret(get_jwt_secret().as_bytes()),
|
|
)?;
|
|
|
|
Ok(token)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_jwt_token_generation() {
|
|
let result = with_timeout(async {
|
|
let token = create_test_token()
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
assert!(!token.is_empty(), "JWT token should not be empty");
|
|
assert!(token.split('.').count() == 3, "JWT should have 3 parts");
|
|
|
|
println!("✅ JWT token generation successful");
|
|
println!(" Token length: {} characters", token.len());
|
|
Ok(())
|
|
}).await;
|
|
|
|
assert!(result.is_ok(), "JWT token generation failed: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_jwt_token_validation() {
|
|
let result = with_timeout(async {
|
|
// Generate token
|
|
let token = create_test_token()
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Validate token
|
|
let validation = Validation::new(Algorithm::HS256);
|
|
let token_data = decode::<Claims>(
|
|
&token,
|
|
&DecodingKey::from_secret(get_jwt_secret().as_bytes()),
|
|
&validation,
|
|
)
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
assert_eq!(token_data.claims.sub, "test_user");
|
|
assert_eq!(token_data.claims.iss, "foxhunt-api");
|
|
assert_eq!(token_data.claims.aud, "foxhunt-services");
|
|
assert!(token_data.claims.roles.contains(&"trader".to_string()));
|
|
|
|
println!("✅ JWT token validation successful");
|
|
println!(" User: {}", token_data.claims.sub);
|
|
println!(" Roles: {:?}", token_data.claims.roles);
|
|
Ok(())
|
|
}).await;
|
|
|
|
assert!(result.is_ok(), "JWT token validation failed: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_jwt_token_expiration() {
|
|
let result = with_timeout(async {
|
|
// Create expired token
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
|
|
|
let expired_claims = Claims {
|
|
sub: "test_user".to_string(),
|
|
iss: "foxhunt-api".to_string(),
|
|
aud: "foxhunt-services".to_string(),
|
|
iat: now - 7200, // Issued 2 hours ago
|
|
exp: now - 3600, // Expired 1 hour ago
|
|
roles: vec!["trader".to_string()],
|
|
};
|
|
|
|
let expired_token = encode(
|
|
&Header::new(Algorithm::HS256),
|
|
&expired_claims,
|
|
&EncodingKey::from_secret(get_jwt_secret().as_bytes()),
|
|
)?;
|
|
|
|
// Try to validate expired token
|
|
let validation = Validation::new(Algorithm::HS256);
|
|
let result = decode::<Claims>(
|
|
&expired_token,
|
|
&DecodingKey::from_secret(get_jwt_secret().as_bytes()),
|
|
&validation,
|
|
);
|
|
|
|
assert!(result.is_err(), "Expired token should not validate");
|
|
|
|
println!("✅ JWT expiration validation successful");
|
|
Ok(())
|
|
}).await;
|
|
|
|
assert!(result.is_ok(), "JWT expiration test failed: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_jwt_invalid_signature() {
|
|
let result = with_timeout(async {
|
|
// Create token with one secret
|
|
let token = create_test_token()
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Try to validate with different secret
|
|
let validation = Validation::new(Algorithm::HS256);
|
|
let wrong_secret = "wrong_secret_key";
|
|
let result = decode::<Claims>(
|
|
&token,
|
|
&DecodingKey::from_secret(wrong_secret.as_bytes()),
|
|
&validation,
|
|
);
|
|
|
|
assert!(result.is_err(), "Token with invalid signature should not validate");
|
|
|
|
println!("✅ JWT signature validation successful");
|
|
Ok(())
|
|
}).await;
|
|
|
|
assert!(result.is_ok(), "JWT signature validation test failed: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_session_storage() {
|
|
let result = with_timeout(async {
|
|
use redis::AsyncCommands;
|
|
|
|
let client = redis::Client::open(redis_url().as_str())
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
let mut con = client.get_multiplexed_async_connection()
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Simulate session storage
|
|
let session_key = "smoke_test:session:test_user";
|
|
let session_data = serde_json::json!({
|
|
"user_id": "test_user",
|
|
"roles": ["trader"],
|
|
"created_at": chrono::Utc::now().to_rfc3339(),
|
|
});
|
|
|
|
// Store session with TTL
|
|
con.set_ex::<_, _, ()>(session_key, session_data.to_string(), 3600)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Retrieve session
|
|
let retrieved: String = con.get(session_key)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
let retrieved_json: serde_json::Value = serde_json::from_str(&retrieved)?;
|
|
assert_eq!(retrieved_json["user_id"], "test_user");
|
|
|
|
// Cleanup
|
|
con.del::<_, ()>(session_key)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
println!("✅ Redis session storage successful");
|
|
Ok(())
|
|
}).await;
|
|
|
|
skip_if_unavailable!("Redis", { result });
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_jwt_revocation_check() {
|
|
let result = with_timeout(async {
|
|
use redis::AsyncCommands;
|
|
|
|
let client = redis::Client::open(redis_url().as_str())
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
let mut con = client.get_multiplexed_async_connection()
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Create token
|
|
let token = create_test_token()
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Simulate token revocation
|
|
let jti = "smoke_test_token_id";
|
|
let revocation_key = format!("revoked_tokens:{}", jti);
|
|
|
|
// Add to revocation list
|
|
con.set_ex::<_, _, ()>(&revocation_key, "revoked", 3600)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Check if token is revoked
|
|
let is_revoked: bool = con.exists(&revocation_key)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
assert!(is_revoked, "Token should be marked as revoked");
|
|
|
|
// Cleanup
|
|
con.del::<_, ()>(&revocation_key)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
println!("✅ JWT revocation check successful");
|
|
Ok(())
|
|
}).await;
|
|
|
|
skip_if_unavailable!("Redis", { result });
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rate_limiting() {
|
|
let result = with_timeout(async {
|
|
use redis::AsyncCommands;
|
|
|
|
let client = redis::Client::open(redis_url().as_str())
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
let mut con = client.get_multiplexed_async_connection()
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
// Simulate rate limiting
|
|
let rate_limit_key = "smoke_test:rate_limit:test_user";
|
|
let window_seconds = 60;
|
|
let max_requests = 100;
|
|
|
|
// Increment counter
|
|
let count: i32 = con.incr(&rate_limit_key, 1)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
if count == 1 {
|
|
// Set expiration on first request
|
|
con.expire::<_, ()>(&rate_limit_key, window_seconds)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
}
|
|
|
|
// Check if rate limit exceeded
|
|
let is_limited = count > max_requests;
|
|
assert!(!is_limited, "Rate limit should not be exceeded on first request");
|
|
|
|
// Get TTL
|
|
let ttl: i32 = con.ttl(&rate_limit_key)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
assert!(ttl > 0 && ttl <= window_seconds, "TTL should be set correctly");
|
|
|
|
// Cleanup
|
|
con.del::<_, ()>(&rate_limit_key)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
|
|
println!("✅ Rate limiting check successful");
|
|
println!(" Request count: {}/{}", count, max_requests);
|
|
println!(" TTL: {} seconds", ttl);
|
|
Ok(())
|
|
}).await;
|
|
|
|
skip_if_unavailable!("Redis", { result });
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_authentication_flow_complete() {
|
|
println!("🔐 Running complete authentication flow...");
|
|
|
|
let mut steps_passed = 0;
|
|
let total_steps = 4;
|
|
|
|
// Step 1: Generate token
|
|
if create_test_token().is_ok() {
|
|
println!(" ✓ JWT token generation");
|
|
steps_passed += 1;
|
|
} else {
|
|
println!(" ✗ JWT token generation failed");
|
|
}
|
|
|
|
// Step 2: Validate token
|
|
if let Ok(token) = create_test_token() {
|
|
let validation = Validation::new(Algorithm::HS256);
|
|
if decode::<Claims>(
|
|
&token,
|
|
&DecodingKey::from_secret(get_jwt_secret().as_bytes()),
|
|
&validation,
|
|
).is_ok() {
|
|
println!(" ✓ JWT token validation");
|
|
steps_passed += 1;
|
|
} else {
|
|
println!(" ✗ JWT token validation failed");
|
|
}
|
|
}
|
|
|
|
// Step 3: Session storage
|
|
if let Ok(client) = redis::Client::open(redis_url().as_str()) {
|
|
if let Ok(mut con) = client.get_multiplexed_async_connection().await {
|
|
use redis::AsyncCommands;
|
|
let test_key = "smoke_test:auth_flow:session";
|
|
if con.set_ex::<_, _, ()>(test_key, "test", 60).await.is_ok() {
|
|
println!(" ✓ Session storage");
|
|
steps_passed += 1;
|
|
let _ = con.del::<_, ()>(test_key).await;
|
|
}
|
|
}
|
|
} else {
|
|
println!(" ⚠ Session storage (Redis unavailable)");
|
|
}
|
|
|
|
// Step 4: Rate limiting
|
|
if let Ok(client) = redis::Client::open(redis_url().as_str()) {
|
|
if let Ok(mut con) = client.get_multiplexed_async_connection().await {
|
|
use redis::AsyncCommands;
|
|
let test_key = "smoke_test:auth_flow:rate_limit";
|
|
if con.incr::<_, _, i32>(test_key, 1).await.is_ok() {
|
|
println!(" ✓ Rate limiting");
|
|
steps_passed += 1;
|
|
let _ = con.del::<_, ()>(test_key).await;
|
|
}
|
|
}
|
|
} else {
|
|
println!(" ⚠ Rate limiting (Redis unavailable)");
|
|
}
|
|
|
|
println!("\n📊 Authentication Flow Summary:");
|
|
println!(" Steps passed: {}/{}", steps_passed, total_steps);
|
|
|
|
assert!(steps_passed >= 2, "Authentication flow requires at least JWT functionality");
|
|
println!("✅ Authentication flow check complete");
|
|
}
|