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:
jgrusewski
2026-02-22 08:09:25 +01:00
parent f930b4f1b7
commit f4586f8982
3 changed files with 334 additions and 0 deletions

View 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

View File

@@ -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);
}
}

View File

@@ -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);
}
}