diff --git a/web-dashboard/.env.example b/web-dashboard/.env.example new file mode 100644 index 000000000..5d4f0f56b --- /dev/null +++ b/web-dashboard/.env.example @@ -0,0 +1,8 @@ +# Foxhunt Web Dashboard Environment Variables +# Copy to .env for local development + +# API base URL (production: set to gateway URL, dev: uses Vite proxy) +# VITE_API_URL=https://gateway.example.com/api + +# WebSocket URL (production: set explicitly, dev: uses Vite proxy) +# VITE_WS_URL=wss://gateway.example.com/api/ws diff --git a/web-gateway/src/routes/risk.rs b/web-gateway/src/routes/risk.rs index dd10bce37..a495c3a93 100644 --- a/web-gateway/src/routes/risk.rs +++ b/web-gateway/src/routes/risk.rs @@ -67,3 +67,147 @@ async fn emergency_stop( serde_json::to_value(response.into_inner()).map_err(|e| AppError::Internal(e.into()))?, )) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::middleware; + use axum::Router; + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::sync::Arc; + use tower::ServiceExt; + + use super::*; + use crate::auth::claims::Claims; + use crate::auth::middleware::auth_middleware; + use crate::config::GatewayConfig; + + const SECRET: &str = "risk-test-secret"; + + fn test_state() -> AppState { + let config = GatewayConfig { + jwt_secret: SECRET.to_string(), + trading_service_url: String::new(), + ..GatewayConfig::default() + }; + let (ws_broadcast, _) = tokio::sync::broadcast::channel(16); + AppState { + config: Arc::new(config), + trading_channel: None, + backtesting_channel: None, + ml_training_channel: None, + ws_broadcast, + } + } + + fn test_app(state: AppState) -> Router { + Router::new() + .nest("/risk", router()) + .layer(middleware::from_fn_with_state( + state.config.clone(), + auth_middleware, + )) + .with_state(state) + } + + fn make_token() -> String { + let claims = Claims { + sub: "risk-mgr-1".into(), + exp: u64::MAX, + iat: 1_700_000_000, + jti: "r1".into(), + roles: vec!["risk_manager".into()], + permissions: vec!["read:risk".into(), "write:risk".into()], + }; + let key = EncodingKey::from_secret(SECRET.as_bytes()); + encode(&Header::default(), &claims, &key).unwrap() + } + + #[tokio::test] + async fn test_risk_metrics_without_auth_returns_401() { + let state = test_state(); + let app = test_app(state); + let req = Request::builder() + .uri("/risk/metrics") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_risk_metrics_no_service_returns_500() { + let state = test_state(); + let app = test_app(state); + let token = make_token(); + let req = Request::builder() + .uri("/risk/metrics") + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn test_emergency_stop_without_auth_returns_401() { + let state = test_state(); + let app = test_app(state); + let body = serde_json::json!({ + "stop_type": 1, + "reason": "test", + "symbols": [], + "confirm": true, + }); + let req = Request::builder() + .method("POST") + .uri("/risk/emergency-stop") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_emergency_stop_no_service_returns_500() { + let state = test_state(); + let app = test_app(state); + let token = make_token(); + let body = serde_json::json!({ + "stop_type": 1, + "reason": "test emergency", + "symbols": ["ES.FUT"], + "confirm": true, + }); + let req = Request::builder() + .method("POST") + .uri("/risk/emergency-stop") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn test_emergency_stop_invalid_body_returns_422() { + let state = test_state(); + let app = test_app(state); + let token = make_token(); + // Missing required fields + let body = serde_json::json!({"stop_type": 1}); + let req = Request::builder() + .method("POST") + .uri("/risk/emergency-stop") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + } +} diff --git a/web-gateway/src/routes/trading.rs b/web-gateway/src/routes/trading.rs index 329c2de81..f93fcd9f0 100644 --- a/web-gateway/src/routes/trading.rs +++ b/web-gateway/src/routes/trading.rs @@ -140,3 +140,185 @@ async fn get_account( .map_err(|e| AppError::Internal(e.into()))?, )) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::middleware; + use axum::Router; + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::sync::Arc; + use tower::ServiceExt; + + use super::*; + use crate::auth::claims::Claims; + use crate::auth::middleware::auth_middleware; + use crate::config::GatewayConfig; + + const SECRET: &str = "trading-test-secret"; + + fn test_state(with_channel: bool) -> AppState { + let config = GatewayConfig { + jwt_secret: SECRET.to_string(), + trading_service_url: if with_channel { + "https://localhost:50051".to_string() + } else { + String::new() + }, + ..GatewayConfig::default() + }; + let channel = if with_channel { + tonic::transport::Channel::from_shared("https://localhost:50051") + .ok() + .map(|c| c.connect_lazy()) + } else { + None + }; + let (ws_broadcast, _) = tokio::sync::broadcast::channel(16); + AppState { + config: Arc::new(config), + trading_channel: channel, + backtesting_channel: None, + ml_training_channel: None, + ws_broadcast, + } + } + + fn test_app(state: AppState) -> Router { + Router::new() + .nest("/trading", router()) + .layer(middleware::from_fn_with_state( + state.config.clone(), + auth_middleware, + )) + .with_state(state) + } + + fn make_token() -> String { + let claims = Claims { + sub: "trader-1".into(), + exp: u64::MAX, + iat: 1_700_000_000, + jti: "t1".into(), + roles: vec!["trader".into()], + permissions: vec!["write:orders".into()], + }; + let key = EncodingKey::from_secret(SECRET.as_bytes()); + encode(&Header::default(), &claims, &key).unwrap() + } + + #[tokio::test] + async fn test_positions_without_auth_returns_401() { + let state = test_state(false); + let app = test_app(state); + let req = Request::builder() + .uri("/trading/positions") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_positions_no_service_returns_500() { + let state = test_state(false); + let app = test_app(state); + let token = make_token(); + let req = Request::builder() + .uri("/trading/positions") + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn test_submit_order_without_auth_returns_401() { + let state = test_state(false); + let app = test_app(state); + let body = serde_json::json!({ + "symbol": "ES.FUT", + "side": 1, + "order_type": 1, + "quantity": 1.0, + }); + let req = Request::builder() + .method("POST") + .uri("/trading/orders") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_submit_order_no_service_returns_500() { + let state = test_state(false); + let app = test_app(state); + let token = make_token(); + let body = serde_json::json!({ + "symbol": "ES.FUT", + "side": 1, + "order_type": 1, + "quantity": 1.0, + }); + let req = Request::builder() + .method("POST") + .uri("/trading/orders") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn test_submit_order_invalid_body_returns_422() { + let state = test_state(false); + let app = test_app(state); + let token = make_token(); + // Missing required fields + let body = serde_json::json!({"symbol": "ES.FUT"}); + let req = Request::builder() + .method("POST") + .uri("/trading/orders") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn test_cancel_order_without_auth_returns_401() { + let state = test_state(false); + let app = test_app(state); + let req = Request::builder() + .method("DELETE") + .uri("/trading/orders/ord-123") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_account_no_service_returns_500() { + let state = test_state(false); + let app = test_app(state); + let token = make_token(); + let req = Request::builder() + .uri("/trading/account") + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +}