#![allow(unused_imports)]
mod admin_scope_tests {
#![allow(clippy::unwrap_used)] use fraiseql_error::FraiseQLError;
use super::super::admin_scope::{ADMIN_SCOPE, has_admin_scope, require_admin_scope};
#[test]
fn has_admin_scope_exact_match() {
assert!(has_admin_scope(ADMIN_SCOPE));
}
#[test]
fn has_admin_scope_in_list() {
assert!(has_admin_scope("read write fraiseql:admin user:list"));
}
#[test]
fn has_admin_scope_first_in_list() {
assert!(has_admin_scope("fraiseql:admin read write"));
}
#[test]
fn has_admin_scope_missing() {
assert!(!has_admin_scope("read write user:list"));
}
#[test]
fn has_admin_scope_empty() {
assert!(!has_admin_scope(""));
}
#[test]
fn has_admin_scope_partial_match_rejected() {
assert!(!has_admin_scope("fraiseql:admin_readonly"));
}
#[test]
fn require_admin_scope_accepts_valid() {
require_admin_scope("fraiseql:admin").unwrap();
}
#[test]
fn require_admin_scope_rejects_missing() {
let err = require_admin_scope("read write").unwrap_err();
assert!(
matches!(err, FraiseQLError::Authorization { .. }),
"Expected Authorization error, got: {err:?}"
);
assert!(err.to_string().contains("fraiseql:admin"));
}
#[test]
fn require_admin_scope_rejects_empty() {
assert!(require_admin_scope("").is_err());
}
}
mod auth_tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::missing_errors_doc)]
#![allow(missing_docs)]
#![allow(clippy::items_after_statements)]
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
middleware,
routing::get,
};
use tower::ServiceExt;
use super::super::auth::{BearerAuthState, bearer_auth_middleware, constant_time_compare};
async fn protected_handler() -> &'static str {
"secret data"
}
fn create_test_app(token: &str) -> Router {
let auth_state = BearerAuthState::new(token.to_string());
Router::new()
.route("/protected", get(protected_handler))
.layer(middleware::from_fn_with_state(auth_state, bearer_auth_middleware))
}
#[tokio::test]
async fn test_valid_token_allows_access() {
let app = create_test_app("secret-token-12345");
let request = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer secret-token-12345")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_missing_auth_header_returns_401() {
let app = create_test_app("secret-token-12345");
let request = Request::builder().uri("/protected").body(Body::empty()).unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response.headers().contains_key("www-authenticate"));
}
#[tokio::test]
async fn test_invalid_auth_format_returns_401() {
let app = create_test_app("secret-token-12345");
let request = Request::builder()
.uri("/protected")
.header("Authorization", "Basic dXNlcjpwYXNz") .body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_wrong_token_returns_403() {
let app = create_test_app("secret-token-12345");
let request = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer wrong-token")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_empty_bearer_token_returns_403() {
let app = create_test_app("secret-token-12345");
let request = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer ")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn test_constant_time_compare_equal() {
assert!(constant_time_compare("hello", "hello"));
assert!(constant_time_compare("", ""));
assert!(constant_time_compare("a-long-token-123", "a-long-token-123"));
}
#[test]
fn test_constant_time_compare_not_equal() {
assert!(!constant_time_compare("hello", "world"));
assert!(!constant_time_compare("hello", "hello!"));
assert!(!constant_time_compare("hello", "hell"));
assert!(!constant_time_compare("abc", "abd"));
}
#[test]
fn test_constant_time_compare_different_lengths() {
assert!(!constant_time_compare("short", "longer-string"));
assert!(!constant_time_compare("", "notempty"));
}
#[test]
fn test_subtle_compare_identical_tokens() {
assert!(constant_time_compare("x", "x"));
assert!(constant_time_compare(
"super-secret-32-char-admin-token",
"super-secret-32-char-admin-token"
));
}
#[test]
fn test_subtle_compare_off_by_one_byte() {
assert!(!constant_time_compare("token-abc", "token-abd")); assert!(!constant_time_compare("Aoken-abc", "token-abc")); }
#[test]
fn test_subtle_compare_empty_strings() {
assert!(constant_time_compare("", ""));
assert!(!constant_time_compare("", "a"));
assert!(!constant_time_compare("a", ""));
}
#[test]
fn test_failure_limiter_not_blocked_initially() {
let limiter = super::super::auth::FailureLimiter::new(3);
assert!(!limiter.is_blocked("1.2.3.4"));
}
#[test]
fn test_failure_limiter_blocks_after_max_failures() {
let limiter = super::super::auth::FailureLimiter::new(3);
assert!(!limiter.record_failure("1.2.3.4")); assert!(!limiter.record_failure("1.2.3.4")); assert!(limiter.record_failure("1.2.3.4")); assert!(limiter.is_blocked("1.2.3.4"));
}
#[test]
fn test_failure_limiter_success_resets_counter() {
let limiter = super::super::auth::FailureLimiter::new(3);
limiter.record_failure("1.2.3.4");
limiter.record_failure("1.2.3.4");
assert_eq!(limiter.failure_count("1.2.3.4"), 2);
limiter.record_success("1.2.3.4");
assert_eq!(limiter.failure_count("1.2.3.4"), 0);
assert!(!limiter.is_blocked("1.2.3.4"));
}
#[test]
fn test_failure_limiter_independent_per_ip() {
let limiter = super::super::auth::FailureLimiter::new(2);
limiter.record_failure("10.0.0.1");
limiter.record_failure("10.0.0.1");
assert!(limiter.is_blocked("10.0.0.1"));
assert!(!limiter.is_blocked("10.0.0.2"));
}
#[tokio::test]
async fn test_middleware_returns_429_after_max_failures() {
let auth_state = BearerAuthState::with_max_failures("correct-token".to_string(), 2);
let app = Router::new()
.route("/protected", get(protected_handler))
.layer(middleware::from_fn_with_state(auth_state, bearer_auth_middleware));
for _ in 0..2 {
let req = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer wrong-token")
.body(Body::empty())
.unwrap();
let _ = app.clone().oneshot(req).await.unwrap();
}
let req = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer wrong-token")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn test_middleware_resets_counter_on_success() {
let auth_state = BearerAuthState::with_max_failures("good-token".to_string(), 2);
let app = Router::new()
.route("/protected", get(protected_handler))
.layer(middleware::from_fn_with_state(auth_state, bearer_auth_middleware));
let bad_req = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer bad-token")
.body(Body::empty())
.unwrap();
let r = app.clone().oneshot(bad_req).await.unwrap();
assert_eq!(r.status(), StatusCode::FORBIDDEN);
let good_req = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer good-token")
.body(Body::empty())
.unwrap();
let r = app.clone().oneshot(good_req).await.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let bad_req2 = Request::builder()
.uri("/protected")
.header("Authorization", "Bearer bad-token")
.body(Body::empty())
.unwrap();
let r = app.oneshot(bad_req2).await.unwrap();
assert_eq!(r.status(), StatusCode::FORBIDDEN);
}
#[test]
fn test_admin_auth_max_failures_default_is_ten() {
use crate::server_config::ServerConfig;
let cfg = ServerConfig::default();
assert_eq!(cfg.admin_auth_max_failures, 10);
}
}
mod content_type_tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::missing_errors_doc)]
#![allow(missing_docs)]
#![allow(clippy::items_after_statements)]
use axum::{
Router,
body::Body,
http::{Request, StatusCode, header::CONTENT_TYPE},
middleware,
routing::post,
};
use tower::ServiceExt;
use super::super::content_type::require_json_content_type;
async fn echo_handler() -> &'static str {
"ok"
}
fn app() -> Router {
Router::new()
.route("/graphql", post(echo_handler))
.layer(middleware::from_fn(require_json_content_type))
}
#[tokio::test]
async fn text_plain_rejected_with_415() {
let res = app()
.oneshot(
Request::post("/graphql")
.header(CONTENT_TYPE, "text/plain")
.body(Body::from(r#"{"query":"{ __typename }"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn form_urlencoded_rejected_with_415() {
let res = app()
.oneshot(
Request::post("/graphql")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("query=%7B+__typename+%7D"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn application_json_passes() {
let res = app()
.oneshot(
Request::post("/graphql")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"query":"{ __typename }"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn application_json_with_charset_passes() {
let res = app()
.oneshot(
Request::post("/graphql")
.header(CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(r#"{"query":"{ __typename }"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn get_request_passes_without_content_type() {
let app = Router::new()
.route("/graphql", axum::routing::get(echo_handler))
.layer(middleware::from_fn(require_json_content_type));
let res = app
.oneshot(Request::get("/graphql").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn missing_content_type_rejected() {
let res = app()
.oneshot(
Request::post("/graphql")
.body(Body::from(r#"{"query":"{ __typename }"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
}
mod cors_tests {
#![allow(clippy::unwrap_used)]
use super::super::cors::{cors_layer, cors_layer_restricted, security_headers_middleware};
#[test]
fn test_cors_layer_creation() {
let _layer = cors_layer();
}
#[test]
fn test_cors_layer_restricted() {
let origins = vec!["https://example.com".to_string()];
let _layer = cors_layer_restricted(&origins);
}
#[test]
fn test_cors_layer_restricted_empty_origins() {
let origins = vec![];
let _layer = cors_layer_restricted(&origins);
}
#[test]
fn test_cors_layer_restricted_invalid_origin() {
let origins = vec![
"not-a-valid-url".to_string(),
"https://valid.com".to_string(),
];
let layer = cors_layer_restricted(&origins);
let _ = layer;
}
use axum::{Router, body::Body, http::Request, middleware, routing::get};
use tower::ServiceExt;
async fn ok_handler() -> &'static str {
"ok"
}
fn sec_app() -> Router {
Router::new()
.route("/", get(ok_handler))
.layer(middleware::from_fn(security_headers_middleware))
}
#[tokio::test]
async fn test_security_headers_nosniff_present() {
let resp = sec_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.headers().get("x-content-type-options").unwrap(), "nosniff");
}
#[tokio::test]
async fn test_security_headers_frame_options_deny() {
let resp = sec_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
}
#[tokio::test]
async fn test_security_headers_xss_protection_zero() {
let resp = sec_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
resp.headers().get("x-xss-protection").unwrap(),
"0",
"X-XSS-Protection must be 0 (legacy auditor disabled)"
);
}
#[test]
fn test_cors_layer_config_comprehensive() {
let origins = vec![
"https://example.com".to_string(),
"https://app.example.com".to_string(),
];
let _ = cors_layer_restricted(&origins);
}
}
mod header_limits_tests {
use axum::{Router, body::Body, middleware, routing::get};
use http::{Request, StatusCode};
use tower::ServiceExt;
use super::super::header_limits::header_limits_middleware;
async fn ok_handler() -> &'static str {
"ok"
}
fn test_app(max_count: usize, max_bytes: usize) -> Router {
Router::new()
.route("/", get(ok_handler))
.layer(middleware::from_fn(move |req, next| {
header_limits_middleware(req, next, max_count, max_bytes)
}))
}
#[tokio::test]
async fn accepts_request_within_limits() {
let app = test_app(10, 4096);
let req = Request::builder()
.uri("/")
.header("x-test", "value")
.body(Body::empty())
.expect("Reason: test request builder should not fail");
let resp = app.oneshot(req).await.expect("Reason: oneshot should not fail in test");
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn rejects_too_many_headers() {
let app = test_app(3, 65_536);
let mut builder = Request::builder().uri("/");
for i in 0..10 {
builder = builder.header(format!("x-test-{i}"), "value");
}
let req = builder
.body(Body::empty())
.expect("Reason: test request builder should not fail");
let resp = app.oneshot(req).await.expect("Reason: oneshot should not fail in test");
assert_eq!(resp.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}
#[tokio::test]
async fn rejects_headers_too_large() {
let app = test_app(100, 64); let req = Request::builder()
.uri("/")
.header("x-large", "a]".repeat(100))
.body(Body::empty())
.expect("Reason: test request builder should not fail");
let resp = app.oneshot(req).await.expect("Reason: oneshot should not fail in test");
assert_eq!(resp.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}
#[tokio::test]
async fn accepts_at_exact_count_limit() {
let app = test_app(5, 65_536);
let mut builder = Request::builder().uri("/");
for i in 0..5 {
builder = builder.header(format!("x-h-{i}"), "v");
}
let req = builder
.body(Body::empty())
.expect("Reason: test request builder should not fail");
let resp = app.oneshot(req).await.expect("Reason: oneshot should not fail in test");
assert_eq!(resp.status(), StatusCode::OK);
}
}
mod hs256_auth_tests {
#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use fraiseql_core::security::{AuthConfig, AuthMiddleware};
use super::super::hs256_auth::{Hs256AuthState, hs256_auth_middleware as _};
#[test]
fn hs256_auth_state_is_cloneable() {
fn assert_clone<T: Clone>() {}
assert_clone::<Hs256AuthState>();
let mw = AuthMiddleware::from_config(AuthConfig::with_hs256("test-secret-123"));
let _state = Hs256AuthState::new(Arc::new(mw), "test".to_string());
}
}
mod metrics_tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::missing_errors_doc)]
#![allow(missing_docs)]
#![allow(clippy::items_after_statements)]
use std::sync::{Arc, atomic::Ordering};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
middleware,
routing::get,
};
use tower::ServiceExt;
use super::super::metrics::metrics_middleware;
use crate::metrics_server::MetricsCollector;
async fn ok_handler() -> StatusCode {
StatusCode::OK
}
async fn bad_request_handler() -> StatusCode {
StatusCode::BAD_REQUEST
}
async fn internal_error_handler() -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
#[tokio::test]
async fn test_metrics_middleware_counts_requests() {
let metrics = Arc::new(MetricsCollector::new());
let app = Router::new()
.route("/ok", get(ok_handler))
.layer(middleware::from_fn_with_state(metrics.clone(), metrics_middleware));
let request = Request::builder().uri("/ok").body(Body::empty()).unwrap();
let _response = app.oneshot(request).await.unwrap();
assert_eq!(metrics.http_requests_total.load(Ordering::Relaxed), 1);
assert_eq!(metrics.http_responses_2xx.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_metrics_middleware_tracks_4xx() {
let metrics = Arc::new(MetricsCollector::new());
let app = Router::new()
.route("/bad", get(bad_request_handler))
.layer(middleware::from_fn_with_state(metrics.clone(), metrics_middleware));
let request = Request::builder().uri("/bad").body(Body::empty()).unwrap();
let _response = app.oneshot(request).await.unwrap();
assert_eq!(metrics.http_requests_total.load(Ordering::Relaxed), 1);
assert_eq!(metrics.http_responses_4xx.load(Ordering::Relaxed), 1);
assert_eq!(metrics.http_responses_2xx.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_metrics_middleware_tracks_5xx() {
let metrics = Arc::new(MetricsCollector::new());
let app = Router::new()
.route("/error", get(internal_error_handler))
.layer(middleware::from_fn_with_state(metrics.clone(), metrics_middleware));
let request = Request::builder().uri("/error").body(Body::empty()).unwrap();
let _response = app.oneshot(request).await.unwrap();
assert_eq!(metrics.http_requests_total.load(Ordering::Relaxed), 1);
assert_eq!(metrics.http_responses_5xx.load(Ordering::Relaxed), 1);
}
}
mod oidc_auth_tests {
#![allow(clippy::unwrap_used)]
use axum::http::header;
use super::super::oidc_auth::{AuthUser, OidcAuthState, extract_access_token_cookie};
#[test]
fn test_auth_user_clone() {
use chrono::Utc;
use fraiseql_core::security::AuthenticatedUser;
let user = AuthenticatedUser {
user_id: fraiseql_core::types::UserId::new("user123"),
scopes: vec!["read".to_string()],
expires_at: Utc::now(),
email: None,
display_name: None,
extra_claims: std::collections::HashMap::new(),
};
let auth_user = AuthUser(user);
let cloned = auth_user.clone();
assert_eq!(auth_user.0.user_id, cloned.0.user_id);
}
#[test]
fn test_oidc_auth_state_clone() {
fn assert_clone<T: Clone>() {}
assert_clone::<OidcAuthState>();
}
#[test]
fn test_cookie_fallback_extracts_token() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
header::COOKIE,
"__Host-access_token=my.jwt.token; Path=/; SameSite=Strict".parse().unwrap(),
);
let token = extract_access_token_cookie(&headers);
assert_eq!(token.as_deref(), Some("my.jwt.token"));
}
#[test]
fn test_cookie_fallback_strips_rfc6265_quotes() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::COOKIE, "__Host-access_token=\"my.jwt.token\"".parse().unwrap());
let token = extract_access_token_cookie(&headers);
assert_eq!(token.as_deref(), Some("my.jwt.token"));
}
#[test]
fn test_cookie_fallback_absent_returns_none() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::COOKIE, "session=abc; other=xyz".parse().unwrap());
let token = extract_access_token_cookie(&headers);
assert!(token.is_none());
}
#[test]
fn test_cookie_fallback_no_cookie_header_returns_none() {
let headers = axum::http::HeaderMap::new();
let token = extract_access_token_cookie(&headers);
assert!(token.is_none());
}
#[test]
fn test_cookie_fallback_multiple_cookies_finds_correct_one() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
header::COOKIE,
"session=abc; __Host-access_token=correct.token; csrf=xyz".parse().unwrap(),
);
let token = extract_access_token_cookie(&headers);
assert_eq!(token.as_deref(), Some("correct.token"));
}
}
mod tenant_tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::missing_errors_doc)]
#![allow(missing_docs)]
#![allow(clippy::items_after_statements)]
use super::super::tenant::TenantContext;
#[test]
fn test_tenant_context_scoped() {
let ctx = TenantContext {
org_id: Some("org-123".to_string()),
};
assert!(ctx.is_tenant_scoped());
assert_eq!(ctx.get_org_id(), Some("org-123"));
}
#[test]
fn test_tenant_context_unscoped() {
let ctx = TenantContext { org_id: None };
assert!(!ctx.is_tenant_scoped());
assert_eq!(ctx.get_org_id(), None);
}
#[test]
fn test_require_org_id_success() {
let ctx = TenantContext {
org_id: Some("org-123".to_string()),
};
assert_eq!(ctx.require_org_id().unwrap(), "org-123");
}
#[test]
fn test_require_org_id_failure() {
let ctx = TenantContext { org_id: None };
assert!(
ctx.require_org_id().is_err(),
"expected Err when org_id is None, got: {:?}",
ctx.require_org_id()
);
assert_eq!(
ctx.require_org_id().unwrap_err(),
"Request must be tenant-scoped (missing org_id)"
);
}
}
mod trace_tests {
use super::super::trace::trace_layer;
#[test]
fn test_trace_layer_creation() {
let _layer = trace_layer();
}
}