use std::borrow::Cow;
use std::task::{Context, Poll};
use autumn_web::app::AppBuilder;
use autumn_web::config::AutumnConfig;
use autumn_web::error::AutumnError;
use autumn_web::middleware::{AutumnErrorInfo, ExceptionFilter};
use autumn_web::plugin::Plugin;
use autumn_web::test::TestApp;
use autumn_web::{ClientAddr, get, post, routes};
use axum::extract::Request;
use axum::response::{IntoResponse, Response};
#[get("/ok")]
async fn ok_handler() -> &'static str {
"ok"
}
#[get("/boom")]
async fn boom_handler() -> Result<String, AutumnError> {
Err(AutumnError::not_found_msg("gone"))
}
#[get("/panic")]
async fn panic_handler() -> &'static str {
panic!("handler exploded");
}
#[get("/whoami")]
async fn whoami(client: ClientAddr) -> String {
client.0.to_string()
}
#[post("/echo")]
async fn echo(body: axum::body::Bytes) -> String {
body.len().to_string()
}
#[get("/upload-config")]
async fn upload_config_probe(
config: Option<axum::Extension<autumn_web::security::UploadConfig>>,
) -> String {
config.map_or_else(
|| "missing".to_owned(),
|axum::Extension(cfg)| cfg.max_request_size_bytes.to_string(),
)
}
struct RewriteStatusTo500;
impl ExceptionFilter for RewriteStatusTo500 {
fn filter(&self, _error: &AutumnErrorInfo, response: Response) -> Response {
let (mut parts, body) = response.into_parts();
parts.status = axum::http::StatusCode::INTERNAL_SERVER_ERROR;
Response::from_parts(parts, body)
}
}
struct RewriteStatusPlugin;
impl Plugin for RewriteStatusPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("rewrite-status-filter")
}
fn build(self, app: AppBuilder) -> AppBuilder {
app.exception_filter(RewriteStatusTo500)
}
}
#[tokio::test]
async fn metrics_record_the_client_visible_status_not_the_pre_filter_one() {
let client = TestApp::new()
.plugin(RewriteStatusPlugin)
.routes(routes![boom_handler])
.build();
client.get("/boom").send().await.assert_status(500);
let snapshot = client.state().metrics().snapshot();
assert_eq!(
snapshot.http.by_status.s5xx,
1,
"MetricsLayer must observe the 500 the client received, not the \
handler's pre-filter 404 — seeing 4xx here means the layer moved \
inside the exception filter. by_status = 2xx:{} 3xx:{} 4xx:{} 5xx:{}",
snapshot.http.by_status.s2xx,
snapshot.http.by_status.s3xx,
snapshot.http.by_status.s4xx,
snapshot.http.by_status.s5xx,
);
assert_eq!(
snapshot.http.by_status.s4xx, 0,
"the pre-filter 404 must not appear in the metrics"
);
}
#[cfg(feature = "reporting")]
#[tokio::test]
async fn handler_panic_becomes_a_500_that_still_carries_the_request_id() {
let client = TestApp::new().routes(routes![panic_handler]).build();
let resp = client.get("/panic").send().await;
resp.assert_status(500);
assert!(
resp.headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("x-request-id")),
"a panic-turned-500 must still carry x-request-id, which only holds \
while the panic catch sits INNER to RequestIdLayer; headers = {:?}",
resp.headers
);
}
#[tokio::test]
async fn trusted_proxy_resolution_runs_before_client_addr_is_read() {
let mut config = AutumnConfig {
profile: Some("test".to_owned()),
..AutumnConfig::default()
};
config.security.trusted_proxies.trust_forwarded_headers = true;
config.security.trusted_proxies.ranges.clear();
config.security.trusted_proxies.trusted_hops = None;
let client = TestApp::new()
.config(config)
.routes(routes![whoami])
.build();
let resp = client
.get("/whoami")
.header("x-forwarded-for", "203.0.113.7")
.send()
.await;
resp.assert_status(200);
resp.assert_body_contains("203.0.113.7");
}
#[derive(Clone)]
struct RedirectGateLayer;
impl<S> tower::Layer<S> for RedirectGateLayer {
type Service = RedirectGateService<S>;
fn layer(&self, inner: S) -> Self::Service {
RedirectGateService { inner }
}
}
#[derive(Clone)]
struct RedirectGateService<S> {
inner: S,
}
impl<S> tower::Service<Request> for RedirectGateService<S>
where
S: tower::Service<Request, Response = Response> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Response = Response;
type Error = S::Error;
type Future =
std::pin::Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request) -> Self::Future {
if req.uri().path() == "/gated" {
return Box::pin(async {
Ok((
axum::http::StatusCode::FOUND,
[(axum::http::header::LOCATION, "/login")],
)
.into_response())
});
}
let mut inner = self.inner.clone();
std::mem::swap(&mut self.inner, &mut inner);
Box::pin(async move { inner.call(req).await })
}
}
#[tokio::test]
async fn gate_short_circuit_still_carries_security_headers() {
let client = TestApp::new()
.routes(routes![ok_handler])
.static_gate(RedirectGateLayer)
.build();
let resp = client.get("/gated").send().await;
resp.assert_status(302);
assert_eq!(resp.header("location"), Some("/login"));
assert!(
resp.header("x-content-type-options").is_some(),
"a gate short-circuit never reaches the inner stack, so it can only \
carry the security headers while SecurityHeadersLayer is applied \
OUTSIDE the static_gate layers; headers = {:?}",
resp.headers
);
client
.get("/ok")
.send()
.await
.assert_status(200)
.assert_body_contains("ok");
}
#[tokio::test]
async fn unmatched_routes_are_wrapped_by_the_framework_stack() {
let client = TestApp::new().routes(routes![ok_handler]).build();
let resp = client.get("/no-such-route").send().await;
resp.assert_status(404);
assert!(
resp.header("x-content-type-options").is_some(),
"the 404 fallback must be wrapped by SecurityHeadersLayer; headers = {:?}",
resp.headers
);
assert!(
resp.header("x-request-id").is_some(),
"the 404 fallback must be wrapped by RequestIdLayer; headers = {:?}",
resp.headers
);
}
#[tokio::test]
async fn option_layer_none_really_means_the_layer_is_absent() {
let mut enabled = AutumnConfig {
profile: Some("test".to_owned()),
..AutumnConfig::default()
};
enabled.cors.allowed_origins = vec!["https://allowed.example".to_owned()];
let with_cors = TestApp::new()
.config(enabled)
.routes(routes![ok_handler])
.build();
let resp = with_cors
.get("/ok")
.header("origin", "https://allowed.example")
.send()
.await;
resp.assert_status(200);
assert!(
resp.header("access-control-allow-origin").is_some(),
"positive control failed: an enabled CorsLayer must emit the header for \
an allowed origin, otherwise the negative case below proves nothing; \
headers = {:?}",
resp.headers
);
let mut disabled = AutumnConfig {
profile: Some("test".to_owned()),
..AutumnConfig::default()
};
disabled.cors.allowed_origins.clear();
let without_cors = TestApp::new()
.config(disabled)
.routes(routes![ok_handler])
.build();
let resp = without_cors
.get("/ok")
.header("origin", "https://allowed.example")
.send()
.await;
resp.assert_status(200);
assert!(
resp.header("access-control-allow-origin").is_none(),
"with no configured origins the CORS layer must be absent; headers = {:?}",
resp.headers
);
}
#[tokio::test]
async fn upload_guards_are_installed_in_the_ingress_stack() {
let mut config = AutumnConfig {
profile: Some("test".to_owned()),
..AutumnConfig::default()
};
config.security.upload.max_request_size_bytes = 128;
let client = TestApp::new()
.config(config)
.routes(routes![echo, upload_config_probe])
.build();
client
.post("/echo")
.header("content-type", "application/octet-stream")
.body("x".repeat(64))
.send()
.await
.assert_status(200)
.assert_body_contains("64");
client
.post("/echo")
.header("content-type", "application/octet-stream")
.body("x".repeat(4096))
.send()
.await
.assert_status(413);
client
.get("/upload-config")
.send()
.await
.assert_status(200)
.assert_body_contains("128");
}
static SUBMIT_HANDLER_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[post("/create")]
async fn counted_create() -> &'static str {
SUBMIT_HANDLER_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
"created"
}
#[tokio::test]
async fn csrf_is_validated_before_submit_token() {
const CSRF_TOKEN: &str = "csrf-order-guard-token";
const SUBMIT_TOKEN: &str = "submit-order-guard-token";
const REPLAYED: &str = "x-submit-token-replayed";
let mut config = AutumnConfig {
profile: Some("test".to_owned()),
..AutumnConfig::default()
};
config.security.csrf.enabled = true;
assert!(
config.security.submit_token.enabled,
"this test needs the submit-token guard installed by the real router"
);
let client = TestApp::new()
.config(config)
.routes(routes![counted_create])
.build();
let submit = |body: String| {
client
.post("/create")
.header("cookie", &format!("autumn-csrf={CSRF_TOKEN}"))
.form(&body)
};
let valid_body = format!("_csrf={CSRF_TOKEN}&_submit_token={SUBMIT_TOKEN}&title=hello");
let first = submit(valid_body.clone()).send().await;
first.assert_status(200);
assert!(
first.header(REPLAYED).is_none(),
"the first submission must be handled, not replayed; headers = {:?}",
first.headers
);
assert_eq!(
SUBMIT_HANDLER_RUNS.load(std::sync::atomic::Ordering::SeqCst),
1,
"the first submission must reach the handler exactly once"
);
let second = submit(valid_body).send().await;
assert_ne!(
second.status.as_u16(),
403,
"a replay carrying a valid `_csrf` must not be refused by CSRF; \
body = {}",
second.text()
);
assert_eq!(
second.header(REPLAYED),
Some("true"),
"the replay guard must short-circuit the second submission; status = {}, \
headers = {:?}",
second.status,
second.headers
);
assert_eq!(
SUBMIT_HANDLER_RUNS.load(std::sync::atomic::Ordering::SeqCst),
1,
"the handler must have run exactly once across both submissions"
);
let unauthenticated_replay = client
.post("/create")
.header("cookie", "unrelated=1")
.form(&format!("_submit_token={SUBMIT_TOKEN}&title=hello"))
.send()
.await;
assert_eq!(
unauthenticated_replay.status.as_u16(),
403,
"a replay with no valid `_csrf` must be refused by CSRF before the \
replay guard can serve the stored response; a 200 here means \
`SubmitTokenLayer` moved OUTSIDE `CsrfLayer`. headers = {:?}, body = {}",
unauthenticated_replay.headers,
unauthenticated_replay.text()
);
assert_eq!(
SUBMIT_HANDLER_RUNS.load(std::sync::atomic::Ordering::SeqCst),
1,
"the CSRF-refused replay must not reach the handler either"
);
}