use std::{
convert::Infallible,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use axum::{
Json, Router,
body::{Body, Bytes, to_bytes},
extract::MatchedPath,
routing::{get, post},
};
use http::{HeaderValue, Method, Request, Response, StatusCode};
use nidus_http::{
error::{ErrorEnvelopeLayer, HttpError},
health::{HealthRegistry, HealthStatus},
middleware::{
ApiDefaults, HttpMetricsHook, InMemoryRateLimitStore, PrometheusMetrics, RateLimitConfig,
RequestContext, RequestIdConfig, RequestIdMode, client_ip_identity, request_context_layer,
validated_request_id_layer,
},
};
use serde_json::json;
use tower::{ServiceBuilder, ServiceExt, service_fn};
#[tokio::test]
async fn validated_request_id_accepts_valid_ids_and_inserts_context() {
let app = Router::new()
.route(
"/context",
get(|context: RequestContext| async move {
Json(json!({
"requestId": context.request_id(),
"correlationId": context.correlation_id(),
"method": context.method().as_str(),
"route": context.route(),
"path": context.path(),
"clientKind": context.client_kind().as_str(),
}))
}),
)
.layer(request_context_layer())
.layer(validated_request_id_layer(
RequestIdConfig::production().mode(RequestIdMode::Strict),
));
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/context")
.header("x-request-id", "018f4ad7-56ce-4f6a-a759-29f4438d8d78")
.header("x-correlation-id", "corr-123")
.header("x-api-key", "secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let request_id = response.headers().get("x-request-id").cloned();
let body = response_json(response).await;
assert_eq!(request_id.unwrap(), "018f4ad7-56ce-4f6a-a759-29f4438d8d78");
assert_eq!(body["requestId"], "018f4ad7-56ce-4f6a-a759-29f4438d8d78");
assert_eq!(body["correlationId"], "corr-123");
assert_eq!(body["method"], "GET");
assert_eq!(body["route"], "/context");
assert_eq!(body["path"], "/context");
assert_eq!(body["clientKind"], "api_key");
}
#[tokio::test]
async fn strict_request_id_policy_rejects_malformed_incoming_ids() {
let app = Router::new()
.route("/", get(|| async { "unreached" }))
.layer(validated_request_id_layer(
RequestIdConfig::production().mode(RequestIdMode::Strict),
));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("x-request-id", "not a uuid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let request_id = response.headers().get("x-request-id").cloned();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(request_id.is_some());
assert_eq!(body["error"]["code"], "invalid_request_id");
assert_eq!(
body["error"]["requestId"],
request_id.unwrap().to_str().unwrap()
);
}
#[tokio::test]
async fn permissive_request_id_policy_replaces_malformed_ids() {
let service = ServiceBuilder::new()
.layer(validated_request_id_layer(
RequestIdConfig::production().mode(RequestIdMode::Permissive),
))
.service(service_fn(|request: Request<Body>| async move {
let context = request.extensions().get::<RequestContext>().unwrap();
Ok::<_, Infallible>(Response::new(Body::from(context.request_id().to_owned())))
}));
let response = service
.oneshot(
Request::builder()
.header("x-request-id", "bad")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let header = response.headers().get("x-request-id").cloned().unwrap();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_ne!(header, HeaderValue::from_static("bad"));
assert_eq!(body.as_ref(), header.to_str().unwrap().as_bytes());
}
#[tokio::test]
async fn generated_request_id_rejects_invalid_header_values_without_calling_inner_service() {
let calls = Arc::new(AtomicUsize::new(0));
let service = ServiceBuilder::new()
.layer(validated_request_id_layer(
RequestIdConfig::production().generator(|| "bad\nrequest-id".to_owned()),
))
.service(service_fn({
let calls = Arc::clone(&calls);
move |_request: Request<Body>| {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok::<_, Infallible>(Response::new(Body::from("unreached")))
}
}
}));
let response = service
.oneshot(
Request::builder()
.uri("/missing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(body["error"]["code"], "invalid_generated_request_id");
assert_eq!(body["error"]["path"], "/missing");
}
#[tokio::test]
async fn strict_request_id_rejection_handles_invalid_generated_error_ids() {
let app = Router::new()
.route("/", get(|| async { "unreached" }))
.layer(validated_request_id_layer(
RequestIdConfig::production().generator(|| "bad\nrequest-id".to_owned()),
));
let response = app
.oneshot(
Request::builder()
.uri("/strict")
.header("x-request-id", "not-a-uuid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["error"]["code"], "invalid_generated_request_id");
assert_eq!(body["error"]["path"], "/strict");
}
#[tokio::test]
async fn error_envelope_includes_request_id_path_and_timestamp() {
let app = Router::new()
.route(
"/users/42",
get(|| async { HttpError::not_found("missing") }),
)
.layer(ErrorEnvelopeLayer::new())
.layer(request_context_layer())
.layer(validated_request_id_layer(
RequestIdConfig::production().mode(RequestIdMode::Strict),
));
let response = app
.oneshot(
Request::builder()
.uri("/users/42")
.header("x-request-id", "018f4ad7-56ce-4f6a-a759-29f4438d8d78")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["statusCode"], 404);
assert_eq!(body["error"]["code"], "not_found");
assert_eq!(body["error"]["message"], "missing");
assert_eq!(body["error"]["path"], "/users/42");
assert_eq!(
body["error"]["requestId"],
"018f4ad7-56ce-4f6a-a759-29f4438d8d78"
);
assert!(body["error"]["timestamp"].as_str().unwrap().ends_with('Z'));
}
#[tokio::test]
async fn error_envelope_preserves_legacy_json_error_details() {
let service = ServiceBuilder::new()
.layer(ErrorEnvelopeLayer::new())
.service(service_fn(|_request: Request<Body>| async move {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.body(Body::from(
r#"{"error":{"code":"invalid_user","message":"invalid user","field":"email"}}"#,
))
.unwrap(),
)
}));
let response = service
.oneshot(
Request::builder()
.uri("/legacy")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["error"]["code"], "invalid_user");
assert_eq!(body["error"]["message"], "invalid user");
assert_eq!(body["error"]["details"]["field"], "email");
assert_eq!(body["error"]["path"], "/legacy");
}
#[tokio::test]
async fn error_envelope_wraps_non_json_error_bodies() {
let service = ServiceBuilder::new()
.layer(ErrorEnvelopeLayer::new())
.service(service_fn(|_request: Request<Body>| async move {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("plain missing"))
.unwrap(),
)
}));
let response = service
.oneshot(
Request::builder()
.uri("/plain")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "not_found");
assert_eq!(body["error"]["message"], "Not Found");
assert_eq!(body["error"]["details"], serde_json::Value::Null);
assert_eq!(body["error"]["path"], "/plain");
}
#[tokio::test]
async fn error_envelope_masks_5xx_legacy_error_code_message_and_details() {
let service = ServiceBuilder::new()
.layer(ErrorEnvelopeLayer::new())
.service(service_fn(|_request: Request<Body>| async move {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(
r#"{"error":{"code":"database_error","message":"database password leaked","secret":"value"}}"#,
))
.unwrap(),
)
}));
let response = service
.oneshot(
Request::builder()
.uri("/panic")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["error"]["code"], "internal_server_error");
assert_eq!(body["error"]["message"], "internal server error");
assert_eq!(body["error"]["details"], serde_json::Value::Null);
assert_eq!(body["error"]["path"], "/panic");
}
#[tokio::test]
async fn error_envelope_skips_oversized_legacy_error_bodies() {
let oversized_message = "x".repeat(128 * 1024);
let oversized_body = serde_json::to_string(&json!({
"error": {
"code": "oversized_legacy",
"message": oversized_message,
}
}))
.unwrap();
let service = ServiceBuilder::new()
.layer(ErrorEnvelopeLayer::new())
.service(service_fn(move |_request: Request<Body>| {
let oversized_body = oversized_body.clone();
async move {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from(oversized_body))
.unwrap(),
)
}
}));
let response = service
.oneshot(Request::builder().uri("/huge").body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "bad_request");
assert_eq!(body["error"]["message"], "Bad Request");
assert_eq!(body["error"]["path"], "/huge");
}
#[tokio::test]
async fn health_registry_runs_ready_checks_in_parallel_and_controls_details() {
let registry = HealthRegistry::new()
.live_check_sync("process", HealthStatus::up)
.ready_check("database", || async { HealthStatus::up() })
.ready_check_sync("cache", || HealthStatus::down("cache unavailable"))
.timeout(Duration::from_secs(1))
.hide_details();
let app = Router::new().merge(registry.routes());
let live = app
.clone()
.oneshot(
Request::builder()
.uri("/health/live")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(live.status(), StatusCode::OK);
let ready = app
.oneshot(
Request::builder()
.uri("/health/ready")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = ready.status();
let body = response_json(ready).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["status"], "down");
assert_eq!(body["checks"]["cache"]["status"], "down");
assert!(body["checks"]["cache"].get("message").is_none());
}
#[tokio::test]
async fn health_registry_reports_panicking_checks_as_down() {
let registry = HealthRegistry::new()
.ready_check_sync("cache", || panic!("cache client panicked"))
.timeout(Duration::from_secs(1));
let app = Router::new().merge(registry.routes());
let response = app
.oneshot(
Request::builder()
.uri("/health/ready")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["status"], "down");
assert_eq!(body["checks"]["cache"]["status"], "down");
assert_eq!(body["checks"]["cache"]["message"], "check panicked");
}
#[tokio::test]
async fn prometheus_metrics_uses_matched_routes_and_excludes_internal_paths() {
let metrics = PrometheusMetrics::new().exclude_route("/metrics");
let app = Router::new()
.route("/users/{id}", get(|| async { "ok" }))
.route(
"/metrics",
get({
let metrics = metrics.clone();
move || async move { metrics.render() }
}),
)
.layer(metrics.layer());
let _ = app
.clone()
.oneshot(
Request::builder()
.uri("/users/42")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let response = app
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains(r#"route="/users/{id}""#), "{text}");
assert!(!text.contains(r#"route="/users/42""#), "{text}");
assert!(!text.contains(r#"route="/metrics""#), "{text}");
}
#[test]
fn prometheus_metrics_renders_bounded_duration_histogram_buckets() {
let metrics = PrometheusMetrics::new();
for milliseconds in 1..=1000 {
metrics.on_request(&Method::GET, Some("/bulk"));
metrics.on_response(
&Method::GET,
Some("/bulk"),
StatusCode::OK,
Duration::from_millis(milliseconds % 20),
);
}
let text = metrics.render();
assert!(
text.contains(
r#"nidus_http_request_duration_seconds_bucket{method="GET",route="/bulk",status="200",le="0.005"}"#
),
"{text}"
);
assert!(
text.contains(
r#"nidus_http_request_duration_seconds_bucket{method="GET",route="/bulk",status="200",le="+Inf"} 1000"#
),
"{text}"
);
assert!(
text.contains(
r#"nidus_http_request_duration_seconds_count{method="GET",route="/bulk",status="200"} 1000"#
),
"{text}"
);
}
#[test]
fn prometheus_metrics_counts_inner_service_errors_as_requests() {
let metrics = PrometheusMetrics::new();
metrics.on_request(&Method::GET, Some("/fallible"));
metrics.on_error(&Method::GET, Some("/fallible"), Duration::from_millis(25));
let text = metrics.render();
assert!(
text.contains(
r#"nidus_http_requests_total{method="GET",route="/fallible",status="500"} 1"#
),
"{text}"
);
assert!(
text.contains(
r#"nidus_http_request_duration_seconds_count{method="GET",route="/fallible",status="500"} 1"#
),
"{text}"
);
assert!(
text.contains(r#"nidus_http_errors_total{method="GET",route="/fallible",status="500"} 1"#),
"{text}"
);
assert!(
text.contains(r#"nidus_http_in_flight_requests{method="GET",route="/fallible"} 0"#),
"{text}"
);
}
#[test]
fn prometheus_metrics_escapes_label_values() {
let metrics = PrometheusMetrics::new();
metrics.on_request(&Method::GET, Some("/quoted\"route\\line\nbreak"));
metrics.on_response(
&Method::GET,
Some("/quoted\"route\\line\nbreak"),
StatusCode::OK,
Duration::from_millis(1),
);
let text = metrics.render();
assert!(
text.contains(r#"route="/quoted\"route\\line\nbreak""#),
"{text}"
);
assert!(
!text.contains("line\nbreak"),
"raw newlines must be escaped in label values: {text}"
);
}
#[test]
fn prometheus_metrics_records_high_cardinality_routes_explicitly() {
let metrics = PrometheusMetrics::new();
for route in (0..25).map(|index| format!("/users/{index}")) {
metrics.on_request(&Method::GET, Some(&route));
metrics.on_response(
&Method::GET,
Some(&route),
StatusCode::OK,
Duration::from_millis(1),
);
}
let text = metrics.render();
assert_eq!(text.matches("nidus_http_requests_total").count(), 26);
assert!(text.contains(r#"route="/users/0""#), "{text}");
assert!(text.contains(r#"route="/users/24""#), "{text}");
}
#[test]
fn prometheus_metrics_caps_distinct_routes_when_max_series_configured() {
let metrics = PrometheusMetrics::new().with_max_series(2);
for route in ["/a", "/b", "/c"] {
metrics.on_request(&Method::GET, Some(route));
metrics.on_response(
&Method::GET,
Some(route),
StatusCode::OK,
Duration::from_millis(1),
);
}
let text = metrics.render();
assert!(text.contains(r#"route="/a""#), "{text}");
assert!(text.contains(r#"route="/b""#), "{text}");
assert!(
!text.contains(r#"route="/c""#),
"route beyond the cap must overflow, not create a new series: {text}"
);
assert!(
text.contains(r#"route="<overflow>""#),
"overflow bucket must be present: {text}"
);
}
#[test]
fn prometheus_metrics_unbounded_by_default_admits_all_routes() {
let metrics = PrometheusMetrics::new();
for route in ["/a", "/b", "/c", "/d"] {
metrics.on_request(&Method::GET, Some(route));
metrics.on_response(
&Method::GET,
Some(route),
StatusCode::OK,
Duration::from_millis(1),
);
}
let text = metrics.render();
assert!(text.contains(r#"route="/a""#), "{text}");
assert!(text.contains(r#"route="/d""#), "{text}");
assert!(
!text.contains(r#"route="<overflow>""#),
"no overflow bucket when cap is not configured: {text}"
);
}
#[test]
fn prometheus_metrics_can_render_while_recording_concurrently() {
let metrics = PrometheusMetrics::new();
let writers = (0..4)
.map(|worker| {
let metrics = metrics.clone();
std::thread::spawn(move || {
for index in 0..200 {
let route = format!("/workers/{worker}/requests/{index}");
metrics.on_request(&Method::GET, Some(&route));
metrics.on_response(
&Method::GET,
Some(&route),
StatusCode::OK,
Duration::from_micros(250),
);
}
})
})
.collect::<Vec<_>>();
for _ in 0..50 {
let text = metrics.render();
assert!(text.contains("# TYPE nidus_http_requests_total counter"));
}
for writer in writers {
writer.join().unwrap();
}
let text = metrics.render();
assert!(text.contains(r#"route="/workers/0/requests/0""#), "{text}");
assert!(
text.contains(r#"route="/workers/3/requests/199""#),
"{text}"
);
}
#[tokio::test]
async fn production_api_defaults_composes_routes_layers_and_overrides() {
async fn matched_path(path: MatchedPath) -> String {
path.as_str().to_owned()
}
let metrics = PrometheusMetrics::new();
let defaults = ApiDefaults::production("users-api")
.metrics(metrics.clone())
.request_ids(RequestIdConfig::production().mode(RequestIdMode::Strict))
.rate_limit(
RateLimitConfig::new(1, Duration::from_secs(60), InMemoryRateLimitStore::new())
.identity(client_ip_identity())
.fail_closed(),
);
let app = defaults.apply(Router::new().route("/users/{id}", get(matched_path)).route(
"/metrics",
get({
let metrics = metrics.clone();
move || async move { metrics.render() }
}),
));
let first = app
.clone()
.oneshot(
Request::builder()
.uri("/users/42")
.header("x-request-id", "018f4ad7-56ce-4f6a-a759-29f4438d8d78")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(
first.headers().get("x-request-id").unwrap(),
"018f4ad7-56ce-4f6a-a759-29f4438d8d78"
);
let second = app
.oneshot(
Request::builder()
.uri("/users/43")
.header("x-request-id", "018f4ad7-56ce-4f6a-a759-29f4438d8d79")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(second.headers().contains_key("retry-after"));
}
#[tokio::test]
async fn production_api_defaults_apply_security_headers_body_limit_and_timeout() {
let metrics = PrometheusMetrics::new();
let app = ApiDefaults::production("users-api")
.metrics(metrics.clone())
.body_limit(4)
.timeout(Duration::from_millis(1))
.apply(
Router::new()
.route(
"/slow",
get(|| async {
tokio::time::sleep(Duration::from_millis(20)).await;
"late"
}),
)
.merge(metrics.routes()),
);
let oversized = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/slow")
.header("content-length", "5")
.body(Body::from("12345"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(oversized.headers()["x-content-type-options"], "nosniff");
let timeout = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/slow")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(timeout.status(), StatusCode::REQUEST_TIMEOUT);
assert!(timeout.headers().contains_key("x-request-id"));
assert_eq!(timeout.headers()["x-content-type-options"], "nosniff");
let metrics = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(metrics.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(
text.contains(r#"nidus_http_requests_total{method="GET",route="/slow",status="408"} 1"#),
"{text}"
);
assert!(
text.contains(r#"nidus_http_in_flight_requests{method="GET",route="/slow"} 0"#),
"{text}"
);
}
#[tokio::test]
async fn production_defaults_envelope_meter_and_identify_handler_errors() {
let metrics = PrometheusMetrics::new();
let app = ApiDefaults::production("users-api")
.metrics(metrics.clone())
.apply(
Router::new()
.route(
"/boom",
get(|| async {
Err::<&'static str, HttpError>(HttpError::internal_server_error())
}),
)
.merge(metrics.routes()),
);
let response = app
.clone()
.oneshot(Request::builder().uri("/boom").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(response.headers().contains_key("x-request-id"));
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
let body = response_json(response).await;
assert_eq!(body["error"]["statusCode"], 500);
assert_eq!(body["error"]["message"], "internal server error");
assert!(body["error"]["requestId"].is_string());
let metrics_response = app
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let text = String::from_utf8(
to_bytes(metrics_response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
text.contains(r#"nidus_http_requests_total{method="GET",route="/boom",status="500"} 1"#),
"{text}"
);
}
#[tokio::test]
async fn body_limit_without_streaming_cap_is_bypassed_without_content_length() {
let app = ApiDefaults::production("users-api")
.body_limit(4)
.apply(Router::new().route(
"/echo",
post(|bytes: Bytes| async move { bytes.len().to_string() }),
));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/echo")
.body(Body::from(vec![b'a'; 1024]))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"a headerless body bypasses the Content-Length-only limit"
);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(&*body, b"1024");
}
#[tokio::test]
async fn streaming_body_limit_caps_bodies_without_content_length() {
let app = ApiDefaults::production("users-api")
.body_limit(4)
.streaming_body_limit(4)
.apply(Router::new().route("/echo", post(|_bytes: Bytes| async move { "ok" })));
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/echo")
.body(Body::from(vec![b'a'; 1024]))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"streaming_body_limit must cap a headerless body as it is read"
);
}
#[tokio::test]
async fn production_defaults_envelope_and_meter_body_limit_rejections() {
let metrics = PrometheusMetrics::new();
let app = ApiDefaults::production("users-api")
.metrics(metrics.clone())
.body_limit(4)
.apply(
Router::new()
.route("/echo", get(|| async { "ok" }))
.merge(metrics.routes()),
);
let oversized = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/echo")
.header("content-length", "5")
.body(Body::from("12345"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert!(
oversized.headers().contains_key("x-request-id"),
"413 must carry a request id"
);
assert_eq!(oversized.headers()["x-content-type-options"], "nosniff");
let body = response_json(oversized).await;
assert_eq!(body["error"]["statusCode"], 413, "413 must be enveloped");
let metrics_response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let text = String::from_utf8(
to_bytes(metrics_response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
text.contains(r#"status="413""#),
"413 must be metered: {text}"
);
}
#[tokio::test]
async fn production_defaults_envelope_unmatched_routes_as_not_found() {
let metrics = PrometheusMetrics::new();
let app = ApiDefaults::production("users-api")
.metrics(metrics.clone())
.apply(
Router::new()
.route("/users", get(|| async { "ok" }))
.merge(metrics.routes()),
);
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/missing")
.header("x-request-id", "018f4ad7-56ce-4f6a-a759-29f4438d8d78")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.headers()["content-type"], "application/json");
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert_eq!(
response.headers()["x-request-id"],
"018f4ad7-56ce-4f6a-a759-29f4438d8d78"
);
let body = response_json(response).await;
assert_eq!(body["error"]["statusCode"], 404);
assert_eq!(body["error"]["code"], "not_found");
assert_eq!(body["error"]["message"], "route not found");
assert_eq!(body["error"]["path"], "/missing");
assert_eq!(
body["error"]["requestId"],
"018f4ad7-56ce-4f6a-a759-29f4438d8d78"
);
assert!(body["error"]["timestamp"].as_str().unwrap().ends_with('Z'));
let metrics_response = app
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let text = String::from_utf8(
to_bytes(metrics_response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
text.contains(
r#"nidus_http_requests_total{method="GET",route="<unknown>",status="404"} 1"#
),
"404 fallback must be metered by the production stack: {text}"
);
}
#[tokio::test]
async fn production_defaults_envelope_panic_as_500() {
async fn panicking_handler() -> &'static str {
panic!("handler panicked");
}
let metrics = PrometheusMetrics::new();
let app = ApiDefaults::production("users-api")
.metrics(metrics.clone())
.apply(
Router::new()
.route("/panic", get(panicking_handler))
.merge(metrics.routes()),
);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/panic")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(
response.headers().contains_key("x-request-id"),
"panic response must carry a request id"
);
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
let body = response_json(response).await;
assert_eq!(body["error"]["statusCode"], 500);
assert_eq!(body["error"]["message"], "internal server error");
assert!(body["error"]["requestId"].is_string());
let metrics_response = app
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let text = String::from_utf8(
to_bytes(metrics_response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
text.contains(r#"nidus_http_requests_total{method="GET",route="/panic",status="500"} 1"#),
"{text}"
);
}
async fn response_json(response: axum::response::Response) -> serde_json::Value {
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&body).unwrap()
}