use std::sync::Arc;
use std::time::Duration;
use crate::app::ScopedGroup;
use crate::config::AutumnConfig;
#[cfg(feature = "maud")]
use crate::error_pages::{self, SharedRenderer};
use crate::extract::State;
use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
use crate::middleware::RequestIdLayer;
use crate::middleware::dev;
use crate::middleware::exception_filter::{
ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
};
use crate::route::Route;
use crate::state::AppState;
use axum::middleware::Next;
use axum::response::IntoResponse;
use http::{Request, StatusCode};
use thiserror::Error;
pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RouterBuildError {
#[error("invalid session backend configuration: {0}")]
InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
#[error("invalid idempotency backend configuration: {0}")]
#[allow(dead_code)] InvalidIdempotencyBackend(String),
#[error("invalid submit-token backend configuration: {0}")]
InvalidSubmitTokenBackend(String),
#[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
FrameworkRouteOverlap {
path: String,
existing: &'static str,
incoming: &'static str,
},
#[cfg(feature = "openapi")]
#[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
InvalidOpenApiPath {
field: &'static str,
value: String,
},
#[cfg(feature = "openapi")]
#[error(
"openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
)]
DuplicateOpenApiPath {
path: String,
},
#[cfg(feature = "openapi")]
#[error(
"OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
)]
OpenApiPathCollision {
field: &'static str,
path: String,
},
#[error("route '{route_name}' uses unregistered API version '{version}'")]
UnregisteredApiVersion { route_name: String, version: String },
#[cfg(feature = "mcp")]
#[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
InvalidMcpPath {
value: String,
},
#[cfg(feature = "mcp")]
#[error(
"MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
)]
McpPathCollision {
path: String,
method: String,
},
#[error(
"duplicate user route: {existing:?} and {incoming:?} both resolve to {method} {path:?}; \
choose a different path for one of them or remove the duplicate registration"
)]
DuplicateUserRoute {
method: String,
path: String,
existing: String,
incoming: String,
},
#[error(
"conflicting route shapes: {existing:?} ({existing_path:?}) and {incoming:?} ({incoming_path:?}) \
resolve to the same Axum path shape but use different path templates; axum's matchit router \
rejects this as a route conflict regardless of HTTP method — rename the captures so both use the \
same template, or make their static paths distinct"
)]
ConflictingRouteShape {
existing: String,
existing_path: String,
incoming: String,
incoming_path: String,
},
}
#[allow(dead_code)]
pub fn build_router(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
) -> axum::Router {
try_build_router(route_list, config, state)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
pub struct RouterContext {
pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
pub scoped_groups: Vec<ScopedGroup>,
pub merge_routers: Vec<axum::Router<AppState>>,
pub nest_routers: Vec<(String, axum::Router<AppState>)>,
pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
pub static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
#[cfg(feature = "maud")]
pub error_page_renderer: Option<SharedRenderer>,
pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
#[cfg(feature = "openapi")]
pub openapi: Option<crate::openapi::OpenApiConfig>,
#[cfg(feature = "mcp")]
pub mcp: Option<crate::mcp::McpRuntime>,
}
pub fn try_build_router(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let router = try_build_router_inner(
route_list,
config,
state,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
},
)?;
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
#[allow(dead_code)]
pub fn build_router_merged(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> axum::Router {
try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
#[allow(dead_code)]
pub fn try_build_router_merged(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let router = try_build_router_inner(
route_list,
config,
state,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers,
nest_routers,
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
},
)?;
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
pub fn try_build_router_inner(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
ctx: RouterContext,
) -> Result<axum::Router, RouterBuildError> {
let router = build_router_pre_state(route_list, config, &state, ctx, None, false)?;
Ok(router.with_state(state))
}
pub fn try_build_probe_only_router(
config: &AutumnConfig,
state: AppState,
) -> Result<axum::Router, RouterBuildError> {
let barrier_state = state.clone();
let no_user_routes = std::collections::HashSet::new();
let (mounted_probe_paths, router) =
mount_probe_endpoints(axum::Router::<AppState>::new(), config, &no_user_routes);
let router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
let router = router.with_state(state);
Ok(apply_startup_barrier(router, config, &barrier_state))
}
#[cfg(feature = "mcp")]
type McpPrepared = (
String,
Vec<crate::mcp::McpToolInfo>,
Option<crate::mcp::McpEndpointLayer>,
);
#[allow(clippy::too_many_lines)]
fn build_router_pre_state(
route_list: Vec<Route>,
config: &AutumnConfig,
state: &AppState,
#[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
opaque_app_layers_override: Option<bool>,
defer_security_headers: bool,
) -> Result<axum::Router<AppState>, RouterBuildError> {
let versions = state.extension::<crate::app::RegisteredApiVersions>();
let registered_versions: std::collections::HashSet<&str> = versions
.as_ref()
.map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
.unwrap_or_default();
let check_route_version = |route: &Route| -> Result<(), RouterBuildError> {
if let Some(version) = route
.api_version
.filter(|ver| !registered_versions.contains(*ver))
{
return Err(RouterBuildError::UnregisteredApiVersion {
route_name: route.name.to_string(),
version: version.to_string(),
});
}
Ok(())
};
for route in &route_list {
check_route_version(route)?;
}
for group in &ctx.scoped_groups {
for route in &group.routes {
check_route_version(route)?;
}
}
reject_duplicate_user_routes(
&route_list,
&ctx.scoped_groups,
&ctx.merge_routers,
&ctx.nest_routers,
)?;
#[cfg(feature = "openapi")]
reject_openapi_path_collisions(
ctx.openapi.as_ref(),
&route_list,
&ctx.scoped_groups,
&ctx.merge_routers,
&ctx.nest_routers,
config,
)?;
#[cfg(feature = "openapi")]
let openapi_router = build_openapi_router(
&route_list,
&ctx.scoped_groups,
ctx.openapi.as_ref(),
&config.session.cookie_name,
versions.as_ref().map_or(&[], |v| v.0.as_slice()),
)?;
#[cfg(feature = "mcp")]
let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
let path = rt.mount_path.as_str();
if path.is_empty()
|| !path.starts_with('/')
|| path.contains("//")
|| path.contains('{')
|| path.contains('*')
|| path.split('/').any(|segment| segment.starts_with(':'))
{
return Err(RouterBuildError::InvalidMcpPath {
value: rt.mount_path,
});
}
reject_mcp_path_collisions(
path,
&route_list,
&ctx.scoped_groups,
config,
ctx.openapi.as_ref(),
&ctx.merge_routers,
&ctx.nest_routers,
)?;
let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
Some((rt.mount_path, tools, rt.endpoint_layer))
} else {
None
};
let route_timeouts = build_route_timeout_table(&route_list, &ctx.scoped_groups);
let idempotency_layers = build_idempotency_layers(config, state)?;
let opaque_app_layers_present = opaque_app_layers_override.unwrap_or_else(|| {
custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
|| custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers)
});
let user_get_paths = collect_user_get_paths(&route_list, &ctx.scoped_groups);
let mut router = group_and_mount_routes(
route_list,
idempotency_layers.as_ref(),
opaque_app_layers_present,
state,
);
let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
router = mount_framework_routes(router, config, dev_reload_enabled);
let (mounted_probe_paths, router_with_probes) =
mount_probe_endpoints(router, config, &user_get_paths);
router = router_with_probes;
router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
#[cfg(feature = "openapi")]
if let Some(openapi_router) = openapi_router {
router = router.merge(openapi_router);
}
#[cfg(feature = "embed-assets")]
let embedded_static = crate::assets::embedded_static_dir().is_some();
#[cfg(not(feature = "embed-assets"))]
let embedded_static = false;
if embedded_static {
#[cfg(feature = "embed-assets")]
{
router = router.route(
"/static/{*path}",
axum::routing::get(crate::assets::serve_embedded),
);
}
} else {
let env = crate::config::OsEnv;
let static_dir = crate::app::project_dir("static", &env);
router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
}
router = router.layer(axum::middleware::from_fn(
crate::assets::asset_cache_control,
));
router = mount_scoped_groups(
router,
ctx.scoped_groups,
idempotency_layers.as_ref(),
state,
);
router = mount_raw_routers(
router,
ctx.merge_routers,
ctx.nest_routers,
idempotency_layers.as_ref(),
);
let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
let load_shed_layer = build_load_shed_layer(config, state);
#[cfg(feature = "mcp")]
let mcp_load_shed_layer = load_shed_layer.clone();
router = apply_middleware(
router,
config,
state,
ctx.exception_filters,
ctx.custom_layers,
#[cfg(feature = "maud")]
ctx.error_page_renderer,
ctx.session_store,
route_timeouts,
load_shed_layer,
)?;
if dev_reload_enabled {
router = router
.layer(axum::middleware::from_fn(dev::disable_static_cache))
.layer(axum::middleware::from_fn(dev::inject_live_reload));
}
let is_dev_profile = matches!(config.profile.as_deref(), Some("dev" | "development"));
if is_dev_profile {
router = router.route_layer(axum::middleware::from_fn(
crate::middleware::dev::capture_matched_path_middleware,
));
}
if is_dev_profile {
let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
let inspector_path = config.dev.inspector_path.clone();
let threshold = config.dev.inspector_n_plus_one_threshold;
router = router.merge(crate::inspector::inspector_router(
buf.clone(),
&inspector_path,
));
tracing::debug!(
path = %inspector_path,
"Mounted dev request inspector"
);
let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
.with_session_cookie_name(config.session.cookie_name.clone());
router = router.layer(layer);
}
#[cfg(feature = "oauth2")]
let router = router.layer(axum::middleware::from_fn_with_state(
state.clone(),
http_interceptor_middleware,
));
let router = router.layer(axum::middleware::from_fn_with_state(
state.clone(),
event_app_context_middleware,
));
#[cfg(feature = "mcp")]
let router = if let Some((mount_path, tools, endpoint_layer)) = mcp_prepared {
let dispatch = router
.clone()
.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
))
.with_state(state.clone());
let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
.then(|| config.tenancy.header_name.clone());
let wiring = crate::mcp::McpWiring {
cors: config.cors.clone(),
trusted_hosts: TrustedHostPolicy::from_config(config),
tenant_header,
csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
envelope_rate_limited: config.security.rate_limit.enabled,
envelope_load_shed: mcp_load_shed_layer.is_some(),
};
let mut mcp_router =
crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
if let Some(load_shed) = mcp_load_shed_layer {
mcp_router = mcp_router.layer(load_shed);
}
mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
config.security.upload.max_request_size_bytes,
));
mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
mcp_router = apply_request_timeout_middleware(
mcp_router,
config,
state.metrics.clone(),
std::sync::Arc::new(std::collections::HashMap::new()),
false,
);
mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
));
mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
router.merge(mcp_router)
} else {
router
};
let router = if defer_security_headers {
router
} else {
let router =
apply_layers_in_registration_order(router, static_gate_layers, "Pre-static gate");
router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
))
};
Ok(router)
}
#[cfg(feature = "openapi")]
pub fn extract_path_params(path: &str) -> Vec<String> {
let mut out = Vec::new();
let mut remaining = path;
while let Some(start) = remaining.find('{') {
let after_brace = &remaining[start + 1..];
if let Some(rest) = after_brace.strip_prefix('{') {
remaining = rest;
continue;
}
let Some(end_rel) = after_brace.find('}') else {
break;
};
let inner = &after_brace[..end_rel];
let name = inner.split(':').next().unwrap_or(inner).trim();
if !name.is_empty() && !name.contains('{') && !name.contains('}') {
out.push(name.to_owned());
}
remaining = &after_brace[end_rel + 1..];
}
out
}
#[cfg(feature = "openapi")]
async fn serve_openapi_spec(
state: axum::extract::State<AppState>,
axum::extract::Extension(config): axum::extract::Extension<
std::sync::Arc<crate::openapi::OpenApiConfig>,
>,
axum::extract::Extension(docs): axum::extract::Extension<
std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
>,
) -> impl axum::response::IntoResponse {
use axum::response::IntoResponse;
let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
let now = state.clock().now();
let spec = crate::openapi::generate_spec_at(&config, &refs, now);
let spec_json = serde_json::to_string_pretty(&spec)
.unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
(
[(http::header::CONTENT_TYPE, "application/json")],
spec_json,
)
.into_response()
}
#[cfg(feature = "openapi")]
fn build_openapi_router(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
openapi_config: Option<&crate::openapi::OpenApiConfig>,
session_cookie_name: &str,
api_versions: &[crate::app::ApiVersion],
) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
let Some(config) = openapi_config else {
return Ok(None);
};
let mut config = config.clone();
session_cookie_name.clone_into(&mut config.session_cookie_name);
config.api_versions = api_versions.to_vec();
validate_route_path("openapi_json_path", &config.openapi_json_path)?;
if let Some(path) = &config.swagger_ui_path {
validate_route_path("swagger_ui_path", path)?;
if path == &config.openapi_json_path {
return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
}
}
let docs = collect_openapi_docs(route_list, scoped_groups);
let json_path = config.openapi_json_path.clone();
let swagger_path = config.swagger_ui_path.clone();
let title = config.title.clone();
let mut router = axum::Router::<AppState>::new()
.route(&json_path, axum::routing::get(serve_openapi_spec))
.layer(axum::extract::Extension(std::sync::Arc::new(
config.clone(),
)))
.layer(axum::extract::Extension(std::sync::Arc::new(docs)));
if let Some(path) = swagger_path {
router = mount_swagger_ui_routes(router, &path, &title, &json_path);
}
tracing::debug!(
openapi_json = %json_path,
swagger_ui = ?config.swagger_ui_path,
swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
"Mounted OpenAPI endpoints"
);
Ok(Some(router))
}
#[allow(dead_code)]
pub fn join_nested_path(prefix: &str, child: &str) -> String {
if child == "/" || child.is_empty() {
if prefix.is_empty() {
"/".to_owned()
} else {
prefix.to_owned()
}
} else {
let prefix_trimmed = prefix.trim_end_matches('/');
if child.starts_with('/') {
format!("{prefix_trimmed}{child}")
} else {
format!("{prefix_trimmed}/{child}")
}
}
}
#[cfg(feature = "openapi")]
fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
let reject = |reason_fragment: &str| {
Err(RouterBuildError::InvalidOpenApiPath {
field,
value: format!("{value:?} {reason_fragment}"),
})
};
if value.is_empty() {
return reject("(must be non-empty)");
}
if !value.starts_with('/') {
return reject("(must start with '/')");
}
if value.contains("//") {
return reject("(must not contain '//')");
}
let mut depth: i32 = 0;
for ch in value.chars() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth < 0 {
return reject("(unbalanced '}')");
}
}
'*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
_ => {}
}
}
if depth != 0 {
return reject("(unbalanced '{')");
}
if value.contains('{') {
return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
}
Ok(())
}
fn collect_user_get_paths(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
) -> std::collections::HashSet<String> {
let mut owned: std::collections::HashSet<String> = std::collections::HashSet::new();
for route in route_list {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
owned.insert(route.path.to_owned());
}
}
for group in scoped_groups {
for route in &group.routes {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
owned.insert(join_nested_path(&group.prefix, route.path));
}
}
}
owned
}
#[cfg(feature = "openapi")]
fn collect_claimed_get_paths(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
) -> std::collections::HashSet<String> {
let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
for route in route_list {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
claimed.insert(route.path.to_owned());
}
}
for group in scoped_groups {
for route in &group.routes {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
claimed.insert(join_nested_path(&group.prefix, route.path));
}
}
}
claimed.insert(config.health.path.clone());
claimed.insert(config.health.live_path.clone());
claimed.insert(config.health.ready_path.clone());
claimed.insert(config.health.startup_path.clone());
for path in crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
) {
claimed.insert(path);
}
#[cfg(feature = "htmx")]
{
if !crate::assets::htmx_is_vendored() {
claimed.insert(crate::htmx::HTMX_JS_PATH.to_owned());
}
claimed.insert(crate::htmx::HTMX_CSRF_JS_PATH.to_owned());
claimed.insert(crate::htmx::AUTUMN_WIDGETS_JS_PATH.to_owned());
claimed.insert(crate::htmx::IDIOMORPH_JS_PATH.to_owned());
claimed.insert(crate::htmx::HTMX_SSE_JS_PATH.to_owned());
}
#[cfg(feature = "flash")]
claimed.insert(crate::flash::FLASH_CSS_PATH.to_owned());
#[cfg(feature = "maud")]
claimed.insert(crate::ui::WIDGETS_CSS_PATH.to_owned());
if dev::is_enabled_with_env(&crate::config::OsEnv) {
claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
}
if matches!(config.profile.as_deref(), Some("dev" | "development")) {
claimed.insert(config.dev.inspector_path.clone());
}
#[cfg(feature = "mail")]
if config
.mail
.preview_routes_enabled(config.profile.as_deref())
{
claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
}
#[cfg(feature = "maud")]
if config.stories.enabled {
claimed.insert(crate::stories::STORIES_PATH.to_owned());
claimed.insert("/_stories/{slug}".to_owned());
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
claimed.insert(crate::mail::UNSUBSCRIBE_PATH.to_owned());
}
if config.jobs.tracking.route_enabled {
claimed.insert(crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned());
}
claimed
}
#[cfg(feature = "mcp")]
fn reject_mcp_path_collisions(
mount_path: &str,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
openapi: Option<&crate::openapi::OpenApiConfig>,
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
) -> Result<(), RouterBuildError> {
let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
if let Some(openapi) = openapi {
claimed_get.insert(openapi.openapi_json_path.clone());
if let Some(ui_path) = &openapi.swagger_ui_path {
claimed_get.insert(ui_path.clone());
claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
}
}
if claimed_get.contains(mount_path) {
return Err(RouterBuildError::McpPathCollision {
path: mount_path.to_owned(),
method: "GET".to_owned(),
});
}
let post_owns_path = route_list
.iter()
.any(|route| route.method == http::Method::POST && route.path == mount_path)
|| scoped_groups.iter().any(|group| {
group.routes.iter().any(|route| {
route.method == http::Method::POST
&& join_nested_path(&group.prefix, route.path) == mount_path
})
});
if post_owns_path {
return Err(RouterBuildError::McpPathCollision {
path: mount_path.to_owned(),
method: "POST".to_owned(),
});
}
let nest_prefixes = nest_routers
.iter()
.map(|(prefix, _)| prefix.as_str())
.chain(std::iter::once("/static"));
for prefix in nest_prefixes {
let prefix_slash = format!("{prefix}/");
if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
return Err(RouterBuildError::McpPathCollision {
path: mount_path.to_owned(),
method: "nested router".to_owned(),
});
}
}
if !merge_routers.is_empty() {
tracing::warn!(
mcp_mount_path = %mount_path,
merged_routers = merge_routers.len(),
"MCP mount collision check skipped for AppBuilder::merge routers: \
axum does not expose their route table, so an overlapping handler \
will still panic at startup. Choose an MCP mount path that doesn't \
overlap with any merged router's handlers."
);
}
Ok(())
}
#[cfg(feature = "openapi")]
fn reject_openapi_path_collisions(
openapi_config: Option<&crate::openapi::OpenApiConfig>,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
config: &AutumnConfig,
) -> Result<(), RouterBuildError> {
let Some(openapi) = openapi_config else {
return Ok(());
};
let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
check_openapi_path_against(
"openapi_json_path",
&openapi.openapi_json_path,
&claimed,
nest_routers,
)?;
if let Some(path) = &openapi.swagger_ui_path {
check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
let mut claimed_with_openapi = claimed;
claimed_with_openapi.insert(openapi.openapi_json_path.clone());
for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
check_openapi_path_against(
"swagger_ui_path",
&asset_path,
&claimed_with_openapi,
nest_routers,
)?;
}
}
if !merge_routers.is_empty() {
tracing::warn!(
openapi_json_path = %openapi.openapi_json_path,
swagger_ui_path = ?openapi.swagger_ui_path,
merged_routers = merge_routers.len(),
"OpenAPI mount collision check skipped for AppBuilder::merge routers: \
axum does not expose their route table, so overlapping GET handlers \
will still panic at startup. Choose OpenAPI paths that don't overlap \
with any merged router's handlers."
);
}
Ok(())
}
#[cfg(feature = "openapi")]
fn check_openapi_path_against(
field: &'static str,
path: &str,
claimed: &std::collections::HashSet<String>,
nest_routers: &[(String, axum::Router<AppState>)],
) -> Result<(), RouterBuildError> {
if claimed.contains(path) {
return Err(RouterBuildError::OpenApiPathCollision {
field,
path: path.to_owned(),
});
}
for (prefix, _) in nest_routers {
let prefix_slash = format!("{prefix}/");
if path == prefix || path.starts_with(&prefix_slash) {
return Err(RouterBuildError::OpenApiPathCollision {
field,
path: path.to_owned(),
});
}
}
Ok(())
}
fn effective_mount_method(method: &http::Method) -> http::Method {
if method.as_str() == "WS" {
http::Method::GET
} else {
method.clone()
}
}
fn paths_conflict_under_matchit(existing: &str, incoming: &str) -> bool {
let mut probe: matchit::Router<()> = matchit::Router::new();
if probe.insert(existing, ()).is_err() {
return false;
}
matches!(
probe.insert(incoming, ()),
Err(matchit::InsertError::Conflict { .. })
)
}
fn reject_duplicate_user_routes(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
) -> Result<(), RouterBuildError> {
let mut claimed: std::collections::HashMap<(String, String), String> =
std::collections::HashMap::new();
let mut shape_router: matchit::Router<String> = matchit::Router::new();
let mut inserted_shapes: Vec<(String, String)> = Vec::new();
let mut record =
|method: &http::Method, path: String, name: &str| -> Result<(), RouterBuildError> {
let effective_method = effective_mount_method(method).to_string();
let already_inserted = inserted_shapes.iter().any(|(p, _)| p == &path);
if !already_inserted {
match shape_router.insert(&path, name.to_owned()) {
Ok(()) => inserted_shapes.push((path.clone(), name.to_owned())),
Err(matchit::InsertError::Conflict { .. }) => {
let (existing_path, existing_name) = inserted_shapes
.iter()
.find(|(prior, _)| paths_conflict_under_matchit(prior, &path))
.cloned()
.unwrap_or_else(|| inserted_shapes[0].clone());
return Err(RouterBuildError::ConflictingRouteShape {
existing: existing_name,
existing_path,
incoming: name.to_owned(),
incoming_path: path,
});
}
Err(_) => {}
}
}
let key = (effective_method.clone(), path.clone());
if let Some(existing) = claimed.get(&key) {
return Err(RouterBuildError::DuplicateUserRoute {
method: effective_method,
path,
existing: existing.clone(),
incoming: name.to_owned(),
});
}
claimed.insert(key, name.to_owned());
Ok(())
};
for route in route_list {
record(&route.method, route.path.to_owned(), route.name)?;
}
for group in scoped_groups {
for route in &group.routes {
record(
&route.method,
join_nested_path(&group.prefix, route.path),
route.name,
)?;
}
}
if !merge_routers.is_empty() {
tracing::warn!(
merged_routers = merge_routers.len(),
"duplicate-route preflight (#1012) skipped for AppBuilder::merge routers: \
axum does not expose their route table, so an overlapping handler on a \
method+path Autumn already owns will still panic at startup. Keep merged \
routers on disjoint paths from your `.routes()`/`.scoped()` registrations."
);
}
if !nest_routers.is_empty() {
tracing::warn!(
nested_routers = nest_routers.len(),
"duplicate-route preflight (#1012) skipped for AppBuilder::nest routers: \
axum does not expose their route table, so an overlapping handler on a \
method+path Autumn already owns will still panic at startup. Keep nested \
routers on disjoint prefixes from your `.routes()`/`.scoped()` registrations."
);
}
Ok(())
}
fn group_and_mount_routes(
route_list: Vec<Route>,
idempotency_layers: Option<&BuiltIdempotencyLayers>,
opaque_app_layers_present: bool,
state: &AppState,
) -> axum::Router<AppState> {
let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
indexmap::IndexMap::new();
for route in &route_list {
tracing::debug!(
method = %route.method,
path = route.path,
name = route.name,
"Mounted route"
);
}
for route in route_list {
let selected_layer = idempotency_layers
.map(|layers| idempotency_layer_for_route(&route, layers, opaque_app_layers_present));
let mut handler = route.handler;
if let Some(layer) = selected_layer {
handler = handler.layer(layer.clone());
}
if let Some(version) = route.api_version {
handler = handler.layer(axum::middleware::from_fn_with_state(
state.clone(),
api_versioning_middleware,
));
handler = handler.layer(axum::Extension(RouteVersionMetadata {
version: version.to_string(),
sunset_opt_out: route.sunset_opt_out,
secured: route.api_doc.secured,
required_roles: route.api_doc.required_roles,
has_policy: route.api_doc.has_policy,
}));
}
grouped
.entry(route.path)
.and_modify(|existing| {
*existing = std::mem::take(existing).merge(handler.clone());
})
.or_insert(handler);
}
let mut router = axum::Router::new();
for (path, method_router) in grouped {
router = router.route(path, method_router);
}
router
}
const fn idempotency_layer_for_route<'a>(
route: &Route,
layers: &'a BuiltIdempotencyLayers,
opaque_app_layers_present: bool,
) -> &'a IdempotencyLayer {
if opaque_app_layers_present {
&layers.manual
} else if route_uses_generated_replay_stop(route) {
&layers.route
} else {
&layers.manual
}
}
const fn route_uses_generated_replay_stop(route: &Route) -> bool {
matches!(
route.idempotency,
crate::route::RouteIdempotency::ReplayThroughInner
)
}
fn custom_layers_require_fail_closed_idempotency(
custom_layers: &[crate::app::CustomLayerRegistration],
) -> bool {
custom_layers
.iter()
.any(|registered| !is_idempotency_transparent_app_layer(registered))
}
fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
registered
.type_name
.starts_with("autumn_web::session::SessionLayer<")
|| registered
.type_name
.starts_with("autumn::session::SessionLayer<")
|| registered.type_id
== std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
|| is_i18n_bundle_extension_layer(registered.type_id)
}
#[cfg(feature = "i18n")]
fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
}
#[cfg(not(feature = "i18n"))]
const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
false
}
#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
fn mount_framework_routes(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
dev_reload_enabled: bool,
) -> axum::Router<AppState> {
#[cfg(not(feature = "mail"))]
let _ = config;
#[cfg(feature = "htmx")]
{
if crate::assets::htmx_is_vendored() {
tracing::debug!(
path = crate::htmx::HTMX_JS_PATH,
"htmx vendored via `autumn assets`; built-in handler skipped, ServeDir serves it"
);
} else {
router = router.route(crate::htmx::HTMX_JS_PATH, axum::routing::get(htmx_handler));
tracing::debug!(
method = "GET",
path = crate::htmx::HTMX_JS_PATH,
name = format!("htmx {}", crate::htmx::HTMX_VERSION),
"Mounted route"
);
}
router = router.route(
crate::htmx::HTMX_CSRF_JS_PATH,
axum::routing::get(htmx_csrf_handler),
);
router = router.route(
crate::htmx::AUTUMN_WIDGETS_JS_PATH,
axum::routing::get(autumn_widgets_handler),
);
router = router.route(
crate::htmx::IDIOMORPH_JS_PATH,
axum::routing::get(idiomorph_handler),
);
router = router.route(
crate::htmx::HTMX_SSE_JS_PATH,
axum::routing::get(htmx_sse_handler),
);
tracing::debug!(
method = "GET",
path = crate::htmx::HTMX_CSRF_JS_PATH,
name = "htmx csrf helper",
"Mounted route"
);
tracing::debug!(
method = "GET",
path = crate::htmx::AUTUMN_WIDGETS_JS_PATH,
name = "autumn widget runtime",
"Mounted route"
);
tracing::debug!(
method = "GET",
path = crate::htmx::IDIOMORPH_JS_PATH,
name = "idiomorph DOM morphing",
"Mounted route"
);
tracing::debug!(
method = "GET",
path = crate::htmx::HTMX_SSE_JS_PATH,
name = "htmx SSE extension",
"Mounted route"
);
}
#[cfg(feature = "flash")]
{
router = router.route(
crate::flash::FLASH_CSS_PATH,
axum::routing::get(flash_css_handler),
);
tracing::debug!(
method = "GET",
path = crate::flash::FLASH_CSS_PATH,
name = "autumn flash stylesheet",
"Mounted route"
);
}
#[cfg(feature = "maud")]
{
router = router.route(
crate::ui::WIDGETS_CSS_PATH,
axum::routing::get(widgets_css_handler),
);
tracing::debug!(
method = "GET",
path = crate::ui::WIDGETS_CSS_PATH,
name = "autumn widget stylesheet",
"Mounted route"
);
}
if dev_reload_enabled {
router = router.route(
dev::LIVE_RELOAD_PATH,
axum::routing::get(dev::live_reload_state_handler),
);
router = router.route(
dev::LIVE_RELOAD_SCRIPT_PATH,
axum::routing::get(dev::live_reload_script_handler),
);
tracing::debug!(
state_path = dev::LIVE_RELOAD_PATH,
script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
"Mounted dev live reload endpoints"
);
}
#[cfg(feature = "mail")]
if config
.mail
.preview_routes_enabled(config.profile.as_deref())
{
router = router.merge(crate::mail::mail_preview_router(
config.mail.file_dir.clone(),
));
tracing::debug!(
path = crate::mail::MAIL_PREVIEW_PATH,
"Mounted dev mail preview endpoints"
);
}
#[cfg(feature = "maud")]
if config.stories.enabled {
router = router.merge(crate::stories::story_router());
tracing::debug!(
path = crate::stories::STORIES_PATH,
"Mounted story gallery endpoints"
);
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
router = router.merge(crate::mail::unsubscribe_router());
tracing::debug!(
path = crate::mail::UNSUBSCRIBE_PATH,
"Mounted default unsubscribe endpoint"
);
}
if config.jobs.tracking.route_enabled {
router = router.merge(crate::job_tracking::status_router());
tracing::debug!(
path = crate::job_tracking::JOB_STATUS_ROUTE_PATH,
"Mounted tracked-job status endpoint"
);
}
router
}
fn mount_probe_endpoints<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
user_get_paths: &std::collections::HashSet<String>,
) -> (std::collections::HashSet<String>, axum::Router<S>)
where
S: Clone + Send + Sync + 'static,
AppState: axum::extract::FromRef<S>,
{
let mut mounted_probe_paths = std::collections::HashSet::new();
let mut mount_probe = |mut router: axum::Router<S>,
path: &str,
label: &'static str,
handler: axum::routing::MethodRouter<S>|
-> axum::Router<S> {
if user_get_paths.contains(path) {
tracing::info!(
probe = label,
path,
"a user route already owns this path; the built-in probe was \
not auto-mounted (the user handler wins)"
);
mounted_probe_paths.insert(path.to_owned());
return router;
}
if mounted_probe_paths.insert(path.to_owned()) {
router = router.route(path, handler);
}
router
};
router = mount_probe(
router,
&config.health.live_path,
"liveness",
axum::routing::get(crate::probe::live_handler::<AppState>),
);
router = mount_probe(
router,
&config.health.ready_path,
"readiness",
axum::routing::get(crate::probe::ready_handler::<AppState>),
);
router = mount_probe(
router,
&config.health.startup_path,
"startup",
axum::routing::get(crate::probe::startup_handler::<AppState>),
);
router = mount_probe(
router,
&config.health.path,
"health",
axum::routing::get(crate::health::handler::<AppState>),
);
tracing::debug!(
health = %config.health.path,
live = %config.health.live_path,
ready = %config.health.ready_path,
startup = %config.health.startup_path,
"Mounted probe endpoints"
);
(mounted_probe_paths, router)
}
fn mount_actuator_endpoints(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
mounted_probe_paths: &std::collections::HashSet<String>,
) -> Result<axum::Router<AppState>, RouterBuildError> {
let actuator_sensitive = config.actuator.sensitive;
let actuator_prometheus = config.actuator.prometheus;
let actuator_paths = crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
actuator_sensitive,
actuator_prometheus,
);
if let Some(path) = actuator_paths
.iter()
.find(|path| mounted_probe_paths.contains(path.as_str()))
{
return Err(RouterBuildError::FrameworkRouteOverlap {
path: path.clone(),
existing: "probe endpoint",
incoming: "actuator endpoint",
});
}
router = router.merge(crate::actuator::actuator_router_with_prefix(
&config.actuator.prefix,
actuator_sensitive,
actuator_prometheus,
));
tracing::debug!(
sensitive = actuator_sensitive,
prometheus = actuator_prometheus,
prefix = %config.actuator.prefix,
"Mounted actuator endpoints"
);
Ok(router)
}
fn mount_scoped_groups(
mut router: axum::Router<AppState>,
scoped_groups: Vec<ScopedGroup>,
idempotency_layers: Option<&BuiltIdempotencyLayers>,
state: &AppState,
) -> axum::Router<AppState> {
for group in scoped_groups {
let mut sub_router = axum::Router::new();
for route in group.routes {
tracing::debug!(
method = %route.method,
path = route.path,
name = route.name,
scope = %group.prefix,
"Mounted scoped route"
);
let selected_layer = idempotency_layers.map(|layers| &layers.manual);
let mut handler = route.handler;
if let Some(layer) = selected_layer {
handler = handler.layer(layer.clone());
}
if let Some(version) = route.api_version {
handler = handler.layer(axum::middleware::from_fn_with_state(
state.clone(),
api_versioning_middleware,
));
handler = handler.layer(axum::Extension(RouteVersionMetadata {
version: version.to_string(),
sunset_opt_out: route.sunset_opt_out,
secured: route.api_doc.secured,
required_roles: route.api_doc.required_roles,
has_policy: route.api_doc.has_policy,
}));
}
sub_router = sub_router.route(route.path, handler);
}
sub_router = (group.apply_layer)(sub_router);
router = router.nest(&group.prefix, sub_router);
}
router
}
fn mount_raw_routers(
mut router: axum::Router<AppState>,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
idempotency_layers: Option<&BuiltIdempotencyLayers>,
) -> axum::Router<AppState> {
for raw_router in merge_routers {
tracing::debug!("Merged raw Axum router");
let raw_router = if let Some(layers) = idempotency_layers {
raw_router.layer(layers.manual.clone())
} else {
raw_router
};
router = router.merge(raw_router);
}
for (prefix, raw_router) in nest_routers {
tracing::debug!(prefix = %prefix, "Nested raw Axum router");
let nested_router =
raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
let nested_router = if let Some(layers) = idempotency_layers {
nested_router.layer(layers.manual.clone())
} else {
nested_router
};
router = router.nest(&prefix, nested_router);
}
router
}
fn apply_compression_middleware<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
if config.compression.enabled {
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
let predicate = DefaultPredicate::new()
.and(NotForContentType::const_new("audio/"))
.and(NotForContentType::const_new("video/"))
.and(NotForContentType::const_new("application/octet-stream"))
.and(NotForContentType::const_new("application/zip"))
.and(NotForContentType::const_new("application/gzip"))
.and(NotForContentType::const_new("application/x-gzip"))
.and(NotForContentType::const_new("application/zstd"))
.and(NotForContentType::const_new("application/x-bzip2"))
.and(NotForContentType::const_new("application/x-bzip"))
.and(NotForContentType::const_new("application/x-rar-compressed"))
.and(NotForContentType::const_new("application/vnd.rar"))
.and(NotForContentType::const_new("application/x-7z-compressed"))
.and(NotForContentType::const_new("font/woff"))
.and(NotForContentType::const_new("font/woff2"));
router =
router.layer(tower_http::compression::CompressionLayer::new().compress_when(predicate));
tracing::info!("Response compression enabled (gzip/brotli)");
}
router
}
fn apply_cors_middleware<S>(mut router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
if !config.cors.allowed_origins.is_empty() {
let cors = build_cors_layer(&config.cors);
tracing::info!(
origins = ?config.cors.allowed_origins,
credentials = config.cors.allow_credentials,
"CORS enabled"
);
router = router.layer(cors);
}
router
}
fn apply_csrf_middleware<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
if config.security.csrf.enabled {
let effective_scan_bytes = config
.security
.csrf
.token_scan_bytes
.min(config.security.upload.max_request_size_bytes);
let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
.with_max_scan_bytes(effective_scan_bytes);
if let Some(keys) = signing_keys {
csrf_layer = csrf_layer.with_signing_keys(keys);
}
for endpoint in &config.security.webhooks.endpoints {
csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
csrf_layer = csrf_layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
}
tracing::info!("CSRF protection enabled");
router = router.layer(csrf_layer);
}
router
}
fn apply_submit_token_middleware<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
is_production: bool,
) -> Result<axum::Router<S>, RouterBuildError>
where
S: Clone + Send + Sync + 'static,
{
let cfg = &config.security.submit_token;
if !cfg.enabled {
return Ok(router);
}
match cfg.production_memory_guard(config.idempotency.backend, is_production) {
crate::security::config::SubmitTokenMemoryGuard::Ok => {}
crate::security::config::SubmitTokenMemoryGuard::WarnInherited => {
tracing::warn!(
"[security.submit_token].backend resolved to the in-memory store in production \
(inherited from [idempotency].backend, which is unset or memory). \
Single-replica deployments are fine, but multi-replica deployments need a shared \
backend: configure [idempotency] with backend = \"redis\" (or set \
[security.submit_token].backend = \"redis\") so consumed tokens are shared across \
replicas — otherwise a duplicate submit can slip through on a different replica."
);
}
crate::security::config::SubmitTokenMemoryGuard::FailExplicit => {
return Err(RouterBuildError::InvalidSubmitTokenBackend(
"the in-memory submit-token backend is not safe for multi-replica production use. \
Set `[security.submit_token].backend = \"redis\"` in autumn.toml (it reuses the \
[idempotency.redis] connection settings), or remove the explicit `backend` \
override to inherit `[idempotency].backend`."
.to_owned(),
));
}
}
let ttl = Duration::from_secs(cfg.ttl_secs);
let backend = cfg.resolved_backend(config.idempotency.backend);
let store: std::sync::Arc<dyn IdempotencyStore> = match backend {
crate::config::IdempotencyBackend::Memory => {
std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
}
#[cfg(feature = "redis")]
crate::config::IdempotencyBackend::Redis => {
match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
Ok(s) => std::sync::Arc::new(s),
Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
}
}
#[cfg(not(feature = "redis"))]
crate::config::IdempotencyBackend::Redis => {
return Err(RouterBuildError::InvalidIdempotencyBackend(
"submit_token backend 'redis' requires the autumn-web 'redis' feature \
flag; rebuild with --features redis or switch to backend = \"memory\""
.to_owned(),
));
}
};
let mut layer = crate::security::SubmitTokenLayer::new(store, cfg)
.with_max_scan_bytes(config.security.upload.max_request_size_bytes);
for endpoint in &config.security.webhooks.endpoints {
layer = layer.with_exempt_path(&endpoint.path);
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
layer = layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
}
tracing::info!(
backend = ?backend,
inherited = cfg.backend.is_none(),
ttl_secs = cfg.ttl_secs,
"One-time submit-token protection enabled"
);
router = router.layer(layer);
Ok(router)
}
fn apply_bot_protection_middleware<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
if config.bot_protection.enabled {
let mut exempt = config.security.captcha_exempt_paths.clone();
for endpoint in &config.security.webhooks.endpoints {
exempt.push(endpoint.path.clone());
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
exempt.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
}
let layer =
crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
.with_max_scan_bytes(config.security.upload.max_request_size_bytes)
.with_exempt_paths(exempt);
tracing::info!(
provider = ?config.bot_protection.provider,
dev_bypass = config.bot_protection.dev_bypass,
"Bot protection (CAPTCHA) enabled"
);
router = router.layer(layer);
}
router
}
async fn populate_rate_limit_principal(
axum::extract::State(state): axum::extract::State<AppState>,
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
if let Some(session) = req.extensions().get::<crate::session::Session>() {
let auth_session_key = state.auth_session_key();
if let Some(user_id) = session.get(auth_session_key).await {
req.extensions_mut()
.insert(crate::security::RateLimitPrincipal(user_id));
}
}
next.run(req).await
}
fn apply_trusted_proxies_middleware<S>(
router: axum::Router<S>,
config: &AutumnConfig,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
let tp = &config.security.trusted_proxies;
let layer = crate::security::TrustedProxiesLayer::from_config(tp);
if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
tracing::info!(
ranges = ?tp.ranges,
trusted_hops = ?tp.trusted_hops,
"Centralized trusted-proxy resolution enabled"
);
}
router.layer(layer)
}
fn apply_rate_limit_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
) -> axum::Router<AppState> {
if config.security.rate_limit.enabled {
let tp = &config.security.trusted_proxies;
let rl = &config.security.rate_limit;
let has_top_level_proxy_config =
tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
let has_rate_limit_proxy_config =
rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
let mut layer = crate::security::RateLimitLayer::from_config(rl).honoring_mcp_exempt();
if has_top_level_proxy_config && !has_rate_limit_proxy_config {
let resolver = crate::security::ProxyResolver::from_config(tp);
layer = layer.with_proxy_resolver(resolver);
}
tracing::info!(
rps = config.security.rate_limit.requests_per_second,
burst = config.security.rate_limit.burst,
"Rate limiting enabled"
);
router = router.layer(layer);
if config.security.rate_limit.key_strategy
== crate::security::KeyStrategy::AuthenticatedPrincipal
{
router = router.layer(axum::middleware::from_fn_with_state(
state.clone(),
populate_rate_limit_principal,
));
}
}
router
}
fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
let upload_config = config.security.upload.clone();
let max_request_size = upload_config.max_request_size_bytes;
tracing::info!(
max_request_size_bytes = max_request_size,
max_file_size_bytes = upload_config.max_file_size_bytes,
allowed_mime_types = ?upload_config.allowed_mime_types,
"Request body size limits enabled (applies to all content types)"
);
let router = router.layer(axum::extract::DefaultBodyLimit::max(max_request_size));
router.layer(axum::middleware::from_fn(
move |mut req: axum::extract::Request, next: axum::middleware::Next| {
let upload_config = upload_config.clone();
async move {
req.extensions_mut().insert(upload_config);
next.run(req).await
}
},
))
}
fn probe_bypass_paths(config: &AutumnConfig) -> Vec<String> {
vec![
config.health.path.clone(),
config.health.live_path.clone(),
config.health.ready_path.clone(),
config.health.startup_path.clone(),
crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
]
}
fn build_maintenance_layer(
config: &AutumnConfig,
state: &AppState,
) -> crate::middleware::maintenance::MaintenanceLayer {
let maintenance_state = state
.extension::<crate::maintenance::MaintenanceState>()
.map(|s| (*s).clone())
.unwrap_or_default();
crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
.with_health_prefix(config.actuator.prefix.clone())
.with_probe_paths(probe_bypass_paths(config))
}
fn build_load_shed_layer(
config: &AutumnConfig,
state: &AppState,
) -> Option<crate::middleware::LoadShedLayer> {
let limit = config.server.max_concurrent_requests.filter(|&n| n > 0)?;
let cors =
(!config.cors.allowed_origins.is_empty()).then(|| std::sync::Arc::new(config.cors.clone()));
Some(
crate::middleware::LoadShedLayer::new(limit, state.metrics.clone())
.with_health_prefix(config.actuator.prefix.clone())
.with_probe_paths(probe_bypass_paths(config))
.with_cors(cors),
)
}
type RouteTimeoutTable = std::sync::Arc<
std::collections::HashMap<
String,
std::collections::HashMap<http::Method, crate::route::RouteTimeout>,
>,
>;
#[derive(Debug)]
struct RequestDeadlineExceeded {
timeout_ms: u64,
}
impl std::fmt::Display for RequestDeadlineExceeded {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"the server did not produce a response within the configured {}ms deadline",
self.timeout_ms
)
}
}
impl std::error::Error for RequestDeadlineExceeded {}
#[derive(Clone, Copy, Debug)]
pub struct RequestDeadlineCancelled;
fn build_route_timeout_table(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
) -> RouteTimeoutTable {
let mut table: std::collections::HashMap<
String,
std::collections::HashMap<http::Method, crate::route::RouteTimeout>,
> = std::collections::HashMap::new();
let mut insert = |path: String, method: &http::Method, timeout: crate::route::RouteTimeout| {
if matches!(timeout, crate::route::RouteTimeout::Inherit) {
return;
}
let by_method = table.entry(path).or_default();
by_method.insert(effective_mount_method(method), timeout);
if *method == http::Method::GET {
by_method.insert(http::Method::HEAD, timeout);
}
};
for route in route_list {
insert(route.path.to_owned(), &route.method, route.timeout);
}
for group in scoped_groups {
for route in &group.routes {
insert(
join_nested_path(&group.prefix, route.path),
&route.method,
route.timeout,
);
}
}
std::sync::Arc::new(table)
}
fn apply_request_timeout_middleware(
router: axum::Router<AppState>,
config: &AutumnConfig,
metrics: crate::middleware::MetricsCollector,
route_timeouts: RouteTimeoutTable,
mirror_cors: bool,
) -> axum::Router<AppState> {
let global = config
.server
.timeouts
.request_timeout_ms
.filter(|ms| *ms > 0)
.map(std::time::Duration::from_millis);
let has_override = route_timeouts
.values()
.flat_map(std::collections::HashMap::values)
.any(|t| matches!(t, crate::route::RouteTimeout::Override(_)));
if global.is_none() && !has_override {
return router;
}
if let Some(duration) = global {
tracing::info!(
timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
"Inbound request timeout enabled"
);
}
let cors = (mirror_cors && !config.cors.allowed_origins.is_empty())
.then(|| std::sync::Arc::new(config.cors.clone()));
router.layer(axum::middleware::from_fn(move |req, next| {
request_timeout_handler(
req,
next,
global,
route_timeouts.clone(),
metrics.clone(),
cors.clone(),
)
}))
}
async fn request_timeout_handler(
req: axum::extract::Request,
next: axum::middleware::Next,
global: Option<std::time::Duration>,
route_timeouts: RouteTimeoutTable,
metrics: crate::middleware::MetricsCollector,
cors: Option<std::sync::Arc<crate::config::CorsConfig>>,
) -> axum::response::Response {
if req
.extensions()
.get::<crate::static_gen::RenderDeadlineExempt>()
.is_some()
{
return next.run(req).await;
}
let matched_path_ref = req
.extensions()
.get::<axum::extract::MatchedPath>()
.map(axum::extract::MatchedPath::as_str);
let route_timeout = matched_path_ref
.and_then(|p| route_timeouts.get(p))
.and_then(|by_method| by_method.get(req.method()))
.copied()
.unwrap_or(crate::route::RouteTimeout::Inherit);
let deadline = match route_timeout {
crate::route::RouteTimeout::Disabled => None,
crate::route::RouteTimeout::Override(d) => Some(d),
crate::route::RouteTimeout::Inherit => global,
};
let Some(duration) = deadline else {
return next.run(req).await;
};
let matched_path = matched_path_ref.map(ToOwned::to_owned);
let request_id = req
.extensions()
.get::<crate::middleware::RequestId>()
.cloned();
let cors_origin = cors
.as_ref()
.and_then(|_| req.headers().get(http::header::ORIGIN).cloned());
let start = std::time::Instant::now();
match tokio::time::timeout(duration, next.run(req)).await {
Ok(response) => response,
Err(_elapsed) => {
let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let route = matched_path.as_deref().unwrap_or("<unmatched>");
tracing::warn!(
target: "autumn::timeout",
route = route,
elapsed_ms = elapsed_ms,
timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
request_id = request_id.as_ref().map(ToString::to_string),
"inbound request exceeded deadline"
);
metrics.record_request_timeout();
let mut response =
crate::error::AutumnError::service_unavailable(RequestDeadlineExceeded {
timeout_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
})
.into_response();
response.extensions_mut().insert(RequestDeadlineCancelled);
if let Some(cors) = cors.as_deref() {
mirror_cors_headers(cors, cors_origin.as_ref(), &mut response);
}
response
}
}
}
struct BuiltIdempotencyLayers {
route: crate::idempotency::IdempotencyLayer,
manual: crate::idempotency::IdempotencyLayer,
}
fn build_idempotency_layers(
config: &AutumnConfig,
state: &AppState,
) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
if !config.idempotency.enabled.unwrap_or(false) {
return Ok(None);
}
let ttl = Duration::from_secs(config.idempotency.ttl_secs);
let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
let store: std::sync::Arc<dyn IdempotencyStore> = match config.idempotency.backend {
crate::config::IdempotencyBackend::Memory => {
std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
}
#[cfg(feature = "redis")]
crate::config::IdempotencyBackend::Redis => {
match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
Ok(s) => std::sync::Arc::new(s),
Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
}
}
#[cfg(not(feature = "redis"))]
crate::config::IdempotencyBackend::Redis => {
return Err(RouterBuildError::InvalidIdempotencyBackend(
"idempotency backend 'redis' requires the autumn-web 'redis' feature \
flag; rebuild with --features redis or switch to backend = \"memory\""
.to_owned(),
));
}
};
tracing::debug!(
backend = ?config.idempotency.backend,
ttl_secs = config.idempotency.ttl_secs,
in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
"Idempotency-key middleware enabled"
);
let base = IdempotencyLayer::new(store)
.with_ttl(ttl)
.with_in_flight_ttl(in_flight_ttl)
.with_metrics(state.metrics.clone());
Ok(Some(BuiltIdempotencyLayers {
route: base.clone().replay_through_inner(),
manual: base.fail_closed_on_replay(),
}))
}
#[allow(
clippy::cognitive_complexity,
clippy::too_many_lines,
clippy::too_many_arguments
)]
fn apply_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
exception_filters: Vec<Arc<dyn ExceptionFilter>>,
custom_layers: Vec<crate::app::CustomLayerRegistration>,
#[cfg(feature = "maud")] error_page_renderer: Option<SharedRenderer>,
session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
route_timeouts: RouteTimeoutTable,
load_shed_layer: Option<crate::middleware::LoadShedLayer>,
) -> Result<axum::Router<AppState>, RouterBuildError> {
router = router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
&config.security.signing_secret,
));
let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
if config.security.signing_secret.secret.is_some() || is_production {
Some(signing_keys)
} else {
None
};
router = apply_cors_middleware(router, config);
let trusted_host_policy = TrustedHostPolicy::from_config(config);
router = router.layer(axum::middleware::from_fn(move |req, next| {
trusted_host_middleware(req, next, trusted_host_policy.clone())
}));
router = apply_submit_token_middleware(router, config, is_production)?;
router = apply_csrf_middleware(router, config, signing_keys_opt.clone());
router = apply_bot_protection_middleware(router, config);
router = router.layer(axum::middleware::from_fn(
crate::middleware::method_override_rejection_filter,
));
router = apply_rate_limit_middleware(router, config, state);
router = router.layer(build_maintenance_layer(config, state));
if let Some(load_shed) = load_shed_layer {
router = router.layer(load_shed);
}
router = router.layer(axum::middleware::from_fn(
crate::webhook::webhook_replay_cleanup_middleware,
));
router = apply_upload_middleware(router, config);
let custom_layer_count = custom_layers.len();
for registered in custom_layers.into_iter().rev() {
router = (registered.apply)(router);
}
if custom_layer_count > 0 {
tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
}
router = apply_trusted_proxies_middleware(router, config);
let mut router = router;
if config.tenancy.enabled {
router = router.layer(axum::middleware::from_fn_with_state(
state.clone(),
crate::tenancy::tenancy_middleware,
));
tracing::debug!("Multi-tenancy middleware enabled");
}
router = apply_request_timeout_middleware(
router,
config,
state.metrics.clone(),
route_timeouts,
true,
);
#[cfg(feature = "reporting")]
{
router = router.layer(crate::reporting::ReportingLayer::new(
state.error_reporters(),
config.reporting.enabled,
config.reporting.sample_rate,
));
}
if config.log.access_log {
router = router.layer(crate::middleware::AccessLogLayer::new(
config.log.access_log_exclude.clone(),
));
}
if crate::config::server_timing_enabled(config) {
router = router.layer(crate::middleware::ServerTimingLayer::new(true));
}
let mut log_context_filter_parameters = config.log.filter_parameters.clone();
log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
&log_context_filter_parameters,
&config.log.unfilter_parameters,
));
let router = router.layer(crate::middleware::LogContextLayer::new(log_context_filter));
let router = router.layer(RequestIdLayer);
#[cfg(feature = "db")]
let signing_keys_for_ryw = signing_keys_opt.clone();
let router = crate::session::apply_session_layer(
router,
&config.session,
config.profile.as_deref(),
session_store,
signing_keys_opt,
)?;
tracing::debug!(backend = ?config.session.backend, "Session management enabled");
#[cfg(feature = "db")]
let router = if config.database.read_your_writes == crate::config::ReadYourWrites::Off {
router
} else {
let ryw_mode = config.database.read_your_writes;
let window_secs = config.database.pin_after_write_secs;
let keys = signing_keys_for_ryw;
if ryw_mode == crate::config::ReadYourWrites::Session && keys.is_none() {
tracing::warn!(
"read_your_writes = \"session\" requires a configured \
security.signing_secret to sign the autumn.ryw cookie; \
cross-request pinning is disabled until a secret is set"
);
}
let metrics = state.metrics().clone();
router.layer(axum::middleware::from_fn(move |req, next| {
crate::read_your_writes::middleware(
req,
next,
ryw_mode,
window_secs,
keys.clone(),
metrics.clone(),
)
}))
};
let is_dev = config
.profile
.as_deref()
.map_or(cfg!(debug_assertions), |p| p == "dev");
let mut all_filters: Vec<Arc<dyn ExceptionFilter>> =
vec![Arc::new(ProblemDetailsFilter { is_dev })];
#[cfg(feature = "maud")]
{
let mut filter_parameters = config.log.filter_parameters.clone();
filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
renderer,
is_dev,
parameter_filter: crate::log::filter::ParameterFilter::new(
&filter_parameters,
&config.log.unfilter_parameters,
),
};
all_filters.push(Arc::new(error_page_filter));
}
all_filters.extend(exception_filters);
let count = all_filters.len();
tracing::debug!(
count,
"Registered exception filters (including error page filter)"
);
let router = router
.layer(crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev })
.layer(ExceptionFilterLayer::new(all_filters))
.layer(crate::middleware::MetricsLayer::new(state.metrics.clone()));
let router = apply_compression_middleware(router, config);
Ok(router)
}
fn apply_layers_in_registration_order(
mut router: axum::Router<AppState>,
layers: Vec<crate::app::CustomLayerRegistration>,
what: &str,
) -> axum::Router<AppState> {
let count = layers.len();
for registered in layers.into_iter().rev() {
router = (registered.apply)(router);
}
if count > 0 {
tracing::debug!(count, "{what} Tower layers applied");
}
router
}
async fn trusted_host_middleware(
req: Request<axum::body::Body>,
next: Next,
policy: TrustedHostPolicy,
) -> axum::response::Response {
let path = req.uri().path();
if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
&& policy.probe_bypass_paths.contains(path)
{
return next.run(req).await;
}
let authority = req.uri().authority().map(http::uri::Authority::as_str);
let host_header = req
.headers()
.get(http::header::HOST)
.and_then(|v| v.to_str().ok());
let raw_host = authority.or(host_header);
let parsed_host = raw_host.and_then(extract_host_without_port);
let host = parsed_host
.map(str::to_ascii_lowercase)
.map(|h| h.trim_end_matches('.').to_owned())
.filter(|h| !h.is_empty());
let host_source_present = raw_host.is_some();
if host.is_none() && !host_source_present && policy.allow_missing_host {
return next.run(req).await;
}
if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
next.run(req).await
} else {
tracing::warn!(host = ?host, "trusted host rejected request");
let body = crate::error::problem_details_json_string(
StatusCode::BAD_REQUEST,
"Invalid Host header",
None,
None,
None,
None,
true,
);
(
StatusCode::BAD_REQUEST,
[(http::header::CONTENT_TYPE, "application/problem+json")],
body,
)
.into_response()
}
}
pub fn extract_host_without_port(header: &str) -> Option<&str> {
let host = header.trim();
if host.is_empty() {
return None;
}
if host.starts_with('[') {
let end = host.find(']')?;
let literal = host.get(1..end)?;
if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
return None;
}
let remainder = host.get(end + 1..)?;
if remainder.is_empty() {
return Some(literal);
}
let maybe_port = remainder.strip_prefix(':')?;
if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
return Some(literal);
}
return None;
}
let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
return Some(host);
};
if candidate.contains(':') {
return Some(host);
}
if !maybe_port.is_empty()
&& maybe_port.chars().all(|c| c.is_ascii_digit())
&& !candidate.is_empty()
{
Some(candidate)
} else {
None
}
}
#[allow(dead_code)]
pub fn build_router_with_static(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
) -> axum::Router {
try_build_router_with_static(route_list, config, state, dist_dir)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
#[allow(dead_code)]
pub fn try_build_router_with_static(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
) -> Result<axum::Router, RouterBuildError> {
try_build_router_with_static_inner(
route_list,
config,
state,
dist_dir,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
},
)
}
#[allow(clippy::too_many_lines)]
pub fn try_build_router_with_static_inner(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
mut ctx: RouterContext,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let Some(dist) = dist_dir else {
let app_router = try_build_router_inner(route_list, config, state, ctx)?;
return Ok(apply_startup_barrier(
app_router,
config,
&startup_barrier_state,
));
};
let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
tracing::debug!(
dist = %dist.display(),
"No valid manifest.json in dist dir; skipping static file layer"
);
let app_router = try_build_router_inner(route_list, config, state, ctx)?;
return Ok(apply_startup_barrier(
app_router,
config,
&startup_barrier_state,
));
};
for (route, entry) in &layer.manifest().routes {
tracing::debug!(
route = %route,
file = %entry.file,
revalidate = ?entry.revalidate,
"Static route"
);
}
let opaque_present = Some(
custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
|| custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers),
);
let custom_layers = std::mem::take(&mut ctx.custom_layers);
let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
let inner_router =
build_router_pre_state(route_list, config, &state, ctx, opaque_present, true)?;
let has_isr = layer
.manifest()
.routes
.values()
.any(|e| e.revalidate.is_some());
let layer = if has_isr {
let regen_router = inner_router
.clone()
.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
))
.with_state(state.clone());
layer.with_router(regen_router)
} else {
layer
};
let layer = Arc::new(layer);
let static_layer = layer;
let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let static_layer = static_layer.clone();
async move {
let is_get = req.method() == http::Method::GET;
let is_head = req.method() == http::Method::HEAD;
if is_get || is_head {
let path = req.uri().path();
let normalized = if path.len() > 1 && path.ends_with('/') {
&path[..path.len() - 1]
} else {
path
};
if let Some(file_path) = static_layer.resolve(normalized)
&& let Ok(contents) = tokio::fs::read(&file_path).await
{
let content_type = crate::assets::content_type_for_opt(normalized)
.unwrap_or_else(|| {
file_path
.file_name()
.and_then(|name| name.to_str())
.map_or("application/octet-stream", |name| {
crate::assets::content_type_for(name)
})
});
let body = if is_head {
axum::body::Body::empty()
} else {
axum::body::Body::from(contents)
};
return http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, content_type)
.body(body)
.expect("infallible response builder");
}
}
next.run(req).await
}
},
));
router = apply_layers_in_registration_order(
router,
custom_layers,
"Custom (outside static middleware)",
);
router = apply_compression_middleware(router, config);
router = apply_layers_in_registration_order(
router,
static_gate_layers,
"Pre-static gate (outside static middleware)",
);
let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
));
Ok(apply_startup_barrier(
router.with_state(state),
config,
&startup_barrier_state,
))
}
#[derive(Clone)]
struct StartupBarrierState {
app_state: AppState,
probe_paths: Vec<String>,
actuator_paths: Vec<String>,
actuator_subtree_paths: Vec<String>,
}
impl StartupBarrierState {
fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
let actuator_subtree_paths = if config.actuator.sensitive {
vec![crate::actuator::actuator_route_path(
&config.actuator.prefix,
"/loggers",
)]
} else {
Vec::new()
};
Self {
app_state: app_state.clone(),
probe_paths: probe_bypass_paths(config),
actuator_paths: crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
),
actuator_subtree_paths,
}
}
fn allows_path(&self, path: &str) -> bool {
self.probe_paths.iter().any(|allowed| path == allowed)
|| self.actuator_paths.iter().any(|allowed| path == allowed)
|| self
.actuator_subtree_paths
.iter()
.any(|allowed| path_matches_route_prefix(path, allowed))
}
}
fn apply_startup_barrier(
router: axum::Router,
config: &AutumnConfig,
state: &AppState,
) -> axum::Router {
let barrier_state = StartupBarrierState::from_config(config, state);
let router = router.layer(axum::middleware::from_fn_with_state(
barrier_state,
startup_barrier,
));
let router = if config.log.access_log {
router.layer(crate::middleware::AccessLogLayer::fallback(
config.log.access_log_exclude.clone(),
))
} else {
router
};
let router = if crate::config::server_timing_enabled(config) {
router.layer(crate::middleware::ServerTimingLayer::fallback(true))
} else {
router
};
#[cfg(feature = "telemetry-otlp")]
let router = router.layer(crate::middleware::TraceContextLayer);
router
}
async fn startup_barrier(
State(state): State<StartupBarrierState>,
request: axum::extract::Request,
next: Next,
) -> axum::response::Response {
if crate::app::is_static_build_mode()
|| state.app_state.probes().is_startup_complete()
|| state.allows_path(request.uri().path())
{
next.run(request).await
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
"Service is still starting up",
)
.into_response()
}
}
pub fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
use http::header::HeaderName;
use tower_http::cors::{AllowOrigin, CorsLayer};
let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
CorsLayer::new().allow_origin(AllowOrigin::any())
} else {
let origins: Vec<http::HeaderValue> = cors
.allowed_origins
.iter()
.filter_map(|o| match o.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
None
}
})
.collect();
CorsLayer::new().allow_origin(origins)
};
let methods: Vec<http::Method> = cors
.allowed_methods
.iter()
.filter_map(|m| match m.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
None
}
})
.collect();
let headers: Vec<HeaderName> = cors
.allowed_headers
.iter()
.filter_map(|h| match h.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
None
}
})
.collect();
layer
.allow_methods(methods)
.allow_headers(headers)
.allow_credentials(cors.allow_credentials)
.max_age(std::time::Duration::from_secs(cors.max_age_secs))
}
pub fn mirror_cors_headers(
cors: &crate::config::CorsConfig,
origin: Option<&http::HeaderValue>,
response: &mut axum::response::Response,
) {
use http::header;
let allow_any = cors.allowed_origins.iter().any(|o| o == "*");
let allow_origin = if allow_any {
Some(http::HeaderValue::from_static("*"))
} else {
origin.and_then(|value| {
let value_str = value.to_str().ok()?;
cors.allowed_origins
.iter()
.any(|allowed| allowed == value_str)
.then(|| value.clone())
})
};
let Some(allow_origin) = allow_origin else {
return;
};
let headers = response.headers_mut();
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
if !allow_any {
headers.insert(header::VARY, http::HeaderValue::from_static("origin"));
}
if cors.allow_credentials {
headers.insert(
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
http::HeaderValue::from_static("true"),
);
}
}
#[cfg(feature = "htmx")]
pub async fn htmx_handler() -> axum::response::Response {
use axum::response::IntoResponse;
(
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
),
],
crate::htmx::HTMX_JS,
)
.into_response()
}
#[cfg(any(feature = "flash", feature = "maud"))]
struct PrecompressedCss {
gzip: bytes::Bytes,
brotli: bytes::Bytes,
}
#[cfg(any(feature = "flash", feature = "maud"))]
impl PrecompressedCss {
fn compute(body: &'static str) -> Self {
use std::io::Write as _;
let mut gzip_encoder =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gzip_encoder
.write_all(body.as_bytes())
.expect("in-memory gzip encoding cannot fail");
let gzip = gzip_encoder
.finish()
.expect("in-memory gzip encoding cannot fail");
let mut brotli_writer = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22);
brotli_writer
.write_all(body.as_bytes())
.expect("in-memory brotli encoding cannot fail");
let brotli = brotli_writer.into_inner();
Self {
gzip: gzip.into(),
brotli: brotli.into(),
}
}
}
#[cfg(any(feature = "flash", feature = "maud"))]
fn accepts_encoding(headers: &http::HeaderMap, coding: &str) -> bool {
let Some(value) = headers
.get(http::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
else {
return false;
};
value.split(',').any(|part| {
let mut segments = part.split(';');
let name = segments.next().unwrap_or("").trim();
name.eq_ignore_ascii_case(coding)
&& segments
.find_map(|q| q.trim().strip_prefix("q="))
.and_then(|q| q.parse::<f32>().ok())
.is_none_or(|q| q > 0.0)
})
}
#[cfg(any(feature = "flash", feature = "maud"))]
fn static_css_response(
headers: &http::HeaderMap,
body: &'static str,
precompressed: &'static PrecompressedCss,
) -> axum::response::Response {
use crate::etag::IntoETag as _;
use axum::response::IntoResponse;
let (encoded_body, content_encoding): (axum::body::Body, Option<&'static str>) =
if accepts_encoding(headers, "br") {
(precompressed.brotli.clone().into(), Some("br"))
} else if accepts_encoding(headers, "gzip") {
(precompressed.gzip.clone().into(), Some("gzip"))
} else {
(body.into(), None)
};
let mut response_headers = http::HeaderMap::new();
response_headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("text/css; charset=utf-8"),
);
response_headers.insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("public, max-age=31536000, immutable"),
);
response_headers.insert(
http::header::VARY,
http::HeaderValue::from_static("Accept-Encoding"),
);
if let Some(encoding) = content_encoding {
response_headers.insert(
http::header::CONTENT_ENCODING,
http::HeaderValue::from_static(encoding),
);
}
let etag = crate::etag::ETag::weak(body.into_etag().tag().to_owned());
crate::etag::fresh_when(headers, etag)
.or((response_headers, encoded_body))
.into_response()
}
#[cfg(feature = "flash")]
pub async fn flash_css_handler(headers: http::HeaderMap) -> axum::response::Response {
static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
static_css_response(
&headers,
crate::flash::FLASH_CSS,
PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::flash::FLASH_CSS)),
)
}
#[cfg(feature = "maud")]
pub async fn widgets_css_handler(headers: http::HeaderMap) -> axum::response::Response {
static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
static_css_response(
&headers,
crate::ui::WIDGETS_CSS,
PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::ui::WIDGETS_CSS)),
)
}
#[cfg(feature = "htmx")]
pub async fn htmx_csrf_handler() -> axum::response::Response {
use axum::response::IntoResponse;
(
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
),
],
crate::htmx::HTMX_CSRF_JS,
)
.into_response()
}
#[cfg(feature = "htmx")]
pub async fn autumn_widgets_handler() -> axum::response::Response {
use axum::response::IntoResponse;
(
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
),
],
crate::htmx::AUTUMN_WIDGETS_JS,
)
.into_response()
}
#[cfg(feature = "htmx")]
static IDIOMORPH_ETAG: std::sync::LazyLock<crate::etag::ETag> = std::sync::LazyLock::new(|| {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let digest = Sha256::digest(crate::htmx::IDIOMORPH_JS);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(hex, "{byte:02x}");
}
crate::etag::ETag::weak(format!("idiomorph-{hex}"))
});
#[cfg(feature = "htmx")]
pub async fn idiomorph_handler() -> axum::response::Response {
use axum::response::IntoResponse;
let mut response = (
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=0, must-revalidate",
),
],
crate::htmx::IDIOMORPH_JS,
)
.into_response();
response
.headers_mut()
.insert(http::header::ETAG, IDIOMORPH_ETAG.header_value());
response
}
#[cfg(feature = "htmx")]
pub async fn htmx_sse_handler() -> axum::response::Response {
use axum::response::IntoResponse;
(
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
),
],
crate::htmx::HTMX_SSE_JS,
)
.into_response()
}
#[cfg(feature = "openapi")]
fn collect_openapi_docs(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
) -> Vec<crate::openapi::ApiDoc> {
let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
for route in route_list {
let mut doc = route.api_doc.clone();
doc.api_version = route.api_version;
doc.sunset_opt_out = route.sunset_opt_out;
docs.push(doc);
}
for group in scoped_groups {
let prefix_params = extract_path_params(&group.prefix);
for route in &group.routes {
let mut doc = route.api_doc.clone();
doc.api_version = route.api_version;
doc.sunset_opt_out = route.sunset_opt_out;
let full = join_nested_path(&group.prefix, route.api_doc.path);
doc.path = Box::leak(full.into_boxed_str());
if !prefix_params.is_empty() {
let mut merged: Vec<&'static str> = prefix_params
.iter()
.map(|p| &*Box::leak(p.clone().into_boxed_str()))
.collect();
for existing in route.api_doc.path_params {
if !merged.iter().any(|n| n == existing) {
merged.push(existing);
}
}
doc.path_params = Box::leak(merged.into_boxed_slice());
}
docs.push(doc);
}
}
docs
}
#[cfg(feature = "openapi")]
fn mount_swagger_ui_routes(
mut router: axum::Router<AppState>,
path: &str,
title: &str,
json_path: &str,
) -> axum::Router<AppState> {
let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
let html_body = Arc::new(crate::openapi::swagger_ui_html(
title,
&css_path,
&bundle_path,
&initializer_path,
));
let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
router = router.route(
path,
axum::routing::get(move || {
let html = html_body.clone();
async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
(*html).clone(),
)
.into_response()
}
}),
);
router = router.route(
&css_path,
axum::routing::get(|| async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
crate::openapi::SWAGGER_UI_CSS,
)
.into_response()
}),
);
router = router.route(
&bundle_path,
axum::routing::get(|| async move {
use axum::body::Bytes;
use axum::response::IntoResponse;
(
[(
http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
)
.into_response()
}),
);
router = router.route(
&initializer_path,
axum::routing::get(move || {
let js = initializer_body.clone();
async move {
use axum::response::IntoResponse;
(
[(
http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
(*js).clone(),
)
.into_response()
}
}),
);
router
}
async fn event_app_context_middleware(
state: axum::extract::State<AppState>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
crate::events::scope_event_app(state.0.clone(), async move { next.run(req).await }).await
}
#[cfg(feature = "oauth2")]
async fn http_interceptor_middleware(
state: axum::extract::State<AppState>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
if let Some(interceptor_arc) = state.extension::<Arc<dyn HttpInterceptor>>() {
let interceptor = (*interceptor_arc).clone();
let interceptors = vec![interceptor];
ACTIVE_HTTP_INTERCEPTORS
.scope(interceptors, async move { next.run(req).await })
.await
} else {
next.run(req).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
fn test_state() -> AppState {
AppState {
extensions: std::sync::Arc::new(std::sync::RwLock::new(
std::collections::HashMap::new(),
)),
#[cfg(feature = "db")]
pool: None,
#[cfg(feature = "db")]
replica_pool: None,
#[cfg(feature = "db")]
shards: None,
profile: Some("test".to_owned()),
role: crate::config::ProcessRole::Combined,
started_at: std::time::Instant::now(),
health_detailed: false,
probes: crate::probe::ProbeState::ready_for_test(),
metrics: crate::middleware::MetricsCollector::new(),
log_levels: crate::actuator::LogLevels::new("info"),
task_registry: crate::actuator::TaskRegistry::new(),
job_registry: crate::actuator::JobRegistry::new(),
config_props: crate::actuator::ConfigProperties::default(),
metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
#[cfg(feature = "ws")]
channels: crate::channels::Channels::new(32),
#[cfg(feature = "presence")]
presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
#[cfg(feature = "ws")]
shutdown: tokio_util::sync::CancellationToken::new(),
policy_registry: crate::authorization::PolicyRegistry::default(),
forbidden_response: crate::authorization::ForbiddenResponse::default(),
auth_session_key: "user_id".to_owned(),
shared_cache: None,
clock: std::sync::Arc::new(crate::time::SystemClock),
app_id: crate::state::AppState::next_app_id(),
}
}
#[test]
fn submit_token_explicit_memory_in_production_fails_router_build() {
let mut config = AutumnConfig::default();
config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
let err = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
.expect_err("explicit memory submit-token backend in prod must fail router build");
assert!(
matches!(err, RouterBuildError::InvalidSubmitTokenBackend(_)),
"expected InvalidSubmitTokenBackend, got {err:?}"
);
}
#[test]
fn submit_token_inherited_memory_in_production_builds() {
let mut config = AutumnConfig::default();
config.security.submit_token.backend = None;
config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
.expect("inherited memory submit-token backend in prod must still build (warn only)");
}
#[test]
fn submit_token_memory_outside_production_builds() {
let mut config = AutumnConfig::default();
config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, false)
.expect("memory submit-token backend outside production must build");
}
#[tokio::test]
async fn build_router_mounts_actuator_at_configured_prefix() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/ops".to_owned();
config.actuator.sensitive = true;
let app = build_router(Vec::new(), &config, test_state());
let prefixed = app
.clone()
.oneshot(
Request::builder()
.uri("/ops/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(prefixed.status(), StatusCode::OK);
let legacy = app
.oneshot(
Request::builder()
.uri("/actuator/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn probe_only_router_mounts_probes_and_actuator_but_no_user_routes() {
let config = AutumnConfig::default();
let app = try_build_probe_only_router(&config, test_state())
.expect("probe-only router should build");
for path in [
config.health.live_path.as_str(),
config.health.ready_path.as_str(),
config.health.startup_path.as_str(),
config.health.path.as_str(),
"/actuator/health",
"/actuator/info",
] {
let response = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::NOT_FOUND,
"probe-only router should serve {path}"
);
}
let missing = app
.oneshot(
Request::builder()
.uri("/definitely-not-a-user-route")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn user_route_at_health_path_overrides_builtin_probe() {
async fn user_health() -> &'static str {
"user-health-handler"
}
let config = AutumnConfig::default();
assert_eq!(config.health.path, "/health");
let route = Route {
method: http::Method::GET,
path: "/health",
handler: axum::routing::get(user_health),
name: "user_health",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/health",
operation_id: "user_health",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let app = build_router(vec![route], &config, test_state());
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
&body[..],
b"user-health-handler",
"user route must win at the health path"
);
for path in ["/live", "/ready", "/startup"] {
let resp = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"built-in probe {path} should still be mounted"
);
}
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_the_shared_stylesheet() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(content_type.contains("text/css"), "{content_type}");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains(".autumn-field"), "{body}");
assert!(body.contains(":root"), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_supports_conditional_get() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let first = app
.clone()
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let etag = first
.headers()
.get(http::header::ETAG)
.expect("widget stylesheet response should carry an ETag")
.clone();
let revalidated = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::IF_NONE_MATCH, etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
let revalidated_body = axum::body::to_bytes(revalidated.into_body(), usize::MAX)
.await
.unwrap();
assert!(revalidated_body.is_empty());
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_etag_is_weak_not_strong() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let etag = response
.headers()
.get(http::header::ETAG)
.expect("widget stylesheet response should carry an ETag")
.to_str()
.unwrap()
.to_owned();
assert!(
etag.starts_with("W/\""),
"ETag must be weak since encoded variants aren't byte-identical: {etag}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_precompressed_brotli_when_accepted() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::ACCEPT_ENCODING, "br")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.unwrap(),
"br"
);
assert_eq!(
response.headers().get(http::header::VARY).unwrap(),
"Accept-Encoding"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let mut decoded = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body.as_ref()), &mut decoded)
.expect("response body must be valid brotli");
assert_eq!(String::from_utf8(decoded).unwrap(), crate::ui::WIDGETS_CSS);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_precompressed_gzip_when_accepted() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.unwrap(),
"gzip"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let mut gz = flate2::read::GzDecoder::new(body.as_ref());
let mut output = String::new();
std::io::Read::read_to_string(&mut gz, &mut output)
.expect("response body must be valid gzip");
assert_eq!(output, crate::ui::WIDGETS_CSS);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_honors_q_zero_opt_out() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::ACCEPT_ENCODING, "br;q=0, gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.unwrap(),
"gzip"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_identity_with_no_accept_encoding() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
!response
.headers()
.contains_key(http::header::CONTENT_ENCODING)
);
}
#[test]
fn startup_barrier_503s_are_access_logged() {
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Default)]
struct Capture {
events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
}
struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
impl tracing::field::Visit for Visitor<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(field.name().to_owned(), format!("{value:?}"));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.insert(field.name().to_owned(), value.to_string());
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
return;
}
let mut fields = std::collections::BTreeMap::new();
event.record(&mut Visitor(&mut fields));
self.events.lock().unwrap().push(fields);
}
}
let capture = Capture::default();
let events = Arc::clone(&capture.events);
let subscriber = tracing_subscriber::registry().with(capture);
tracing::subscriber::with_default(subscriber, || {
let state = AppState::for_test()
.with_profile("test")
.with_startup_complete(false);
let app = build_router(Vec::new(), &AutumnConfig::default(), state);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut response = None;
for attempt in 1..=5 {
tracing::callsite::rebuild_interest_cache();
let resp = rt.block_on(async {
app.clone()
.oneshot(
Request::builder()
.uri("/not-a-probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
});
let captured = !events.lock().unwrap().is_empty();
response = Some(resp);
if captured {
break;
}
assert!(
attempt < 5,
"access-log event was not captured after {attempt} attempts \
(tracing interest-cache race with a concurrent test)"
);
}
assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
});
let events = events.lock().unwrap().clone();
assert_eq!(
events.len(),
1,
"a barrier-rejected request should emit one access event: {events:?}"
);
assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
assert!(
!events[0].contains_key("request_id"),
"barrier short-circuits before RequestIdLayer, so no request id"
);
}
#[tokio::test]
async fn startup_barrier_503s_carry_server_timing_header() {
let state = AppState::for_test()
.with_profile("test")
.with_startup_complete(false);
let mut config = AutumnConfig::default();
config.observability.server_timing = Some(true);
let app = build_router(Vec::new(), &config, state);
let response = app
.oneshot(
Request::builder()
.uri("/not-a-probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let header = response
.headers()
.get("server-timing")
.expect("startup 503 short-circuit should still carry Server-Timing via the fallback")
.to_str()
.expect("server-timing header should be valid ASCII");
assert!(
header.starts_with("total;dur="),
"fallback should emit a `total` metric, got {header:?}"
);
assert_eq!(
header.matches("total;dur=").count(),
1,
"short-circuit response must carry a single total metric: {header:?}"
);
}
#[test]
fn try_build_router_rejects_invalid_session_backend_config() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
let error = try_build_router(Vec::new(), &config, test_state())
.expect_err("missing redis config should fail checked router build");
assert!(matches!(
error,
RouterBuildError::InvalidSessionBackend(
crate::session::SessionBackendConfigError::MissingRedisUrl
)
));
}
#[test]
fn try_build_router_with_static_rejects_invalid_session_backend_config() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
.expect_err("missing redis config should fail checked static router build");
assert!(matches!(
error,
RouterBuildError::InvalidSessionBackend(
crate::session::SessionBackendConfigError::MissingRedisUrl
)
));
}
#[test]
fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/".to_owned();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
try_build_router(Vec::new(), &config, test_state())
}));
assert!(result.is_ok(), "try_build_router panicked on route overlap");
assert!(
result.unwrap().is_err(),
"route overlap should be reported as a checked router build error"
);
}
#[test]
fn probe_actuator_overlap_detected_when_user_route_owns_probe_path() {
async fn user_health() -> &'static str {
"user-health-handler"
}
let mut config = AutumnConfig::default();
config.actuator.prefix = "/".to_owned();
assert_eq!(config.health.path, "/health");
let route = Route {
method: http::Method::GET,
path: "/health",
handler: axum::routing::get(user_health),
name: "user_health",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/health",
operation_id: "user_health",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
try_build_router(vec![route], &config, test_state())
}));
assert!(
result.is_ok(),
"try_build_router panicked instead of returning a checked overlap error"
);
let build = result.unwrap();
assert!(
matches!(
&build,
Err(RouterBuildError::FrameworkRouteOverlap {
path,
incoming: "actuator endpoint",
..
}) if path == "/health"
),
"root-prefix actuator over a user-owned probe path must yield a checked \
FrameworkRouteOverlap for /health, got: {:?}",
build.as_ref().map(|_| "Ok(router)"),
);
}
#[tokio::test]
async fn apply_cors_middleware_skipped_when_no_origins() {
let config = AutumnConfig::default();
assert!(config.cors.allowed_origins.is_empty());
let base: axum::Router<AppState> =
axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.uri("/test")
.header("Origin", "https://example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_none(),
"CORS header must be absent when no origins are configured"
);
}
#[tokio::test]
async fn apply_cors_middleware_present_when_origins_configured() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://example.com".to_owned()];
let base: axum::Router<AppState> =
axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.uri("/test")
.header("Origin", "https://example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_some(),
"CORS header must be present when origins are configured"
);
}
#[tokio::test]
async fn apply_cors_middleware_handles_preflight_request() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://example.com".to_owned()];
let base: axum::Router<AppState> =
axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/widgets")
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "Content-Type")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let headers = response.headers();
assert_eq!(
headers
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://example.com"),
"preflight must echo the allowed origin"
);
assert!(
headers.get("access-control-allow-methods").is_some(),
"preflight must advertise allowed methods"
);
assert!(
headers.get("access-control-allow-headers").is_some(),
"preflight must advertise allowed headers"
);
assert!(
headers.get("access-control-max-age").is_some(),
"preflight must advertise max-age so browsers can cache it"
);
}
#[tokio::test]
async fn apply_csrf_middleware_skipped_when_disabled() {
let config = AutumnConfig::default();
assert!(!config.security.csrf.enabled);
let base: axum::Router<AppState> =
axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/form")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn apply_rate_limit_middleware_skipped_when_disabled() {
let config = AutumnConfig::default();
assert!(!config.security.rate_limit.enabled);
let base: axum::Router<AppState> =
axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
let state = test_state();
let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
for _ in 0..5 {
let response = router
.clone()
.oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
let mut config = AutumnConfig::default();
config.security.rate_limit.enabled = true;
config.security.rate_limit.requests_per_second = 0.1;
config.security.rate_limit.burst = 1;
config.security.rate_limit.trust_forwarded_headers = true;
let base: axum::Router<AppState> =
axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
let state = test_state();
let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
let ok = router
.clone()
.oneshot(
Request::builder()
.uri("/ping")
.header("X-Forwarded-For", "203.0.113.9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK);
let blocked = router
.oneshot(
Request::builder()
.uri("/ping")
.header("X-Forwarded-For", "203.0.113.9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(blocked.headers().get("retry-after").is_some());
}
#[cfg(feature = "mcp")]
#[tokio::test]
async fn mcp_envelope_is_gated_during_maintenance() {
use crate::maintenance::{MaintenanceConfig, MaintenanceState};
let mut config = AutumnConfig::default();
config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
let wiring = crate::mcp::McpWiring {
cors: crate::config::CorsConfig::default(),
trusted_hosts: TrustedHostPolicy::from_config(&config),
tenant_header: None,
csrf_header: "x-csrf-token".to_owned(),
envelope_rate_limited: false,
envelope_load_shed: false,
};
let mcp_router =
crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
let initialize = || {
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "app.example")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
))
.unwrap()
};
let state = test_state();
let maintenance = MaintenanceState::new();
maintenance.enable(MaintenanceConfig::default());
state.insert_extension(maintenance);
let gated = mcp_router
.clone()
.layer(build_maintenance_layer(&config, &state))
.with_state(state);
let resp = gated.oneshot(initialize()).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let state = test_state();
let open = mcp_router
.layer(build_maintenance_layer(&config, &state))
.with_state(state);
let resp = open.oneshot(initialize()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[cfg(feature = "mail")]
fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
let mut config = AutumnConfig {
profile: Some("dev".to_owned()),
mail: crate::mail::MailConfig {
transport: crate::mail::Transport::File,
file_dir: dir.to_path_buf(),
..Default::default()
},
..Default::default()
};
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
config
}
#[cfg(any(feature = "mail", feature = "maud"))]
async fn response_text(response: axum::response::Response) -> String {
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
String::from_utf8(body.to_vec()).expect("body should be utf8")
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
let dir = tempfile::tempdir().expect("tempdir");
let config = dev_mail_preview_config(dir.path());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("No captured emails"),
"missing empty state: {body}"
);
assert!(
body.contains("mail.transport = "file""),
"empty state should explain capture setup: {body}"
);
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_lists_captured_mail_newest_first() {
let dir = tempfile::tempdir().expect("tempdir");
let older = dir.path().join("older.eml");
let newer = dir.path().join("newer.eml");
std::fs::write(
&older,
"To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
)
.expect("write older eml");
std::fs::write(
&newer,
"To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
)
.expect("write newer eml");
filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
.expect("set older mtime");
filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
.expect("set newer mtime");
let config = dev_mail_preview_config(dir.path());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
let second = body.find("Second").expect("newer subject should render");
let first = body.find("First").expect("older subject should render");
assert!(second < first, "newest message should render first: {body}");
assert!(
body.contains("second@example.com"),
"missing To column: {body}"
);
assert!(
body.contains("Timestamp"),
"missing timestamp column: {body}"
);
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("detail.eml"),
"From: Autumn <noreply@example.com>\nTo: ada@example.com\nReply-To: support@example.com\nSubject: Reset\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <reset@example.com>\nMIME-Version: 1.0\nContent-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\nPlain reset\n--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n<h1>Hello iframe</h1>\n--autumn-mail--\n",
)
.expect("write detail eml");
let config = dev_mail_preview_config(dir.path());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail/messages/detail.eml")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("<iframe"), "missing iframe: {body}");
assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
assert!(body.contains("Hello iframe"), "missing html body: {body}");
assert!(body.contains("Plain text"), "missing text toggle: {body}");
assert!(body.contains("Headers"), "missing headers toggle: {body}");
assert!(
body.contains("Raw .eml"),
"missing raw source toggle: {body}"
);
assert!(
body.contains("Message-Id"),
"missing message id header: {body}"
);
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_does_not_mount_mail_preview_outside_dev() {
let dir = tempfile::tempdir().expect("tempdir");
let mut config = dev_mail_preview_config(dir.path());
config.profile = Some("prod".to_owned());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[cfg(feature = "maud")]
fn story_gallery_config() -> AutumnConfig {
let mut config = AutumnConfig::default();
config.stories.enabled = true;
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
config
}
#[cfg(feature = "maud")]
fn stories_state_with_builtin() -> AppState {
let state = test_state();
state.insert_extension(crate::stories::builtin());
state
}
#[cfg(feature = "maud")]
async fn get_with_host(router: axum::Router, uri: &str) -> axum::response::Response {
router
.oneshot(
Request::builder()
.uri(uri)
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn build_router_mounts_story_gallery_when_enabled() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let response = get_with_host(router, crate::stories::STORIES_PATH).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("Data table"),
"index should list builtin story names: {body}"
);
assert!(
body.contains("autumn-widgets.css"),
"index should link the framework widget stylesheet: {body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_detail_route_serves_render_source_and_html() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let response = get_with_host(router, "/_stories/data-table").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("<table"),
"detail page must contain the live data_table render: {body}"
);
assert!(
body.contains("data_table("),
"detail page must show the source snippet that produced the render: {body}"
);
assert!(
body.contains("Rendered HTML"),
"detail page must offer the rendered-HTML tab: {body}"
);
assert!(
body.contains("Source"),
"detail page must offer the source tab: {body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_detail_unknown_slug_is_404() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let missing = get_with_host(router.clone(), "/_stories/nope").await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let index = get_with_host(router, "/_stories").await;
assert_eq!(
index.status(),
StatusCode::OK,
"index route must exist even when a slug misses"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn build_router_omits_story_gallery_by_default() {
let mut config = AutumnConfig::default();
assert!(
!config.stories.enabled,
"stories gallery must be off by default"
);
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
let router = build_router(Vec::new(), &config, stories_state_with_builtin());
let response = get_with_host(router, "/_stories").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[cfg(feature = "maud")]
async fn stories_status_for_layered_profile(toml: &str, profile: &str) -> StatusCode {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), toml).expect("write autumn.toml");
let env = crate::config::MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
.with("AUTUMN_ENV", profile);
let mut config = AutumnConfig::load_with_env(&env).expect("layered config should load");
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
let router = build_router(Vec::new(), &config, stories_state_with_builtin());
get_with_host(router, "/_stories").await.status()
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_routes_mount_iff_resolved_profile_flag() {
let dev_only = r"
[stories]
enabled = false
[profile.dev.stories]
enabled = true
";
assert_eq!(
stories_status_for_layered_profile(dev_only, "dev").await,
StatusCode::OK,
"dev profile override must mount the gallery"
);
assert_eq!(
stories_status_for_layered_profile(dev_only, "prod").await,
StatusCode::NOT_FOUND,
"prod must not mount the gallery when only dev enables it"
);
let public_showcase = r"
[stories]
enabled = false
[profile.prod.stories]
enabled = true
";
assert_eq!(
stories_status_for_layered_profile(public_showcase, "prod").await,
StatusCode::OK,
"prod profile override must mount the gallery for a public showcase"
);
assert_eq!(
stories_status_for_layered_profile(public_showcase, "dev").await,
StatusCode::NOT_FOUND,
"dev must not mount the gallery when only prod enables it"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn custom_story_served_alongside_builtins() {
let custom = crate::stories::story! {
"App",
"Greeting",
{
maud::html! { span class="app-greeting" { "hi from the app" } }
}
};
let state = test_state();
state.insert_extension(
crate::stories::StoryGallery::builtin()
.extend([custom])
.into_registry(),
);
let router = build_router(Vec::new(), &story_gallery_config(), state);
let detail = get_with_host(router.clone(), "/_stories/greeting").await;
assert_eq!(detail.status(), StatusCode::OK);
let body = response_text(detail).await;
assert!(
body.contains("hi from the app"),
"custom story must render at its slug: {body}"
);
let index = get_with_host(router, "/_stories").await;
let body = response_text(index).await;
assert!(
body.contains("Greeting"),
"index must list the custom story: {body}"
);
assert!(
body.contains("App"),
"index must show the custom story's group: {body}"
);
assert!(
body.contains("Data table"),
"builtins must still be listed alongside the custom story: {body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_pages_inline_style_carries_csp_header_nonce() {
let mut config = story_gallery_config();
config.security.headers.csp_nonce.enabled = true;
let router = build_router(Vec::new(), &config, stories_state_with_builtin());
for uri in ["/_stories", "/_stories/data-table"] {
let response = get_with_host(router.clone(), uri).await;
assert_eq!(response.status(), StatusCode::OK);
let csp = response
.headers()
.get("content-security-policy")
.expect("CSP header must be present")
.to_str()
.unwrap()
.to_owned();
let nonce = csp
.split("'nonce-")
.nth(1)
.and_then(|rest| rest.split('\'').next())
.unwrap_or_else(|| panic!("CSP header must advertise a nonce: {csp}"))
.to_owned();
assert!(!nonce.is_empty(), "advertised nonce must be non-empty");
let body = response_text(response).await;
assert!(
body.contains(&format!(r#"<style nonce="{nonce}">"#)),
"{uri} inline style must carry the CSP header nonce {nonce}: {body}"
);
}
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn enabled_without_registry_shows_empty_state() {
let router = build_router(Vec::new(), &story_gallery_config(), test_state());
let response = get_with_host(router, "/_stories").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("with_story_gallery"),
"empty state should point at AppBuilder::with_story_gallery: {body}"
);
}
#[tokio::test]
async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
let mut config = AutumnConfig::default();
config.security.csrf.enabled = true;
let base: axum::Router<AppState> =
axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/form")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::OK,
"POST without CSRF token should be rejected when CSRF is enabled"
);
}
#[test]
fn join_nested_path_normalizes_like_axum() {
assert_eq!(super::join_nested_path("/api", "/"), "/api");
assert_eq!(super::join_nested_path("/api/", "/"), "/api/");
assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
assert_eq!(super::join_nested_path("", "/"), "/");
assert_eq!(super::join_nested_path("", "/users"), "/users");
}
#[tokio::test]
async fn join_nested_path_matches_axum_matched_path() {
use axum::routing::get;
async fn matched(mp: Option<axum::extract::MatchedPath>) -> String {
mp.map(|m| m.as_str().to_owned()).unwrap_or_default()
}
for (prefix, child, req) in [
("/api", "/", "/api"),
("/api/", "/", "/api/"),
("/api", "/users", "/api/users"),
("/api/", "/users", "/api/users"),
] {
let sub = axum::Router::new().route(child, get(matched));
let app: axum::Router = axum::Router::new().nest(prefix, sub);
let resp = tower::ServiceExt::oneshot(
app,
axum::http::Request::builder()
.uri(req)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK, "{prefix} + {child}");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let axum_matched = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(
super::join_nested_path(prefix, child),
axum_matched,
"join_nested_path must equal axum MatchedPath for nest({prefix:?}, {child:?})"
);
}
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_detects_scoped_root_collision() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn child() -> &'static str {
"inner"
}
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(child),
name: "root",
api_doc: ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
}],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: vec![group],
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(all(feature = "openapi", feature = "maud"))]
#[test]
fn try_build_router_detects_widgets_css_path_collision() {
use crate::openapi::OpenApiConfig;
let openapi =
OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::ui::WIDGETS_CSS_PATH);
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
"openapi_json_path colliding with the widget stylesheet route should be rejected",
);
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(all(feature = "openapi", feature = "flash"))]
#[test]
fn try_build_router_detects_flash_css_path_collision() {
use crate::openapi::OpenApiConfig;
let openapi =
OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::flash::FLASH_CSS_PATH);
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
"openapi_json_path colliding with the flash stylesheet route should be rejected",
);
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn extract_path_params_matches_macro_behavior() {
assert_eq!(
super::extract_path_params("/orgs/{org_id}/users/{id}"),
vec!["org_id".to_owned(), "id".to_owned()]
);
assert_eq!(
super::extract_path_params("/users/{id}/posts/{slug}"),
vec!["id".to_owned(), "slug".to_owned()]
);
assert!(super::extract_path_params("/static").is_empty());
assert_eq!(
super::extract_path_params("/users/{id:[0-9]+}"),
vec!["id".to_owned()]
);
assert_eq!(
super::extract_path_params("{id:[0-9]{1,3}}"),
vec!["id".to_owned()]
);
assert!(super::extract_path_params("{{hello}}").is_empty());
assert_eq!(
super::extract_path_params("{{literal}}/{id}"),
vec!["id".to_owned()]
);
assert!(super::extract_path_params("{{}").is_empty());
assert!(super::extract_path_params("{a{b}").is_empty());
assert!(super::extract_path_params("{").is_empty());
assert!(super::extract_path_params("}").is_empty());
assert!(super::extract_path_params("{}").is_empty());
}
#[cfg(feature = "openapi")]
#[test]
fn extract_path_params_handles_unbalanced_braces() {
for path in ["{{}", "{", "}", "{a{b}"] {
for name in super::extract_path_params(path) {
assert!(
!name.contains('{') && !name.contains('}'),
"param name should be brace-free for {path:?}: {name:?}"
);
assert!(
!name.is_empty(),
"param name should be non-empty for {path:?}"
);
}
}
assert!(super::extract_path_params("{{}").is_empty());
assert!(super::extract_path_params("{a{b}").is_empty());
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn openapi_merges_scoped_prefix_path_params() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn handler() -> &'static str {
"ok"
}
let child = Route {
method: http::Method::GET,
path: "/users/{id}",
handler: axum::routing::get(handler),
name: "child",
api_doc: ApiDoc {
method: "GET",
path: "/users/{id}",
operation_id: "child",
path_params: &["id"],
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let group = crate::app::ScopedGroup {
prefix: "/orgs/{org_id}".to_owned(),
routes: vec![child],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let config = OpenApiConfig::new("Demo", "1.0.0");
let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
.expect("openapi sub-router builds")
.expect("openapi sub-router present when config is Some");
let state = test_state();
let router = router.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
let names: Vec<&str> = params
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
assert!(names.contains(&"id"), "missing id: {names:?}");
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn openapi_documents_configured_session_cookie_name() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn handler() -> &'static str {
"ok"
}
let route = Route {
method: http::Method::GET,
path: "/protected",
handler: axum::routing::get(handler),
name: "protected",
api_doc: ApiDoc {
method: "GET",
path: "/protected",
operation_id: "protected",
success_status: 200,
secured: true,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let protected_routes = vec![route];
let config = OpenApiConfig::new("Demo", "1.0.0");
let docs_router =
super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
.expect("openapi sub-router builds")
.expect("openapi sub-router present when config is Some");
let docs_router = docs_router.with_state(test_state());
let response = docs_router
.oneshot(
Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
let schemes = &spec["components"]["securitySchemes"];
assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
assert_eq!(schemes["SessionAuth"]["in"], "cookie");
assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
assert!(
schemes.get("BearerAuth").is_none(),
"secured routes must not be documented as bearer JWT routes"
);
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_json_path_without_leading_slash() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("non-slash path should be rejected");
assert!(matches!(
err,
RouterBuildError::InvalidOpenApiPath {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_captures() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("captures should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_unbalanced_brace() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("unbalanced brace should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_wildcard() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("wildcard should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_double_slash() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("double-slash should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_swagger_ui_path_without_leading_slash() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.swagger_ui_path(Some("docs".to_owned()));
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("non-slash path should be rejected");
assert!(matches!(
err,
RouterBuildError::InvalidOpenApiPath {
field: "swagger_ui_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_empty_json_path() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("empty path should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_accepts_valid_paths() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/api-docs")
.swagger_ui_path(Some("/ui".to_owned()));
let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect("valid paths must not error");
assert!(out.is_some());
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_duplicate_json_and_swagger_paths() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/docs")
.swagger_ui_path(Some("/docs".to_owned()));
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("colliding paths should be rejected before axum panics");
assert!(matches!(
err,
RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
));
}
#[cfg(feature = "openapi")]
async fn collision_test_handler() -> &'static str {
"user"
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/ops".to_owned();
let openapi =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
let user_route = Route {
method: http::Method::GET,
path: "/my-api-docs",
handler: axum::routing::get(collision_test_handler),
name: "collides",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/my-api-docs",
operation_id: "collides",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
.expect_err("user-owned path should prevent OpenAPI mount");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
let config = AutumnConfig::default(); let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/actuator/health");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("framework-owned path should prevent OpenAPI mount");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
let user_route = Route {
method: http::Method::GET,
path: "/swagger-ui/swagger-ui.css",
handler: axum::routing::get(collision_test_handler),
name: "swagger-ui-asset-collides",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/swagger-ui/swagger-ui.css",
operation_id: "swagger_ui_asset_collides",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
.expect_err("swagger ui asset path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "swagger_ui_path",
ref path,
} if path == "/swagger-ui/swagger-ui.css"
));
}
#[cfg(all(feature = "openapi", feature = "htmx"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("htmx csrf helper path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::htmx::HTMX_CSRF_JS_PATH
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
let config = AutumnConfig::default();
let openapi =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
let nested = axum::Router::<AppState>::new()
.route("/inner", axum::routing::get(|| async { "inner" }));
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: vec![("/api".to_owned(), nested)],
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("OpenAPI path under a nest prefix should collide");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == "/api/docs"
));
}
#[cfg(all(feature = "openapi", feature = "mail"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_unsubscribe_endpoint() {
let mut config = AutumnConfig::default();
config.mail.mount_unsubscribe_endpoint = true;
config.mail.unsubscribe_base_url = Some("https://app.example.com".to_owned());
assert!(config.mail.should_mount_unsubscribe_endpoint());
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::mail::UNSUBSCRIBE_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("unsubscribe endpoint path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::mail::UNSUBSCRIBE_PATH
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_job_status_endpoint() {
let config = AutumnConfig::default();
assert!(config.jobs.tracking.route_enabled);
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::job_tracking::JOB_STATUS_ROUTE_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("job status endpoint path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
));
}
#[cfg(all(feature = "openapi", feature = "maud"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_story_gallery() {
let mut config = AutumnConfig::default();
config.stories.enabled = true;
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::stories::STORIES_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("story gallery path should be reserved while stories are enabled");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::stories::STORIES_PATH
));
}
#[cfg(feature = "openapi")]
#[test]
fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
temp_env::with_vars(
[
("AUTUMN_DEV_RELOAD", Some("1")),
("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
],
|| {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/__autumn/live-reload");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("dev reload path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
},
);
}
async fn duplicate_route_handler() -> &'static str {
"ok"
}
fn duplicate_test_route(method: http::Method, path: &'static str, name: &'static str) -> Route {
let handler = match method {
http::Method::POST => axum::routing::post(duplicate_route_handler),
http::Method::PUT => axum::routing::put(duplicate_route_handler),
http::Method::PATCH => axum::routing::patch(duplicate_route_handler),
http::Method::DELETE => axum::routing::delete(duplicate_route_handler),
_ => axum::routing::get(duplicate_route_handler),
};
let method_str = if method == http::Method::POST {
"POST"
} else if method == http::Method::PUT {
"PUT"
} else if method == http::Method::PATCH {
"PATCH"
} else if method == http::Method::DELETE {
"DELETE"
} else {
"GET"
};
Route {
method,
path,
handler,
name,
api_doc: crate::openapi::ApiDoc {
method: method_str,
path,
operation_id: name,
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
}
}
fn duplicate_test_ctx() -> RouterContext {
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
}
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_user_route_paths() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/", "root_a");
let b = duplicate_test_route(http::Method::GET, "/", "root_b");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("two GET / routes should be rejected before mount");
let display = err.to_string();
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
ref existing,
ref incoming,
} => {
assert_eq!(method, "GET");
assert_eq!(path, "/");
assert_eq!(existing, "root_a");
assert_eq!(incoming, "root_b");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
assert!(
display.contains("root_a"),
"error message must name first handler; got: {display}"
);
assert!(
display.contains("root_b"),
"error message must name second handler; got: {display}"
);
assert!(
display.contains("GET"),
"error message must name the HTTP method; got: {display}"
);
assert!(
display.contains('/'),
"error message must contain the path; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_allows_distinct_methods_on_same_path() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/admin", "admin_index");
let post = duplicate_test_route(http::Method::POST, "/admin", "admin_create");
let _router = super::try_build_router_inner(
vec![get, post],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect("GET + POST on the same path should build cleanly");
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_across_scoped_group() {
let config = AutumnConfig::default();
let top = duplicate_test_route(http::Method::GET, "/api/posts", "top_posts");
let scoped_child = duplicate_test_route(http::Method::GET, "/posts", "scoped_posts");
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![scoped_child],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
.expect_err("top-level + scoped resolving to same path should be rejected");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
..
} => {
assert_eq!(method, "GET");
assert_eq!(path, "/api/posts");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_within_scoped_groups() {
let config = AutumnConfig::default();
let a = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![duplicate_test_route(
http::Method::GET,
"/posts",
"user_posts",
)],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let b = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![duplicate_test_route(
http::Method::GET,
"/posts",
"plugin_posts",
)],
source: crate::route_listing::RouteSource::Plugin("blog".to_owned()),
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(a);
ctx.scoped_groups.push(b);
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("two scoped groups colliding on /api/posts should be rejected");
assert!(matches!(
err,
RouterBuildError::DuplicateUserRoute { ref existing, ref incoming, .. }
if existing == "user_posts" && incoming == "plugin_posts"
));
}
#[tokio::test]
async fn try_build_router_skips_duplicate_check_for_opaque_merge_router() {
let config = AutumnConfig::default();
let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
let raw = axum::Router::<AppState>::new()
.route("/raw", axum::routing::get(duplicate_route_handler));
let mut ctx = duplicate_test_ctx();
ctx.merge_routers.push(raw);
let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
.expect("opaque merge routers must not fail the duplicate preflight");
}
#[tokio::test]
async fn try_build_router_skips_duplicate_check_for_opaque_nest_router() {
let config = AutumnConfig::default();
let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
let nested = axum::Router::<AppState>::new()
.route("/child", axum::routing::get(duplicate_route_handler));
let mut ctx = duplicate_test_ctx();
ctx.nest_routers.push(("/plugin".to_owned(), nested));
let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
.expect("opaque nest routers must not fail the duplicate preflight");
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_capture_name_paths() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
let b = duplicate_test_route(http::Method::GET, "/users/{slug}", "by_slug");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("capture-name-only difference must be rejected before mount");
match err {
RouterBuildError::ConflictingRouteShape {
ref existing,
ref existing_path,
ref incoming,
ref incoming_path,
} => {
assert_eq!(existing, "by_id");
assert_eq!(existing_path, "/users/{id}");
assert_eq!(incoming, "by_slug");
assert_eq!(incoming_path, "/users/{slug}");
}
other => panic!("expected ConflictingRouteShape, got {other:?}"),
}
let display = err.to_string();
assert!(
display.contains("/users/{id}") && display.contains("/users/{slug}"),
"error must show both original path templates; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_capture_name_across_scoped_group() {
let config = AutumnConfig::default();
let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
let scoped_child =
duplicate_test_route(http::Method::GET, "/users/{slug}", "scoped_by_slug");
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![scoped_child],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
.expect_err("scoped capture-name collision must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing, ref incoming, ref existing_path, ref incoming_path
}
if existing == "top_by_id" && incoming == "scoped_by_slug"
&& existing_path == "/api/users/{id}"
&& incoming_path == "/api/users/{slug}"
),
"expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_distinct_route_shapes() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
let b = duplicate_test_route(http::Method::GET, "/users/{id}/posts", "posts");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("distinct route shapes must not be flagged as duplicates");
}
#[tokio::test]
async fn try_build_router_rejects_ws_get_collision() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/live", "live_poll");
let ws = duplicate_test_route(
http::Method::from_bytes(b"WS").unwrap(),
"/live",
"live_socket",
);
let err = super::try_build_router_inner(
vec![get, ws],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect_err("GET + WS on the same path must be rejected before mount");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
ref existing,
ref incoming,
} => {
assert_eq!(method, "GET", "WS must be normalized to its effective GET");
assert_eq!(path, "/live");
assert_eq!(existing, "live_poll");
assert_eq!(incoming, "live_socket");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_rejects_cross_method_shape_conflict() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
let post = duplicate_test_route(http::Method::POST, "/users/{slug}", "by_slug");
let err = super::try_build_router_inner(
vec![get, post],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect_err("cross-method capture-name-only conflict must be rejected before mount");
match err {
RouterBuildError::ConflictingRouteShape {
ref existing,
ref existing_path,
ref incoming,
ref incoming_path,
} => {
assert_eq!(existing, "by_id");
assert_eq!(existing_path, "/users/{id}");
assert_eq!(incoming, "by_slug");
assert_eq!(incoming_path, "/users/{slug}");
}
other => panic!("expected ConflictingRouteShape, got {other:?}"),
}
let display = err.to_string();
assert!(
display.contains("by_id") && display.contains("by_slug"),
"error must name both handlers; got: {display}"
);
assert!(
display.contains("/users/{id}") && display.contains("/users/{slug}"),
"error must name both original templates; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_rejects_cross_method_shape_conflict_across_scoped_group() {
let config = AutumnConfig::default();
let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
let scoped_child =
duplicate_test_route(http::Method::POST, "/users/{slug}", "scoped_by_slug");
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![scoped_child],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
.expect_err("scoped cross-method shape conflict must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing, ref incoming, ref existing_path, ref incoming_path
}
if existing == "top_by_id" && incoming == "scoped_by_slug"
&& existing_path == "/api/users/{id}"
&& incoming_path == "/api/users/{slug}"
),
"expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_same_capture_template_distinct_methods() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
let post = duplicate_test_route(http::Method::POST, "/users/{id}", "update");
let _router = super::try_build_router_inner(
vec![get, post],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect("same capture template on GET + POST must build cleanly");
}
#[tokio::test]
async fn try_build_router_allows_escaped_brace_literals() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/{{foo}}", "lit_foo");
let b = duplicate_test_route(http::Method::GET, "/{{bar}}", "lit_bar");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("distinct escaped-literal paths must not be flagged as duplicates");
}
#[tokio::test]
async fn try_build_router_allows_escaped_literal_prefix_with_capture() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/{{x}}/{id}", "x_show");
let b = duplicate_test_route(http::Method::GET, "/{{y}}/{id}", "y_show");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("distinct escaped-literal prefixes with a shared capture must build");
}
#[tokio::test]
async fn try_build_router_rejects_mixed_literal_capture_shape_conflict() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
let b = duplicate_test_route(http::Method::GET, "/file.{kind}", "by_kind");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("mixed literal+capture shape conflict must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing_path, ref incoming_path, ..
}
if existing_path == "/file.{ext}" && incoming_path == "/file.{kind}"
),
"expected ConflictingRouteShape naming both templates, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_mixed_capture_vs_static_segment() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
let b = duplicate_test_route(http::Method::GET, "/file.json", "static_json");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("a capture segment and a static segment must not be flagged as duplicates");
}
#[tokio::test]
async fn try_build_router_rejects_catch_all_vs_normal_capture() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/u/{id}", "one");
let b = duplicate_test_route(http::Method::GET, "/u/{*rest}", "rest");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("catch-all vs normal capture must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing_path, ref incoming_path, ..
}
if existing_path == "/u/{id}" && incoming_path == "/u/{*rest}"
),
"expected ConflictingRouteShape naming both templates, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_rejects_catch_all_vs_dynamic_descendant() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/cmd/{tool}/{sub}", "cmd_sub");
let b = duplicate_test_route(http::Method::POST, "/cmd/{*path}", "cmd_all");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("catch-all vs dynamic descendant must be rejected before mount");
match err {
RouterBuildError::ConflictingRouteShape {
ref existing,
ref existing_path,
ref incoming,
ref incoming_path,
} => {
assert_eq!(existing, "cmd_sub");
assert_eq!(existing_path, "/cmd/{tool}/{sub}");
assert_eq!(incoming, "cmd_all");
assert_eq!(incoming_path, "/cmd/{*path}");
}
other => panic!("expected ConflictingRouteShape, got {other:?}"),
}
let display = err.to_string();
assert!(
display.contains("/cmd/{tool}/{sub}") && display.contains("/cmd/{*path}"),
"error must name both original templates; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_allows_static_vs_dynamic_segment() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/users/me", "me");
let b = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("a static segment and a dynamic capture must not be flagged as a conflict");
}
#[test]
fn matchit_agrees_with_axum_route_conflicts() {
let matrix: &[(&str, &str, bool)] = &[
("/users/{id}", "/users/{slug}", true),
("/users/{id}", "/users/{id}/posts", false),
("/cmd/{tool}/{sub}", "/cmd/{*path}", true),
("/users/me", "/users/{id}", false),
("/{{foo}}", "/{{bar}}", false),
("/file.{ext}", "/file.{kind}", true),
("/file.{ext}", "/file.json", false),
("/u/{id}", "/u/{*rest}", true),
];
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut rows = Vec::new();
let mut mismatches = Vec::new();
for &(a, b, expect_conflict) in matrix {
let axum_panics = std::panic::catch_unwind(|| {
let _ = axum::Router::<()>::new()
.route(a, axum::routing::get(|| async { "a" }))
.route(b, axum::routing::get(|| async { "b" }));
})
.is_err();
let mut r: matchit::Router<()> = matchit::Router::new();
r.insert(a, ()).expect("first template must insert cleanly");
let matchit_conflicts =
matches!(r.insert(b, ()), Err(matchit::InsertError::Conflict { .. }));
rows.push(format!(
"{a:<20} vs {b:<20} axum={} matchit={} expected={}",
if axum_panics { "PANIC" } else { "ok" },
if matchit_conflicts { "Err" } else { "Ok" },
if expect_conflict { "conflict" } else { "ok" },
));
if axum_panics != matchit_conflicts || axum_panics != expect_conflict {
mismatches.push(rows.last().unwrap().clone());
}
}
std::panic::set_hook(prev_hook);
assert!(
mismatches.is_empty(),
"matchit must agree with axum 0.8.9 AND the expected outcome on every \
case (oracle divergence => false positives/negatives at mount).\n\
full matrix:\n{}\nmismatches:\n{}",
rows.join("\n"),
mismatches.join("\n"),
);
}
fn create_ssg_dist(entries: &[(&str, &str, &[u8])]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
let mut routes = std::collections::HashMap::new();
for (route, file, bytes) in entries {
let path = dist.join(file);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
std::fs::write(&path, bytes).expect("write file");
routes.insert(
(*route).to_owned(),
crate::static_gen::ManifestEntry {
file: (*file).to_owned(),
revalidate: None,
},
);
}
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-07-12T00:00:00Z".to_owned(),
autumn_version: "0.6.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).unwrap(),
)
.unwrap();
dir
}
fn compression_enabled_config() -> AutumnConfig {
let mut config = AutumnConfig::default();
config.compression.enabled = true;
config
}
#[tokio::test]
async fn ssg_html_hit_is_gzip_compressed() {
let html = format!(
"<html><body>{}</body></html>",
"Lorem ipsum dolor sit amet. ".repeat(64)
);
let tmp = create_ssg_dist(&[("/", "index.html", html.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"manifest-backed SSG HTML page must be gzip-compressed"
);
let vary = response
.headers()
.get(http::header::VARY)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
vary.to_lowercase().contains("accept-encoding"),
"Vary must advertise Accept-Encoding, got {vary:?}"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"HTML page keeps its text/html content type"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_ne!(
body.as_ref(),
html.as_bytes(),
"compressed body must differ from the raw HTML"
);
}
#[tokio::test]
async fn ssg_binary_asset_is_not_compressed_and_keeps_mime() {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
let tmp = create_ssg_dist(&[("/logo", "logo.png", &bytes)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/logo")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("image/png"),
"binary manifest asset must keep its real MIME type, not text/html"
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"binary asset must not be blindly compressed"
);
}
#[tokio::test]
async fn ssg_woff2_font_is_not_compressed_and_keeps_mime() {
let mut bytes = b"wOF2".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
let tmp = create_ssg_dist(&[("/inter", "fonts/inter.woff2", &bytes)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/inter")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("font/woff2"),
"woff2 manifest asset must keep its font/woff2 MIME type"
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"pre-compressed woff2 font must not be re-compressed"
);
}
#[tokio::test]
async fn ssg_nested_multidot_asset_resolves_js_mime() {
let js = format!("console.log({:?});", "x".repeat(256));
let tmp = create_ssg_dist(&[("/app.js", "assets/js/app.min.js", js.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/app.js")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/javascript; charset=utf-8"),
"nested multi-dot JS asset must resolve to the JavaScript MIME type"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"compressible JS asset must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_generated_html_page_keeps_text_html_and_is_compressed() {
let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
let tmp = create_ssg_dist(&[("/about", "about/index.html", html.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"extensionless generated page must stay text/html, not octet-stream"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"generated HTML page must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_generated_txt_route_is_text_plain_and_compressed() {
let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128));
let tmp =
create_ssg_dist(&[("/robots.txt", "robots.txt/index.html", body_text.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/robots.txt")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
"generated .txt route must be text/plain, derived from the route extension"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"compressible text/plain route must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_generated_xml_route_is_xml_mime() {
let xml = format!(
"<?xml version=\"1.0\"?><urlset>{}</urlset>",
"<url><loc>https://example.com/</loc></url>".repeat(64)
);
let tmp = create_ssg_dist(&[("/sitemap.xml", "sitemap.xml/index.html", xml.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/sitemap.xml")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/xml"),
"generated .xml route must be application/xml, derived from the route extension"
);
}
#[tokio::test]
async fn ssg_dotted_slug_generated_page_stays_html_and_compressed() {
let html = format!(
"<html><body>{}</body></html>",
"Release notes. ".repeat(128)
);
let tmp = create_ssg_dist(&[(
"/posts/release.v1",
"release.v1/index.html",
html.as_bytes(),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/posts/release.v1")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"dotted-slug generated page must stay text/html, not octet-stream"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"dotted-slug generated HTML page must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_email_slug_generated_page_stays_html() {
let html = format!("<html><body>{}</body></html>", "Profile. ".repeat(64));
let tmp = create_ssg_dist(&[(
"/users/alice@example.com",
"alice@example.com/index.html",
html.as_bytes(),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/users/alice@example.com")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"email-like dotted-slug generated page must stay text/html"
);
}
#[tokio::test]
async fn ssg_dynamic_fallback_route_is_gzip_compressed() {
async fn dynamic() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
format!(
"<html><body>{}</body></html>",
"dynamic content ".repeat(64)
),
)
}
let route = Route {
method: http::Method::GET,
path: "/dynamic",
handler: axum::routing::get(dynamic),
name: "dynamic",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/dynamic",
operation_id: "dynamic",
success_status: 200,
..Default::default()
},
api_version: None,
sunset_opt_out: false,
repository: None,
idempotency: crate::route::RouteIdempotency::default(),
timeout: crate::route::RouteTimeout::default(),
};
let tmp = create_ssg_dist(&[("/", "index.html", b"<h1>home</h1>")]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
vec![route],
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/dynamic")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"dynamic fallback route must be compressed just like SSG pages"
);
}
fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
let mut routes = std::collections::HashMap::new();
routes.insert(
"/".to_owned(),
crate::static_gen::ManifestEntry {
file: "index.html".to_owned(),
revalidate: None,
},
);
routes.insert(
"/about".to_owned(),
crate::static_gen::ManifestEntry {
file: "about/index.html".to_owned(),
revalidate,
},
);
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-05-18T00:00:00Z".to_owned(),
autumn_version: "0.5.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
#[tokio::test]
async fn static_serving_serves_get_request_inside_user_layers() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"<h1>About</h1>");
}
#[tokio::test]
async fn static_serving_serves_head_request() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.method("HEAD")
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty(), "HEAD response body should be empty");
}
#[tokio::test]
async fn static_serving_normalizes_trailing_slash() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn static_serving_falls_through_for_unknown_route() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/not-in-manifest")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn static_serving_skipped_when_no_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).expect("mkdir dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds even without manifest");
let response = router
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn static_serving_with_isr_manifest_builds_successfully() {
let tmp = create_static_dist(Some(3600));
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router with ISR manifest should build");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
#[cfg(test)]
mod trusted_host_tests {
use super::*;
use axum::body::Body;
use http::Request;
use tower::util::ServiceExt;
#[tokio::test]
async fn trusted_host_allows_matching_and_blocks_nonmatching() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
let state = crate::state::AppState::for_test();
let router = build_router(vec![], &cfg, state);
let ok = router
.clone()
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "api.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::NOT_FOUND);
let blocked = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "evil.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_wildcard_allows_any_host() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["*".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "anything.example")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_bypasses_probe_paths() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/actuator/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn trusted_host_bypasses_actuator_health_path() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/actuator/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn probe_bypass_paths_is_the_single_source_for_trusted_host_and_startup_barrier() {
let mut cfg = AutumnConfig::default();
cfg.health.path = "/custom-health-check".into();
let expected = probe_bypass_paths(&cfg);
assert!(expected.contains(&"/custom-health-check".to_string()));
let trusted_host = TrustedHostPolicy::from_config(&cfg);
for path in &expected {
assert!(
trusted_host.probe_bypass_paths.contains(path),
"TrustedHostPolicy must derive its bypass set from probe_bypass_paths(): missing {path}"
);
}
let state = crate::state::AppState::for_test();
let barrier = StartupBarrierState::from_config(&cfg, &state);
for path in &expected {
assert!(
barrier.allows_path(path),
"StartupBarrierState must derive its bypass set from probe_bypass_paths(): missing {path}"
);
}
}
#[cfg(feature = "http-client")]
#[test]
fn startup_barrier_allows_webhook_replay_post_path() {
let mut cfg = AutumnConfig::default();
cfg.actuator.sensitive = true;
let state = crate::state::AppState::for_test();
let barrier = StartupBarrierState::from_config(&cfg, &state);
let replay_path =
crate::actuator::actuator_route_path(&cfg.actuator.prefix, "/webhooks/replay");
assert!(
barrier.allows_path(&replay_path),
"startup barrier must allow {replay_path} to bypass admission"
);
}
#[tokio::test]
async fn trusted_host_release_rejects_loopback_unless_listed() {
let mut cfg = AutumnConfig {
profile: Some("prod".into()),
..AutumnConfig::default()
};
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "localhost")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_uses_uri_authority_when_host_header_missing() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("http://EXAMPLE.COM/nope")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
let cfg = AutumnConfig::default();
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "[::1]:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_matching_is_case_insensitive() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "EXAMPLE.COM")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_rejects_malformed_port() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com:abc")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_rejects_empty_port_suffix() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com:")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_rejects_bracketed_reg_name() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "[example.com]")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_accepts_trailing_dot_fqdn() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com.")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_bypasses_custom_probe_path_only() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
cfg.health.path = "/healthz".into();
cfg.health.startup_path = "/startupz".into();
cfg.health.ready_path = "/readyz".into();
cfg.health.live_path = "/livez".into();
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let bypassed = router
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(bypassed.status(), StatusCode::OK);
let not_bypassed = router
.oneshot(
Request::builder()
.uri("/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn apply_upload_middleware_rejects_oversized_json_body() {
let mut config = AutumnConfig::default();
config.security.upload.max_request_size_bytes = 100;
let base: axum::Router<AppState> = axum::Router::new().route(
"/data",
axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
);
let router =
apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
let big_body = "x".repeat(200);
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/data")
.header("content-type", "application/json")
.body(Body::from(big_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"oversized body must be rejected with 413 regardless of content type"
);
}
#[tokio::test]
async fn apply_upload_middleware_accepts_body_within_limit() {
let mut config = AutumnConfig::default();
config.security.upload.max_request_size_bytes = 1024;
let base: axum::Router<AppState> = axum::Router::new().route(
"/data",
axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
);
let router =
apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/data")
.header("content-type", "application/json")
.body(Body::from("hello"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
fn no_route_timeouts() -> RouteTimeoutTable {
std::sync::Arc::new(std::collections::HashMap::new())
}
fn get_route_timeouts(path: &str, timeout: crate::route::RouteTimeout) -> RouteTimeoutTable {
let mut by_method = std::collections::HashMap::new();
by_method.insert(http::Method::GET, timeout);
let mut table = std::collections::HashMap::new();
table.insert(path.to_owned(), by_method);
std::sync::Arc::new(table)
}
#[tokio::test(start_paused = true)]
async fn request_timeout_returns_503_when_exceeded() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.layer(RequestIdLayer)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a slow handler must trigger 503"
);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
"timeout response must use Problem Details content type"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_increments_metric() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.layer(RequestIdLayer)
.with_state(state.clone());
router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
let snap = state.metrics.snapshot();
assert_eq!(
snap.http.request_timeouts_total, 1,
"autumn_request_timeouts_total must be incremented on timeout"
);
}
#[tokio::test(start_paused = true)]
async fn render_deadline_exempt_marker_skips_timeout() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.layer(RequestIdLayer)
.with_state(state);
let live = router
.clone()
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
live.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a live request to a slow handler must still time out"
);
let exempt = router
.oneshot(
Request::builder()
.uri("/slow")
.extension(crate::static_gen::RenderDeadlineExempt)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
exempt.status(),
StatusCode::OK,
"the build/ISR render marker must exempt the request from the deadline"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_503_mirrors_cors_headers() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
config.cors.allow_credentials = true;
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
true,
)
.layer(RequestIdLayer)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/slow")
.header("origin", "https://app.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://app.example.com"),
"an allowed origin must be reflected on the timeout 503 so browsers can read it"
);
assert_eq!(
response
.headers()
.get("access-control-allow-credentials")
.and_then(|v| v.to_str().ok()),
Some("true"),
"credentials flag must be mirrored when configured"
);
assert!(
response
.headers()
.get_all("vary")
.iter()
.any(|v| v.to_str().is_ok_and(|s| s.eq_ignore_ascii_case("origin"))),
"a reflected origin must carry Vary: origin"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_503_omits_cors_for_disallowed_origin() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
true,
)
.layer(RequestIdLayer)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/slow")
.header("origin", "https://evil.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_none(),
"a disallowed origin must not be reflected, mirroring CorsLayer"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_response_includes_request_id_header() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.layer(RequestIdLayer)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
response.headers().contains_key("x-request-id"),
"503 response must carry the X-Request-Id header"
);
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["status"], 503);
}
#[tokio::test]
async fn request_timeout_disabled_when_none() {
let config = AutumnConfig::default();
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> =
axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn request_timeout_zero_treated_as_disabled() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(0);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> =
axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_503_without_request_id_layer() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router = apply_request_timeout_middleware(
router,
&config,
state.metrics.clone(),
no_route_timeouts(),
false,
)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_per_route_override_extends_deadline() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/export",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
"report"
}),
);
let table = get_route_timeouts(
"/export",
crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10)),
);
let router =
apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"the override must let the slow route complete past the global deadline"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_per_route_disabled_exempts_route() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/stream",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
"done"
}),
);
let table = get_route_timeouts("/stream", crate::route::RouteTimeout::Disabled);
let router =
apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
.with_state(state.clone());
let response = router
.oneshot(
Request::builder()
.uri("/stream")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
state.metrics.snapshot().http.request_timeouts_total,
0,
"an exempt route must not record a timeout"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_override_active_when_global_disabled() {
let config = AutumnConfig::default();
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/export",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"report"
}),
);
let table = get_route_timeouts(
"/export",
crate::route::RouteTimeout::Override(std::time::Duration::from_millis(100)),
);
let router =
apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a per-route override must be enforced even with the global timeout off"
);
}
#[test]
fn build_route_timeout_table_is_empty_without_routes() {
let table = build_route_timeout_table(&[], &[]);
assert!(table.is_empty(), "no routes ⇒ empty override table");
}
fn timeout_route(
method: http::Method,
path: &'static str,
timeout: crate::route::RouteTimeout,
) -> Route {
async fn noop() -> &'static str {
"ok"
}
Route {
method,
path,
handler: axum::routing::get(noop),
name: "noop",
api_doc: crate::openapi::ApiDoc::default(),
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout,
api_version: None,
sunset_opt_out: false,
}
}
#[test]
fn build_route_timeout_table_normalizes_method_aliases() {
let override_10s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10));
let routes = vec![
timeout_route(http::Method::GET, "/export", override_10s),
timeout_route(
http::Method::from_bytes(b"WS").unwrap(),
"/live",
crate::route::RouteTimeout::Disabled,
),
timeout_route(http::Method::POST, "/submit", override_10s),
];
let table = build_route_timeout_table(&routes, &[]);
let export = table.get("/export").expect("/export keyed");
assert_eq!(export.get(&http::Method::GET), Some(&override_10s));
assert_eq!(
export.get(&http::Method::HEAD),
Some(&override_10s),
"a GET override must also cover the HEAD alias axum serves"
);
let live = table.get("/live").expect("/live keyed");
assert_eq!(
live.get(&http::Method::GET),
Some(&crate::route::RouteTimeout::Disabled),
"a WS override must be keyed under the GET the upgrade arrives as"
);
assert!(
live.get(&http::Method::from_bytes(b"WS").unwrap())
.is_none(),
"the synthetic WS method is never seen at lookup time"
);
let submit = table.get("/submit").expect("/submit keyed");
assert_eq!(submit.get(&http::Method::POST), Some(&override_10s));
assert!(submit.get(&http::Method::HEAD).is_none());
}
#[test]
fn build_route_timeout_table_keys_scoped_root_by_axum_matched_path() {
let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
let make_group = |prefix: &str| crate::app::ScopedGroup {
prefix: prefix.to_owned(),
routes: vec![timeout_route(http::Method::GET, "/", override_5s)],
source: crate::route_listing::RouteSource::User,
apply_layer: Box::new(|r| r),
};
let table = build_route_timeout_table(&[], &[make_group("/api/")]);
assert_eq!(
table.get("/api/").and_then(|m| m.get(&http::Method::GET)),
Some(&override_5s),
"trailing-slash scoped root must key the override at /api/"
);
assert!(
table.get("/api").is_none(),
"the stripped /api key would never match the runtime lookup"
);
let table = build_route_timeout_table(&[], &[make_group("/api")]);
assert_eq!(
table.get("/api").and_then(|m| m.get(&http::Method::GET)),
Some(&override_5s),
);
}
fn redirect_gate_registration() -> crate::app::CustomLayerRegistration {
let gate = axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
if req.headers().contains_key("x-authed") {
next.run(req).await
} else {
http::Response::builder()
.status(StatusCode::FOUND)
.header(http::header::LOCATION, "/login")
.body(Body::empty())
.unwrap()
}
},
);
crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<()>(),
type_name: "redirect_gate",
apply: Box::new(move |router| router.layer(gate)),
}
}
fn build_cached_dist(marker: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).expect("create dist");
std::fs::write(dist.join("index.html"), marker).expect("write index.html");
let mut routes = std::collections::HashMap::new();
routes.insert(
"/".to_owned(),
crate::static_gen::ManifestEntry {
file: "index.html".to_owned(),
revalidate: None,
},
);
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-06-14T00:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).expect("serialize manifest"),
)
.expect("write manifest");
(tmp, dist)
}
fn ctx_with_static_gate(gate: crate::app::CustomLayerRegistration) -> RouterContext {
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: vec![gate],
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
}
}
#[tokio::test]
async fn static_gate_runs_before_cached_static_page() {
let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
Vec::new(),
&config,
crate::state::AppState::for_test(),
Some(dist.as_path()),
ctx,
)
.expect("router builds");
let unauthed = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
unauthed.status(),
StatusCode::FOUND,
"static_gate must redirect before the cached page is served"
);
assert_eq!(
unauthed.headers().get(http::header::LOCATION).unwrap(),
"/login"
);
let authed = app
.oneshot(
Request::builder()
.uri("/")
.header("x-authed", "1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authed.status(), StatusCode::OK);
let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8_lossy(&body).contains("cached"),
"authenticated request should receive the cached page"
);
}
#[tokio::test]
async fn static_gate_runs_in_dynamic_mode() {
async fn dynamic_handler() -> &'static str {
"dynamic"
}
let route = Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(dynamic_handler),
name: "root",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
vec![route],
&config,
crate::state::AppState::for_test(),
None,
ctx,
)
.expect("router builds");
let unauthed = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthed.status(), StatusCode::FOUND);
let authed = app
.oneshot(
Request::builder()
.uri("/")
.header("x-authed", "1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authed.status(), StatusCode::OK);
let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(String::from_utf8_lossy(&body), "dynamic");
}
#[tokio::test]
async fn static_gate_redirect_carries_security_headers_ssg() {
let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
Vec::new(),
&config,
crate::state::AppState::for_test(),
Some(dist.as_path()),
ctx,
)
.expect("router builds");
let unauthed = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthed.status(), StatusCode::FOUND);
assert_eq!(
unauthed
.headers()
.get("x-content-type-options")
.expect("gate redirect must carry security headers"),
"nosniff"
);
}
#[tokio::test]
async fn static_gate_redirect_carries_security_headers_dynamic() {
async fn dynamic_handler() -> &'static str {
"dynamic"
}
let route = Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(dynamic_handler),
name: "root",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
api_version: None,
sunset_opt_out: false,
};
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
vec![route],
&config,
crate::state::AppState::for_test(),
None,
ctx,
)
.expect("router builds");
let unauthed = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthed.status(), StatusCode::FOUND);
assert_eq!(
unauthed
.headers()
.get("x-content-type-options")
.expect("dynamic gate redirect must carry security headers"),
"nosniff"
);
}
#[test]
fn static_gate_layer_requires_fail_closed_idempotency() {
let gate = vec![redirect_gate_registration()];
assert!(super::custom_layers_require_fail_closed_idempotency(&gate));
assert!(!super::custom_layers_require_fail_closed_idempotency(&[]));
}
}
#[derive(Clone, Debug)]
pub struct TrustedHostPolicy {
rules: Arc<Vec<String>>,
allow_any: bool,
allow_missing_host: bool,
probe_bypass_paths: Arc<std::collections::HashSet<String>>,
}
impl TrustedHostPolicy {
pub fn from_config(config: &AutumnConfig) -> Self {
let mut rules: Vec<String> = config
.security
.trusted_hosts
.hosts
.iter()
.map(|h| h.trim().to_ascii_lowercase())
.map(|h| h.trim_end_matches('.').to_owned())
.filter(|h| !h.is_empty())
.collect();
let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
if !is_production {
rules.extend(
["localhost", "127.0.0.1", "::1"]
.into_iter()
.map(std::borrow::ToOwned::to_owned),
);
}
let allow_any = rules.iter().any(|h| h == "*");
let probe_bypass_paths = probe_bypass_paths(config).into_iter().collect();
Self {
rules: Arc::new(rules),
allow_any,
allow_missing_host: !is_production,
probe_bypass_paths: Arc::new(probe_bypass_paths),
}
}
#[cfg(feature = "mcp")]
pub const fn allows_missing_host(&self) -> bool {
self.allow_missing_host
}
pub fn allows_host(&self, host: &str) -> bool {
if self.allow_any {
return true;
}
self.rules.iter().any(|rule| {
rule.strip_prefix('.').map_or_else(
|| host == rule,
|suffix| {
host == suffix
|| host
.strip_suffix(suffix)
.is_some_and(|prefix| prefix.ends_with('.'))
},
)
})
}
}
#[derive(Clone, Debug)]
pub struct RouteVersionMetadata {
pub version: String,
pub sunset_opt_out: bool,
pub secured: bool,
pub required_roles: &'static [&'static str],
pub has_policy: bool,
}
async fn api_versioning_middleware(
state: axum::extract::State<AppState>,
route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let Some(axum::extract::Extension(meta)) = route_version else {
return next.run(request).await;
};
let clock = state.clock();
let now = clock.now();
let versions = state.extension::<crate::app::RegisteredApiVersions>();
let matching_version = versions
.as_ref()
.and_then(|v| v.0.iter().find(|av| av.version == meta.version));
let Some(version) = matching_version else {
return next.run(request).await;
};
let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
if is_sunset && !meta.sunset_opt_out {
if meta.has_policy {
return next.run(request).await;
}
if meta.secured {
let session = request.extensions().get::<crate::session::Session>();
let mut auth_failed = false;
let mut auth_error = None;
if let Some(session) = session {
if let Err(err) = crate::auth::__check_secured_with_key(
session,
state.auth_session_key(),
meta.required_roles,
)
.await
{
auth_failed = true;
auth_error = Some(err);
}
} else {
auth_failed = true;
auth_error = Some(crate::error::AutumnError::unauthorized_msg(
"authentication required",
));
}
if auth_failed {
return auth_error.unwrap().into_response();
}
}
let err = crate::error::AutumnError::gone_msg(format!(
"API version '{}' has been sunsetted.",
meta.version
));
let mut response = err.into_response();
if let Some(sunset) = version.sunset_at {
let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
response.headers_mut().insert("Sunset", val);
}
}
let deprecation_date = match (version.deprecated_at, version.sunset_at) {
(Some(d), Some(s)) => Some(d.min(s)),
(d, s) => d.or(s),
};
if let Some(date) = deprecation_date {
let timestamp = date.timestamp();
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
response.headers_mut().insert("Deprecation", val);
}
}
return response;
}
let mut response = next.run(request).await;
if is_deprecated || is_sunset {
let deprecation_date = match (version.deprecated_at, version.sunset_at) {
(Some(d), Some(s)) => Some(d.min(s)),
(d, s) => d.or(s),
};
if let Some(date) = deprecation_date {
let timestamp = date.timestamp();
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
response.headers_mut().insert("Deprecation", val);
}
}
}
if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
response.headers_mut().insert("Sunset", val);
}
}
response
}
#[must_use]
pub fn check_sunset(
state: &crate::state::AppState,
meta: &RouteVersionMetadata,
) -> Option<axum::response::Response> {
let clock = state.clock();
let now = clock.now();
let versions = state.extension::<crate::app::RegisteredApiVersions>();
let matching_version = versions
.as_ref()
.and_then(|v| v.0.iter().find(|av| av.version == meta.version));
let version = matching_version?;
let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
if is_sunset && !meta.sunset_opt_out {
let err = crate::error::AutumnError::gone_msg(format!(
"API version '{}' has been sunsetted.",
meta.version
));
let mut response = axum::response::IntoResponse::into_response(err);
if let Some(sunset) = version.sunset_at {
let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
response.headers_mut().insert("Sunset", val);
}
}
let deprecation_date = match (version.deprecated_at, version.sunset_at) {
(Some(d), Some(s)) => Some(d.min(s)),
(d, s) => d.or(s),
};
if let Some(date) = deprecation_date {
let timestamp = date.timestamp();
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
response.headers_mut().insert("Deprecation", val);
}
}
return Some(response);
}
None
}
#[cfg(all(test, feature = "htmx"))]
mod idiomorph_tests {
use super::*;
use http::StatusCode;
use http_body_util::BodyExt;
#[tokio::test]
async fn idiomorph_handler_returns_js_with_correct_headers() {
let response = idiomorph_handler().await;
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(ct, "application/javascript");
let cc = response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
cc.contains("must-revalidate"),
"expected revalidating cache-control, got: {cc}"
);
assert!(
!cc.contains("immutable"),
"cache-control must not be immutable for a non-fingerprinted URL, got: {cc}"
);
let etag = response
.headers()
.get(http::header::ETAG)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
etag.starts_with("W/\"idiomorph-") && etag.ends_with('"'),
"expected a weak quoted idiomorph ETag, got: {etag}"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(!body.is_empty(), "idiomorph JS body must be non-empty");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn join_nested_path_root_child_is_identity(prefix in "/?[a-z0-9/]{0,20}", root in prop::sample::select(vec!["/", ""])) {
let once = join_nested_path(&prefix, root);
let expected = if prefix.is_empty() { "/".to_owned() } else { prefix };
prop_assert_eq!(&once, &expected);
let twice = join_nested_path(&once, root);
prop_assert_eq!(once, twice);
}
#[test]
fn join_nested_path_no_double_slash_at_seam(prefix in "/[a-z0-9]{1,8}/?", child in "/[a-z0-9]{1,8}") {
let joined = join_nested_path(&prefix, &child);
prop_assert!(!joined.contains("//"), "unexpected `//` in {joined:?}");
}
#[test]
fn extract_host_without_port_never_panics(header in ".*") {
if let Some(host) = extract_host_without_port(&header) {
prop_assert!(header.contains(host));
}
}
#[test]
fn path_matches_route_prefix_reflexive(path in ".*") {
prop_assert!(path_matches_route_prefix(&path, &path));
}
#[test]
fn path_matches_route_prefix_boundary(path in "/?[a-z0-9/]{0,24}", prefix in "/?[a-z0-9/]{0,24}") {
if path_matches_route_prefix(&path, &prefix) {
let boundary_ok = path == prefix
|| path.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('/'));
prop_assert!(boundary_ok, "match without boundary: path={path:?} prefix={prefix:?}");
}
}
}
#[cfg(feature = "openapi")]
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn extract_path_params_never_panics(path in ".*") {
for name in extract_path_params(&path) {
prop_assert!(!name.is_empty());
let has_brace = name.contains('{') || name.contains('}');
prop_assert!(!has_brace, "param name should be brace-free: {name:?}");
}
}
#[test]
fn extract_path_params_brace_inputs_are_brace_free(path in "[{}a-z:]{0,6}") {
for name in extract_path_params(&path) {
prop_assert!(!name.is_empty());
let has_brace = name.contains('{') || name.contains('}');
prop_assert!(!has_brace, "param name should be brace-free for {path:?}: {name:?}");
}
}
}
}