migrations: - 001_trading_events.sql, 003_audit_system.sql: the hard-coded node_id literals (`trading-node-01`, `audit-node-01`, `ml-node-01`, `system-node-01`, `change-tracker-01`) are overridden per-deployment by later migrations rather than read from the environment. Describe that in the inline comment. - 004_compliance_views.sql: `generate_compliance_report` is a log stub — actual report generation is performed by the compliance service. Say so explicitly. services: - ml_training_service/tests/orchestrator_225_features_test.rs: the empty `#[ignore]`d placeholder for the 225-feature orchestrator loader has been removed; it held no assertions and only tracked a TODO (feedback_no_stubs.md). - trading_agent_service/src/service.rs: portfolio volatility uses the diagonal-only approximation because cross-asset return correlations are not maintained in this service. Document that. - trading_service/src/services/risk.rs: `get_risk_metrics` uses `calculate_marginal_var` + asset-class fallback; describe why `calculate_comprehensive_var` is not wired at this boundary. - trading_service/tests/auth_comprehensive.rs: delete the entire commented-out legacy BackupCodeValidator test block — the old `generate_backup_codes` / `store_backup_code` / `verify_backup_code` surface no longer exists, and MFA integration tests already cover the new API. testing: - harness/grpc_clients.rs: no BacktestingServiceClient proto exists; reword the stale TODO import line. - chaos/*: reword the family of "TODO: Implement ..." stubs as "Currently a no-op / synthetic result" descriptions so readers know exactly how much of the chaos framework is live. - compliance_automation_tests.rs: delete the file; it was a giant /* ... */ block referencing a nonexistent compliance module and was not wired into any Cargo target. - framework.rs: describe why `setup()` uses `println!` instead of `tracing_subscriber` (tracing_subscriber is not a dep of this integration crate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1907 lines
50 KiB
Rust
1907 lines
50 KiB
Rust
#![allow(dead_code, unused_variables)]
|
||
//! Comprehensive Authentication System Tests for Wave 102
|
||
//!
|
||
//! This test suite achieves 95%+ coverage for authentication components:
|
||
//! - JWT token revocation (Redis blacklist, concurrent operations, error recovery)
|
||
//! - MFA flows (TOTP enrollment, backup codes, QR generation, verification)
|
||
//! - Token refresh mechanism (access/refresh token pairs, rotation)
|
||
//! - Error paths (Redis failures, network timeouts, invalid input)
|
||
//! - Concurrent safety (race conditions, atomicity, cache coherency)
|
||
//!
|
||
//! Test Coverage: 130+ test cases, ~3,500 lines
|
||
//! Components: JwtRevocationService, TotpGenerator, TotpVerifier, MFA enrollment
|
||
|
||
use anyhow::Result;
|
||
use redis::aio::ConnectionManager;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
use tokio::time::sleep;
|
||
use uuid::Uuid;
|
||
|
||
// Import API Gateway auth components
|
||
use api::auth::jwt::revocation::{
|
||
EnhancedJwtClaims, Jti, JwtRevocationService, RevocationConfig, RevocationMetadata,
|
||
RevocationReason, RevocationStatistics,
|
||
};
|
||
use api::auth::mfa::totp::{TotpAlgorithm, TotpConfig, TotpGenerator, TotpVerifier};
|
||
use secrecy::{ExposeSecret, SecretString};
|
||
|
||
// ============================================================================
|
||
// TEST HELPERS & FIXTURES
|
||
// ============================================================================
|
||
|
||
/// Setup Redis connection for testing
|
||
async fn setup_redis() -> Result<ConnectionManager> {
|
||
let redis_url =
|
||
std::env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://localhost:6380".to_string());
|
||
|
||
let client = redis::Client::open(redis_url)?;
|
||
let conn = ConnectionManager::new(client).await?;
|
||
|
||
Ok(conn)
|
||
}
|
||
|
||
/// Cleanup Redis test data
|
||
async fn cleanup_redis(conn: &mut ConnectionManager) -> Result<()> {
|
||
let _: () = redis::cmd("FLUSHDB").query_async(conn).await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Create test JwtRevocationService
|
||
async fn create_test_revocation_service() -> Result<JwtRevocationService> {
|
||
let redis_url =
|
||
std::env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://localhost:6380".to_string());
|
||
|
||
let config = RevocationConfig {
|
||
redis_prefix: "test:jwt:blacklist:".to_string(),
|
||
session_prefix: "test:jwt:user_sessions:".to_string(),
|
||
enable_audit_logging: true,
|
||
max_tokens_per_user: 100,
|
||
};
|
||
|
||
JwtRevocationService::new(&redis_url, config).await
|
||
}
|
||
|
||
// ============================================================================
|
||
// MODULE 1: JWT REVOCATION - BASIC OPERATIONS (20 tests)
|
||
// ============================================================================
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_check_not_revoked_token() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(!is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_revoke_single_token() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
let user_id = "test_user_001";
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
Some("192.168.1.1".to_string()),
|
||
)
|
||
.await?;
|
||
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_metadata_storage() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
let user_id = "test_user_002";
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::SuspiciousActivity,
|
||
"admin",
|
||
Some("10.0.0.1".to_string()),
|
||
)
|
||
.await?;
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
|
||
let meta = metadata.unwrap();
|
||
assert_eq!(meta.user_id(), user_id);
|
||
assert_eq!(meta.reason(), "suspicious_activity");
|
||
assert_eq!(meta.revoked_by(), "admin");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_expired_token_skipped() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
let user_id = "test_user_003";
|
||
|
||
// Revoke with 0 TTL (already expired)
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
0,
|
||
RevocationReason::SessionTimeout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Should not be blacklisted
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(!is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_ttl_expiration() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
let user_id = "test_user_004";
|
||
|
||
// Revoke with 2 second TTL
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
2,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
assert!(service.is_revoked(&jti).await?);
|
||
|
||
// Wait for expiration
|
||
sleep(Duration::from_secs(3)).await;
|
||
|
||
// Should no longer be revoked
|
||
assert!(!service.is_revoked(&jti).await?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_multiple_tokens_same_user() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let user_id = "test_user_005";
|
||
|
||
let jti1 = Jti::new();
|
||
let jti2 = Jti::new();
|
||
let jti3 = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti1,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
service
|
||
.revoke_token(
|
||
&jti2,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
service
|
||
.revoke_token(
|
||
&jti3,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
assert!(service.is_revoked(&jti1).await?);
|
||
assert!(service.is_revoked(&jti2).await?);
|
||
assert!(service.is_revoked(&jti3).await?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_bulk_user_revocation() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let user_id = "test_user_006";
|
||
|
||
// Create and revoke multiple tokens
|
||
let jti1 = Jti::new();
|
||
let jti2 = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti1,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
service
|
||
.revoke_token(
|
||
&jti2,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Revoke all user tokens
|
||
let count = service
|
||
.revoke_all_user_tokens(user_id, RevocationReason::PasswordChange, "admin")
|
||
.await?;
|
||
|
||
assert!(count > 0);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_statistics() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
|
||
let jti1 = Jti::new();
|
||
let jti2 = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti1,
|
||
"user1",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user1",
|
||
None,
|
||
)
|
||
.await?;
|
||
service
|
||
.revoke_token(
|
||
&jti2,
|
||
"user2",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user2",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
let stats = service.get_statistics().await?;
|
||
assert!(stats.revoked_tokens >= 2);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_all_reasons() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
|
||
let reasons = vec![
|
||
RevocationReason::UserLogout,
|
||
RevocationReason::AdminRevocation,
|
||
RevocationReason::SuspiciousActivity,
|
||
RevocationReason::PasswordChange,
|
||
RevocationReason::AccountLocked,
|
||
RevocationReason::TokenCompromised,
|
||
RevocationReason::SessionTimeout,
|
||
RevocationReason::Other("test".to_string()),
|
||
];
|
||
|
||
for (i, reason) in reasons.into_iter().enumerate() {
|
||
let jti = Jti::new();
|
||
let user_id = format!("user_{}", i);
|
||
|
||
service
|
||
.revoke_token(&jti, &user_id, 3600, reason.clone(), &user_id, None)
|
||
.await?;
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_jti_generation_uniqueness() {
|
||
let jti1 = Jti::new();
|
||
let jti2 = Jti::new();
|
||
let jti3 = Jti::new();
|
||
|
||
assert_ne!(jti1.as_str(), jti2.as_str());
|
||
assert_ne!(jti2.as_str(), jti3.as_str());
|
||
assert_ne!(jti1.as_str(), jti3.as_str());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_jti_display() {
|
||
let jti = Jti::from_string("test-jti-123".to_string());
|
||
assert_eq!(format!("{}", jti), "test-jti-123");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_jti_redis_key_format() {
|
||
let jti = Jti::from_string("abc123".to_string());
|
||
let key = format!("jwt:blacklist:{}", jti.as_str());
|
||
assert_eq!(key, "jwt:blacklist:abc123");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_enhanced_jwt_claims_access_token() -> Result<()> {
|
||
let claims = EnhancedJwtClaims::new_access_token(
|
||
"user123".to_string(),
|
||
vec!["trader".to_string()],
|
||
vec!["trading.submit_order".to_string()],
|
||
"foxhunt".to_string(),
|
||
"trading-api".to_string(),
|
||
3600,
|
||
)?;
|
||
|
||
assert_eq!(claims.sub, "user123");
|
||
assert_eq!(claims.token_type, "access");
|
||
assert!(claims.roles.contains(&"trader".to_string()));
|
||
assert!(!claims.jti.is_empty());
|
||
assert!(claims.remaining_ttl()? > 3500);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_enhanced_jwt_claims_refresh_token() -> Result<()> {
|
||
let session_id = Uuid::new_v4().to_string();
|
||
let claims = EnhancedJwtClaims::new_refresh_token(
|
||
"user456".to_string(),
|
||
"foxhunt".to_string(),
|
||
"trading-api".to_string(),
|
||
session_id.clone(),
|
||
86400,
|
||
)?;
|
||
|
||
assert_eq!(claims.sub, "user456");
|
||
assert_eq!(claims.token_type, "refresh");
|
||
assert_eq!(claims.session_id, session_id);
|
||
assert!(claims.permissions.contains(&"refresh_token".to_string()));
|
||
assert!(claims.roles.is_empty());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_enhanced_jwt_claims_remaining_ttl() -> Result<()> {
|
||
let claims = EnhancedJwtClaims::new_access_token(
|
||
"user789".to_string(),
|
||
vec!["trader".to_string()],
|
||
vec![],
|
||
"foxhunt".to_string(),
|
||
"trading-api".to_string(),
|
||
3600,
|
||
)?;
|
||
|
||
let ttl = claims.remaining_ttl()?;
|
||
assert!(ttl > 3500 && ttl <= 3600);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_enhanced_jwt_claims_expired_ttl() -> Result<()> {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||
|
||
let claims = EnhancedJwtClaims {
|
||
jti: Jti::new().to_string(),
|
||
sub: "user_expired".to_string(),
|
||
iat: now - 7200,
|
||
exp: now - 3600, // Expired 1 hour ago
|
||
nbf: now - 7200,
|
||
iss: "foxhunt".to_string(),
|
||
aud: "trading-api".to_string(),
|
||
roles: vec![],
|
||
permissions: vec![],
|
||
token_type: "access".to_string(),
|
||
session_id: Uuid::new_v4().to_string(),
|
||
};
|
||
|
||
let ttl = claims.remaining_ttl()?;
|
||
assert_eq!(ttl, 0);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_reason_display() {
|
||
assert_eq!(format!("{}", RevocationReason::UserLogout), "user_logout");
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::AdminRevocation),
|
||
"admin_revocation"
|
||
);
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::SuspiciousActivity),
|
||
"suspicious_activity"
|
||
);
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::PasswordChange),
|
||
"password_change"
|
||
);
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::AccountLocked),
|
||
"account_locked"
|
||
);
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::TokenCompromised),
|
||
"token_compromised"
|
||
);
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::SessionTimeout),
|
||
"session_timeout"
|
||
);
|
||
assert_eq!(
|
||
format!("{}", RevocationReason::Other("custom".to_string())),
|
||
"other:custom"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_config_default() {
|
||
let config = RevocationConfig::default();
|
||
assert_eq!(config.redis_prefix, "jwt:blacklist:");
|
||
assert_eq!(config.session_prefix, "jwt:user_sessions:");
|
||
assert!(config.enable_audit_logging);
|
||
assert_eq!(config.max_tokens_per_user, 100);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_no_tokens_for_user() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let user_id = "nonexistent_user";
|
||
|
||
let count = service
|
||
.revoke_all_user_tokens(user_id, RevocationReason::UserLogout, "admin")
|
||
.await?;
|
||
|
||
assert_eq!(count, 0);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_metadata_none_for_valid_token() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_none());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// MODULE 2: JWT REVOCATION - CONCURRENT OPERATIONS (15 tests)
|
||
// ============================================================================
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_revoke_same_token() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let jti = Arc::new(Jti::new());
|
||
let user_id = "concurrent_user_001";
|
||
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = vec![];
|
||
|
||
for i in 0..10 {
|
||
let service_clone = Arc::clone(&service);
|
||
let jti_clone = Arc::clone(&jti);
|
||
let uid = user_id.to_string();
|
||
|
||
let handle = tokio::spawn(async move {
|
||
service_clone
|
||
.revoke_token(
|
||
&jti_clone,
|
||
&uid,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&format!("worker_{}", i),
|
||
None,
|
||
)
|
||
.await
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
handle.await??;
|
||
}
|
||
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_check_revocation() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let jti = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
let jti = Arc::new(jti);
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<bool>>> = vec![];
|
||
|
||
for _ in 0..100 {
|
||
let service_clone = Arc::clone(&service);
|
||
let jti_clone = Arc::clone(&jti);
|
||
|
||
let handle = tokio::spawn(async move { service_clone.is_revoked(&jti_clone).await });
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
let is_revoked = handle.await??;
|
||
assert!(is_revoked);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_bulk_revocation() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let user_id = "concurrent_user_002";
|
||
|
||
// Create tokens first
|
||
for i in 0..5 {
|
||
let jti = Jti::new();
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<usize>>> = vec![];
|
||
|
||
for i in 0..3 {
|
||
let service_clone = Arc::clone(&service);
|
||
let uid = user_id.to_string();
|
||
|
||
let handle = tokio::spawn(async move {
|
||
service_clone
|
||
.revoke_all_user_tokens(
|
||
&uid,
|
||
RevocationReason::PasswordChange,
|
||
&format!("admin_{}", i),
|
||
)
|
||
.await
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
let _count = handle.await??;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_race_condition_token_tracking() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let user_id = "race_user_001";
|
||
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = vec![];
|
||
|
||
for i in 0..20 {
|
||
let service_clone = Arc::clone(&service);
|
||
let uid = user_id.to_string();
|
||
|
||
let handle = tokio::spawn(async move {
|
||
let jti = Jti::new();
|
||
service_clone
|
||
.revoke_token(
|
||
&jti,
|
||
&uid,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&format!("client_{}", i),
|
||
None,
|
||
)
|
||
.await
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
handle.await??;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_statistics_queries() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
|
||
// Create some tokens
|
||
for i in 0..5 {
|
||
let jti = Jti::new();
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
&format!("user_{}", i),
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&format!("user_{}", i),
|
||
None,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<RevocationStatistics>>> = vec![];
|
||
|
||
for _ in 0..10 {
|
||
let service_clone = Arc::clone(&service);
|
||
|
||
let handle = tokio::spawn(async move { service_clone.get_statistics().await });
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
let stats = handle.await??;
|
||
assert!(stats.revoked_tokens > 0);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_interleaved_revoke_check() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let jti1 = Arc::new(Jti::new());
|
||
let jti2 = Arc::new(Jti::new());
|
||
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = vec![];
|
||
|
||
// Interleave revocations and checks
|
||
for i in 0..5 {
|
||
let service_clone = Arc::clone(&service);
|
||
let jti_clone = Arc::clone(&jti1);
|
||
|
||
handles.push(tokio::spawn(async move {
|
||
service_clone
|
||
.revoke_token(
|
||
&jti_clone,
|
||
"user1",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user1",
|
||
None,
|
||
)
|
||
.await?;
|
||
Ok(())
|
||
}));
|
||
|
||
let service_clone = Arc::clone(&service);
|
||
let jti_clone = Arc::clone(&jti2);
|
||
|
||
handles.push(tokio::spawn(async move {
|
||
service_clone.is_revoked(&jti_clone).await?;
|
||
Ok(())
|
||
}));
|
||
}
|
||
|
||
for handle in handles {
|
||
handle.await??;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_metadata_retrieval() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let jti = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::SuspiciousActivity,
|
||
"admin",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
let jti = Arc::new(jti);
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<Option<RevocationMetadata>>>> = vec![];
|
||
|
||
for _ in 0..20 {
|
||
let service_clone = Arc::clone(&service);
|
||
let jti_clone = Arc::clone(&jti);
|
||
|
||
let handle =
|
||
tokio::spawn(async move { service_clone.get_revocation_metadata(&jti_clone).await });
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
let metadata = handle.await??;
|
||
assert!(metadata.is_some());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_high_concurrency_stress() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<bool>>> = vec![];
|
||
|
||
for i in 0..100 {
|
||
let service_clone = Arc::clone(&service);
|
||
|
||
let handle = tokio::spawn(async move {
|
||
let jti = Jti::new();
|
||
let user_id = format!("stress_user_{}", i % 10);
|
||
|
||
service_clone
|
||
.revoke_token(
|
||
&jti,
|
||
&user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
service_clone.is_revoked(&jti).await
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
let is_revoked = handle.await??;
|
||
assert!(is_revoked);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_mixed_operations_concurrency() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = vec![];
|
||
|
||
for i in 0..30 {
|
||
let service_clone = Arc::clone(&service);
|
||
|
||
let handle = tokio::spawn(async move {
|
||
match i % 3 {
|
||
0 => {
|
||
// Revoke token
|
||
let jti = Jti::new();
|
||
service_clone
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
Ok(())
|
||
},
|
||
1 => {
|
||
// Check revocation
|
||
let jti = Jti::new();
|
||
service_clone.is_revoked(&jti).await?;
|
||
Ok(())
|
||
},
|
||
_ => {
|
||
// Get statistics
|
||
service_clone.get_statistics().await?;
|
||
Ok(())
|
||
},
|
||
}
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
handle.await??;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_sequential_consistency() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
// Should not be revoked initially
|
||
assert!(!service.is_revoked(&jti).await?);
|
||
|
||
// Revoke
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Should be revoked immediately
|
||
assert!(service.is_revoked(&jti).await?);
|
||
|
||
// Metadata should exist
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_atomicity_single_operation() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Both checks should succeed atomically
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
|
||
assert!(is_revoked);
|
||
assert!(metadata.is_some());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_different_users() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<bool>>> = vec![];
|
||
|
||
for i in 0..10 {
|
||
let service_clone = Arc::clone(&service);
|
||
|
||
let handle = tokio::spawn(async move {
|
||
let user_id = format!("user_{}", i);
|
||
let jti = Jti::new();
|
||
|
||
service_clone
|
||
.revoke_token(
|
||
&jti,
|
||
&user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
service_clone.is_revoked(&jti).await
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
let is_revoked = handle.await??;
|
||
assert!(is_revoked);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_no_race_on_user_session_tracking() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
let user_id = "session_race_user";
|
||
|
||
let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = vec![];
|
||
|
||
for i in 0..15 {
|
||
let service_clone = Arc::clone(&service);
|
||
let uid = user_id.to_string();
|
||
|
||
let handle = tokio::spawn(async move {
|
||
let jti = Jti::new();
|
||
service_clone
|
||
.revoke_token(&jti, &uid, 3600, RevocationReason::UserLogout, &uid, None)
|
||
.await
|
||
});
|
||
|
||
handles.push(handle);
|
||
}
|
||
|
||
for handle in handles {
|
||
handle.await??;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_concurrent_bulk_operations() -> Result<()> {
|
||
let service = Arc::new(create_test_revocation_service().await?);
|
||
|
||
// Create tokens for multiple users
|
||
for user_idx in 0..3 {
|
||
for token_idx in 0..5 {
|
||
let jti = Jti::new();
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
&format!("bulk_user_{}", user_idx),
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&format!("bulk_user_{}", user_idx),
|
||
None,
|
||
)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
// Change handle type for usize return
|
||
let mut usize_handles: Vec<tokio::task::JoinHandle<Result<usize>>> = vec![];
|
||
|
||
// Concurrently revoke all tokens for each user
|
||
for user_idx in 0..3 {
|
||
let service_clone = Arc::clone(&service);
|
||
|
||
let handle = tokio::spawn(async move {
|
||
service_clone
|
||
.revoke_all_user_tokens(
|
||
&format!("bulk_user_{}", user_idx),
|
||
RevocationReason::PasswordChange,
|
||
"admin",
|
||
)
|
||
.await
|
||
});
|
||
|
||
usize_handles.push(handle);
|
||
}
|
||
|
||
for handle in usize_handles {
|
||
let _count = handle.await??;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_eventual_consistency_check() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Check multiple times to ensure consistency
|
||
for _ in 0..5 {
|
||
assert!(service.is_revoked(&jti).await?);
|
||
sleep(Duration::from_millis(10)).await;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// MODULE 3: MFA/TOTP - GENERATION AND VERIFICATION (25 tests)
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_totp_generate_secret() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = generator.generate_secret().unwrap();
|
||
|
||
assert!(!secret.expose_secret().is_empty());
|
||
assert!(base32::decode(
|
||
base32::Alphabet::Rfc4648 { padding: false },
|
||
secret.expose_secret()
|
||
)
|
||
.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_secret_uniqueness() {
|
||
let generator = TotpGenerator::new();
|
||
let secret1 = generator.generate_secret().unwrap();
|
||
let secret2 = generator.generate_secret().unwrap();
|
||
|
||
assert_ne!(secret1.expose_secret(), secret2.expose_secret());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_generate_qr_uri() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = SecretString::new("JBSWY3DPEHPK3PXP".to_string());
|
||
let uri = generator
|
||
.generate_qr_uri(&secret, "FoxhuntHFT", "trader@example.com")
|
||
.unwrap();
|
||
|
||
assert!(uri.starts_with("otpauth://totp/"));
|
||
assert!(uri.contains("secret=JBSWY3DPEHPK3PXP"));
|
||
assert!(uri.contains("issuer=FoxhuntHFT"));
|
||
assert!(uri.contains("trader@example.com"));
|
||
assert!(uri.contains("digits=6"));
|
||
assert!(uri.contains("period=30"));
|
||
assert!(uri.contains("algorithm=SHA1"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_qr_uri_url_encoding() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = SecretString::new("TESTSECRET".to_string());
|
||
let uri = generator
|
||
.generate_qr_uri(&secret, "Foxhunt HFT", "user+test@example.com")
|
||
.unwrap();
|
||
|
||
// Should URL-encode spaces and special characters
|
||
assert!(uri.contains("Foxhunt%20HFT") || uri.contains("Foxhunt+HFT"));
|
||
assert!(uri.contains("user%2Btest@example.com") || uri.contains("user+test@example.com"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_generate_code_format() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let code = generator.generate_code(secret).unwrap();
|
||
|
||
assert_eq!(code.len(), 6);
|
||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_verify_valid_code() {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
|
||
let time = 1234567890u64;
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
assert!(verifier.verify_at_time(secret, &code, time, 1).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_verify_invalid_code() {
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
|
||
assert!(!verifier.verify_at_time(secret, "000000", time, 1).unwrap());
|
||
assert!(!verifier.verify_at_time(secret, "999999", time, 1).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_drift_tolerance_forward() {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
let period = 30u64;
|
||
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
// Should verify in next period with drift_tolerance=1
|
||
assert!(verifier
|
||
.verify_at_time(secret, &code, time + period, 1)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_drift_tolerance_backward() {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
let period = 30u64;
|
||
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
// Should verify in previous period with drift_tolerance=1
|
||
assert!(verifier
|
||
.verify_at_time(secret, &code, time - period, 1)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_drift_tolerance_exceeded() {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
let period = 30u64;
|
||
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
// Should NOT verify 2 periods away with drift_tolerance=1
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &code, time + period * 2, 1)
|
||
.unwrap());
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &code, time - period * 2, 1)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_verify_wrong_length() {
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
|
||
assert!(!verifier.verify_at_time(secret, "12345", time, 1).unwrap());
|
||
assert!(!verifier.verify_at_time(secret, "1234567", time, 1).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_verify_non_numeric() {
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
|
||
assert!(!verifier.verify_at_time(secret, "12345a", time, 1).unwrap());
|
||
assert!(!verifier.verify_at_time(secret, "abcdef", time, 1).unwrap());
|
||
assert!(!verifier.verify_at_time(secret, "12 456", time, 1).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_current_counter() {
|
||
let verifier = TotpVerifier::new();
|
||
let counter = verifier.current_counter();
|
||
|
||
// Should be a reasonable Unix timestamp / 30
|
||
assert!(counter > 0);
|
||
assert!(counter < u64::MAX / 30);
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_time_remaining() {
|
||
let verifier = TotpVerifier::new();
|
||
let remaining = verifier.time_remaining();
|
||
|
||
// Should be between 0 and 30 seconds
|
||
assert!(remaining > 0 && remaining <= 30);
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_algorithm_display() {
|
||
assert_eq!(format!("{}", TotpAlgorithm::SHA1), "SHA1");
|
||
assert_eq!(format!("{}", TotpAlgorithm::SHA256), "SHA256");
|
||
assert_eq!(format!("{}", TotpAlgorithm::SHA512), "SHA512");
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_config_default() {
|
||
let config = TotpConfig::default();
|
||
|
||
assert_eq!(config.digits, 6);
|
||
assert_eq!(config.period, 30);
|
||
assert_eq!(config.algorithm, TotpAlgorithm::SHA1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_multiple_codes_different_times() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
|
||
let time1 = 1000000000u64;
|
||
let time2 = 1000000030u64; // 30 seconds later
|
||
|
||
let code1 = generator.generate_code_at_time(secret, time1).unwrap();
|
||
let code2 = generator.generate_code_at_time(secret, time2).unwrap();
|
||
|
||
// Different time periods should generate different codes
|
||
assert_ne!(code1, code2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_same_time_same_code() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
|
||
let code1 = generator.generate_code_at_time(secret, time).unwrap();
|
||
let code2 = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
assert_eq!(code1, code2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_verify_zero_drift_tolerance() {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
let period = 30u64;
|
||
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
// With drift_tolerance=0, should only verify exact time
|
||
assert!(verifier.verify_at_time(secret, &code, time, 0).unwrap());
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &code, time + period, 0)
|
||
.unwrap());
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &code, time - period, 0)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_verify_max_drift_tolerance() {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
let period = 30u64;
|
||
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
// With drift_tolerance=2, should verify ±2 periods
|
||
assert!(verifier
|
||
.verify_at_time(secret, &code, time + period * 2, 2)
|
||
.unwrap());
|
||
assert!(verifier
|
||
.verify_at_time(secret, &code, time - period * 2, 2)
|
||
.unwrap());
|
||
|
||
// But not ±3 periods
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &code, time + period * 3, 2)
|
||
.unwrap());
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &code, time - period * 3, 2)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_code_leading_zeros() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
|
||
// Find a time that generates a code with leading zeros
|
||
for time in 1000000000u64..1000001000u64 {
|
||
let code = generator.generate_code_at_time(secret, time).unwrap();
|
||
if code.starts_with('0') {
|
||
assert_eq!(code.len(), 6);
|
||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_constant_time_compare() {
|
||
// This tests the internal constant_time_compare function indirectly
|
||
let verifier = TotpVerifier::new();
|
||
let secret = "JBSWY3DPEHPK3PXP";
|
||
let time = 1234567890u64;
|
||
|
||
// Generate valid code
|
||
let generator = TotpGenerator::new();
|
||
let valid_code = generator.generate_code_at_time(secret, time).unwrap();
|
||
|
||
// Verify uses constant-time comparison
|
||
assert!(verifier
|
||
.verify_at_time(secret, &valid_code, time, 0)
|
||
.unwrap());
|
||
|
||
// Similar but wrong code (differs by 1 digit)
|
||
let wrong_code = valid_code.clone();
|
||
let mut chars: Vec<char> = wrong_code.chars().collect();
|
||
chars[0] = if chars[0] == '0' { '1' } else { '0' };
|
||
let wrong_code: String = chars.into_iter().collect();
|
||
|
||
assert!(!verifier
|
||
.verify_at_time(secret, &wrong_code, time, 0)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_base32_encoding_validation() {
|
||
let generator = TotpGenerator::new();
|
||
let secret = generator.generate_secret().unwrap();
|
||
|
||
// Should be valid Base32 (RFC 4648, no padding)
|
||
let decoded = base32::decode(
|
||
base32::Alphabet::Rfc4648 { padding: false },
|
||
secret.expose_secret(),
|
||
);
|
||
assert!(decoded.is_some());
|
||
|
||
// Should be 20 bytes (160 bits for SHA1)
|
||
assert_eq!(decoded.unwrap().len(), 20);
|
||
}
|
||
|
||
#[test]
|
||
fn test_totp_different_secrets_different_codes() {
|
||
let generator = TotpGenerator::new();
|
||
let time = 1234567890u64;
|
||
|
||
let secret1 = "JBSWY3DPEHPK3PXP";
|
||
let secret2 = "MNOPQRSTUVWXYZ23";
|
||
|
||
let code1 = generator.generate_code_at_time(secret1, time).unwrap();
|
||
let code2 = generator.generate_code_at_time(secret2, time).unwrap();
|
||
|
||
assert_ne!(code1, code2);
|
||
}
|
||
|
||
// ============================================================================
|
||
// MODULE 4: JWT REVOCATION - ERROR HANDLING (20 tests)
|
||
// ============================================================================
|
||
// Note: Redis failure tests require mocking or manual Redis shutdown
|
||
// These tests document expected behavior when Redis is unavailable
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_invalid_redis_url() {
|
||
let config = RevocationConfig::default();
|
||
let result = JwtRevocationService::new("invalid://url", config).await;
|
||
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_empty_user_id() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
// Should succeed (edge case, but valid)
|
||
service
|
||
.revoke_token(&jti, "", 3600, RevocationReason::UserLogout, "admin", None)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_very_long_user_id() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
let long_user_id = "a".repeat(10000);
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
&long_user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
&long_user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_max_ttl() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
// Very long TTL (1 year)
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
31536000,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
assert!(service.is_revoked(&jti).await?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_unicode_user_id() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
let unicode_user = "用户_🚀_тест";
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
unicode_user,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
unicode_user,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
assert_eq!(metadata.unwrap().user_id(), unicode_user);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_special_characters_in_reason() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
let reason = RevocationReason::Other("Test with <special> & \"characters\"".to_string());
|
||
|
||
service
|
||
.revoke_token(&jti, "user", 3600, reason, "admin", None)
|
||
.await?;
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_very_long_client_ip() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
let long_ip = "192.168.1.".to_string() + &"100".repeat(100);
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
Some(long_ip.clone()),
|
||
)
|
||
.await?;
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_malformed_jti() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
|
||
// Malformed JTI (empty string)
|
||
let jti = Jti::from_string(String::new());
|
||
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(!is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_special_characters_jti() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
|
||
let jti = Jti::from_string("jti-with-special:chars!@#$".to_string());
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
assert!(service.is_revoked(&jti).await?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_duplicate_revocation() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
// Revoke twice
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::AdminRevocation,
|
||
"admin",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Should still be revoked
|
||
assert!(service.is_revoked(&jti).await?);
|
||
|
||
// Metadata should reflect last revocation
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
assert_eq!(metadata.unwrap().reason(), "admin_revocation");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_null_byte_in_user_id() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
// User ID with null byte (edge case)
|
||
let user_id = "user\0id";
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_max_tokens_per_user_tracking() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let user_id = "max_tokens_user";
|
||
|
||
// Create more than max_tokens_per_user (100)
|
||
for i in 0..105 {
|
||
let jti = Jti::new();
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
// Should complete without error (warning logged internally)
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_empty_jti_string() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::from_string(String::new());
|
||
|
||
let is_revoked = service.is_revoked(&jti).await?;
|
||
assert!(!is_revoked);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_statistics_with_no_tokens() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
|
||
let stats = service.get_statistics().await?;
|
||
|
||
// May have tokens from other tests, but should not error
|
||
// Stats are u64, so just verify we got valid data (no panic means success)
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_metadata_serialization() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let jti = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::SuspiciousActivity,
|
||
"admin",
|
||
Some("192.168.1.1".to_string()),
|
||
)
|
||
.await?;
|
||
|
||
let metadata = service.get_revocation_metadata(&jti).await?;
|
||
assert!(metadata.is_some());
|
||
|
||
let meta = metadata.unwrap();
|
||
|
||
// Verify all fields are accessible
|
||
assert!(!meta.user_id().is_empty());
|
||
assert!(!meta.reason().is_empty());
|
||
assert!(!meta.revoked_by().is_empty());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_enhanced_jwt_claims_system_time_failure() {
|
||
// This test documents expected behavior if system time fails
|
||
// In practice, SystemTime::now() should never fail on modern systems
|
||
|
||
let result = EnhancedJwtClaims::new_access_token(
|
||
"user".to_string(),
|
||
vec!["trader".to_string()],
|
||
vec![],
|
||
"foxhunt".to_string(),
|
||
"trading-api".to_string(),
|
||
3600,
|
||
);
|
||
|
||
// Should succeed in normal conditions
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_bulk_revocation_empty_list() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let user_id = "empty_list_user";
|
||
|
||
// Revoke all when user has no tokens
|
||
let count = service
|
||
.revoke_all_user_tokens(user_id, RevocationReason::PasswordChange, "admin")
|
||
.await?;
|
||
|
||
assert_eq!(count, 0);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_bulk_revocation_partially_revoked() -> Result<()> {
|
||
let service = create_test_revocation_service().await?;
|
||
let user_id = "partial_revoke_user";
|
||
|
||
// Create 5 tokens
|
||
let jtis: Vec<Jti> = (0..5).map(|_| Jti::new()).collect();
|
||
|
||
for jti in &jtis {
|
||
service
|
||
.revoke_token(
|
||
jti,
|
||
user_id,
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
user_id,
|
||
None,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
// Manually revoke some tokens directly
|
||
service
|
||
.revoke_token(
|
||
&jtis[0],
|
||
user_id,
|
||
3600,
|
||
RevocationReason::AdminRevocation,
|
||
"admin",
|
||
None,
|
||
)
|
||
.await?;
|
||
service
|
||
.revoke_token(
|
||
&jtis[1],
|
||
user_id,
|
||
3600,
|
||
RevocationReason::AdminRevocation,
|
||
"admin",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
// Bulk revoke all
|
||
let count = service
|
||
.revoke_all_user_tokens(user_id, RevocationReason::PasswordChange, "admin")
|
||
.await?;
|
||
|
||
// Should revoke remaining 3 tokens
|
||
assert!(count > 0);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_revocation_config_custom_prefixes() -> Result<()> {
|
||
let redis_url =
|
||
std::env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://localhost:6380".to_string());
|
||
|
||
let config = RevocationConfig {
|
||
redis_prefix: "custom:blacklist:".to_string(),
|
||
session_prefix: "custom:sessions:".to_string(),
|
||
enable_audit_logging: false,
|
||
max_tokens_per_user: 50,
|
||
};
|
||
|
||
let service = JwtRevocationService::new(&redis_url, config).await?;
|
||
let jti = Jti::new();
|
||
|
||
service
|
||
.revoke_token(
|
||
&jti,
|
||
"user",
|
||
3600,
|
||
RevocationReason::UserLogout,
|
||
"user",
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
assert!(service.is_revoked(&jti).await?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// MODULE 5: MFA ENROLLMENT FLOW (20 tests)
|
||
// ============================================================================
|
||
// Note: These tests require BackupCodeValidator and MfaEnrollment implementations
|
||
// Tests document expected behavior based on API Gateway architecture
|
||
|
||
#[tokio::test]
|
||
async fn test_mfa_enrollment_initiate() -> Result<()> {
|
||
let generator = TotpGenerator::new();
|
||
let secret = generator.generate_secret()?;
|
||
let qr_uri = generator.generate_qr_uri(&secret, "FoxhuntHFT", "user@example.com")?;
|
||
|
||
assert!(qr_uri.starts_with("otpauth://totp/"));
|
||
assert!(!qr_uri.is_empty());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_mfa_enrollment_verify_setup() -> Result<()> {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = generator.generate_secret()?;
|
||
|
||
// Simulate user scanning QR and entering code
|
||
let code = generator.generate_code(secret.expose_secret())?;
|
||
|
||
// Verify code (would complete enrollment)
|
||
assert!(verifier.verify(secret.expose_secret(), &code, 1)?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_mfa_enrollment_invalid_code_rejection() -> Result<()> {
|
||
let generator = TotpGenerator::new();
|
||
let verifier = TotpVerifier::new();
|
||
let secret = generator.generate_secret()?;
|
||
|
||
// User enters wrong code
|
||
assert!(!verifier.verify(secret.expose_secret(), "000000", 1)?);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_mfa_enrollment_multiple_users() -> Result<()> {
|
||
let generator = TotpGenerator::new();
|
||
|
||
let secret1 = generator.generate_secret()?;
|
||
let secret2 = generator.generate_secret()?;
|
||
let secret3 = generator.generate_secret()?;
|
||
|
||
// Each user gets unique secret
|
||
assert_ne!(secret1.expose_secret(), secret2.expose_secret());
|
||
assert_ne!(secret2.expose_secret(), secret3.expose_secret());
|
||
assert_ne!(secret1.expose_secret(), secret3.expose_secret());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// Legacy BackupCodeValidator-based tests were removed when the validator
|
||
// moved to a database-backed API (`validate`, `get_remaining_count`,
|
||
// `needs_regeneration`, `get_usage_history`). The old in-memory
|
||
// `generate_backup_codes` / `store_backup_code` / `verify_backup_code`
|
||
// surface no longer exists; equivalent coverage now lives in the MFA
|
||
// integration tests that target the new API directly.
|
||
|
||
// ============================================================================
|
||
// END OF COMPREHENSIVE AUTHENTICATION TESTS
|
||
// ============================================================================
|