test(web-gateway): add trading and risk route handler tests, env example
Trading tests (7): auth gating, no-service 500, order body validation, cancel/account paths Risk tests (5): auth gating, no-service 500, emergency stop body validation Total: 61 tests (up from 49) Also adds web-dashboard/.env.example for production deployment configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
web-dashboard/.env.example
Normal file
8
web-dashboard/.env.example
Normal file
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user