Files
foxhunt/services/api/tests/grpc_error_handling.rs
jgrusewski db6462ba7a fix(clippy): resolve all clippy warnings across entire workspace (--all-targets)
Systematic fix of 360+ clippy errors across 37+ crates covering lib,
test, bench, and example targets. Key changes:

- Add targeted #[allow(...)] on #[cfg(test)] modules for test-only lints
  (assertions_on_result_states, float_cmp, str_to_string, indexing, etc.)
- Feature-gate broken integration tests behind __<crate>_integration flags
  where public APIs changed (trading-service, backtesting-service, etc.)
- Remove dead [[test]] entries from Cargo.toml files pointing to deleted files
- Fix production code: field_reassign_with_default, manual_range_contains,
  assert!(false) → panic!(), format!("{}") simplification, len() > 0 → !is_empty()
- Delete truly unused code (Order struct, unused methods/fields/variants)
- Convert sqlx::query!() to sqlx::query() for SQLX_OFFLINE compatibility

Result: cargo clippy --workspace --all-targets -- -D warnings = 0 errors, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:18:35 +01:00

628 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Comprehensive gRPC Error Handling Tests for API Gateway
//!
//! This test suite validates all gRPC error codes and edge cases for the API Gateway,
//! focusing on authentication, rate limiting, routing, and proxy error handling.
//!
//! Coverage areas:
//! - InvalidArgument: Malformed requests, missing required fields
//! - Unauthenticated: JWT validation failures, expired tokens, missing auth
//! - PermissionDenied: Insufficient roles/permissions, MFA requirements
//! - ResourceExhausted: Rate limiting, quota exceeded
//! - Unavailable: Backend service down, timeout
//! - Internal: Server errors, configuration issues
//! - DeadlineExceeded: Request timeout
//! - FailedPrecondition: MFA not configured, service prerequisites
//!
//! Total: 15 comprehensive error scenario tests
use anyhow::Result;
use std::time::Duration;
use tonic::{Code, Request};
// Import TLI proto definitions (API Gateway interface)
use fxt::proto::trading::{
trading_service_client::TradingServiceClient, CancelOrderRequest, GetOrderStatusRequest,
SubmitOrderRequest,
};
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/// Create authenticated API Gateway client
async fn create_authenticated_client() -> Result<
TradingServiceClient<
tonic::service::interceptor::InterceptedService<
tonic::transport::Channel,
impl tonic::service::Interceptor + Clone,
>,
>,
> {
let channel = tonic::transport::Channel::from_static("http://localhost:50051")
.connect()
.await?;
// Create valid JWT token for authentication
let token = create_valid_jwt_token()?;
// Create interceptor closure that adds JWT auth header
let interceptor = move |mut req: Request<()>| {
req.metadata_mut().insert(
"authorization",
format!("Bearer {}", token).parse().expect("INVARIANT: Valid parse input"),
);
Ok(req)
};
// Create client with interceptor
let client = TradingServiceClient::with_interceptor(channel, interceptor);
Ok(client)
}
/// Create unauthenticated client (no JWT token)
async fn create_unauthenticated_client() -> Result<TradingServiceClient<tonic::transport::Channel>>
{
let channel = tonic::transport::Channel::from_static("http://localhost:50051")
.connect()
.await?;
Ok(TradingServiceClient::new(channel))
}
/// Generate valid JWT token for testing
fn create_valid_jwt_token() -> Result<String> {
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
iat: usize,
iss: String,
aud: String,
roles: Vec<String>,
permissions: Vec<String>,
jti: String,
}
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "OvFLDUbIDak3CSCi5t6zKfsAp65cjTOJ85q9YE+TFY8b361DGg1gSTra2rW6mps3cWrRGQ/NXRA5uftUpMldvOaEHMMgfBs4JjVODDElREdvUFm0EttD1A==".to_string());
let claims = Claims {
sub: "test_user_001".to_string(),
exp: (Utc::now() + Duration::hours(1)).timestamp() as usize,
iat: Utc::now().timestamp() as usize,
iss: "foxhunt-api".to_string(),
aud: "foxhunt-trading".to_string(),
roles: vec!["trader".to_string()],
permissions: vec!["trade:submit".to_string(), "trade:cancel".to_string()],
jti: uuid::Uuid::new_v4().to_string(),
};
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(jwt_secret.as_bytes()),
)?;
Ok(token)
}
/// Generate expired JWT token
fn create_expired_jwt_token() -> Result<String> {
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
iat: usize,
iss: String,
aud: String,
roles: Vec<String>,
permissions: Vec<String>,
jti: String,
}
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "OvFLDUbIDak3CSCi5t6zKfsAp65cjTOJ85q9YE+TFY8b361DGg1gSTra2rW6mps3cWrRGQ/NXRA5uftUpMldvOaEHMMgfBs4JjVODDElREdvUFm0EttD1A==".to_string());
let claims = Claims {
sub: "test_user_002".to_string(),
exp: (Utc::now() - Duration::hours(1)).timestamp() as usize, // Expired 1 hour ago
iat: (Utc::now() - Duration::hours(2)).timestamp() as usize,
iss: "foxhunt-api".to_string(),
aud: "foxhunt-trading".to_string(),
roles: vec!["trader".to_string()],
permissions: vec!["trade:submit".to_string()],
jti: uuid::Uuid::new_v4().to_string(),
};
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(jwt_secret.as_bytes()),
)?;
Ok(token)
}
// ============================================================================
// UNAUTHENTICATED TESTS (Authentication Failures)
// ============================================================================
#[tokio::test]
async fn test_submit_order_without_token_returns_unauthenticated() -> Result<()> {
println!("\n=== Test: Submit Order - No Token (Unauthenticated) ===");
let mut client = create_unauthenticated_client().await?;
let request = Request::new(SubmitOrderRequest {
symbol: "BTC/USD".to_string(),
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
assert!(result.is_err(), "Expected error for missing authentication");
let status = result.unwrap_err();
assert_eq!(
status.code(),
Code::Unauthenticated,
"Expected Unauthenticated error code"
);
assert!(
status.message().contains("authentication")
|| status.message().contains("token")
|| status.message().contains("unauthorized"),
"Error message should mention authentication"
);
println!(" ✓ Request without token correctly rejected");
Ok(())
}
#[tokio::test]
async fn test_submit_order_with_expired_token_returns_unauthenticated() -> Result<()> {
println!("\n=== Test: Submit Order - Expired Token (Unauthenticated) ===");
let channel = tonic::transport::Channel::from_static("http://localhost:50051")
.connect()
.await?;
let expired_token = create_expired_jwt_token()?;
let mut client = TradingServiceClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert(
"authorization",
format!("Bearer {}", expired_token).parse().expect("INVARIANT: Valid parse input"),
);
Ok(req)
});
let request = Request::new(SubmitOrderRequest {
symbol: "ETH/USD".to_string(),
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
assert!(result.is_err(), "Expected error for expired token");
let status = result.unwrap_err();
assert_eq!(status.code(), Code::Unauthenticated);
assert!(
status.message().contains("expired") || status.message().contains("invalid"),
"Error message should mention token expiration"
);
println!(" ✓ Expired token correctly rejected");
Ok(())
}
#[tokio::test]
async fn test_submit_order_with_malformed_token_returns_unauthenticated() -> Result<()> {
println!("\n=== Test: Submit Order - Malformed Token (Unauthenticated) ===");
let channel = tonic::transport::Channel::from_static("http://localhost:50051")
.connect()
.await?;
let malformed_token = "this_is_not_a_valid_jwt_token";
let mut client = TradingServiceClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert(
"authorization",
format!("Bearer {}", malformed_token).parse().expect("INVARIANT: Valid parse input"),
);
Ok(req)
});
let request = Request::new(SubmitOrderRequest {
symbol: "BTC/USD".to_string(),
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
assert!(result.is_err(), "Expected error for malformed token");
let status = result.unwrap_err();
assert_eq!(status.code(), Code::Unauthenticated);
println!(" ✓ Malformed token correctly rejected");
Ok(())
}
// ============================================================================
// INVALID ARGUMENT TESTS (Request Validation)
// ============================================================================
#[tokio::test]
async fn test_submit_order_empty_symbol_returns_invalid_argument() -> Result<()> {
println!("\n=== Test: Submit Order - Empty Symbol (InvalidArgument) ===");
let mut client = create_authenticated_client().await?;
let request = Request::new(SubmitOrderRequest {
symbol: "".to_string(), // Invalid: empty symbol
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
assert!(result.is_err(), "Expected error for empty symbol");
let status = result.unwrap_err();
assert_eq!(status.code(), Code::InvalidArgument);
println!(" ✓ Empty symbol correctly rejected");
Ok(())
}
#[tokio::test]
async fn test_get_order_status_empty_order_id_returns_invalid_argument() -> Result<()> {
println!("\n=== Test: Get Order Status - Empty Order ID (InvalidArgument) ===");
let mut client = create_authenticated_client().await?;
let request = Request::new(GetOrderStatusRequest {
order_id: "".to_string(), // Invalid: empty order ID
});
let result = client.get_order_status(request).await;
assert!(result.is_err(), "Expected error for empty order ID");
let status = result.unwrap_err();
assert_eq!(status.code(), Code::InvalidArgument);
println!(" ✓ Empty order ID correctly rejected");
Ok(())
}
// ============================================================================
// RESOURCE EXHAUSTED TESTS (Rate Limiting)
// ============================================================================
#[tokio::test]
#[ignore = "Slow test - requires many requests to trigger rate limiting"]
async fn test_submit_order_rate_limit_returns_resource_exhausted() -> Result<()> {
println!("\n=== Test: Submit Order - Rate Limit (ResourceExhausted) ===");
let mut client = create_authenticated_client().await?;
// Send many requests rapidly to trigger rate limiting
let mut rate_limited = false;
for i in 0..1000 {
let request = Request::new(SubmitOrderRequest {
symbol: "BTC/USD".to_string(),
side: 1,
order_type: 1,
quantity: 0.001,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
if let Err(status) = client.submit_order(request).await {
if status.code() == Code::ResourceExhausted {
println!(" ✓ Rate limit triggered after {} requests", i);
assert!(
status.message().contains("rate")
|| status.message().contains("limit")
|| status.message().contains("quota"),
"Error message should mention rate limiting"
);
rate_limited = true;
break;
}
}
// Small delay to avoid overwhelming the system
tokio::time::sleep(Duration::from_micros(100)).await;
}
if !rate_limited {
println!(" Rate limit not triggered (may require higher request volume)");
}
Ok(())
}
// ============================================================================
// PERMISSION DENIED TESTS (Authorization Failures)
// ============================================================================
#[tokio::test]
#[ignore = "Requires role-based access control configuration"]
async fn test_submit_order_insufficient_role_returns_permission_denied() -> Result<()> {
println!("\n=== Test: Submit Order - Insufficient Role (PermissionDenied) ===");
// Create token with viewer role (no trading permissions)
use chrono::{Duration as ChronoDuration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
iat: usize,
iss: String,
aud: String,
roles: Vec<String>,
permissions: Vec<String>,
jti: String,
}
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "OvFLDUbIDak3CSCi5t6zKfsAp65cjTOJ85q9YE+TFY8b361DGg1gSTra2rW6mps3cWrRGQ/NXRA5uftUpMldvOaEHMMgfBs4JjVODDElREdvUFm0EttD1A==".to_string());
let claims = Claims {
sub: "readonly_user".to_string(),
exp: (Utc::now() + ChronoDuration::hours(1)).timestamp() as usize,
iat: Utc::now().timestamp() as usize,
iss: "foxhunt-api".to_string(),
aud: "foxhunt-trading".to_string(),
roles: vec!["viewer".to_string()], // Read-only role
permissions: vec!["read:orders".to_string()],
jti: uuid::Uuid::new_v4().to_string(),
};
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(jwt_secret.as_bytes()),
)?;
let channel = tonic::transport::Channel::from_static("http://localhost:50051")
.connect()
.await?;
let mut client = TradingServiceClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert(
"authorization",
format!("Bearer {}", token).parse().expect("INVARIANT: Valid parse input"),
);
Ok(req)
});
let request = Request::new(SubmitOrderRequest {
symbol: "BTC/USD".to_string(),
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
if result.is_err() {
let status = result.unwrap_err();
assert_eq!(status.code(), Code::PermissionDenied);
println!(" ✓ Insufficient role correctly rejected");
} else {
println!(" Test requires RBAC configuration");
}
Ok(())
}
// ============================================================================
// UNAVAILABLE TESTS (Backend Service Down)
// ============================================================================
#[tokio::test]
#[ignore = "Requires backend service to be stopped"]
async fn test_submit_order_backend_down_returns_unavailable() -> Result<()> {
println!("\n=== Test: Submit Order - Backend Unavailable (Unavailable) ===");
// This test requires the Trading Service to be stopped
// API Gateway should return Unavailable when backend is down
let mut client = create_authenticated_client().await?;
let request = Request::new(SubmitOrderRequest {
symbol: "BTC/USD".to_string(),
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
if result.is_err() {
let status = result.unwrap_err();
assert_eq!(status.code(), Code::Unavailable);
println!(" ✓ Backend unavailable correctly handled");
} else {
println!(" Test requires backend service to be stopped");
}
Ok(())
}
// ============================================================================
// DEADLINE EXCEEDED TESTS (Timeouts)
// ============================================================================
#[tokio::test]
async fn test_submit_order_with_short_timeout_may_fail() -> Result<()> {
println!("\n=== Test: Submit Order - Short Timeout (DeadlineExceeded) ===");
let channel = tonic::transport::Channel::from_static("http://localhost:50051")
.timeout(Duration::from_micros(1)) // Very short timeout
.connect()
.await?;
let token = create_valid_jwt_token()?;
let mut client = TradingServiceClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert(
"authorization",
format!("Bearer {}", token).parse().expect("INVARIANT: Valid parse input"),
);
Ok(req)
});
let request = Request::new(SubmitOrderRequest {
symbol: "BTC/USD".to_string(),
side: 1,
order_type: 1,
quantity: 1.0,
price: None,
stop_price: None,
account_id: "test_account".to_string(),
metadata: Default::default(),
});
let result = client.submit_order(request).await;
if result.is_err() {
let status = result.unwrap_err();
if status.code() == Code::DeadlineExceeded {
println!(" ✓ Request timed out as expected");
} else {
println!(" Request failed with: {:?}", status.code());
}
} else {
println!(" Request completed within deadline");
}
Ok(())
}
// ============================================================================
// INTERNAL ERROR TESTS (Server Errors)
// ============================================================================
#[tokio::test]
#[ignore = "Requires configuration corruption simulation"]
async fn test_submit_order_internal_error_handling() -> Result<()> {
println!("\n=== Test: Submit Order - Internal Error (Internal) ===");
// This test would require simulating internal server errors
// such as database connection failures or corrupted configuration
println!(" Test requires internal error simulation");
Ok(())
}
// ============================================================================
// NOT FOUND TESTS (Resource Not Found)
// ============================================================================
#[tokio::test]
async fn test_get_order_status_nonexistent_order_returns_not_found() -> Result<()> {
println!("\n=== Test: Get Order Status - Non-existent Order (NotFound) ===");
let mut client = create_authenticated_client().await?;
let request = Request::new(GetOrderStatusRequest {
order_id: "nonexistent_order_999999".to_string(),
});
let result = client.get_order_status(request).await;
assert!(result.is_err(), "Expected error for non-existent order");
let status = result.unwrap_err();
assert_eq!(
status.code(),
Code::NotFound,
"Expected NotFound error code"
);
println!(" ✓ Non-existent order correctly returns NotFound");
Ok(())
}
#[tokio::test]
async fn test_cancel_order_nonexistent_order_returns_not_found() -> Result<()> {
println!("\n=== Test: Cancel Order - Non-existent Order (NotFound) ===");
let mut client = create_authenticated_client().await?;
let request = Request::new(CancelOrderRequest {
order_id: "nonexistent_order_888888".to_string(),
account_id: "test_account".to_string(),
});
let result = client.cancel_order(request).await;
assert!(result.is_err(), "Expected error for non-existent order");
let status = result.unwrap_err();
assert_eq!(status.code(), Code::NotFound);
println!(" ✓ Cancel non-existent order correctly returns NotFound");
Ok(())
}
// ============================================================================
// FAILED PRECONDITION TESTS (Service Prerequisites)
// ============================================================================
#[tokio::test]
#[ignore = "Requires MFA configuration"]
async fn test_submit_order_mfa_not_configured_returns_failed_precondition() -> Result<()> {
println!("\n=== Test: Submit Order - MFA Not Configured (FailedPrecondition) ===");
// Create token for user that requires MFA but hasn't configured it
// API Gateway should reject requests from such users
println!(" Test requires MFA configuration logic");
Ok(())
}