#![allow(clippy::panic)] mod api_key_tests {
#![allow(clippy::unwrap_used)]
use crate::api_key::*;
fn sha256_hex(input: &str) -> String {
hex::encode(sha256_hash(input.as_bytes()))
}
fn test_config(key: &str) -> ApiKeyConfig {
ApiKeyConfig {
enabled: true,
header: "x-api-key".into(),
hash_algorithm: "sha256".into(),
storage: "env".into(),
static_keys: vec![StaticApiKeyConfig {
key_hash: format!("sha256:{}", sha256_hex(key)),
scopes: vec!["read:*".into()],
name: "test-key".into(),
}],
}
}
#[tokio::test]
async fn valid_api_key_returns_security_context() {
let config = test_config("my-secret-key");
let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-api-key", "my-secret-key".parse().unwrap());
match auth.authenticate(&headers).await {
ApiKeyResult::Authenticated(ctx) => {
assert_eq!(ctx.user_id, fraiseql_core::types::UserId::new("apikey:test-key"));
assert_eq!(ctx.scopes, vec!["read:*".to_string()]);
},
ref other => panic!("expected Authenticated, got {other:?}"),
}
}
#[tokio::test]
async fn invalid_api_key_returns_invalid() {
let config = test_config("my-secret-key");
let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-api-key", "wrong-key".parse().unwrap());
assert!(matches!(auth.authenticate(&headers).await, ApiKeyResult::Invalid));
}
#[tokio::test]
async fn missing_api_key_returns_not_present() {
let config = test_config("my-secret-key");
let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
let headers = axum::http::HeaderMap::new();
assert!(matches!(auth.authenticate(&headers).await, ApiKeyResult::NotPresent));
}
#[tokio::test]
async fn api_key_prefix_stripped() {
let config = test_config("my-secret-key");
let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-api-key", "ApiKey my-secret-key".parse().unwrap());
assert!(matches!(auth.authenticate(&headers).await, ApiKeyResult::Authenticated(_)));
}
#[test]
fn disabled_config_returns_none() {
let mut config = test_config("key");
config.enabled = false;
assert!(ApiKeyAuthenticator::from_config(&config).is_none());
}
#[test]
fn invalid_hash_hex_is_skipped() {
let config = ApiKeyConfig {
enabled: true,
header: "x-api-key".into(),
hash_algorithm: "sha256".into(),
storage: "env".into(),
static_keys: vec![StaticApiKeyConfig {
key_hash: "not-valid-hex".into(),
scopes: vec![],
name: "bad-key".into(),
}],
};
let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
assert_eq!(auth.static_keys.len(), 0);
}
#[test]
fn hash_without_prefix_works() {
let hash = sha256_hex("test");
let config = ApiKeyConfig {
enabled: true,
header: "x-api-key".into(),
hash_algorithm: "sha256".into(),
storage: "env".into(),
static_keys: vec![StaticApiKeyConfig {
key_hash: hash, scopes: vec![],
name: "no-prefix".into(),
}],
};
let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
assert_eq!(auth.static_keys.len(), 1);
}
#[test]
fn sha256_hash_is_deterministic() {
let h1 = sha256_hash(b"hello");
let h2 = sha256_hash(b"hello");
assert_eq!(h1, h2);
let h3 = sha256_hash(b"world");
assert_ne!(h1, h3);
}
#[test]
fn unsupported_algorithm_returns_none() {
let mut config = test_config("key");
config.hash_algorithm = "bcrypt".into();
assert!(ApiKeyAuthenticator::from_config(&config).is_none());
}
}
#[cfg(feature = "cli")]
mod cli_tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::field_reassign_with_default)]
use clap::Parser as _;
use crate::{cli::*, middleware::RateLimitConfig, server_config::ServerConfig};
#[test]
fn cli_parse_config_flag() {
let cli = Cli::parse_from(["fraiseql-server", "--config", "/etc/fraiseql.toml"]);
assert_eq!(cli.server.config.as_deref(), Some("/etc/fraiseql.toml"));
}
#[test]
fn cli_parse_database_url_flag() {
let cli = Cli::parse_from([
"fraiseql-server",
"--database-url",
"postgres://localhost/db",
]);
assert_eq!(cli.server.database_url.as_deref(), Some("postgres://localhost/db"));
}
#[test]
fn cli_parse_bind_addr_flag() {
let cli = Cli::parse_from(["fraiseql-server", "--bind-addr", "127.0.0.1:3000"]);
assert_eq!(cli.server.bind_addr, Some("127.0.0.1:3000".parse().unwrap()));
}
#[test]
fn cli_defaults_are_none_when_no_flags_or_env() {
let cli = Cli::parse_from(["fraiseql-server"]);
assert!(cli.server.config.is_none());
assert!(cli.server.schema_path.is_none());
assert!(cli.server.metrics_token.is_none());
assert!(cli.server.admin_token.is_none());
}
#[test]
fn cli_parse_bool_flag_with_value() {
let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled", "true"]);
assert_eq!(cli.server.metrics_enabled, Some(true));
let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled", "false"]);
assert_eq!(cli.server.metrics_enabled, Some(false));
}
#[test]
fn cli_parse_bool_flag_without_value() {
let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled"]);
assert_eq!(cli.server.metrics_enabled, Some(true));
}
#[test]
fn cli_parse_rate_limit_flags() {
let cli = Cli::parse_from([
"fraiseql-server",
"--rate-limit-rps-per-ip",
"200",
"--rate-limit-burst-size",
"1000",
]);
assert_eq!(cli.server.rate_limit_rps_per_ip, Some(200));
assert_eq!(cli.server.rate_limit_burst_size, Some(1000));
assert!(cli.server.rate_limit_rps_per_user.is_none());
}
#[test]
fn cli_parse_log_format() {
let cli = Cli::parse_from(["fraiseql-server", "--log-format", "json"]);
assert_eq!(cli.server.log_format.as_deref(), Some("json"));
assert!(cli.server.is_json_log_format());
}
#[test]
fn apply_overrides_database_url() {
let args = ServerArgs {
database_url: Some("postgres://override/db".into()),
..Default::default()
};
let mut config = ServerConfig::default();
args.apply_to_config(&mut config);
assert_eq!(config.database_url, "postgres://override/db");
}
#[test]
fn apply_leaves_config_unchanged_when_no_overrides() {
let args = ServerArgs::default();
let mut config = ServerConfig::default();
let original_db = config.database_url.clone();
let original_addr = config.bind_addr;
args.apply_to_config(&mut config);
assert_eq!(config.database_url, original_db);
assert_eq!(config.bind_addr, original_addr);
}
#[test]
fn apply_metrics_enabled_override() {
let args = ServerArgs {
metrics_enabled: Some(true),
..Default::default()
};
let mut config = ServerConfig::default();
assert!(!config.metrics_enabled);
args.apply_to_config(&mut config);
assert!(config.metrics_enabled);
}
#[test]
fn apply_rate_limit_creates_config_when_absent() {
let args = ServerArgs {
rate_limit_rps_per_ip: Some(50),
..Default::default()
};
let mut config = ServerConfig::default();
config.rate_limiting = None;
args.apply_to_config(&mut config);
let rl = config.rate_limiting.unwrap();
assert_eq!(rl.rps_per_ip, 50);
assert!(rl.enabled);
assert_eq!(rl.burst_size, 500);
}
#[test]
fn apply_rate_limit_preserves_existing_fields() {
let args = ServerArgs {
rate_limit_burst_size: Some(999),
..Default::default()
};
let mut config = ServerConfig::default();
config.rate_limiting = Some(RateLimitConfig {
enabled: true,
rps_per_ip: 42,
rps_per_user: 420,
burst_size: 100,
cleanup_interval_secs: 60,
trust_proxy_headers: true,
trusted_proxy_cidrs: Vec::new(),
max_buckets: 100_000,
});
args.apply_to_config(&mut config);
let rl = config.rate_limiting.unwrap();
assert_eq!(rl.burst_size, 999);
assert_eq!(rl.rps_per_ip, 42);
assert_eq!(rl.rps_per_user, 420);
assert!(rl.trust_proxy_headers);
}
#[test]
fn apply_introspection_overrides() {
let args = ServerArgs {
introspection_enabled: Some(true),
introspection_require_auth: Some(false),
..Default::default()
};
let mut config = ServerConfig::default();
args.apply_to_config(&mut config);
assert!(config.introspection_enabled);
assert!(!config.introspection_require_auth);
}
#[test]
fn is_json_log_format_case_insensitive() {
let args = ServerArgs {
log_format: Some("JSON".into()),
..Default::default()
};
assert!(args.is_json_log_format());
let args = ServerArgs {
log_format: Some("text".into()),
..Default::default()
};
assert!(!args.is_json_log_format());
let args = ServerArgs::default();
assert!(!args.is_json_log_format());
}
}
mod error_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 crate::error::*;
#[test]
fn test_error_serialization() {
let error = GraphQLError::validation("Invalid query")
.with_location(1, 5)
.with_path(vec!["user".to_string(), "id".to_string()]);
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("Invalid query"));
assert!(json.contains("VALIDATION_ERROR"));
assert!(json.contains("\"line\":1"));
}
#[test]
fn test_error_response_serialization() {
let response = ErrorResponse::new(vec![
GraphQLError::validation("Field not found"),
GraphQLError::database("Connection timeout"),
]);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("Field not found"));
assert!(json.contains("Connection timeout"));
}
#[test]
fn test_error_code_status_codes() {
use axum::http::StatusCode;
assert_eq!(ErrorCode::ValidationError.status_code(), StatusCode::OK);
assert_eq!(ErrorCode::ParseError.status_code(), StatusCode::OK);
assert_eq!(ErrorCode::RequestError.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(ErrorCode::Unauthenticated.status_code(), StatusCode::UNAUTHORIZED);
assert_eq!(ErrorCode::Forbidden.status_code(), StatusCode::FORBIDDEN);
assert_eq!(ErrorCode::DatabaseError.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(ErrorCode::CircuitBreakerOpen.status_code(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn test_circuit_breaker_open_error() {
let error = GraphQLError::circuit_breaker_open("Product", 30);
assert_eq!(error.code, ErrorCode::CircuitBreakerOpen);
assert!(error.message.contains("Product"));
assert!(error.message.contains("30"));
let ext = error.extensions.unwrap();
assert_eq!(ext.retry_after_secs, Some(30));
assert_eq!(ext.category, Some("CIRCUIT_BREAKER".to_string()));
}
#[test]
fn test_circuit_breaker_response_has_retry_after_header() {
use axum::{http::StatusCode, response::IntoResponse};
let response = ErrorResponse::from_error(GraphQLError::circuit_breaker_open("User", 60))
.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let retry_after = response.headers().get(axum::http::header::RETRY_AFTER);
assert_eq!(retry_after.and_then(|v| v.to_str().ok()), Some("60"));
}
#[test]
fn test_from_fraiseql_error_database_maps_to_database_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Database {
message: "relation \"users\" does not exist".into(),
sql_state: None,
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::DatabaseError);
}
#[test]
fn test_from_fraiseql_error_sqlstate_22_maps_to_bad_user_input_400() {
use axum::http::StatusCode;
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Database {
message: "invalid input syntax for type uuid: \"not-a-uuid\"".into(),
sql_state: Some("22P02".into()),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::BadUserInput);
assert_eq!(graphql_err.code.status_code(), StatusCode::BAD_REQUEST);
assert!(graphql_err.message.contains("not-a-uuid"));
}
#[test]
fn test_from_fraiseql_error_sqlstate_23_maps_to_constraint_violation_400() {
use axum::http::StatusCode;
use fraiseql_core::error::FraiseQLError;
for code in ["23502", "23503", "23505", "23514"] {
let err = FraiseQLError::Database {
message: "violates constraint".into(),
sql_state: Some(code.into()),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(
graphql_err.code,
ErrorCode::ConstraintViolation,
"SQLSTATE {code} should map to ConstraintViolation"
);
assert_eq!(graphql_err.code.status_code(), StatusCode::BAD_REQUEST);
}
}
#[test]
fn test_from_fraiseql_error_non_client_sqlstate_stays_database_500() {
use axum::http::StatusCode;
use fraiseql_core::error::FraiseQLError;
for sql_state in [Some("08006".to_string()), None] {
let err = FraiseQLError::Database {
message: "connection failure".into(),
sql_state,
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::DatabaseError);
assert_eq!(graphql_err.code.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
}
}
#[test]
fn test_from_fraiseql_error_connection_pool_stays_database_500() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::ConnectionPool {
message: "pool exhausted".into(),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::DatabaseError);
}
#[test]
fn test_from_fraiseql_error_validation_maps_to_validation_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Validation {
message: "field 'id' is required".into(),
path: None,
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::ValidationError);
}
#[test]
fn test_from_fraiseql_error_not_found_maps_to_not_found_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::NotFound {
resource_type: "User".into(),
identifier: "123".into(),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::NotFound);
}
#[test]
fn test_from_fraiseql_error_authorization_maps_to_forbidden() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Authorization {
message: "insufficient permissions".into(),
action: Some("write".into()),
resource: Some("User".into()),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::Forbidden);
}
#[test]
fn test_from_fraiseql_error_authentication_maps_to_unauthenticated() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Authentication {
message: "token expired".into(),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::Unauthenticated);
}
#[test]
fn test_error_extensions() {
let extensions = ErrorExtensions {
category: Some("VALIDATION".to_string()),
status: Some(400),
request_id: Some("req-123".to_string()),
retry_after_secs: None,
detail: None,
};
let error = GraphQLError::validation("Invalid").with_extensions(extensions);
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("VALIDATION"));
assert!(json.contains("req-123"));
}
#[test]
fn test_all_error_codes_have_expected_status() {
use axum::http::StatusCode;
assert_eq!(ErrorCode::ParseError.status_code(), StatusCode::OK);
assert_eq!(ErrorCode::RequestError.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(ErrorCode::NotFound.status_code(), StatusCode::NOT_FOUND);
assert_eq!(ErrorCode::Conflict.status_code(), StatusCode::CONFLICT);
assert_eq!(ErrorCode::RateLimitExceeded.status_code(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(ErrorCode::Timeout.status_code(), StatusCode::REQUEST_TIMEOUT);
assert_eq!(ErrorCode::InternalServerError.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(ErrorCode::PersistedQueryMismatch.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(ErrorCode::ForbiddenQuery.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(ErrorCode::DocumentNotFound.status_code(), StatusCode::BAD_REQUEST);
}
#[test]
fn test_persisted_query_not_found_maps_to_200() {
use axum::{http::StatusCode, response::IntoResponse};
assert_eq!(ErrorCode::PersistedQueryNotFound.status_code(), StatusCode::OK);
let response =
ErrorResponse::from_error(GraphQLError::persisted_query_not_found()).into_response();
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn test_from_fraiseql_timeout_maps_to_timeout_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Timeout {
timeout_ms: 5000,
query: Some("{ users { id } }".into()),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::Timeout);
}
#[test]
fn test_from_fraiseql_rate_limited_maps_to_rate_limit_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::RateLimited {
message: "too many requests".into(),
retry_after_secs: 60,
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::RateLimitExceeded);
}
#[test]
fn test_from_fraiseql_conflict_maps_to_conflict_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Conflict {
message: "unique constraint violated".into(),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::Conflict);
}
#[test]
fn test_from_fraiseql_parse_maps_to_parse_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Parse {
message: "unexpected token".into(),
location: "line 1, col 5".into(),
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::ParseError);
}
#[test]
fn test_from_fraiseql_internal_maps_to_internal_code() {
use fraiseql_core::error::FraiseQLError;
let err = FraiseQLError::Internal {
message: "unexpected nil pointer".into(),
source: None,
};
let graphql_err = GraphQLError::from_fraiseql_error(&err);
assert_eq!(graphql_err.code, ErrorCode::InternalServerError);
}
#[test]
fn test_timeout_response_has_correct_status() {
use axum::{http::StatusCode, response::IntoResponse};
let response =
ErrorResponse::from_error(GraphQLError::new("timed out", ErrorCode::Timeout))
.into_response();
assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
}
#[test]
fn test_rate_limit_response_has_correct_status() {
use axum::{http::StatusCode, response::IntoResponse};
let response = ErrorResponse::from_error(GraphQLError::rate_limited("too many requests"))
.into_response();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn test_not_found_response_has_correct_status() {
use axum::{http::StatusCode, response::IntoResponse};
let response = ErrorResponse::from_error(GraphQLError::not_found("resource not found"))
.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn test_complexity_rejection_returns_200() {
use axum::{http::StatusCode, response::IntoResponse};
let response = ErrorResponse::from_error(GraphQLError::validation(
"Query exceeds maximum complexity: 121 > 100",
))
.into_response();
assert_eq!(
response.status(),
StatusCode::OK,
"complexity validation errors must return HTTP 200 per GraphQL-over-HTTP spec"
);
}
#[test]
fn test_depth_rejection_returns_200() {
use axum::{http::StatusCode, response::IntoResponse};
let response = ErrorResponse::from_error(GraphQLError::validation(
"Query exceeds maximum depth: 16 > 15",
))
.into_response();
assert_eq!(
response.status(),
StatusCode::OK,
"depth validation errors must return HTTP 200 per GraphQL-over-HTTP spec"
);
}
#[test]
fn test_parse_error_returns_200() {
use axum::{http::StatusCode, response::IntoResponse};
let response =
ErrorResponse::from_error(GraphQLError::parse("unexpected token '}'")).into_response();
assert_eq!(
response.status(),
StatusCode::OK,
"GraphQL parse errors must return HTTP 200 per GraphQL-over-HTTP spec"
);
}
}
mod extractors_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 crate::extractors::*;
#[test]
fn test_extract_request_id_from_header() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-request-id", "req-12345".parse().unwrap());
let request_id = extract_request_id(&headers);
assert_eq!(request_id, "req-12345");
}
#[test]
fn test_extract_request_id_generates_default() {
let headers = axum::http::HeaderMap::new();
let request_id = extract_request_id(&headers);
assert!(request_id.starts_with("req-"));
assert_eq!(request_id.len(), 40);
}
#[test]
fn test_extract_ip_ignores_x_forwarded_for() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-forwarded-for", "192.0.2.1, 10.0.0.1".parse().unwrap());
let ip = extract_ip_address(&headers);
assert_eq!(ip, None, "Must not trust X-Forwarded-For header");
}
#[test]
fn test_extract_ip_ignores_x_real_ip() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-real-ip", "10.0.0.2".parse().unwrap());
let ip = extract_ip_address(&headers);
assert_eq!(ip, None, "Must not trust X-Real-IP header");
}
#[test]
fn test_extract_ip_address_none_when_missing() {
let headers = axum::http::HeaderMap::new();
let ip = extract_ip_address(&headers);
assert_eq!(ip, None);
}
#[test]
fn test_extract_tenant_id_ignores_header() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-tenant-id", "tenant-acme".parse().unwrap());
let tenant_id = extract_tenant_id(&headers);
assert_eq!(tenant_id, None, "Must not trust X-Tenant-ID header");
}
#[test]
fn test_extract_tenant_id_none_when_missing() {
let headers = axum::http::HeaderMap::new();
let tenant_id = extract_tenant_id(&headers);
assert_eq!(tenant_id, None);
}
#[test]
fn test_optional_security_context_creation_from_auth_user() {
use chrono::Utc;
let auth_user = crate::middleware::AuthUser(fraiseql_core::security::AuthenticatedUser {
user_id: fraiseql_core::types::UserId::new("user123"),
scopes: vec!["read:user".to_string(), "write:post".to_string()],
expires_at: Utc::now() + chrono::Duration::hours(1),
email: None,
display_name: None,
extra_claims: std::collections::HashMap::new(),
});
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-request-id", "req-test-123".parse().unwrap());
headers.insert("x-tenant-id", "tenant-acme".parse().unwrap());
headers.insert("x-forwarded-for", "192.0.2.100".parse().unwrap());
let security_context = Some(auth_user).map(|auth_user| {
let authenticated_user = auth_user.0;
let request_id = extract_request_id(&headers);
let ip_address = extract_ip_address(&headers);
let tenant_id = extract_tenant_id(&headers);
let mut context = fraiseql_core::security::SecurityContext::from_user(
&authenticated_user,
request_id,
);
context.ip_address = ip_address;
context.tenant_id = tenant_id.map(fraiseql_core::types::TenantId::new);
context
});
let sec_ctx = security_context.unwrap();
assert_eq!(sec_ctx.user_id, fraiseql_core::types::UserId::new("user123"));
assert_eq!(sec_ctx.scopes, vec!["read:user".to_string(), "write:post".to_string()]);
assert_eq!(sec_ctx.tenant_id, None);
assert_eq!(sec_ctx.request_id, "req-test-123");
assert_eq!(sec_ctx.ip_address, None);
}
}
mod logging_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 crate::logging::*;
#[test]
fn test_request_id_generation() {
let id1 = RequestId::new();
let id2 = RequestId::new();
assert_ne!(id1.0, id2.0);
}
#[test]
fn test_request_context_builder() {
let context = RequestContext::new()
.with_operation("GetUser".to_string())
.with_user_id("user123".to_string())
.with_client_ip("192.168.1.1".to_string())
.with_api_version("v1".to_string());
assert_eq!(context.operation, Some("GetUser".to_string()));
assert_eq!(context.user_id, Some("user123".to_string()));
assert_eq!(context.client_ip, Some("192.168.1.1".to_string()));
assert_eq!(context.api_version, Some("v1".to_string()));
}
#[test]
fn test_log_entry_creation() {
let entry = StructuredLogEntry::new(LogLevel::Info, "test message".to_string());
assert_eq!(entry.level, LogLevel::Info);
assert_eq!(entry.message, "test message");
assert!(entry.request_context.is_none());
}
#[test]
fn test_log_entry_with_context() {
let context = RequestContext::new().with_operation("Query".to_string());
let entry = StructuredLogEntry::new(LogLevel::Info, "operation executed".to_string())
.with_request_context(context);
assert_eq!(entry.request_context.unwrap().operation, Some("Query".to_string()));
}
#[test]
fn test_log_metrics_builder() {
let metrics = LogMetrics::new()
.with_duration_ms(123.45)
.with_complexity(5)
.with_items_processed(100)
.with_cache_hit(true)
.with_db_queries(3);
assert_eq!(metrics.duration_ms, Some(123.45));
assert_eq!(metrics.complexity, Some(5));
assert_eq!(metrics.items_processed, Some(100));
assert_eq!(metrics.cache_hit, Some(true));
assert_eq!(metrics.db_queries, Some(3));
}
#[test]
fn test_error_details_builder() {
let error =
ErrorDetails::new("DatabaseError".to_string(), "Connection timeout".to_string())
.with_code("DB_TIMEOUT".to_string());
assert_eq!(error.error_type, "DatabaseError");
assert_eq!(error.message, "Connection timeout");
assert_eq!(error.code, Some("DB_TIMEOUT".to_string()));
}
#[test]
fn test_log_entry_json_serialization() {
let entry = StructuredLogEntry::new(LogLevel::Error, "test error".to_string());
let json = entry.to_json_string();
assert!(json.contains("\"level\":\"ERROR\""));
assert!(json.contains("\"message\":\"test error\""));
assert!(json.contains("\"timestamp\":"));
}
#[test]
fn test_request_logger_creation() {
let context = RequestContext::new().with_operation("Query".to_string());
let logger = RequestLogger::new(context);
assert_eq!(logger.context().operation, Some("Query".to_string()));
}
#[test]
fn test_request_logger_log_entry() {
let logger = RequestLogger::with_request_id(RequestId::new());
let entry = logger.info("test message");
assert_eq!(entry.level, LogLevel::Info);
assert!(
entry.request_context.is_some(),
"RequestLogger should attach request_context to every log entry"
);
}
#[test]
fn test_log_level_conversion() {
assert_eq!(LogLevel::from(tracing::Level::INFO), LogLevel::Info);
assert_eq!(LogLevel::from(tracing::Level::WARN), LogLevel::Warn);
assert_eq!(LogLevel::from(tracing::Level::ERROR), LogLevel::Error);
assert_eq!(LogLevel::from(tracing::Level::DEBUG), LogLevel::Debug);
assert_eq!(LogLevel::from(tracing::Level::TRACE), LogLevel::Trace);
}
#[test]
fn test_complex_log_entry() {
let context = RequestContext::new()
.with_operation("GetUsers".to_string())
.with_user_id("user123".to_string());
let metrics = LogMetrics::new()
.with_duration_ms(45.67)
.with_db_queries(2)
.with_cache_hit(true);
let error =
ErrorDetails::new("ValidationError".to_string(), "Invalid query parameter".to_string());
let source = SourceLocation::new(
"routes/graphql.rs".to_string(),
42,
"fraiseql_server::routes".to_string(),
);
let entry = StructuredLogEntry::new(LogLevel::Warn, "Query validation warning".to_string())
.with_request_context(context)
.with_metrics(metrics)
.with_error(error)
.with_source(source);
let json = entry.to_json_string();
assert!(json.contains("\"level\":\"WARN\""));
assert!(json.contains("\"duration_ms\":"));
assert!(json.contains("\"error_type\":"));
assert!(json.contains("\"file\":"));
}
}
mod metrics_server_tests {
use std::sync::{Arc, atomic::Ordering};
use crate::metrics_server::*;
#[test]
fn test_metrics_collector_creation() {
let collector = MetricsCollector::new();
assert_eq!(collector.queries_total.load(Ordering::Relaxed), 0);
assert_eq!(collector.queries_success.load(Ordering::Relaxed), 0);
}
#[test]
fn test_metrics_increment() {
let collector = MetricsCollector::new();
collector.queries_total.fetch_add(5, Ordering::Relaxed);
collector.queries_success.fetch_add(4, Ordering::Relaxed);
collector.queries_error.fetch_add(1, Ordering::Relaxed);
assert_eq!(collector.queries_total.load(Ordering::Relaxed), 5);
assert_eq!(collector.queries_success.load(Ordering::Relaxed), 4);
assert_eq!(collector.queries_error.load(Ordering::Relaxed), 1);
}
#[test]
fn test_prometheus_output_format() {
let collector = MetricsCollector::new();
collector.queries_total.store(100, Ordering::Relaxed);
collector.queries_success.store(95, Ordering::Relaxed);
collector.queries_error.store(5, Ordering::Relaxed);
let metrics = PrometheusMetrics::from(&collector);
let output = metrics.to_prometheus_format();
assert!(output.contains("fraiseql_graphql_queries_total 100"));
assert!(output.contains("fraiseql_graphql_queries_success 95"));
assert!(output.contains("fraiseql_graphql_queries_error 5"));
assert!(output.contains("# HELP"));
assert!(output.contains("# TYPE"));
}
#[test]
fn test_timing_guard() {
let duration_atomic = Arc::new(std::sync::atomic::AtomicU64::new(0));
let guard = TimingGuard::new(duration_atomic.clone());
guard.record();
let recorded = duration_atomic.load(Ordering::Relaxed);
assert!(recorded < 1_000_000); }
#[test]
fn test_cache_hit_ratio_calculation() {
let collector = MetricsCollector::new();
collector.cache_hits.store(75, Ordering::Relaxed);
collector.cache_misses.store(25, Ordering::Relaxed);
let metrics = PrometheusMetrics::from(&collector);
assert!((metrics.cache_hit_ratio - 0.75).abs() < 0.001);
}
#[test]
fn test_average_duration_calculation() {
let collector = MetricsCollector::new();
collector.queries_total.store(10, Ordering::Relaxed);
collector.queries_duration_us.store(50_000, Ordering::Relaxed);
let metrics = PrometheusMetrics::from(&collector);
assert!((metrics.queries_avg_duration_ms - 5.0).abs() < 0.01); }
#[test]
fn test_operation_metrics_record_and_render() {
let registry = OperationMetricsRegistry::new(500);
registry.record("GetUsers", 10_000, false); registry.record("GetUsers", 20_000, false); registry.record("GetPosts", 5_000, true);
let output = registry.to_prometheus_format();
assert!(output.contains("fraiseql_query_duration_seconds_bucket{operation=\"GetUsers\""));
assert!(output.contains("fraiseql_query_duration_seconds_count{operation=\"GetUsers\"} 2"));
assert!(output.contains("fraiseql_query_duration_seconds_count{operation=\"GetPosts\"} 1"));
assert!(output.contains("fraiseql_query_errors_total{operation=\"GetPosts\"} 1"));
assert!(output.contains("fraiseql_query_errors_total{operation=\"GetUsers\"} 0"));
}
#[test]
fn test_anonymous_operation_label() {
let registry = OperationMetricsRegistry::new(500);
registry.record("", 1_000, false);
let output = registry.to_prometheus_format();
assert!(output.contains("operation=\"__anonymous__\""));
}
#[test]
fn test_overflow_bucketing() {
let registry = OperationMetricsRegistry::new(3);
registry.record("Op1", 1_000, false);
registry.record("Op2", 1_000, false);
registry.record("Op3", 1_000, false);
registry.record("Op4", 1_000, false);
let output = registry.to_prometheus_format();
assert!(output.contains("operation=\"__overflow__\""));
assert!(
output.contains("fraiseql_query_duration_seconds_count{operation=\"__overflow__\"} 1")
);
}
#[test]
fn test_histogram_bucket_correctness() {
let registry = OperationMetricsRegistry::new(500);
registry.record("TestOp", 50_000, false);
let output = registry.to_prometheus_format();
assert!(output.contains(
"fraiseql_query_duration_seconds_bucket{operation=\"TestOp\",le=\"0.025\"} 0"
));
assert!(output.contains(
"fraiseql_query_duration_seconds_bucket{operation=\"TestOp\",le=\"0.05\"} 1"
));
assert!(
output.contains(
"fraiseql_query_duration_seconds_bucket{operation=\"TestOp\",le=\"0.1\"} 1"
)
);
}
}
mod tls_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::path::{Path, PathBuf};
use crate::{
server_config::{DatabaseTlsConfig, TlsServerConfig},
tls::*,
};
#[test]
fn test_tls_setup_disabled() {
let setup = TlsSetup::new(None, None);
assert!(!setup.is_tls_enabled());
}
#[test]
fn test_server_tls_enabled_is_detected_for_boot_refusal() {
let tls_config = TlsServerConfig {
enabled: true,
cert_path: PathBuf::from("/etc/ssl/cert.pem"),
key_path: PathBuf::from("/etc/ssl/key.pem"),
require_client_cert: false,
client_ca_path: None,
min_version: "1.2".to_string(),
};
let setup = TlsSetup::new(Some(tls_config), None);
assert!(setup.is_tls_enabled(), "an enabled [tls] config must be detected");
}
#[test]
fn test_database_tls_defaults() {
let setup = TlsSetup::new(None, None);
assert_eq!(setup.postgres_ssl_mode(), "prefer");
assert!(!setup.redis_ssl_enabled());
assert!(!setup.clickhouse_https_enabled());
assert!(!setup.elasticsearch_https_enabled());
assert!(setup.verify_certificates());
}
#[test]
fn test_postgres_url_tls_application() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "require".to_string(),
redis_ssl: false,
clickhouse_https: false,
elasticsearch_https: false,
verify_certificates: true,
ca_bundle_path: None,
};
let setup = TlsSetup::new(None, Some(db_config));
let url = "postgresql://localhost/fraiseql";
let tls_url = setup.apply_postgres_tls(url);
assert!(tls_url.contains("sslmode=require"));
}
#[test]
fn test_redis_url_tls_application() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "prefer".to_string(),
redis_ssl: true,
clickhouse_https: false,
elasticsearch_https: false,
verify_certificates: true,
ca_bundle_path: None,
};
let setup = TlsSetup::new(None, Some(db_config));
let url = "redis://localhost:6379";
let tls_url = setup.apply_redis_tls(url);
assert_eq!(tls_url, "rediss://localhost:6379");
}
#[test]
fn test_clickhouse_url_tls_application() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "prefer".to_string(),
redis_ssl: false,
clickhouse_https: true,
elasticsearch_https: false,
verify_certificates: true,
ca_bundle_path: None,
};
let setup = TlsSetup::new(None, Some(db_config));
let url = "http://localhost:8123";
let tls_url = setup.apply_clickhouse_tls(url);
assert_eq!(tls_url, "https://localhost:8123");
}
#[test]
fn test_elasticsearch_url_tls_application() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "prefer".to_string(),
redis_ssl: false,
clickhouse_https: false,
elasticsearch_https: true,
verify_certificates: true,
ca_bundle_path: None,
};
let setup = TlsSetup::new(None, Some(db_config));
let url = "http://localhost:9200";
let tls_url = setup.apply_elasticsearch_tls(url);
assert_eq!(tls_url, "https://localhost:9200");
}
#[test]
fn test_all_database_tls_enabled() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "require".to_string(),
redis_ssl: true,
clickhouse_https: true,
elasticsearch_https: true,
verify_certificates: true,
ca_bundle_path: Some(PathBuf::from("/etc/ssl/certs/ca-bundle.crt")),
};
let setup = TlsSetup::new(None, Some(db_config));
assert_eq!(setup.postgres_ssl_mode(), "require");
assert!(setup.redis_ssl_enabled());
assert!(setup.clickhouse_https_enabled());
assert!(setup.elasticsearch_https_enabled());
assert!(setup.verify_certificates());
assert!(
setup.ca_bundle_path().is_some(),
"ca_bundle_path should be propagated from DatabaseTlsConfig"
);
}
#[test]
fn test_postgres_url_with_existing_params() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "require".to_string(),
redis_ssl: false,
clickhouse_https: false,
elasticsearch_https: false,
verify_certificates: true,
ca_bundle_path: None,
};
let setup = TlsSetup::new(None, Some(db_config));
let url = "postgresql://localhost/fraiseql?application_name=fraiseql";
let tls_url = setup.apply_postgres_tls(url);
assert!(tls_url.contains("application_name=fraiseql"));
assert!(tls_url.contains("sslmode=require"));
}
#[test]
fn test_database_tls_config_getters() {
let db_config = DatabaseTlsConfig {
postgres_ssl_mode: "verify-full".to_string(),
redis_ssl: true,
clickhouse_https: true,
elasticsearch_https: false,
verify_certificates: true,
ca_bundle_path: Some(PathBuf::from("/etc/ssl/certs/ca.pem")),
};
let setup = TlsSetup::new(None, Some(db_config));
assert!(
setup.db_config().is_some(),
"db_config should be present when constructed with a DatabaseTlsConfig"
);
assert_eq!(setup.postgres_ssl_mode(), "verify-full");
assert!(setup.redis_ssl_enabled());
assert!(setup.clickhouse_https_enabled());
assert!(!setup.elasticsearch_https_enabled());
assert_eq!(setup.ca_bundle_path(), Some(Path::new("/etc/ssl/certs/ca.pem")));
}
}
mod token_revocation_tests {
#![allow(clippy::unwrap_used)]
use crate::token_revocation::*;
fn memory_store() -> std::sync::Arc<dyn RevocationStore> {
std::sync::Arc::new(InMemoryRevocationStore::new())
}
#[tokio::test]
async fn revoke_then_check_is_revoked() {
let store = memory_store();
store.revoke("jti-1", 3600).await.unwrap();
assert!(store.is_revoked("jti-1").await.unwrap());
}
#[tokio::test]
async fn non_revoked_jti_passes() {
let store = memory_store();
assert!(!store.is_revoked("jti-unknown").await.unwrap());
}
#[tokio::test]
async fn expired_entry_not_revoked() {
let store = InMemoryRevocationStore::new();
store.revoke("jti-expired", 0).await.unwrap();
assert!(!store.is_revoked("jti-expired").await.unwrap());
}
#[tokio::test]
async fn cleanup_removes_expired() {
let store = InMemoryRevocationStore::new();
store.revoke("jti-a", 0).await.unwrap();
store.revoke("jti-b", 3600).await.unwrap();
store.cleanup_expired();
assert_eq!(store.entries.len(), 1);
}
#[tokio::test]
async fn manager_rejects_revoked_token() {
let store = memory_store();
store.revoke("jti-x", 3600).await.unwrap();
let mgr = TokenRevocationManager::new(store, true, false, 3600);
assert_eq!(
mgr.check_token(Some("jti-x"), "alice", Some(1000)).await,
Err(TokenRejection::Revoked)
);
}
#[tokio::test]
async fn manager_allows_non_revoked_token() {
let mgr = TokenRevocationManager::new(memory_store(), true, false, 3600);
mgr.check_token(Some("jti-ok"), "alice", Some(1000))
.await
.unwrap_or_else(|e| panic!("expected Ok for non-revoked token: {e:?}"));
}
#[tokio::test]
async fn manager_rejects_missing_jti_when_required() {
let mgr = TokenRevocationManager::new(memory_store(), true, false, 3600);
assert_eq!(
mgr.check_token(None, "alice", Some(1000)).await,
Err(TokenRejection::MissingJti)
);
}
#[tokio::test]
async fn manager_allows_missing_jti_when_not_required() {
let mgr = TokenRevocationManager::new(memory_store(), false, false, 3600);
assert!(
mgr.check_token(None, "alice", Some(1000)).await.is_ok(),
"missing jti should be allowed when jti is not required"
);
}
#[tokio::test]
async fn manager_allows_empty_jti_when_not_required() {
let mgr = TokenRevocationManager::new(memory_store(), false, false, 3600);
assert!(
mgr.check_token(Some(""), "alice", Some(1000)).await.is_ok(),
"empty jti should be allowed when jti is not required"
);
}
#[tokio::test]
async fn revoke_all_revokes_tokens_by_epoch_not_individual_jti() {
let store = std::sync::Arc::new(InMemoryRevocationStore::new());
let mgr = TokenRevocationManager::new(
std::sync::Arc::clone(&store) as std::sync::Arc<dyn RevocationStore>,
true,
false,
3600,
);
mgr.check_token(Some("alice-old"), "alice", Some(1000))
.await
.expect("token is valid before revoke-all");
mgr.revoke_all_for_user("alice").await.unwrap();
assert_eq!(
mgr.check_token(Some("alice-old"), "alice", Some(1000)).await,
Err(TokenRejection::Revoked),
"a token issued before revoke-all must be rejected even though its jti was never revoked"
);
let future_iat = chrono::Utc::now().timestamp() + 100;
mgr.check_token(Some("alice-new"), "alice", Some(future_iat))
.await
.expect("a token issued after revoke-all must be accepted");
mgr.check_token(Some("bob-token"), "bob", Some(1000))
.await
.expect("bob's tokens are not affected by alice's revoke-all");
}
#[tokio::test]
async fn user_revoked_after_is_none_without_revoke_all() {
let store = InMemoryRevocationStore::new();
assert!(
store.user_revoked_after("unknown-user").await.unwrap().is_none(),
"a user with no revoke-all has no epoch"
);
}
#[tokio::test]
async fn revoke_all_epoch_expires_with_zero_ttl() {
let store = InMemoryRevocationStore::new();
store.revoke_all_for_user("alice", 0).await.unwrap();
assert!(
store.user_revoked_after("alice").await.unwrap().is_none(),
"a zero-ttl epoch is already expired and must not be returned"
);
}
#[test]
fn unix_now_helper_in_session_returns_reasonable_value() {
use fraiseql_auth::session::unix_now;
let now = unix_now().expect("unix_now should succeed on a normal system");
assert!(now >= 1_577_836_800, "unix_now should return a timestamp after 2020");
}
#[test]
fn hmac_fallback_tokens_differ_between_calls_even_for_same_user() {
use rand::Rng;
let key1: [u8; 32] = rand::rng().random();
let key2: [u8; 32] = rand::rng().random();
assert_ne!(key1, key2, "two OsRng-generated 256-bit keys must differ");
}
}
#[cfg(feature = "federation")]
mod tracing_utils_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 crate::tracing_utils::*;
#[test]
fn test_extract_valid_traceparent() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"traceparent",
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".parse().unwrap(),
);
let ctx = extract_trace_context(&headers);
let ctx = ctx.unwrap();
assert_eq!(ctx.trace_id, "4bf92f3577b34da6a3ce929d0e0e4736");
assert_eq!(ctx.parent_span_id, "00f067aa0ba902b7");
assert_eq!(ctx.trace_flags, "01");
}
#[test]
fn test_extract_invalid_traceparent() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("traceparent", "invalid-header".parse().unwrap());
let ctx = extract_trace_context(&headers);
assert!(ctx.is_none());
}
#[test]
fn test_extract_missing_traceparent() {
let headers = axum::http::HeaderMap::new();
let ctx = extract_trace_context(&headers);
assert!(ctx.is_none());
}
#[test]
fn test_extract_invalid_utf8_traceparent() {
let headers = axum::http::HeaderMap::new();
let ctx = extract_trace_context(&headers);
assert!(ctx.is_none());
}
}
mod trusted_documents_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::collections::HashMap;
use crate::trusted_documents::*;
fn test_documents() -> HashMap<String, String> {
let mut docs = HashMap::new();
docs.insert("sha256:abc123".to_string(), "{ users { id } }".to_string());
docs.insert("sha256:def456".to_string(), "mutation { createUser { id } }".to_string());
docs
}
#[test]
fn strict_mode_rejects_raw_query() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Strict);
let result = store.resolve(None, Some("{ users { id } }"));
assert!(matches!(result, Err(TrustedDocumentError::ForbiddenRawQuery)));
}
#[test]
fn strict_mode_accepts_valid_document_id() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Strict);
let result = store.resolve(Some("sha256:abc123"), None);
assert_eq!(result.unwrap(), "{ users { id } }");
}
#[test]
fn strict_mode_rejects_unknown_document_id() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Strict);
let result = store.resolve(Some("sha256:unknown"), None);
assert!(matches!(result, Err(TrustedDocumentError::DocumentNotFound { .. })));
}
#[test]
fn permissive_mode_allows_raw_queries() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Permissive);
let result = store.resolve(None, Some("{ arbitrary { query } }"));
assert_eq!(result.unwrap(), "{ arbitrary { query } }");
}
#[test]
fn permissive_mode_uses_manifest_for_document_id() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Permissive);
let result = store.resolve(Some("sha256:abc123"), None);
assert_eq!(result.unwrap(), "{ users { id } }");
}
#[test]
fn document_id_without_prefix_is_resolved() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Strict);
let result = store.resolve(Some("abc123"), None);
assert_eq!(result.unwrap(), "{ users { id } }");
}
#[test]
fn disabled_store_passes_through() {
let store = TrustedDocumentStore::disabled();
let result = store.resolve(None, Some("{ anything }"));
assert_eq!(result.unwrap(), "{ anything }");
}
#[test]
fn hot_reload_replaces_documents() {
let store =
TrustedDocumentStore::from_documents(test_documents(), TrustedDocumentMode::Strict);
assert_eq!(store.document_count(), 2);
let mut new_docs = HashMap::new();
new_docs.insert("sha256:new123".to_string(), "{ new query }".to_string());
store.replace_documents(new_docs);
assert_eq!(store.document_count(), 1);
let result = store.resolve(Some("sha256:new123"), None);
assert_eq!(result.unwrap(), "{ new query }");
let result = store.resolve(Some("sha256:abc123"), None);
assert!(
matches!(result, Err(TrustedDocumentError::DocumentNotFound { .. })),
"expected DocumentNotFound after hot-reload removed old document, got: {result:?}"
);
}
#[test]
fn manifest_file_loading() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trusted-documents.json");
let manifest = serde_json::json!({
"version": 1,
"documents": {
"sha256:aaa": "{ users { id } }",
"sha256:bbb": "{ posts { title } }"
}
});
std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
let store =
TrustedDocumentStore::from_manifest_file(&path, TrustedDocumentMode::Strict).unwrap();
assert_eq!(store.document_count(), 2);
let result = store.resolve(Some("sha256:aaa"), None);
assert_eq!(result.unwrap(), "{ users { id } }");
}
#[test]
fn manifest_file_exceeding_size_limit_is_rejected() {
use std::io::Write as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge-manifest.json");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"{\"version\":1,\"documents\":{}}").unwrap();
let padding = vec![b' '; (MAX_MANIFEST_BYTES + 1) as usize];
f.write_all(&padding).unwrap();
drop(f);
let result = TrustedDocumentStore::from_manifest_file(&path, TrustedDocumentMode::Strict);
assert!(result.is_err(), "oversized manifest must be rejected");
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("too large") || msg.contains("10485760"),
"error must mention size limit: {msg}"
);
}
#[test]
fn manifest_file_at_size_limit_is_accepted_if_valid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small-manifest.json");
let manifest = serde_json::json!({"version": 1, "documents": {}});
std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
TrustedDocumentStore::from_manifest_file(&path, TrustedDocumentMode::Permissive)
.unwrap_or_else(|e| panic!("small valid manifest must be accepted: {e}"));
}
}
mod trace_id_extraction_tests {
#![allow(clippy::unwrap_used)]
use axum::http::HeaderMap;
use crate::tracing_utils::extract_trace_id;
fn headers_with(traceparent: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("traceparent", traceparent.parse().unwrap());
h
}
#[test]
fn extracts_the_trace_id_field() {
let h = headers_with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
assert_eq!(extract_trace_id(&h).as_deref(), Some("4bf92f3577b34da6a3ce929d0e0e4736"));
}
#[test]
fn lowercases_hex_trace_id() {
let h = headers_with("00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01");
assert_eq!(extract_trace_id(&h).as_deref(), Some("4bf92f3577b34da6a3ce929d0e0e4736"));
}
#[test]
fn missing_header_is_none() {
assert_eq!(extract_trace_id(&HeaderMap::new()), None);
}
#[test]
fn malformed_or_all_zero_is_none() {
assert_eq!(extract_trace_id(&headers_with("garbage")), None);
assert_eq!(extract_trace_id(&headers_with("00-tooshort-00f067aa0ba902b7-01")), None);
assert_eq!(
extract_trace_id(&headers_with(
"00-00000000000000000000000000000000-00f067aa0ba902b7-01"
)),
None
);
}
}
mod trace_context_extraction_tests {
#![allow(clippy::unwrap_used)]
use axum::http::HeaderMap;
use serde_json::json;
use crate::tracing_utils::extract_trace_context_json;
fn headers(traceparent: Option<&str>, tracestate: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(tp) = traceparent {
h.insert("traceparent", tp.parse().unwrap());
}
if let Some(ts) = tracestate {
h.insert("tracestate", ts.parse().unwrap());
}
h
}
#[test]
fn parses_a_well_formed_traceparent_into_the_object() {
let h = headers(Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"), None);
assert_eq!(
extract_trace_context_json(&h),
Some(json!({
"version": "00",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"parent_id": "00f067aa0ba902b7",
"trace_flags": "01"
}))
);
}
#[test]
fn includes_tracestate_when_present_and_lowercases_hex() {
let h = headers(
Some("00-4BF92F3577B34DA6A3CE929D0E0E4736-00F067AA0BA902B7-01"),
Some("congo=t61rcWkgMzE,rojo=00f067aa0ba902b7"),
);
assert_eq!(
extract_trace_context_json(&h),
Some(json!({
"version": "00",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"parent_id": "00f067aa0ba902b7",
"trace_flags": "01",
"tracestate": "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7"
}))
);
}
#[test]
fn blank_tracestate_is_omitted() {
let h =
headers(Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"), Some(" "));
let v = extract_trace_context_json(&h).unwrap();
assert!(v.get("tracestate").is_none(), "blank tracestate omitted: {v}");
}
#[test]
fn missing_or_malformed_is_none() {
assert_eq!(extract_trace_context_json(&HeaderMap::new()), None);
assert_eq!(extract_trace_context_json(&headers(Some("garbage"), None)), None);
assert_eq!(
extract_trace_context_json(&headers(
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7"),
None
)),
None
);
assert_eq!(
extract_trace_context_json(&headers(
Some("00-00000000000000000000000000000000-00f067aa0ba902b7-01"),
None
)),
None
);
assert_eq!(
extract_trace_context_json(&headers(
Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f0-01"),
None
)),
None
);
}
}