diff --git a/web-gateway/src/auth/middleware.rs b/web-gateway/src/auth/middleware.rs index 7ca69f8b3..3239b9fba 100644 --- a/web-gateway/src/auth/middleware.rs +++ b/web-gateway/src/auth/middleware.rs @@ -33,3 +33,145 @@ pub async fn auth_middleware( request.extensions_mut().insert(token_data.claims); Ok(next.run(request).await) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request as HttpRequest, StatusCode}; + use axum::middleware; + use axum::response::IntoResponse; + use axum::routing::get; + use axum::Router; + use jsonwebtoken::{encode, EncodingKey, Header}; + use tower::ServiceExt; + + use crate::auth::claims::Claims; + + fn test_config(secret: &str) -> Arc { + Arc::new(GatewayConfig { + jwt_secret: secret.to_string(), + ..GatewayConfig::default() + }) + } + + fn make_token(secret: &str, claims: &Claims) -> String { + let key = EncodingKey::from_secret(secret.as_bytes()); + encode(&Header::default(), claims, &key).unwrap() + } + + fn valid_claims() -> Claims { + Claims { + sub: "user-1".into(), + exp: u64::MAX, + iat: 1_700_000_000, + jti: "t1".into(), + roles: vec!["trader".into()], + permissions: vec!["read:data".into()], + } + } + + fn test_app(config: Arc) -> Router { + let handler = get(|| async { "ok" }); + Router::new() + .route("/protected", handler) + .layer(middleware::from_fn_with_state(config.clone(), auth_middleware)) + .with_state(config) + } + + #[tokio::test] + async fn test_missing_auth_header_returns_401() { + let config = test_config("secret"); + let app = test_app(config); + + let req = HttpRequest::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_malformed_bearer_prefix_returns_401() { + let config = test_config("secret"); + let app = test_app(config); + + let req = HttpRequest::builder() + .uri("/protected") + .header("Authorization", "Token abc123") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_invalid_jwt_returns_401() { + let config = test_config("secret"); + let app = test_app(config); + + let req = HttpRequest::builder() + .uri("/protected") + .header("Authorization", "Bearer not.a.valid.jwt") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_wrong_secret_returns_401() { + let config = test_config("correct-secret"); + let app = test_app(config); + + let token = make_token("wrong-secret", &valid_claims()); + let req = HttpRequest::builder() + .uri("/protected") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_expired_token_returns_401() { + let config = test_config("secret"); + let app = test_app(config); + + let mut claims = valid_claims(); + claims.exp = 1; // Expired in 1970 + let token = make_token("secret", &claims); + + let req = HttpRequest::builder() + .uri("/protected") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_valid_token_passes_through() { + let config = test_config("secret"); + let app = test_app(config); + + let token = make_token("secret", &valid_claims()); + let req = HttpRequest::builder() + .uri("/protected") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } +} diff --git a/web-gateway/src/ws/handler.rs b/web-gateway/src/ws/handler.rs index 215025d5e..9b229534d 100644 --- a/web-gateway/src/ws/handler.rs +++ b/web-gateway/src/ws/handler.rs @@ -126,3 +126,74 @@ async fn handle_ws_connection(socket: WebSocket, state: AppState) { tracing::debug!("WebSocket connection closed"); } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use jsonwebtoken::{encode, EncodingKey, Header}; + + use crate::auth::claims::Claims; + + fn test_config(secret: &str) -> Arc { + Arc::new(GatewayConfig { + jwt_secret: secret.to_string(), + ..GatewayConfig::default() + }) + } + + fn make_token(secret: &str, claims: &Claims) -> String { + let key = EncodingKey::from_secret(secret.as_bytes()); + encode(&Header::default(), claims, &key).unwrap() + } + + fn valid_claims() -> Claims { + Claims { + sub: "user-1".into(), + exp: u64::MAX, + iat: 1_700_000_000, + jti: "t1".into(), + roles: vec!["trader".into()], + permissions: vec!["read:data".into()], + } + } + + #[test] + fn test_valid_ws_token_returns_claims() { + let config = test_config("ws-secret"); + let claims = valid_claims(); + let token = make_token("ws-secret", &claims); + + let result = validate_ws_token(&token, &config); + assert!(result.is_ok()); + let extracted = result.unwrap(); + assert_eq!(extracted.sub, "user-1"); + assert_eq!(extracted.roles, vec!["trader"]); + } + + #[test] + fn test_invalid_ws_token_returns_unauthorized() { + let config = test_config("ws-secret"); + let result = validate_ws_token("not.a.jwt", &config); + assert!(result.is_err()); + } + + #[test] + fn test_wrong_secret_ws_token_returns_unauthorized() { + let config = test_config("correct-secret"); + let token = make_token("wrong-secret", &valid_claims()); + let result = validate_ws_token(&token, &config); + assert!(result.is_err()); + } + + #[test] + fn test_expired_ws_token_returns_unauthorized() { + let config = test_config("ws-secret"); + let mut claims = valid_claims(); + claims.exp = 1; // Expired in 1970 + let token = make_token("ws-secret", &claims); + + let result = validate_ws_token(&token, &config); + assert!(result.is_err()); + } +}