1use std::sync::Arc;
9use std::time::Duration;
10
11use crate::app::ScopedGroup;
12use crate::config::AutumnConfig;
13#[cfg(feature = "maud")]
14use crate::error_pages::{self, SharedRenderer};
15use crate::extract::State;
16use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
17use crate::middleware::RequestIdLayer;
18use crate::middleware::dev;
19use crate::middleware::exception_filter::{
20 ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
21};
22use crate::route::Route;
23use crate::state::AppState;
24use axum::middleware::Next;
25use axum::response::IntoResponse;
26use http::{Request, StatusCode};
27use thiserror::Error;
28
29pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
30
31#[derive(Debug, Error, PartialEq, Eq)]
36pub enum RouterBuildError {
37 #[error("invalid session backend configuration: {0}")]
39 InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
40 #[error("invalid idempotency backend configuration: {0}")]
42 #[allow(dead_code)] InvalidIdempotencyBackend(String),
44 #[error("invalid submit-token backend configuration: {0}")]
49 InvalidSubmitTokenBackend(String),
50 #[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
52 FrameworkRouteOverlap {
53 path: String,
55 existing: &'static str,
57 incoming: &'static str,
59 },
60 #[cfg(feature = "openapi")]
64 #[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
65 InvalidOpenApiPath {
66 field: &'static str,
68 value: String,
70 },
71 #[cfg(feature = "openapi")]
75 #[error(
76 "openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
77 )]
78 DuplicateOpenApiPath {
79 path: String,
81 },
82 #[cfg(feature = "openapi")]
85 #[error(
86 "OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
87 )]
88 OpenApiPathCollision {
89 field: &'static str,
91 path: String,
93 },
94 #[error("route '{route_name}' uses unregistered API version '{version}'")]
96 UnregisteredApiVersion { route_name: String, version: String },
97 #[cfg(feature = "mcp")]
101 #[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
102 InvalidMcpPath {
103 value: String,
105 },
106 #[cfg(feature = "mcp")]
111 #[error(
112 "MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
113 )]
114 McpPathCollision {
115 path: String,
117 method: String,
119 },
120 #[error(
137 "duplicate user route: {existing:?} and {incoming:?} both resolve to {method} {path:?}; \
138 choose a different path for one of them or remove the duplicate registration"
139 )]
140 DuplicateUserRoute {
141 method: String,
143 path: String,
145 existing: String,
147 incoming: String,
150 },
151 #[error(
167 "conflicting route shapes: {existing:?} ({existing_path:?}) and {incoming:?} ({incoming_path:?}) \
168 resolve to the same Axum path shape but use different path templates; axum's matchit router \
169 rejects this as a route conflict regardless of HTTP method — rename the captures so both use the \
170 same template, or make their static paths distinct"
171 )]
172 ConflictingRouteShape {
173 existing: String,
175 existing_path: String,
177 incoming: String,
179 incoming_path: String,
181 },
182}
183
184#[allow(dead_code)]
194pub fn build_router(
195 route_list: Vec<Route>,
196 config: &AutumnConfig,
197 state: AppState,
198) -> axum::Router {
199 try_build_router(route_list, config, state)
200 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
201}
202
203pub struct RouterContext {
211 pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
212 pub scoped_groups: Vec<ScopedGroup>,
213 pub merge_routers: Vec<axum::Router<AppState>>,
214 pub nest_routers: Vec<(String, axum::Router<AppState>)>,
215 pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
228 pub static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
237 #[cfg(feature = "maud")]
238 pub error_page_renderer: Option<SharedRenderer>,
239 pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
244 #[cfg(feature = "openapi")]
250 pub openapi: Option<crate::openapi::OpenApiConfig>,
251 #[cfg(feature = "mcp")]
258 pub mcp: Option<crate::mcp::McpRuntime>,
259}
260
261pub fn try_build_router(
269 route_list: Vec<Route>,
270 config: &AutumnConfig,
271 state: AppState,
272) -> Result<axum::Router, RouterBuildError> {
273 let startup_barrier_state = state.clone();
274 let router = try_build_router_inner(
275 route_list,
276 config,
277 state,
278 RouterContext {
279 exception_filters: Vec::new(),
280 scoped_groups: Vec::new(),
281 merge_routers: Vec::new(),
282 nest_routers: Vec::new(),
283 custom_layers: Vec::new(),
284 static_gate_layers: Vec::new(),
285 #[cfg(feature = "maud")]
286 error_page_renderer: None,
287 session_store: None,
288 #[cfg(feature = "openapi")]
289 openapi: None,
290 #[cfg(feature = "mcp")]
291 mcp: None,
292 },
293 )?;
294 Ok(apply_startup_barrier(
295 router,
296 config,
297 &startup_barrier_state,
298 ))
299}
300
301#[allow(dead_code)]
312pub fn build_router_merged(
313 route_list: Vec<Route>,
314 config: &AutumnConfig,
315 state: AppState,
316 merge_routers: Vec<axum::Router<AppState>>,
317 nest_routers: Vec<(String, axum::Router<AppState>)>,
318) -> axum::Router {
319 try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
320 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
321}
322
323#[allow(dead_code)]
331pub fn try_build_router_merged(
332 route_list: Vec<Route>,
333 config: &AutumnConfig,
334 state: AppState,
335 merge_routers: Vec<axum::Router<AppState>>,
336 nest_routers: Vec<(String, axum::Router<AppState>)>,
337) -> Result<axum::Router, RouterBuildError> {
338 let startup_barrier_state = state.clone();
339 let router = try_build_router_inner(
340 route_list,
341 config,
342 state,
343 RouterContext {
344 exception_filters: Vec::new(),
345 scoped_groups: Vec::new(),
346 merge_routers,
347 nest_routers,
348 custom_layers: Vec::new(),
349 static_gate_layers: Vec::new(),
350 #[cfg(feature = "maud")]
351 error_page_renderer: None,
352 session_store: None,
353 #[cfg(feature = "openapi")]
354 openapi: None,
355 #[cfg(feature = "mcp")]
356 mcp: None,
357 },
358 )?;
359 Ok(apply_startup_barrier(
360 router,
361 config,
362 &startup_barrier_state,
363 ))
364}
365
366pub fn try_build_router_inner(
367 route_list: Vec<Route>,
368 config: &AutumnConfig,
369 state: AppState,
370 ctx: RouterContext,
371) -> Result<axum::Router, RouterBuildError> {
372 let router = build_router_pre_state(route_list, config, &state, ctx, None, false)?;
376 Ok(router.with_state(state))
377}
378
379pub fn try_build_probe_only_router(
395 config: &AutumnConfig,
396 state: AppState,
397) -> Result<axum::Router, RouterBuildError> {
398 let barrier_state = state.clone();
399 let no_user_routes = std::collections::HashSet::new();
401 let (mounted_probe_paths, router) =
402 mount_probe_endpoints(axum::Router::<AppState>::new(), config, &no_user_routes);
403 let router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
404 let router = router.with_state(state);
405 Ok(apply_startup_barrier(router, config, &barrier_state))
406}
407
408#[cfg(feature = "mcp")]
411type McpPrepared = (
412 String,
413 Vec<crate::mcp::McpToolInfo>,
414 Option<crate::mcp::McpEndpointLayer>,
415);
416
417#[allow(clippy::too_many_lines)]
422fn build_router_pre_state(
423 route_list: Vec<Route>,
424 config: &AutumnConfig,
425 state: &AppState,
426 #[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
427 opaque_app_layers_override: Option<bool>,
431 defer_security_headers: bool,
438) -> Result<axum::Router<AppState>, RouterBuildError> {
439 let versions = state.extension::<crate::app::RegisteredApiVersions>();
441 let registered_versions: std::collections::HashSet<&str> = versions
442 .as_ref()
443 .map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
444 .unwrap_or_default();
445
446 let check_route_version = |route: &Route| -> Result<(), RouterBuildError> {
447 if let Some(version) = route
448 .api_version
449 .filter(|ver| !registered_versions.contains(*ver))
450 {
451 return Err(RouterBuildError::UnregisteredApiVersion {
452 route_name: route.name.to_string(),
453 version: version.to_string(),
454 });
455 }
456 Ok(())
457 };
458
459 for route in &route_list {
460 check_route_version(route)?;
461 }
462 for group in &ctx.scoped_groups {
463 for route in &group.routes {
464 check_route_version(route)?;
465 }
466 }
467
468 reject_duplicate_user_routes(
477 &route_list,
478 &ctx.scoped_groups,
479 &ctx.merge_routers,
480 &ctx.nest_routers,
481 )?;
482
483 #[cfg(feature = "openapi")]
487 reject_openapi_path_collisions(
488 ctx.openapi.as_ref(),
489 &route_list,
490 &ctx.scoped_groups,
491 &ctx.merge_routers,
492 &ctx.nest_routers,
493 config,
494 )?;
495
496 #[cfg(feature = "openapi")]
499 let openapi_router = build_openapi_router(
500 &route_list,
501 &ctx.scoped_groups,
502 ctx.openapi.as_ref(),
503 &config.session.cookie_name,
504 versions.as_ref().map_or(&[], |v| v.0.as_slice()),
505 )?;
506
507 #[cfg(feature = "mcp")]
513 let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
514 let path = rt.mount_path.as_str();
515 if path.is_empty()
524 || !path.starts_with('/')
525 || path.contains("//")
526 || path.contains('{')
527 || path.contains('*')
528 || path.split('/').any(|segment| segment.starts_with(':'))
529 {
530 return Err(RouterBuildError::InvalidMcpPath {
531 value: rt.mount_path,
532 });
533 }
534 reject_mcp_path_collisions(
539 path,
540 &route_list,
541 &ctx.scoped_groups,
542 config,
543 ctx.openapi.as_ref(),
544 &ctx.merge_routers,
545 &ctx.nest_routers,
546 )?;
547 let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
548 let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
551 Some((rt.mount_path, tools, rt.endpoint_layer))
552 } else {
553 None
554 };
555
556 let route_timeouts = build_route_timeout_table(&route_list, &ctx.scoped_groups);
559
560 let idempotency_layers = build_idempotency_layers(config, state)?;
561 let opaque_app_layers_present = opaque_app_layers_override.unwrap_or_else(|| {
566 custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
567 || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers)
568 });
569 let user_get_paths = collect_user_get_paths(&route_list, &ctx.scoped_groups);
573
574 let mut router = group_and_mount_routes(
575 route_list,
576 idempotency_layers.as_ref(),
577 opaque_app_layers_present,
578 state,
579 );
580
581 let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
582
583 router = mount_framework_routes(router, config, dev_reload_enabled);
584
585 let (mounted_probe_paths, router_with_probes) =
586 mount_probe_endpoints(router, config, &user_get_paths);
587 router = router_with_probes;
588
589 router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
590
591 #[cfg(feature = "openapi")]
592 if let Some(openapi_router) = openapi_router {
593 router = router.merge(openapi_router);
594 }
595
596 #[cfg(feature = "embed-assets")]
605 let embedded_static = crate::assets::embedded_static_dir().is_some();
606 #[cfg(not(feature = "embed-assets"))]
607 let embedded_static = false;
608
609 if embedded_static {
610 #[cfg(feature = "embed-assets")]
611 {
612 router = router.route(
613 "/static/{*path}",
614 axum::routing::get(crate::assets::serve_embedded),
615 );
616 }
617 } else {
618 let env = crate::config::OsEnv;
619 let static_dir = crate::app::project_dir("static", &env);
620 router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
621 }
622 router = router.layer(axum::middleware::from_fn(
623 crate::assets::asset_cache_control,
624 ));
625
626 router = mount_scoped_groups(
627 router,
628 ctx.scoped_groups,
629 idempotency_layers.as_ref(),
630 state,
631 );
632
633 router = mount_raw_routers(
634 router,
635 ctx.merge_routers,
636 ctx.nest_routers,
637 idempotency_layers.as_ref(),
638 );
639
640 let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
647
648 let load_shed_layer = build_load_shed_layer(config, state);
654 #[cfg(feature = "mcp")]
655 let mcp_load_shed_layer = load_shed_layer.clone();
656
657 router = apply_middleware(
658 router,
659 config,
660 state,
661 ctx.exception_filters,
662 ctx.custom_layers,
663 #[cfg(feature = "maud")]
664 ctx.error_page_renderer,
665 ctx.session_store,
666 route_timeouts,
667 load_shed_layer,
668 )?;
669
670 if dev_reload_enabled {
671 router = router
672 .layer(axum::middleware::from_fn(dev::disable_static_cache))
673 .layer(axum::middleware::from_fn(dev::inject_live_reload));
674 }
675
676 let is_dev_profile = matches!(config.profile.as_deref(), Some("dev" | "development"));
679 if is_dev_profile {
680 router = router.route_layer(axum::middleware::from_fn(
683 crate::middleware::dev::capture_matched_path_middleware,
684 ));
685 }
686 if is_dev_profile {
687 let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
688 let inspector_path = config.dev.inspector_path.clone();
689 let threshold = config.dev.inspector_n_plus_one_threshold;
690
691 router = router.merge(crate::inspector::inspector_router(
693 buf.clone(),
694 &inspector_path,
695 ));
696 tracing::debug!(
697 path = %inspector_path,
698 "Mounted dev request inspector"
699 );
700
701 let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
704 .with_session_cookie_name(config.session.cookie_name.clone());
705 router = router.layer(layer);
706 }
707
708 #[cfg(feature = "oauth2")]
709 let router = router.layer(axum::middleware::from_fn_with_state(
710 state.clone(),
711 http_interceptor_middleware,
712 ));
713
714 let router = router.layer(axum::middleware::from_fn_with_state(
719 state.clone(),
720 event_app_context_middleware,
721 ));
722
723 #[cfg(feature = "mcp")]
747 let router = if let Some((mount_path, tools, endpoint_layer)) = mcp_prepared {
748 let dispatch = router
759 .clone()
760 .layer(crate::security::SecurityHeadersLayer::from_config(
761 &config.security.headers,
762 ))
763 .with_state(state.clone());
764 let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
768 .then(|| config.tenancy.header_name.clone());
769 let wiring = crate::mcp::McpWiring {
770 cors: config.cors.clone(),
773 trusted_hosts: TrustedHostPolicy::from_config(config),
776 tenant_header,
777 csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
780 envelope_rate_limited: config.security.rate_limit.enabled,
784 envelope_load_shed: mcp_load_shed_layer.is_some(),
790 };
791 let mut mcp_router =
792 crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
793 mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
809 if let Some(load_shed) = mcp_load_shed_layer {
819 mcp_router = mcp_router.layer(load_shed);
820 }
821 mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
829 mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
835 config.security.upload.max_request_size_bytes,
836 ));
837 mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
854 mcp_router = apply_request_timeout_middleware(
883 mcp_router,
884 config,
885 state.metrics.clone(),
886 std::sync::Arc::new(std::collections::HashMap::new()),
887 false,
888 );
889 mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
898 &config.security.headers,
899 ));
900 mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
905 router.merge(mcp_router)
906 } else {
907 router
908 };
909
910 let router = if defer_security_headers {
924 router
925 } else {
926 let router =
927 apply_layers_in_registration_order(router, static_gate_layers, "Pre-static gate");
928 router.layer(crate::security::SecurityHeadersLayer::from_config(
929 &config.security.headers,
930 ))
931 };
932
933 Ok(router)
934}
935
936#[cfg(feature = "openapi")]
942pub fn extract_path_params(path: &str) -> Vec<String> {
943 let mut out = Vec::new();
944 let mut remaining = path;
945
946 while let Some(start) = remaining.find('{') {
947 let after_brace = &remaining[start + 1..];
948 if let Some(rest) = after_brace.strip_prefix('{') {
954 remaining = rest;
955 continue;
956 }
957 let Some(end_rel) = after_brace.find('}') else {
958 break;
959 };
960
961 let inner = &after_brace[..end_rel];
962 let name = inner.split(':').next().unwrap_or(inner).trim();
965 if !name.is_empty() && !name.contains('{') && !name.contains('}') {
971 out.push(name.to_owned());
972 }
973
974 remaining = &after_brace[end_rel + 1..];
975 }
976
977 out
978}
979
980#[cfg(feature = "openapi")]
983async fn serve_openapi_spec(
984 state: axum::extract::State<AppState>,
985 axum::extract::Extension(config): axum::extract::Extension<
986 std::sync::Arc<crate::openapi::OpenApiConfig>,
987 >,
988 axum::extract::Extension(docs): axum::extract::Extension<
989 std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
990 >,
991) -> impl axum::response::IntoResponse {
992 use axum::response::IntoResponse;
993 let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
994 let now = state.clock().now();
995 let spec = crate::openapi::generate_spec_at(&config, &refs, now);
996 let spec_json = serde_json::to_string_pretty(&spec)
997 .unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
998 (
999 [(http::header::CONTENT_TYPE, "application/json")],
1000 spec_json,
1001 )
1002 .into_response()
1003}
1004
1005#[cfg(feature = "openapi")]
1013fn build_openapi_router(
1014 route_list: &[Route],
1015 scoped_groups: &[ScopedGroup],
1016 openapi_config: Option<&crate::openapi::OpenApiConfig>,
1017 session_cookie_name: &str,
1018 api_versions: &[crate::app::ApiVersion],
1019) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
1020 let Some(config) = openapi_config else {
1021 return Ok(None);
1022 };
1023 let mut config = config.clone();
1024 session_cookie_name.clone_into(&mut config.session_cookie_name);
1025 config.api_versions = api_versions.to_vec();
1026
1027 validate_route_path("openapi_json_path", &config.openapi_json_path)?;
1031 if let Some(path) = &config.swagger_ui_path {
1032 validate_route_path("swagger_ui_path", path)?;
1033 if path == &config.openapi_json_path {
1037 return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
1038 }
1039 }
1040
1041 let docs = collect_openapi_docs(route_list, scoped_groups);
1042
1043 let json_path = config.openapi_json_path.clone();
1044 let swagger_path = config.swagger_ui_path.clone();
1045 let title = config.title.clone();
1046
1047 let mut router = axum::Router::<AppState>::new()
1048 .route(&json_path, axum::routing::get(serve_openapi_spec))
1049 .layer(axum::extract::Extension(std::sync::Arc::new(
1050 config.clone(),
1051 )))
1052 .layer(axum::extract::Extension(std::sync::Arc::new(docs)));
1053
1054 if let Some(path) = swagger_path {
1055 router = mount_swagger_ui_routes(router, &path, &title, &json_path);
1056 }
1057
1058 tracing::debug!(
1059 openapi_json = %json_path,
1060 swagger_ui = ?config.swagger_ui_path,
1061 swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
1062 "Mounted OpenAPI endpoints"
1063 );
1064
1065 Ok(Some(router))
1066}
1067
1068#[allow(dead_code)]
1078pub fn join_nested_path(prefix: &str, child: &str) -> String {
1079 if child == "/" || child.is_empty() {
1080 if prefix.is_empty() {
1087 "/".to_owned()
1088 } else {
1089 prefix.to_owned()
1090 }
1091 } else {
1092 let prefix_trimmed = prefix.trim_end_matches('/');
1095 if child.starts_with('/') {
1096 format!("{prefix_trimmed}{child}")
1097 } else {
1098 format!("{prefix_trimmed}/{child}")
1099 }
1100 }
1101}
1102
1103#[cfg(feature = "openapi")]
1119fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
1120 let reject = |reason_fragment: &str| {
1121 Err(RouterBuildError::InvalidOpenApiPath {
1122 field,
1123 value: format!("{value:?} {reason_fragment}"),
1124 })
1125 };
1126
1127 if value.is_empty() {
1128 return reject("(must be non-empty)");
1129 }
1130 if !value.starts_with('/') {
1131 return reject("(must start with '/')");
1132 }
1133 if value.contains("//") {
1138 return reject("(must not contain '//')");
1139 }
1140
1141 let mut depth: i32 = 0;
1142 for ch in value.chars() {
1143 match ch {
1144 '{' => depth += 1,
1145 '}' => {
1146 depth -= 1;
1147 if depth < 0 {
1148 return reject("(unbalanced '}')");
1149 }
1150 }
1151 '*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
1152 _ => {}
1153 }
1154 }
1155 if depth != 0 {
1156 return reject("(unbalanced '{')");
1157 }
1158 if value.contains('{') {
1159 return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
1160 }
1161 Ok(())
1162}
1163
1164fn collect_user_get_paths(
1179 route_list: &[Route],
1180 scoped_groups: &[ScopedGroup],
1181) -> std::collections::HashSet<String> {
1182 let mut owned: std::collections::HashSet<String> = std::collections::HashSet::new();
1183 for route in route_list {
1184 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1185 owned.insert(route.path.to_owned());
1186 }
1187 }
1188 for group in scoped_groups {
1189 for route in &group.routes {
1190 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1191 owned.insert(join_nested_path(&group.prefix, route.path));
1192 }
1193 }
1194 }
1195 owned
1196}
1197
1198#[cfg(feature = "openapi")]
1204fn collect_claimed_get_paths(
1205 route_list: &[Route],
1206 scoped_groups: &[ScopedGroup],
1207 config: &AutumnConfig,
1208) -> std::collections::HashSet<String> {
1209 let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1210 for route in route_list {
1211 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1212 claimed.insert(route.path.to_owned());
1213 }
1214 }
1215 for group in scoped_groups {
1216 for route in &group.routes {
1217 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1218 claimed.insert(join_nested_path(&group.prefix, route.path));
1219 }
1220 }
1221 }
1222 claimed.insert(config.health.path.clone());
1224 claimed.insert(config.health.live_path.clone());
1225 claimed.insert(config.health.ready_path.clone());
1226 claimed.insert(config.health.startup_path.clone());
1227 for path in crate::actuator::actuator_endpoint_paths(
1228 &config.actuator.prefix,
1229 config.actuator.sensitive,
1230 config.actuator.prometheus,
1231 ) {
1232 claimed.insert(path);
1233 }
1234 #[cfg(feature = "htmx")]
1235 {
1236 if !crate::assets::htmx_is_vendored() {
1240 claimed.insert(crate::htmx::HTMX_JS_PATH.to_owned());
1241 }
1242 claimed.insert(crate::htmx::HTMX_CSRF_JS_PATH.to_owned());
1243 claimed.insert(crate::htmx::AUTUMN_WIDGETS_JS_PATH.to_owned());
1244 claimed.insert(crate::htmx::IDIOMORPH_JS_PATH.to_owned());
1245 claimed.insert(crate::htmx::HTMX_SSE_JS_PATH.to_owned());
1246 }
1247 #[cfg(feature = "flash")]
1253 claimed.insert(crate::flash::FLASH_CSS_PATH.to_owned());
1254 #[cfg(feature = "maud")]
1255 claimed.insert(crate::ui::WIDGETS_CSS_PATH.to_owned());
1256 if dev::is_enabled_with_env(&crate::config::OsEnv) {
1260 claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
1261 claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
1262 }
1263 if matches!(config.profile.as_deref(), Some("dev" | "development")) {
1268 claimed.insert(config.dev.inspector_path.clone());
1269 }
1270 #[cfg(feature = "mail")]
1271 if config
1272 .mail
1273 .preview_routes_enabled(config.profile.as_deref())
1274 {
1275 claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
1276 claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
1277 claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
1278 }
1279 #[cfg(feature = "maud")]
1285 if config.stories.enabled {
1286 claimed.insert(crate::stories::STORIES_PATH.to_owned());
1287 claimed.insert("/_stories/{slug}".to_owned());
1288 }
1289 #[cfg(feature = "mail")]
1294 if config.mail.should_mount_unsubscribe_endpoint() {
1295 claimed.insert(crate::mail::UNSUBSCRIBE_PATH.to_owned());
1296 }
1297 if config.jobs.tracking.route_enabled {
1302 claimed.insert(crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned());
1303 }
1304 claimed
1305}
1306
1307#[cfg(feature = "mcp")]
1317fn reject_mcp_path_collisions(
1318 mount_path: &str,
1319 route_list: &[Route],
1320 scoped_groups: &[ScopedGroup],
1321 config: &AutumnConfig,
1322 openapi: Option<&crate::openapi::OpenApiConfig>,
1323 merge_routers: &[axum::Router<AppState>],
1324 nest_routers: &[(String, axum::Router<AppState>)],
1325) -> Result<(), RouterBuildError> {
1326 let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
1327 if let Some(openapi) = openapi {
1330 claimed_get.insert(openapi.openapi_json_path.clone());
1331 if let Some(ui_path) = &openapi.swagger_ui_path {
1332 claimed_get.insert(ui_path.clone());
1333 claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
1334 }
1335 }
1336 if claimed_get.contains(mount_path) {
1337 return Err(RouterBuildError::McpPathCollision {
1338 path: mount_path.to_owned(),
1339 method: "GET".to_owned(),
1340 });
1341 }
1342 let post_owns_path = route_list
1344 .iter()
1345 .any(|route| route.method == http::Method::POST && route.path == mount_path)
1346 || scoped_groups.iter().any(|group| {
1347 group.routes.iter().any(|route| {
1348 route.method == http::Method::POST
1349 && join_nested_path(&group.prefix, route.path) == mount_path
1350 })
1351 });
1352 if post_owns_path {
1353 return Err(RouterBuildError::McpPathCollision {
1354 path: mount_path.to_owned(),
1355 method: "POST".to_owned(),
1356 });
1357 }
1358 let nest_prefixes = nest_routers
1365 .iter()
1366 .map(|(prefix, _)| prefix.as_str())
1367 .chain(std::iter::once("/static"));
1368 for prefix in nest_prefixes {
1369 let prefix_slash = format!("{prefix}/");
1370 if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
1371 return Err(RouterBuildError::McpPathCollision {
1372 path: mount_path.to_owned(),
1373 method: "nested router".to_owned(),
1374 });
1375 }
1376 }
1377 if !merge_routers.is_empty() {
1381 tracing::warn!(
1382 mcp_mount_path = %mount_path,
1383 merged_routers = merge_routers.len(),
1384 "MCP mount collision check skipped for AppBuilder::merge routers: \
1385 axum does not expose their route table, so an overlapping handler \
1386 will still panic at startup. Choose an MCP mount path that doesn't \
1387 overlap with any merged router's handlers."
1388 );
1389 }
1390 Ok(())
1391}
1392
1393#[cfg(feature = "openapi")]
1415fn reject_openapi_path_collisions(
1416 openapi_config: Option<&crate::openapi::OpenApiConfig>,
1417 route_list: &[Route],
1418 scoped_groups: &[ScopedGroup],
1419 merge_routers: &[axum::Router<AppState>],
1420 nest_routers: &[(String, axum::Router<AppState>)],
1421 config: &AutumnConfig,
1422) -> Result<(), RouterBuildError> {
1423 let Some(openapi) = openapi_config else {
1424 return Ok(());
1425 };
1426
1427 let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
1430
1431 check_openapi_path_against(
1432 "openapi_json_path",
1433 &openapi.openapi_json_path,
1434 &claimed,
1435 nest_routers,
1436 )?;
1437 if let Some(path) = &openapi.swagger_ui_path {
1438 check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
1439 let mut claimed_with_openapi = claimed;
1440 claimed_with_openapi.insert(openapi.openapi_json_path.clone());
1441 for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
1442 check_openapi_path_against(
1443 "swagger_ui_path",
1444 &asset_path,
1445 &claimed_with_openapi,
1446 nest_routers,
1447 )?;
1448 }
1449 }
1450
1451 if !merge_routers.is_empty() {
1455 tracing::warn!(
1456 openapi_json_path = %openapi.openapi_json_path,
1457 swagger_ui_path = ?openapi.swagger_ui_path,
1458 merged_routers = merge_routers.len(),
1459 "OpenAPI mount collision check skipped for AppBuilder::merge routers: \
1460 axum does not expose their route table, so overlapping GET handlers \
1461 will still panic at startup. Choose OpenAPI paths that don't overlap \
1462 with any merged router's handlers."
1463 );
1464 }
1465
1466 Ok(())
1467}
1468
1469#[cfg(feature = "openapi")]
1473fn check_openapi_path_against(
1474 field: &'static str,
1475 path: &str,
1476 claimed: &std::collections::HashSet<String>,
1477 nest_routers: &[(String, axum::Router<AppState>)],
1478) -> Result<(), RouterBuildError> {
1479 if claimed.contains(path) {
1480 return Err(RouterBuildError::OpenApiPathCollision {
1481 field,
1482 path: path.to_owned(),
1483 });
1484 }
1485 for (prefix, _) in nest_routers {
1491 let prefix_slash = format!("{prefix}/");
1492 if path == prefix || path.starts_with(&prefix_slash) {
1493 return Err(RouterBuildError::OpenApiPathCollision {
1494 field,
1495 path: path.to_owned(),
1496 });
1497 }
1498 }
1499 Ok(())
1500}
1501
1502fn effective_mount_method(method: &http::Method) -> http::Method {
1512 if method.as_str() == "WS" {
1513 http::Method::GET
1514 } else {
1515 method.clone()
1516 }
1517}
1518
1519fn paths_conflict_under_matchit(existing: &str, incoming: &str) -> bool {
1526 let mut probe: matchit::Router<()> = matchit::Router::new();
1527 if probe.insert(existing, ()).is_err() {
1531 return false;
1532 }
1533 matches!(
1534 probe.insert(incoming, ()),
1535 Err(matchit::InsertError::Conflict { .. })
1536 )
1537}
1538
1539fn reject_duplicate_user_routes(
1566 route_list: &[Route],
1567 scoped_groups: &[ScopedGroup],
1568 merge_routers: &[axum::Router<AppState>],
1569 nest_routers: &[(String, axum::Router<AppState>)],
1570) -> Result<(), RouterBuildError> {
1571 let mut claimed: std::collections::HashMap<(String, String), String> =
1583 std::collections::HashMap::new();
1584
1585 let mut shape_router: matchit::Router<String> = matchit::Router::new();
1604 let mut inserted_shapes: Vec<(String, String)> = Vec::new();
1611
1612 let mut record =
1613 |method: &http::Method, path: String, name: &str| -> Result<(), RouterBuildError> {
1614 let effective_method = effective_mount_method(method).to_string();
1615
1616 let already_inserted = inserted_shapes.iter().any(|(p, _)| p == &path);
1621 if !already_inserted {
1622 match shape_router.insert(&path, name.to_owned()) {
1623 Ok(()) => inserted_shapes.push((path.clone(), name.to_owned())),
1624 Err(matchit::InsertError::Conflict { .. }) => {
1625 let (existing_path, existing_name) = inserted_shapes
1627 .iter()
1628 .find(|(prior, _)| paths_conflict_under_matchit(prior, &path))
1629 .cloned()
1630 .unwrap_or_else(|| inserted_shapes[0].clone());
1634 return Err(RouterBuildError::ConflictingRouteShape {
1635 existing: existing_name,
1636 existing_path,
1637 incoming: name.to_owned(),
1638 incoming_path: path,
1639 });
1640 }
1641 Err(_) => {}
1646 }
1647 }
1648
1649 let key = (effective_method.clone(), path.clone());
1653 if let Some(existing) = claimed.get(&key) {
1654 return Err(RouterBuildError::DuplicateUserRoute {
1655 method: effective_method,
1656 path,
1657 existing: existing.clone(),
1658 incoming: name.to_owned(),
1659 });
1660 }
1661 claimed.insert(key, name.to_owned());
1662 Ok(())
1663 };
1664
1665 for route in route_list {
1666 record(&route.method, route.path.to_owned(), route.name)?;
1667 }
1668 for group in scoped_groups {
1669 for route in &group.routes {
1670 record(
1671 &route.method,
1672 join_nested_path(&group.prefix, route.path),
1673 route.name,
1674 )?;
1675 }
1676 }
1677
1678 if !merge_routers.is_empty() {
1682 tracing::warn!(
1683 merged_routers = merge_routers.len(),
1684 "duplicate-route preflight (#1012) skipped for AppBuilder::merge routers: \
1685 axum does not expose their route table, so an overlapping handler on a \
1686 method+path Autumn already owns will still panic at startup. Keep merged \
1687 routers on disjoint paths from your `.routes()`/`.scoped()` registrations."
1688 );
1689 }
1690 if !nest_routers.is_empty() {
1691 tracing::warn!(
1692 nested_routers = nest_routers.len(),
1693 "duplicate-route preflight (#1012) skipped for AppBuilder::nest routers: \
1694 axum does not expose their route table, so an overlapping handler on a \
1695 method+path Autumn already owns will still panic at startup. Keep nested \
1696 routers on disjoint prefixes from your `.routes()`/`.scoped()` registrations."
1697 );
1698 }
1699
1700 Ok(())
1701}
1702
1703fn group_and_mount_routes(
1704 route_list: Vec<Route>,
1705 idempotency_layers: Option<&BuiltIdempotencyLayers>,
1706 opaque_app_layers_present: bool,
1707 state: &AppState,
1708) -> axum::Router<AppState> {
1709 let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
1714 indexmap::IndexMap::new();
1715 for route in &route_list {
1716 tracing::debug!(
1717 method = %route.method,
1718 path = route.path,
1719 name = route.name,
1720 "Mounted route"
1721 );
1722 }
1723 for route in route_list {
1724 let selected_layer = idempotency_layers
1725 .map(|layers| idempotency_layer_for_route(&route, layers, opaque_app_layers_present));
1726 let mut handler = route.handler;
1727 if let Some(layer) = selected_layer {
1728 handler = handler.layer(layer.clone());
1729 }
1730 if let Some(version) = route.api_version {
1731 handler = handler.layer(axum::middleware::from_fn_with_state(
1732 state.clone(),
1733 api_versioning_middleware,
1734 ));
1735 handler = handler.layer(axum::Extension(RouteVersionMetadata {
1736 version: version.to_string(),
1737 sunset_opt_out: route.sunset_opt_out,
1738 secured: route.api_doc.secured,
1739 required_roles: route.api_doc.required_roles,
1740 has_policy: route.api_doc.has_policy,
1741 }));
1742 }
1743 grouped
1744 .entry(route.path)
1745 .and_modify(|existing| {
1746 *existing = std::mem::take(existing).merge(handler.clone());
1747 })
1748 .or_insert(handler);
1749 }
1750
1751 let mut router = axum::Router::new();
1752 for (path, method_router) in grouped {
1753 router = router.route(path, method_router);
1754 }
1755 router
1756}
1757
1758const fn idempotency_layer_for_route<'a>(
1759 route: &Route,
1760 layers: &'a BuiltIdempotencyLayers,
1761 opaque_app_layers_present: bool,
1762) -> &'a IdempotencyLayer {
1763 if opaque_app_layers_present {
1764 &layers.manual
1765 } else if route_uses_generated_replay_stop(route) {
1766 &layers.route
1767 } else {
1768 &layers.manual
1769 }
1770}
1771
1772const fn route_uses_generated_replay_stop(route: &Route) -> bool {
1773 matches!(
1774 route.idempotency,
1775 crate::route::RouteIdempotency::ReplayThroughInner
1776 )
1777}
1778
1779fn custom_layers_require_fail_closed_idempotency(
1780 custom_layers: &[crate::app::CustomLayerRegistration],
1781) -> bool {
1782 custom_layers
1783 .iter()
1784 .any(|registered| !is_idempotency_transparent_app_layer(registered))
1785}
1786
1787fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
1788 registered
1789 .type_name
1790 .starts_with("autumn_web::session::SessionLayer<")
1791 || registered
1792 .type_name
1793 .starts_with("autumn::session::SessionLayer<")
1794 || registered.type_id
1795 == std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
1796 || is_i18n_bundle_extension_layer(registered.type_id)
1797}
1798
1799#[cfg(feature = "i18n")]
1800fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
1801 type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
1802}
1803
1804#[cfg(not(feature = "i18n"))]
1805const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
1806 false
1807}
1808
1809#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
1810#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
1811fn mount_framework_routes(
1812 mut router: axum::Router<AppState>,
1813 config: &AutumnConfig,
1814 dev_reload_enabled: bool,
1815) -> axum::Router<AppState> {
1816 #[cfg(not(feature = "mail"))]
1817 let _ = config;
1818
1819 #[cfg(feature = "htmx")]
1821 {
1822 if crate::assets::htmx_is_vendored() {
1827 tracing::debug!(
1828 path = crate::htmx::HTMX_JS_PATH,
1829 "htmx vendored via `autumn assets`; built-in handler skipped, ServeDir serves it"
1830 );
1831 } else {
1832 router = router.route(crate::htmx::HTMX_JS_PATH, axum::routing::get(htmx_handler));
1833 tracing::debug!(
1834 method = "GET",
1835 path = crate::htmx::HTMX_JS_PATH,
1836 name = format!("htmx {}", crate::htmx::HTMX_VERSION),
1837 "Mounted route"
1838 );
1839 }
1840 router = router.route(
1841 crate::htmx::HTMX_CSRF_JS_PATH,
1842 axum::routing::get(htmx_csrf_handler),
1843 );
1844 router = router.route(
1845 crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1846 axum::routing::get(autumn_widgets_handler),
1847 );
1848 router = router.route(
1849 crate::htmx::IDIOMORPH_JS_PATH,
1850 axum::routing::get(idiomorph_handler),
1851 );
1852 router = router.route(
1853 crate::htmx::HTMX_SSE_JS_PATH,
1854 axum::routing::get(htmx_sse_handler),
1855 );
1856 tracing::debug!(
1857 method = "GET",
1858 path = crate::htmx::HTMX_CSRF_JS_PATH,
1859 name = "htmx csrf helper",
1860 "Mounted route"
1861 );
1862 tracing::debug!(
1863 method = "GET",
1864 path = crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1865 name = "autumn widget runtime",
1866 "Mounted route"
1867 );
1868 tracing::debug!(
1869 method = "GET",
1870 path = crate::htmx::IDIOMORPH_JS_PATH,
1871 name = "idiomorph DOM morphing",
1872 "Mounted route"
1873 );
1874 tracing::debug!(
1875 method = "GET",
1876 path = crate::htmx::HTMX_SSE_JS_PATH,
1877 name = "htmx SSE extension",
1878 "Mounted route"
1879 );
1880 }
1881
1882 #[cfg(feature = "flash")]
1886 {
1887 router = router.route(
1888 crate::flash::FLASH_CSS_PATH,
1889 axum::routing::get(flash_css_handler),
1890 );
1891 tracing::debug!(
1892 method = "GET",
1893 path = crate::flash::FLASH_CSS_PATH,
1894 name = "autumn flash stylesheet",
1895 "Mounted route"
1896 );
1897 }
1898
1899 #[cfg(feature = "maud")]
1903 {
1904 router = router.route(
1905 crate::ui::WIDGETS_CSS_PATH,
1906 axum::routing::get(widgets_css_handler),
1907 );
1908 tracing::debug!(
1909 method = "GET",
1910 path = crate::ui::WIDGETS_CSS_PATH,
1911 name = "autumn widget stylesheet",
1912 "Mounted route"
1913 );
1914 }
1915
1916 if dev_reload_enabled {
1917 router = router.route(
1918 dev::LIVE_RELOAD_PATH,
1919 axum::routing::get(dev::live_reload_state_handler),
1920 );
1921 router = router.route(
1922 dev::LIVE_RELOAD_SCRIPT_PATH,
1923 axum::routing::get(dev::live_reload_script_handler),
1924 );
1925 tracing::debug!(
1926 state_path = dev::LIVE_RELOAD_PATH,
1927 script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
1928 "Mounted dev live reload endpoints"
1929 );
1930 }
1931
1932 #[cfg(feature = "mail")]
1933 if config
1934 .mail
1935 .preview_routes_enabled(config.profile.as_deref())
1936 {
1937 router = router.merge(crate::mail::mail_preview_router(
1938 config.mail.file_dir.clone(),
1939 ));
1940 tracing::debug!(
1941 path = crate::mail::MAIL_PREVIEW_PATH,
1942 "Mounted dev mail preview endpoints"
1943 );
1944 }
1945
1946 #[cfg(feature = "maud")]
1951 if config.stories.enabled {
1952 router = router.merge(crate::stories::story_router());
1953 tracing::debug!(
1954 path = crate::stories::STORIES_PATH,
1955 "Mounted story gallery endpoints"
1956 );
1957 }
1958
1959 #[cfg(feature = "mail")]
1963 if config.mail.should_mount_unsubscribe_endpoint() {
1964 router = router.merge(crate::mail::unsubscribe_router());
1965 tracing::debug!(
1966 path = crate::mail::UNSUBSCRIBE_PATH,
1967 "Mounted default unsubscribe endpoint"
1968 );
1969 }
1970
1971 if config.jobs.tracking.route_enabled {
1974 router = router.merge(crate::job_tracking::status_router());
1975 tracing::debug!(
1976 path = crate::job_tracking::JOB_STATUS_ROUTE_PATH,
1977 "Mounted tracked-job status endpoint"
1978 );
1979 }
1980
1981 router
1982}
1983
1984fn mount_probe_endpoints<S>(
1985 mut router: axum::Router<S>,
1986 config: &AutumnConfig,
1987 user_get_paths: &std::collections::HashSet<String>,
1988) -> (std::collections::HashSet<String>, axum::Router<S>)
1989where
1990 S: Clone + Send + Sync + 'static,
1991 AppState: axum::extract::FromRef<S>,
1992{
1993 let mut mounted_probe_paths = std::collections::HashSet::new();
2000
2001 let mut mount_probe = |mut router: axum::Router<S>,
2002 path: &str,
2003 label: &'static str,
2004 handler: axum::routing::MethodRouter<S>|
2005 -> axum::Router<S> {
2006 if user_get_paths.contains(path) {
2007 tracing::info!(
2008 probe = label,
2009 path,
2010 "a user route already owns this path; the built-in probe was \
2011 not auto-mounted (the user handler wins)"
2012 );
2013 mounted_probe_paths.insert(path.to_owned());
2021 return router;
2022 }
2023 if mounted_probe_paths.insert(path.to_owned()) {
2024 router = router.route(path, handler);
2025 }
2026 router
2027 };
2028
2029 router = mount_probe(
2030 router,
2031 &config.health.live_path,
2032 "liveness",
2033 axum::routing::get(crate::probe::live_handler::<AppState>),
2034 );
2035 router = mount_probe(
2036 router,
2037 &config.health.ready_path,
2038 "readiness",
2039 axum::routing::get(crate::probe::ready_handler::<AppState>),
2040 );
2041 router = mount_probe(
2042 router,
2043 &config.health.startup_path,
2044 "startup",
2045 axum::routing::get(crate::probe::startup_handler::<AppState>),
2046 );
2047 router = mount_probe(
2048 router,
2049 &config.health.path,
2050 "health",
2051 axum::routing::get(crate::health::handler::<AppState>),
2052 );
2053 tracing::debug!(
2054 health = %config.health.path,
2055 live = %config.health.live_path,
2056 ready = %config.health.ready_path,
2057 startup = %config.health.startup_path,
2058 "Mounted probe endpoints"
2059 );
2060
2061 (mounted_probe_paths, router)
2062}
2063
2064fn mount_actuator_endpoints(
2065 mut router: axum::Router<AppState>,
2066 config: &AutumnConfig,
2067 mounted_probe_paths: &std::collections::HashSet<String>,
2068) -> Result<axum::Router<AppState>, RouterBuildError> {
2069 let actuator_sensitive = config.actuator.sensitive;
2071 let actuator_prometheus = config.actuator.prometheus;
2072 let actuator_paths = crate::actuator::actuator_endpoint_paths(
2073 &config.actuator.prefix,
2074 actuator_sensitive,
2075 actuator_prometheus,
2076 );
2077 if let Some(path) = actuator_paths
2078 .iter()
2079 .find(|path| mounted_probe_paths.contains(path.as_str()))
2080 {
2081 return Err(RouterBuildError::FrameworkRouteOverlap {
2082 path: path.clone(),
2083 existing: "probe endpoint",
2084 incoming: "actuator endpoint",
2085 });
2086 }
2087 router = router.merge(crate::actuator::actuator_router_with_prefix(
2088 &config.actuator.prefix,
2089 actuator_sensitive,
2090 actuator_prometheus,
2091 ));
2092 tracing::debug!(
2093 sensitive = actuator_sensitive,
2094 prometheus = actuator_prometheus,
2095 prefix = %config.actuator.prefix,
2096 "Mounted actuator endpoints"
2097 );
2098 Ok(router)
2099}
2100
2101fn mount_scoped_groups(
2102 mut router: axum::Router<AppState>,
2103 scoped_groups: Vec<ScopedGroup>,
2104 idempotency_layers: Option<&BuiltIdempotencyLayers>,
2105 state: &AppState,
2106) -> axum::Router<AppState> {
2107 for group in scoped_groups {
2109 let mut sub_router = axum::Router::new();
2110 for route in group.routes {
2111 tracing::debug!(
2112 method = %route.method,
2113 path = route.path,
2114 name = route.name,
2115 scope = %group.prefix,
2116 "Mounted scoped route"
2117 );
2118 let selected_layer = idempotency_layers.map(|layers| &layers.manual);
2125 let mut handler = route.handler;
2126 if let Some(layer) = selected_layer {
2127 handler = handler.layer(layer.clone());
2128 }
2129 if let Some(version) = route.api_version {
2130 handler = handler.layer(axum::middleware::from_fn_with_state(
2131 state.clone(),
2132 api_versioning_middleware,
2133 ));
2134 handler = handler.layer(axum::Extension(RouteVersionMetadata {
2135 version: version.to_string(),
2136 sunset_opt_out: route.sunset_opt_out,
2137 secured: route.api_doc.secured,
2138 required_roles: route.api_doc.required_roles,
2139 has_policy: route.api_doc.has_policy,
2140 }));
2141 }
2142 sub_router = sub_router.route(route.path, handler);
2143 }
2144 sub_router = (group.apply_layer)(sub_router);
2145 router = router.nest(&group.prefix, sub_router);
2146 }
2147 router
2148}
2149
2150fn mount_raw_routers(
2151 mut router: axum::Router<AppState>,
2152 merge_routers: Vec<axum::Router<AppState>>,
2153 nest_routers: Vec<(String, axum::Router<AppState>)>,
2154 idempotency_layers: Option<&BuiltIdempotencyLayers>,
2155) -> axum::Router<AppState> {
2156 for raw_router in merge_routers {
2159 tracing::debug!("Merged raw Axum router");
2160 let raw_router = if let Some(layers) = idempotency_layers {
2161 raw_router.layer(layers.manual.clone())
2162 } else {
2163 raw_router
2164 };
2165 router = router.merge(raw_router);
2166 }
2167
2168 for (prefix, raw_router) in nest_routers {
2170 tracing::debug!(prefix = %prefix, "Nested raw Axum router");
2171 let nested_router =
2174 raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
2175 let nested_router = if let Some(layers) = idempotency_layers {
2176 nested_router.layer(layers.manual.clone())
2177 } else {
2178 nested_router
2179 };
2180 router = router.nest(&prefix, nested_router);
2181 }
2182 router
2183}
2184
2185fn apply_compression_middleware<S>(
2186 mut router: axum::Router<S>,
2187 config: &AutumnConfig,
2188) -> axum::Router<S>
2189where
2190 S: Clone + Send + Sync + 'static,
2191{
2192 if config.compression.enabled {
2193 use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
2194 let predicate = DefaultPredicate::new()
2198 .and(NotForContentType::const_new("audio/"))
2200 .and(NotForContentType::const_new("video/"))
2201 .and(NotForContentType::const_new("application/octet-stream"))
2202 .and(NotForContentType::const_new("application/zip"))
2204 .and(NotForContentType::const_new("application/gzip"))
2205 .and(NotForContentType::const_new("application/x-gzip"))
2206 .and(NotForContentType::const_new("application/zstd"))
2207 .and(NotForContentType::const_new("application/x-bzip2"))
2208 .and(NotForContentType::const_new("application/x-bzip"))
2209 .and(NotForContentType::const_new("application/x-rar-compressed"))
2210 .and(NotForContentType::const_new("application/vnd.rar"))
2211 .and(NotForContentType::const_new("application/x-7z-compressed"))
2212 .and(NotForContentType::const_new("font/woff"))
2217 .and(NotForContentType::const_new("font/woff2"));
2218 router =
2219 router.layer(tower_http::compression::CompressionLayer::new().compress_when(predicate));
2220 tracing::info!("Response compression enabled (gzip/brotli)");
2221 }
2222 router
2223}
2224
2225fn apply_cors_middleware<S>(mut router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
2226where
2227 S: Clone + Send + Sync + 'static,
2228{
2229 if !config.cors.allowed_origins.is_empty() {
2231 let cors = build_cors_layer(&config.cors);
2232 tracing::info!(
2233 origins = ?config.cors.allowed_origins,
2234 credentials = config.cors.allow_credentials,
2235 "CORS enabled"
2236 );
2237 router = router.layer(cors);
2238 }
2239 router
2240}
2241
2242fn apply_csrf_middleware<S>(
2243 mut router: axum::Router<S>,
2244 config: &AutumnConfig,
2245 signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
2246) -> axum::Router<S>
2247where
2248 S: Clone + Send + Sync + 'static,
2249{
2250 if config.security.csrf.enabled {
2252 let effective_scan_bytes = config
2267 .security
2268 .csrf
2269 .token_scan_bytes
2270 .min(config.security.upload.max_request_size_bytes);
2271 let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
2272 .with_max_scan_bytes(effective_scan_bytes);
2273 if let Some(keys) = signing_keys {
2274 csrf_layer = csrf_layer.with_signing_keys(keys);
2275 }
2276 for endpoint in &config.security.webhooks.endpoints {
2277 csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
2278 }
2279 #[cfg(feature = "mail")]
2283 if config.mail.should_mount_unsubscribe_endpoint() {
2284 csrf_layer = csrf_layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
2285 }
2286 tracing::info!("CSRF protection enabled");
2287 router = router.layer(csrf_layer);
2288 }
2289 router
2290}
2291
2292fn apply_submit_token_middleware<S>(
2301 mut router: axum::Router<S>,
2302 config: &AutumnConfig,
2303 is_production: bool,
2304) -> Result<axum::Router<S>, RouterBuildError>
2305where
2306 S: Clone + Send + Sync + 'static,
2307{
2308 let cfg = &config.security.submit_token;
2309 if !cfg.enabled {
2310 return Ok(router);
2311 }
2312
2313 match cfg.production_memory_guard(config.idempotency.backend, is_production) {
2322 crate::security::config::SubmitTokenMemoryGuard::Ok => {}
2323 crate::security::config::SubmitTokenMemoryGuard::WarnInherited => {
2324 tracing::warn!(
2325 "[security.submit_token].backend resolved to the in-memory store in production \
2326 (inherited from [idempotency].backend, which is unset or memory). \
2327 Single-replica deployments are fine, but multi-replica deployments need a shared \
2328 backend: configure [idempotency] with backend = \"redis\" (or set \
2329 [security.submit_token].backend = \"redis\") so consumed tokens are shared across \
2330 replicas — otherwise a duplicate submit can slip through on a different replica."
2331 );
2332 }
2333 crate::security::config::SubmitTokenMemoryGuard::FailExplicit => {
2334 return Err(RouterBuildError::InvalidSubmitTokenBackend(
2335 "the in-memory submit-token backend is not safe for multi-replica production use. \
2336 Set `[security.submit_token].backend = \"redis\"` in autumn.toml (it reuses the \
2337 [idempotency.redis] connection settings), or remove the explicit `backend` \
2338 override to inherit `[idempotency].backend`."
2339 .to_owned(),
2340 ));
2341 }
2342 }
2343
2344 let ttl = Duration::from_secs(cfg.ttl_secs);
2345 let backend = cfg.resolved_backend(config.idempotency.backend);
2352 let store: std::sync::Arc<dyn IdempotencyStore> = match backend {
2353 crate::config::IdempotencyBackend::Memory => {
2354 std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
2355 }
2356 #[cfg(feature = "redis")]
2357 crate::config::IdempotencyBackend::Redis => {
2358 match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
2359 Ok(s) => std::sync::Arc::new(s),
2360 Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
2361 }
2362 }
2363 #[cfg(not(feature = "redis"))]
2364 crate::config::IdempotencyBackend::Redis => {
2365 return Err(RouterBuildError::InvalidIdempotencyBackend(
2366 "submit_token backend 'redis' requires the autumn-web 'redis' feature \
2367 flag; rebuild with --features redis or switch to backend = \"memory\""
2368 .to_owned(),
2369 ));
2370 }
2371 };
2372
2373 let mut layer = crate::security::SubmitTokenLayer::new(store, cfg)
2374 .with_max_scan_bytes(config.security.upload.max_request_size_bytes);
2375 for endpoint in &config.security.webhooks.endpoints {
2376 layer = layer.with_exempt_path(&endpoint.path);
2377 }
2378 #[cfg(feature = "mail")]
2379 if config.mail.should_mount_unsubscribe_endpoint() {
2380 layer = layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
2381 }
2382 tracing::info!(
2383 backend = ?backend,
2384 inherited = cfg.backend.is_none(),
2385 ttl_secs = cfg.ttl_secs,
2386 "One-time submit-token protection enabled"
2387 );
2388 router = router.layer(layer);
2389 Ok(router)
2390}
2391
2392fn apply_bot_protection_middleware<S>(
2393 mut router: axum::Router<S>,
2394 config: &AutumnConfig,
2395) -> axum::Router<S>
2396where
2397 S: Clone + Send + Sync + 'static,
2398{
2399 if config.bot_protection.enabled {
2400 let mut exempt = config.security.captcha_exempt_paths.clone();
2404 for endpoint in &config.security.webhooks.endpoints {
2405 exempt.push(endpoint.path.clone());
2406 }
2407 #[cfg(feature = "mail")]
2410 if config.mail.should_mount_unsubscribe_endpoint() {
2411 exempt.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
2412 }
2413 let layer =
2414 crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
2415 .with_max_scan_bytes(config.security.upload.max_request_size_bytes)
2416 .with_exempt_paths(exempt);
2417 tracing::info!(
2418 provider = ?config.bot_protection.provider,
2419 dev_bypass = config.bot_protection.dev_bypass,
2420 "Bot protection (CAPTCHA) enabled"
2421 );
2422 router = router.layer(layer);
2423 }
2424 router
2425}
2426
2427async fn populate_rate_limit_principal(
2428 axum::extract::State(state): axum::extract::State<AppState>,
2429 mut req: axum::extract::Request,
2430 next: axum::middleware::Next,
2431) -> axum::response::Response {
2432 if let Some(session) = req.extensions().get::<crate::session::Session>() {
2446 let auth_session_key = state.auth_session_key();
2447 if let Some(user_id) = session.get(auth_session_key).await {
2448 req.extensions_mut()
2449 .insert(crate::security::RateLimitPrincipal(user_id));
2450 }
2451 }
2452 next.run(req).await
2453}
2454
2455fn apply_trusted_proxies_middleware<S>(
2456 router: axum::Router<S>,
2457 config: &AutumnConfig,
2458) -> axum::Router<S>
2459where
2460 S: Clone + Send + Sync + 'static,
2461{
2462 let tp = &config.security.trusted_proxies;
2463 let layer = crate::security::TrustedProxiesLayer::from_config(tp);
2464 if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
2465 tracing::info!(
2466 ranges = ?tp.ranges,
2467 trusted_hops = ?tp.trusted_hops,
2468 "Centralized trusted-proxy resolution enabled"
2469 );
2470 }
2471 router.layer(layer)
2472}
2473
2474fn apply_rate_limit_middleware(
2475 mut router: axum::Router<AppState>,
2476 config: &AutumnConfig,
2477 state: &AppState,
2478) -> axum::Router<AppState> {
2479 if config.security.rate_limit.enabled {
2480 let tp = &config.security.trusted_proxies;
2481 let rl = &config.security.rate_limit;
2482 let has_top_level_proxy_config =
2483 tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
2484 let has_rate_limit_proxy_config =
2489 rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
2490 let mut layer = crate::security::RateLimitLayer::from_config(rl).honoring_mcp_exempt();
2495 if has_top_level_proxy_config && !has_rate_limit_proxy_config {
2496 let resolver = crate::security::ProxyResolver::from_config(tp);
2497 layer = layer.with_proxy_resolver(resolver);
2498 }
2499 tracing::info!(
2500 rps = config.security.rate_limit.requests_per_second,
2501 burst = config.security.rate_limit.burst,
2502 "Rate limiting enabled"
2503 );
2504 router = router.layer(layer);
2505
2506 if config.security.rate_limit.key_strategy
2507 == crate::security::KeyStrategy::AuthenticatedPrincipal
2508 {
2509 router = router.layer(axum::middleware::from_fn_with_state(
2510 state.clone(),
2511 populate_rate_limit_principal,
2512 ));
2513 }
2514 }
2515 router
2516}
2517
2518fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
2519where
2520 S: Clone + Send + Sync + 'static,
2521{
2522 let upload_config = config.security.upload.clone();
2523 let max_request_size = upload_config.max_request_size_bytes;
2524 tracing::info!(
2525 max_request_size_bytes = max_request_size,
2526 max_file_size_bytes = upload_config.max_file_size_bytes,
2527 allowed_mime_types = ?upload_config.allowed_mime_types,
2528 "Request body size limits enabled (applies to all content types)"
2529 );
2530
2531 let router = router.layer(axum::extract::DefaultBodyLimit::max(max_request_size));
2534
2535 router.layer(axum::middleware::from_fn(
2538 move |mut req: axum::extract::Request, next: axum::middleware::Next| {
2539 let upload_config = upload_config.clone();
2540 async move {
2541 req.extensions_mut().insert(upload_config);
2542 next.run(req).await
2543 }
2544 },
2545 ))
2546}
2547
2548fn probe_bypass_paths(config: &AutumnConfig) -> Vec<String> {
2555 vec![
2556 config.health.path.clone(),
2557 config.health.live_path.clone(),
2558 config.health.ready_path.clone(),
2559 config.health.startup_path.clone(),
2560 crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
2561 ]
2562}
2563
2564fn build_maintenance_layer(
2573 config: &AutumnConfig,
2574 state: &AppState,
2575) -> crate::middleware::maintenance::MaintenanceLayer {
2576 let maintenance_state = state
2577 .extension::<crate::maintenance::MaintenanceState>()
2578 .map(|s| (*s).clone())
2579 .unwrap_or_default();
2580 crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
2581 .with_health_prefix(config.actuator.prefix.clone())
2582 .with_probe_paths(probe_bypass_paths(config))
2583}
2584
2585fn build_load_shed_layer(
2593 config: &AutumnConfig,
2594 state: &AppState,
2595) -> Option<crate::middleware::LoadShedLayer> {
2596 let limit = config.server.max_concurrent_requests.filter(|&n| n > 0)?;
2597 let cors =
2605 (!config.cors.allowed_origins.is_empty()).then(|| std::sync::Arc::new(config.cors.clone()));
2606 Some(
2607 crate::middleware::LoadShedLayer::new(limit, state.metrics.clone())
2608 .with_health_prefix(config.actuator.prefix.clone())
2609 .with_probe_paths(probe_bypass_paths(config))
2610 .with_cors(cors),
2611 )
2612}
2613
2614type RouteTimeoutTable = std::sync::Arc<
2623 std::collections::HashMap<
2624 String,
2625 std::collections::HashMap<http::Method, crate::route::RouteTimeout>,
2626 >,
2627>;
2628
2629#[derive(Debug)]
2634struct RequestDeadlineExceeded {
2635 timeout_ms: u64,
2636}
2637
2638impl std::fmt::Display for RequestDeadlineExceeded {
2639 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2640 write!(
2641 f,
2642 "the server did not produce a response within the configured {}ms deadline",
2643 self.timeout_ms
2644 )
2645 }
2646}
2647
2648impl std::error::Error for RequestDeadlineExceeded {}
2649
2650#[derive(Clone, Copy, Debug)]
2661pub struct RequestDeadlineCancelled;
2662
2663fn build_route_timeout_table(
2667 route_list: &[Route],
2668 scoped_groups: &[ScopedGroup],
2669) -> RouteTimeoutTable {
2670 let mut table: std::collections::HashMap<
2671 String,
2672 std::collections::HashMap<http::Method, crate::route::RouteTimeout>,
2673 > = std::collections::HashMap::new();
2674 let mut insert = |path: String, method: &http::Method, timeout: crate::route::RouteTimeout| {
2675 if matches!(timeout, crate::route::RouteTimeout::Inherit) {
2677 return;
2678 }
2679 let by_method = table.entry(path).or_default();
2691 by_method.insert(effective_mount_method(method), timeout);
2696 if *method == http::Method::GET {
2699 by_method.insert(http::Method::HEAD, timeout);
2700 }
2701 };
2702 for route in route_list {
2703 insert(route.path.to_owned(), &route.method, route.timeout);
2704 }
2705 for group in scoped_groups {
2706 for route in &group.routes {
2707 insert(
2708 join_nested_path(&group.prefix, route.path),
2709 &route.method,
2710 route.timeout,
2711 );
2712 }
2713 }
2714 std::sync::Arc::new(table)
2715}
2716
2717fn apply_request_timeout_middleware(
2746 router: axum::Router<AppState>,
2747 config: &AutumnConfig,
2748 metrics: crate::middleware::MetricsCollector,
2749 route_timeouts: RouteTimeoutTable,
2750 mirror_cors: bool,
2751) -> axum::Router<AppState> {
2752 let global = config
2753 .server
2754 .timeouts
2755 .request_timeout_ms
2756 .filter(|ms| *ms > 0)
2757 .map(std::time::Duration::from_millis);
2758 let has_override = route_timeouts
2759 .values()
2760 .flat_map(std::collections::HashMap::values)
2761 .any(|t| matches!(t, crate::route::RouteTimeout::Override(_)));
2762 if global.is_none() && !has_override {
2763 return router;
2764 }
2765 if let Some(duration) = global {
2766 tracing::info!(
2767 timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
2768 "Inbound request timeout enabled"
2769 );
2770 }
2771 let cors = (mirror_cors && !config.cors.allowed_origins.is_empty())
2774 .then(|| std::sync::Arc::new(config.cors.clone()));
2775 router.layer(axum::middleware::from_fn(move |req, next| {
2776 request_timeout_handler(
2777 req,
2778 next,
2779 global,
2780 route_timeouts.clone(),
2781 metrics.clone(),
2782 cors.clone(),
2783 )
2784 }))
2785}
2786
2787async fn request_timeout_handler(
2788 req: axum::extract::Request,
2789 next: axum::middleware::Next,
2790 global: Option<std::time::Duration>,
2791 route_timeouts: RouteTimeoutTable,
2792 metrics: crate::middleware::MetricsCollector,
2793 cors: Option<std::sync::Arc<crate::config::CorsConfig>>,
2794) -> axum::response::Response {
2795 if req
2801 .extensions()
2802 .get::<crate::static_gen::RenderDeadlineExempt>()
2803 .is_some()
2804 {
2805 return next.run(req).await;
2806 }
2807
2808 let matched_path_ref = req
2811 .extensions()
2812 .get::<axum::extract::MatchedPath>()
2813 .map(axum::extract::MatchedPath::as_str);
2814 let route_timeout = matched_path_ref
2815 .and_then(|p| route_timeouts.get(p))
2816 .and_then(|by_method| by_method.get(req.method()))
2817 .copied()
2818 .unwrap_or(crate::route::RouteTimeout::Inherit);
2819 let deadline = match route_timeout {
2820 crate::route::RouteTimeout::Disabled => None,
2821 crate::route::RouteTimeout::Override(d) => Some(d),
2822 crate::route::RouteTimeout::Inherit => global,
2823 };
2824 let Some(duration) = deadline else {
2825 return next.run(req).await;
2828 };
2829
2830 let matched_path = matched_path_ref.map(ToOwned::to_owned);
2832 let request_id = req
2833 .extensions()
2834 .get::<crate::middleware::RequestId>()
2835 .cloned();
2836 let cors_origin = cors
2840 .as_ref()
2841 .and_then(|_| req.headers().get(http::header::ORIGIN).cloned());
2842 let start = std::time::Instant::now();
2843 match tokio::time::timeout(duration, next.run(req)).await {
2844 Ok(response) => response,
2845 Err(_elapsed) => {
2846 let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2847 let route = matched_path.as_deref().unwrap_or("<unmatched>");
2848 tracing::warn!(
2851 target: "autumn::timeout",
2852 route = route,
2853 elapsed_ms = elapsed_ms,
2854 timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
2855 request_id = request_id.as_ref().map(ToString::to_string),
2856 "inbound request exceeded deadline"
2857 );
2858 metrics.record_request_timeout();
2859 let mut response =
2863 crate::error::AutumnError::service_unavailable(RequestDeadlineExceeded {
2864 timeout_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
2865 })
2866 .into_response();
2867 response.extensions_mut().insert(RequestDeadlineCancelled);
2870 if let Some(cors) = cors.as_deref() {
2875 mirror_cors_headers(cors, cors_origin.as_ref(), &mut response);
2876 }
2877 response
2878 }
2879 }
2880}
2881
2882struct BuiltIdempotencyLayers {
2883 route: crate::idempotency::IdempotencyLayer,
2884 manual: crate::idempotency::IdempotencyLayer,
2885}
2886
2887fn build_idempotency_layers(
2888 config: &AutumnConfig,
2889 state: &AppState,
2890) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
2891 if !config.idempotency.enabled.unwrap_or(false) {
2892 return Ok(None);
2893 }
2894
2895 let ttl = Duration::from_secs(config.idempotency.ttl_secs);
2896 let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
2897 let store: std::sync::Arc<dyn IdempotencyStore> = match config.idempotency.backend {
2898 crate::config::IdempotencyBackend::Memory => {
2899 std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
2900 }
2901 #[cfg(feature = "redis")]
2902 crate::config::IdempotencyBackend::Redis => {
2903 match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
2904 Ok(s) => std::sync::Arc::new(s),
2905 Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
2906 }
2907 }
2908 #[cfg(not(feature = "redis"))]
2909 crate::config::IdempotencyBackend::Redis => {
2910 return Err(RouterBuildError::InvalidIdempotencyBackend(
2911 "idempotency backend 'redis' requires the autumn-web 'redis' feature \
2912 flag; rebuild with --features redis or switch to backend = \"memory\""
2913 .to_owned(),
2914 ));
2915 }
2916 };
2917
2918 tracing::debug!(
2919 backend = ?config.idempotency.backend,
2920 ttl_secs = config.idempotency.ttl_secs,
2921 in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
2922 "Idempotency-key middleware enabled"
2923 );
2924
2925 let base = IdempotencyLayer::new(store)
2926 .with_ttl(ttl)
2927 .with_in_flight_ttl(in_flight_ttl)
2928 .with_metrics(state.metrics.clone());
2929
2930 Ok(Some(BuiltIdempotencyLayers {
2931 route: base.clone().replay_through_inner(),
2932 manual: base.fail_closed_on_replay(),
2933 }))
2934}
2935
2936#[allow(
2937 clippy::cognitive_complexity,
2938 clippy::too_many_lines,
2939 clippy::too_many_arguments
2940)]
2941fn apply_middleware(
2942 mut router: axum::Router<AppState>,
2943 config: &AutumnConfig,
2944 state: &AppState,
2945 exception_filters: Vec<Arc<dyn ExceptionFilter>>,
2946 custom_layers: Vec<crate::app::CustomLayerRegistration>,
2947 #[cfg(feature = "maud")] error_page_renderer: Option<SharedRenderer>,
2948 session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
2949 route_timeouts: RouteTimeoutTable,
2950 load_shed_layer: Option<crate::middleware::LoadShedLayer>,
2956) -> Result<axum::Router<AppState>, RouterBuildError> {
2957 router = router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
2960
2961 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
2963 let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
2964 &config.security.signing_secret,
2965 ));
2966 let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
2970 if config.security.signing_secret.secret.is_some() || is_production {
2971 Some(signing_keys)
2972 } else {
2973 None
2974 };
2975
2976 router = apply_cors_middleware(router, config);
2977 let trusted_host_policy = TrustedHostPolicy::from_config(config);
2978 router = router.layer(axum::middleware::from_fn(move |req, next| {
2979 trusted_host_middleware(req, next, trusted_host_policy.clone())
2980 }));
2981 router = apply_submit_token_middleware(router, config, is_production)?;
2985 router = apply_csrf_middleware(router, config, signing_keys_opt.clone());
2986 router = apply_bot_protection_middleware(router, config);
2987 router = router.layer(axum::middleware::from_fn(
3000 crate::middleware::method_override_rejection_filter,
3001 ));
3002 router = apply_rate_limit_middleware(router, config, state);
3003
3004 router = router.layer(build_maintenance_layer(config, state));
3007
3008 if let Some(load_shed) = load_shed_layer {
3014 router = router.layer(load_shed);
3015 }
3016
3017 router = router.layer(axum::middleware::from_fn(
3018 crate::webhook::webhook_replay_cleanup_middleware,
3019 ));
3020 router = apply_upload_middleware(router, config);
3021
3022 let custom_layer_count = custom_layers.len();
3032 for registered in custom_layers.into_iter().rev() {
3033 router = (registered.apply)(router);
3034 }
3035 if custom_layer_count > 0 {
3036 tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
3037 }
3038
3039 router = apply_trusted_proxies_middleware(router, config);
3043
3044 let mut router = router;
3045
3046 if config.tenancy.enabled {
3047 router = router.layer(axum::middleware::from_fn_with_state(
3048 state.clone(),
3049 crate::tenancy::tenancy_middleware,
3050 ));
3051 tracing::debug!("Multi-tenancy middleware enabled");
3052 }
3053
3054 router = apply_request_timeout_middleware(
3087 router,
3088 config,
3089 state.metrics.clone(),
3090 route_timeouts,
3091 true,
3092 );
3093
3094 #[cfg(feature = "reporting")]
3100 {
3101 router = router.layer(crate::reporting::ReportingLayer::new(
3102 state.error_reporters(),
3103 config.reporting.enabled,
3104 config.reporting.sample_rate,
3105 ));
3106 }
3107
3108 if config.log.access_log {
3118 router = router.layer(crate::middleware::AccessLogLayer::new(
3119 config.log.access_log_exclude.clone(),
3120 ));
3121 }
3122
3123 if crate::config::server_timing_enabled(config) {
3131 router = router.layer(crate::middleware::ServerTimingLayer::new(true));
3132 }
3133
3134 let mut log_context_filter_parameters = config.log.filter_parameters.clone();
3141 log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
3142 let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
3143 &log_context_filter_parameters,
3144 &config.log.unfilter_parameters,
3145 ));
3146 let router = router.layer(crate::middleware::LogContextLayer::new(log_context_filter));
3147
3148 let router = router.layer(RequestIdLayer);
3154
3155 #[cfg(feature = "db")]
3158 let signing_keys_for_ryw = signing_keys_opt.clone();
3159
3160 let router = crate::session::apply_session_layer(
3161 router,
3162 &config.session,
3163 config.profile.as_deref(),
3164 session_store,
3165 signing_keys_opt,
3166 )?;
3167 tracing::debug!(backend = ?config.session.backend, "Session management enabled");
3168
3169 #[cfg(feature = "db")]
3176 let router = if config.database.read_your_writes == crate::config::ReadYourWrites::Off {
3177 router
3178 } else {
3179 let ryw_mode = config.database.read_your_writes;
3180 let window_secs = config.database.pin_after_write_secs;
3181 let keys = signing_keys_for_ryw;
3182 if ryw_mode == crate::config::ReadYourWrites::Session && keys.is_none() {
3183 tracing::warn!(
3184 "read_your_writes = \"session\" requires a configured \
3185 security.signing_secret to sign the autumn.ryw cookie; \
3186 cross-request pinning is disabled until a secret is set"
3187 );
3188 }
3189 let metrics = state.metrics().clone();
3190 router.layer(axum::middleware::from_fn(move |req, next| {
3191 crate::read_your_writes::middleware(
3192 req,
3193 next,
3194 ryw_mode,
3195 window_secs,
3196 keys.clone(),
3197 metrics.clone(),
3198 )
3199 }))
3200 };
3201
3202 let is_dev = config
3205 .profile
3206 .as_deref()
3207 .map_or(cfg!(debug_assertions), |p| p == "dev");
3208
3209 let mut all_filters: Vec<Arc<dyn ExceptionFilter>> =
3213 vec![Arc::new(ProblemDetailsFilter { is_dev })];
3214 #[cfg(feature = "maud")]
3215 {
3216 let mut filter_parameters = config.log.filter_parameters.clone();
3220 filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
3221 let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
3222 let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
3223 renderer,
3224 is_dev,
3225 parameter_filter: crate::log::filter::ParameterFilter::new(
3226 &filter_parameters,
3227 &config.log.unfilter_parameters,
3228 ),
3229 };
3230 all_filters.push(Arc::new(error_page_filter));
3231 }
3232 all_filters.extend(exception_filters);
3233
3234 let count = all_filters.len();
3235 tracing::debug!(
3236 count,
3237 "Registered exception filters (including error page filter)"
3238 );
3239
3240 let router = router
3260 .layer(crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev })
3261 .layer(ExceptionFilterLayer::new(all_filters))
3262 .layer(crate::middleware::MetricsLayer::new(state.metrics.clone()));
3263
3264 let router = apply_compression_middleware(router, config);
3273
3274 Ok(router)
3280}
3281
3282fn apply_layers_in_registration_order(
3286 mut router: axum::Router<AppState>,
3287 layers: Vec<crate::app::CustomLayerRegistration>,
3288 what: &str,
3289) -> axum::Router<AppState> {
3290 let count = layers.len();
3291 for registered in layers.into_iter().rev() {
3292 router = (registered.apply)(router);
3293 }
3294 if count > 0 {
3295 tracing::debug!(count, "{what} Tower layers applied");
3296 }
3297 router
3298}
3299
3300async fn trusted_host_middleware(
3301 req: Request<axum::body::Body>,
3302 next: Next,
3303 policy: TrustedHostPolicy,
3304) -> axum::response::Response {
3305 let path = req.uri().path();
3306 if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
3307 && policy.probe_bypass_paths.contains(path)
3308 {
3309 return next.run(req).await;
3310 }
3311 let authority = req.uri().authority().map(http::uri::Authority::as_str);
3312 let host_header = req
3313 .headers()
3314 .get(http::header::HOST)
3315 .and_then(|v| v.to_str().ok());
3316 let raw_host = authority.or(host_header);
3317 let parsed_host = raw_host.and_then(extract_host_without_port);
3318 let host = parsed_host
3319 .map(str::to_ascii_lowercase)
3320 .map(|h| h.trim_end_matches('.').to_owned())
3321 .filter(|h| !h.is_empty());
3322 let host_source_present = raw_host.is_some();
3323 if host.is_none() && !host_source_present && policy.allow_missing_host {
3324 return next.run(req).await;
3325 }
3326 if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
3327 next.run(req).await
3328 } else {
3329 tracing::warn!(host = ?host, "trusted host rejected request");
3330 let body = crate::error::problem_details_json_string(
3331 StatusCode::BAD_REQUEST,
3332 "Invalid Host header",
3333 None,
3334 None,
3335 None,
3336 None,
3337 true,
3338 );
3339 (
3340 StatusCode::BAD_REQUEST,
3341 [(http::header::CONTENT_TYPE, "application/problem+json")],
3342 body,
3343 )
3344 .into_response()
3345 }
3346}
3347
3348pub fn extract_host_without_port(header: &str) -> Option<&str> {
3349 let host = header.trim();
3350 if host.is_empty() {
3351 return None;
3352 }
3353 if host.starts_with('[') {
3354 let end = host.find(']')?;
3355 let literal = host.get(1..end)?;
3356 if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
3357 return None;
3358 }
3359
3360 let remainder = host.get(end + 1..)?;
3361 if remainder.is_empty() {
3362 return Some(literal);
3363 }
3364
3365 let maybe_port = remainder.strip_prefix(':')?;
3366 if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
3367 return Some(literal);
3368 }
3369
3370 return None;
3371 }
3372 let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
3373 return Some(host);
3374 };
3375 if candidate.contains(':') {
3376 return Some(host);
3378 }
3379 if !maybe_port.is_empty()
3380 && maybe_port.chars().all(|c| c.is_ascii_digit())
3381 && !candidate.is_empty()
3382 {
3383 Some(candidate)
3384 } else {
3385 None
3386 }
3387}
3388
3389#[allow(dead_code)]
3411pub fn build_router_with_static(
3412 route_list: Vec<Route>,
3413 config: &AutumnConfig,
3414 state: AppState,
3415 dist_dir: Option<&std::path::Path>,
3416) -> axum::Router {
3417 try_build_router_with_static(route_list, config, state, dist_dir)
3418 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
3419}
3420
3421#[allow(dead_code)]
3429pub fn try_build_router_with_static(
3430 route_list: Vec<Route>,
3431 config: &AutumnConfig,
3432 state: AppState,
3433 dist_dir: Option<&std::path::Path>,
3434) -> Result<axum::Router, RouterBuildError> {
3435 try_build_router_with_static_inner(
3436 route_list,
3437 config,
3438 state,
3439 dist_dir,
3440 RouterContext {
3441 exception_filters: Vec::new(),
3442 scoped_groups: Vec::new(),
3443 merge_routers: Vec::new(),
3444 nest_routers: Vec::new(),
3445 custom_layers: Vec::new(),
3446 static_gate_layers: Vec::new(),
3447 #[cfg(feature = "maud")]
3448 error_page_renderer: None,
3449 session_store: None,
3450 #[cfg(feature = "openapi")]
3451 openapi: None,
3452 #[cfg(feature = "mcp")]
3453 mcp: None,
3454 },
3455 )
3456}
3457
3458#[allow(clippy::too_many_lines)]
3459pub fn try_build_router_with_static_inner(
3460 route_list: Vec<Route>,
3461 config: &AutumnConfig,
3462 state: AppState,
3463 dist_dir: Option<&std::path::Path>,
3464 mut ctx: RouterContext,
3465) -> Result<axum::Router, RouterBuildError> {
3466 let startup_barrier_state = state.clone();
3467
3468 let Some(dist) = dist_dir else {
3469 let app_router = try_build_router_inner(route_list, config, state, ctx)?;
3470 return Ok(apply_startup_barrier(
3471 app_router,
3472 config,
3473 &startup_barrier_state,
3474 ));
3475 };
3476
3477 let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
3478 tracing::debug!(
3479 dist = %dist.display(),
3480 "No valid manifest.json in dist dir; skipping static file layer"
3481 );
3482 let app_router = try_build_router_inner(route_list, config, state, ctx)?;
3483 return Ok(apply_startup_barrier(
3484 app_router,
3485 config,
3486 &startup_barrier_state,
3487 ));
3488 };
3489
3490 for (route, entry) in &layer.manifest().routes {
3491 tracing::debug!(
3492 route = %route,
3493 file = %entry.file,
3494 revalidate = ?entry.revalidate,
3495 "Static route"
3496 );
3497 }
3498
3499 let opaque_present = Some(
3534 custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
3535 || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers),
3536 );
3537 let custom_layers = std::mem::take(&mut ctx.custom_layers);
3538
3539 let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
3546
3547 let inner_router =
3551 build_router_pre_state(route_list, config, &state, ctx, opaque_present, true)?;
3552
3553 let has_isr = layer
3558 .manifest()
3559 .routes
3560 .values()
3561 .any(|e| e.revalidate.is_some());
3562 let layer = if has_isr {
3563 let regen_router = inner_router
3574 .clone()
3575 .layer(crate::security::SecurityHeadersLayer::from_config(
3576 &config.security.headers,
3577 ))
3578 .with_state(state.clone());
3579 layer.with_router(regen_router)
3580 } else {
3581 layer
3582 };
3583 let layer = Arc::new(layer);
3584
3585 let static_layer = layer;
3597 let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
3598 move |req: axum::extract::Request, next: axum::middleware::Next| {
3599 let static_layer = static_layer.clone();
3600 async move {
3601 let is_get = req.method() == http::Method::GET;
3602 let is_head = req.method() == http::Method::HEAD;
3603 if is_get || is_head {
3604 let path = req.uri().path();
3605 let normalized = if path.len() > 1 && path.ends_with('/') {
3607 &path[..path.len() - 1]
3608 } else {
3609 path
3610 };
3611 if let Some(file_path) = static_layer.resolve(normalized)
3612 && let Ok(contents) = tokio::fs::read(&file_path).await
3613 {
3614 let content_type = crate::assets::content_type_for_opt(normalized)
3655 .unwrap_or_else(|| {
3656 file_path
3657 .file_name()
3658 .and_then(|name| name.to_str())
3659 .map_or("application/octet-stream", |name| {
3660 crate::assets::content_type_for(name)
3661 })
3662 });
3663 let body = if is_head {
3664 axum::body::Body::empty()
3665 } else {
3666 axum::body::Body::from(contents)
3667 };
3668 return http::Response::builder()
3669 .status(http::StatusCode::OK)
3670 .header(http::header::CONTENT_TYPE, content_type)
3671 .body(body)
3672 .expect("infallible response builder");
3673 }
3674 }
3675 next.run(req).await
3676 }
3677 },
3678 ));
3679
3680 router = apply_layers_in_registration_order(
3685 router,
3686 custom_layers,
3687 "Custom (outside static middleware)",
3688 );
3689
3690 router = apply_compression_middleware(router, config);
3695
3696 router = apply_layers_in_registration_order(
3703 router,
3704 static_gate_layers,
3705 "Pre-static gate (outside static middleware)",
3706 );
3707
3708 let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
3714 &config.security.headers,
3715 ));
3716
3717 Ok(apply_startup_barrier(
3718 router.with_state(state),
3719 config,
3720 &startup_barrier_state,
3721 ))
3722}
3723
3724#[derive(Clone)]
3725struct StartupBarrierState {
3726 app_state: AppState,
3727 probe_paths: Vec<String>,
3731 actuator_paths: Vec<String>,
3732 actuator_subtree_paths: Vec<String>,
3733}
3734
3735impl StartupBarrierState {
3736 fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
3737 let actuator_subtree_paths = if config.actuator.sensitive {
3738 vec![crate::actuator::actuator_route_path(
3739 &config.actuator.prefix,
3740 "/loggers",
3741 )]
3742 } else {
3743 Vec::new()
3744 };
3745
3746 Self {
3747 app_state: app_state.clone(),
3748 probe_paths: probe_bypass_paths(config),
3749 actuator_paths: crate::actuator::actuator_endpoint_paths(
3750 &config.actuator.prefix,
3751 config.actuator.sensitive,
3752 config.actuator.prometheus,
3753 ),
3754 actuator_subtree_paths,
3755 }
3756 }
3757
3758 fn allows_path(&self, path: &str) -> bool {
3759 self.probe_paths.iter().any(|allowed| path == allowed)
3760 || self.actuator_paths.iter().any(|allowed| path == allowed)
3761 || self
3762 .actuator_subtree_paths
3763 .iter()
3764 .any(|allowed| path_matches_route_prefix(path, allowed))
3765 }
3766}
3767
3768fn apply_startup_barrier(
3769 router: axum::Router,
3770 config: &AutumnConfig,
3771 state: &AppState,
3772) -> axum::Router {
3773 let barrier_state = StartupBarrierState::from_config(config, state);
3774 let router = router.layer(axum::middleware::from_fn_with_state(
3775 barrier_state,
3776 startup_barrier,
3777 ));
3778 let router = if config.log.access_log {
3789 router.layer(crate::middleware::AccessLogLayer::fallback(
3790 config.log.access_log_exclude.clone(),
3791 ))
3792 } else {
3793 router
3794 };
3795 let router = if crate::config::server_timing_enabled(config) {
3804 router.layer(crate::middleware::ServerTimingLayer::fallback(true))
3805 } else {
3806 router
3807 };
3808 #[cfg(feature = "telemetry-otlp")]
3817 let router = router.layer(crate::middleware::TraceContextLayer);
3818 router
3819}
3820
3821async fn startup_barrier(
3822 State(state): State<StartupBarrierState>,
3823 request: axum::extract::Request,
3824 next: Next,
3825) -> axum::response::Response {
3826 if crate::app::is_static_build_mode()
3827 || state.app_state.probes().is_startup_complete()
3828 || state.allows_path(request.uri().path())
3829 {
3830 next.run(request).await
3831 } else {
3832 (
3833 StatusCode::SERVICE_UNAVAILABLE,
3834 "Service is still starting up",
3835 )
3836 .into_response()
3837 }
3838}
3839
3840pub fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
3841 path == prefix
3842 || path
3843 .strip_prefix(prefix)
3844 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
3845}
3846
3847pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
3851 use http::header::HeaderName;
3852 use tower_http::cors::{AllowOrigin, CorsLayer};
3853
3854 let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
3855 CorsLayer::new().allow_origin(AllowOrigin::any())
3856 } else {
3857 let origins: Vec<http::HeaderValue> = cors
3858 .allowed_origins
3859 .iter()
3860 .filter_map(|o| match o.parse() {
3861 Ok(v) => Some(v),
3862 Err(e) => {
3863 tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
3864 None
3865 }
3866 })
3867 .collect();
3868 CorsLayer::new().allow_origin(origins)
3869 };
3870
3871 let methods: Vec<http::Method> = cors
3872 .allowed_methods
3873 .iter()
3874 .filter_map(|m| match m.parse() {
3875 Ok(v) => Some(v),
3876 Err(e) => {
3877 tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
3878 None
3879 }
3880 })
3881 .collect();
3882
3883 let headers: Vec<HeaderName> = cors
3884 .allowed_headers
3885 .iter()
3886 .filter_map(|h| match h.parse() {
3887 Ok(v) => Some(v),
3888 Err(e) => {
3889 tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
3890 None
3891 }
3892 })
3893 .collect();
3894
3895 layer
3896 .allow_methods(methods)
3897 .allow_headers(headers)
3898 .allow_credentials(cors.allow_credentials)
3899 .max_age(std::time::Duration::from_secs(cors.max_age_secs))
3900}
3901
3902pub fn mirror_cors_headers(
3921 cors: &crate::config::CorsConfig,
3922 origin: Option<&http::HeaderValue>,
3923 response: &mut axum::response::Response,
3924) {
3925 use http::header;
3926 let allow_any = cors.allowed_origins.iter().any(|o| o == "*");
3927 let allow_origin = if allow_any {
3928 Some(http::HeaderValue::from_static("*"))
3929 } else {
3930 origin.and_then(|value| {
3933 let value_str = value.to_str().ok()?;
3934 cors.allowed_origins
3935 .iter()
3936 .any(|allowed| allowed == value_str)
3937 .then(|| value.clone())
3938 })
3939 };
3940 let Some(allow_origin) = allow_origin else {
3941 return;
3943 };
3944 let headers = response.headers_mut();
3945 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
3946 if !allow_any {
3947 headers.insert(header::VARY, http::HeaderValue::from_static("origin"));
3951 }
3952 if cors.allow_credentials {
3953 headers.insert(
3954 header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
3955 http::HeaderValue::from_static("true"),
3956 );
3957 }
3958}
3959
3960#[cfg(feature = "htmx")]
3961pub async fn htmx_handler() -> axum::response::Response {
3962 use axum::response::IntoResponse;
3963 (
3964 [
3965 (http::header::CONTENT_TYPE, "application/javascript"),
3966 (
3967 http::header::CACHE_CONTROL,
3968 "public, max-age=31536000, immutable",
3969 ),
3970 ],
3971 crate::htmx::HTMX_JS,
3972 )
3973 .into_response()
3974}
3975
3976#[cfg(any(feature = "flash", feature = "maud"))]
3982struct PrecompressedCss {
3983 gzip: bytes::Bytes,
3984 brotli: bytes::Bytes,
3985}
3986
3987#[cfg(any(feature = "flash", feature = "maud"))]
3988impl PrecompressedCss {
3989 fn compute(body: &'static str) -> Self {
3990 use std::io::Write as _;
3991
3992 let mut gzip_encoder =
3993 flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
3994 gzip_encoder
3995 .write_all(body.as_bytes())
3996 .expect("in-memory gzip encoding cannot fail");
3997 let gzip = gzip_encoder
3998 .finish()
3999 .expect("in-memory gzip encoding cannot fail");
4000
4001 let mut brotli_writer = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22);
4002 brotli_writer
4003 .write_all(body.as_bytes())
4004 .expect("in-memory brotli encoding cannot fail");
4005 let brotli = brotli_writer.into_inner();
4006
4007 Self {
4008 gzip: gzip.into(),
4009 brotli: brotli.into(),
4010 }
4011 }
4012}
4013
4014#[cfg(any(feature = "flash", feature = "maud"))]
4019fn accepts_encoding(headers: &http::HeaderMap, coding: &str) -> bool {
4020 let Some(value) = headers
4021 .get(http::header::ACCEPT_ENCODING)
4022 .and_then(|v| v.to_str().ok())
4023 else {
4024 return false;
4025 };
4026 value.split(',').any(|part| {
4027 let mut segments = part.split(';');
4028 let name = segments.next().unwrap_or("").trim();
4029 name.eq_ignore_ascii_case(coding)
4030 && segments
4031 .find_map(|q| q.trim().strip_prefix("q="))
4032 .and_then(|q| q.parse::<f32>().ok())
4033 .is_none_or(|q| q > 0.0)
4034 })
4035}
4036
4037#[cfg(any(feature = "flash", feature = "maud"))]
4046fn static_css_response(
4047 headers: &http::HeaderMap,
4048 body: &'static str,
4049 precompressed: &'static PrecompressedCss,
4050) -> axum::response::Response {
4051 use crate::etag::IntoETag as _;
4052 use axum::response::IntoResponse;
4053
4054 let (encoded_body, content_encoding): (axum::body::Body, Option<&'static str>) =
4055 if accepts_encoding(headers, "br") {
4056 (precompressed.brotli.clone().into(), Some("br"))
4057 } else if accepts_encoding(headers, "gzip") {
4058 (precompressed.gzip.clone().into(), Some("gzip"))
4059 } else {
4060 (body.into(), None)
4061 };
4062
4063 let mut response_headers = http::HeaderMap::new();
4064 response_headers.insert(
4065 http::header::CONTENT_TYPE,
4066 http::HeaderValue::from_static("text/css; charset=utf-8"),
4067 );
4068 response_headers.insert(
4069 http::header::CACHE_CONTROL,
4070 http::HeaderValue::from_static("public, max-age=31536000, immutable"),
4071 );
4072 response_headers.insert(
4075 http::header::VARY,
4076 http::HeaderValue::from_static("Accept-Encoding"),
4077 );
4078 if let Some(encoding) = content_encoding {
4079 response_headers.insert(
4080 http::header::CONTENT_ENCODING,
4081 http::HeaderValue::from_static(encoding),
4082 );
4083 }
4084
4085 let etag = crate::etag::ETag::weak(body.into_etag().tag().to_owned());
4091
4092 crate::etag::fresh_when(headers, etag)
4093 .or((response_headers, encoded_body))
4094 .into_response()
4095}
4096
4097#[cfg(feature = "flash")]
4100pub async fn flash_css_handler(headers: http::HeaderMap) -> axum::response::Response {
4101 static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
4102 static_css_response(
4103 &headers,
4104 crate::flash::FLASH_CSS,
4105 PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::flash::FLASH_CSS)),
4106 )
4107}
4108
4109#[cfg(feature = "maud")]
4112pub async fn widgets_css_handler(headers: http::HeaderMap) -> axum::response::Response {
4113 static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
4114 static_css_response(
4115 &headers,
4116 crate::ui::WIDGETS_CSS,
4117 PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::ui::WIDGETS_CSS)),
4118 )
4119}
4120
4121#[cfg(feature = "htmx")]
4122pub async fn htmx_csrf_handler() -> axum::response::Response {
4123 use axum::response::IntoResponse;
4124 (
4125 [
4126 (http::header::CONTENT_TYPE, "application/javascript"),
4127 (
4128 http::header::CACHE_CONTROL,
4129 "public, max-age=31536000, immutable",
4130 ),
4131 ],
4132 crate::htmx::HTMX_CSRF_JS,
4133 )
4134 .into_response()
4135}
4136
4137#[cfg(feature = "htmx")]
4138pub async fn autumn_widgets_handler() -> axum::response::Response {
4139 use axum::response::IntoResponse;
4140 (
4141 [
4142 (http::header::CONTENT_TYPE, "application/javascript"),
4143 (
4144 http::header::CACHE_CONTROL,
4145 "public, max-age=31536000, immutable",
4146 ),
4147 ],
4148 crate::htmx::AUTUMN_WIDGETS_JS,
4149 )
4150 .into_response()
4151}
4152
4153#[cfg(feature = "htmx")]
4168static IDIOMORPH_ETAG: std::sync::LazyLock<crate::etag::ETag> = std::sync::LazyLock::new(|| {
4169 use sha2::{Digest, Sha256};
4170 use std::fmt::Write as _;
4171
4172 let digest = Sha256::digest(crate::htmx::IDIOMORPH_JS);
4173 let mut hex = String::with_capacity(digest.len() * 2);
4174 for byte in digest {
4175 let _ = write!(hex, "{byte:02x}");
4176 }
4177 crate::etag::ETag::weak(format!("idiomorph-{hex}"))
4178});
4179
4180#[cfg(feature = "htmx")]
4190pub async fn idiomorph_handler() -> axum::response::Response {
4191 use axum::response::IntoResponse;
4192 let mut response = (
4193 [
4194 (http::header::CONTENT_TYPE, "application/javascript"),
4195 (
4196 http::header::CACHE_CONTROL,
4197 "public, max-age=0, must-revalidate",
4198 ),
4199 ],
4200 crate::htmx::IDIOMORPH_JS,
4201 )
4202 .into_response();
4203 response
4204 .headers_mut()
4205 .insert(http::header::ETAG, IDIOMORPH_ETAG.header_value());
4206 response
4207}
4208
4209#[cfg(feature = "htmx")]
4213pub async fn htmx_sse_handler() -> axum::response::Response {
4214 use axum::response::IntoResponse;
4215 (
4216 [
4217 (http::header::CONTENT_TYPE, "application/javascript"),
4218 (
4219 http::header::CACHE_CONTROL,
4220 "public, max-age=31536000, immutable",
4221 ),
4222 ],
4223 crate::htmx::HTMX_SSE_JS,
4224 )
4225 .into_response()
4226}
4227
4228#[cfg(feature = "openapi")]
4229fn collect_openapi_docs(
4230 route_list: &[Route],
4231 scoped_groups: &[ScopedGroup],
4232) -> Vec<crate::openapi::ApiDoc> {
4233 let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
4238 for route in route_list {
4239 let mut doc = route.api_doc.clone();
4240 doc.api_version = route.api_version;
4241 doc.sunset_opt_out = route.sunset_opt_out;
4242 docs.push(doc);
4243 }
4244 for group in scoped_groups {
4245 let prefix_params = extract_path_params(&group.prefix);
4249 for route in &group.routes {
4250 let mut doc = route.api_doc.clone();
4251 doc.api_version = route.api_version;
4252 doc.sunset_opt_out = route.sunset_opt_out;
4253 let full = join_nested_path(&group.prefix, route.api_doc.path);
4259 doc.path = Box::leak(full.into_boxed_str());
4260
4261 if !prefix_params.is_empty() {
4262 let mut merged: Vec<&'static str> = prefix_params
4263 .iter()
4264 .map(|p| &*Box::leak(p.clone().into_boxed_str()))
4265 .collect();
4266 for existing in route.api_doc.path_params {
4267 if !merged.iter().any(|n| n == existing) {
4268 merged.push(existing);
4269 }
4270 }
4271 doc.path_params = Box::leak(merged.into_boxed_slice());
4272 }
4273
4274 docs.push(doc);
4275 }
4276 }
4277 docs
4278}
4279
4280#[cfg(feature = "openapi")]
4281fn mount_swagger_ui_routes(
4282 mut router: axum::Router<AppState>,
4283 path: &str,
4284 title: &str,
4285 json_path: &str,
4286) -> axum::Router<AppState> {
4287 let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
4288 let html_body = Arc::new(crate::openapi::swagger_ui_html(
4289 title,
4290 &css_path,
4291 &bundle_path,
4292 &initializer_path,
4293 ));
4294 let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
4295 router = router.route(
4296 path,
4297 axum::routing::get(move || {
4298 let html = html_body.clone();
4299 async move {
4300 use axum::response::IntoResponse;
4301 (
4302 [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
4303 (*html).clone(),
4304 )
4305 .into_response()
4306 }
4307 }),
4308 );
4309 router = router.route(
4310 &css_path,
4311 axum::routing::get(|| async move {
4312 use axum::response::IntoResponse;
4313 (
4314 [(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
4315 crate::openapi::SWAGGER_UI_CSS,
4316 )
4317 .into_response()
4318 }),
4319 );
4320 router = router.route(
4321 &bundle_path,
4322 axum::routing::get(|| async move {
4323 use axum::body::Bytes;
4324 use axum::response::IntoResponse;
4325 (
4326 [(
4327 http::header::CONTENT_TYPE,
4328 "application/javascript; charset=utf-8",
4329 )],
4330 Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
4331 )
4332 .into_response()
4333 }),
4334 );
4335 router = router.route(
4336 &initializer_path,
4337 axum::routing::get(move || {
4338 let js = initializer_body.clone();
4339 async move {
4340 use axum::response::IntoResponse;
4341 (
4342 [(
4343 http::header::CONTENT_TYPE,
4344 "application/javascript; charset=utf-8",
4345 )],
4346 (*js).clone(),
4347 )
4348 .into_response()
4349 }
4350 }),
4351 );
4352 router
4353}
4354
4355async fn event_app_context_middleware(
4358 state: axum::extract::State<AppState>,
4359 req: axum::extract::Request,
4360 next: axum::middleware::Next,
4361) -> axum::response::Response {
4362 crate::events::scope_event_app(state.0.clone(), async move { next.run(req).await }).await
4363}
4364
4365#[cfg(feature = "oauth2")]
4366async fn http_interceptor_middleware(
4367 state: axum::extract::State<AppState>,
4368 req: axum::extract::Request,
4369 next: axum::middleware::Next,
4370) -> axum::response::Response {
4371 use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
4372 if let Some(interceptor_arc) = state.extension::<Arc<dyn HttpInterceptor>>() {
4373 let interceptor = (*interceptor_arc).clone();
4374 let interceptors = vec![interceptor];
4375 ACTIVE_HTTP_INTERCEPTORS
4376 .scope(interceptors, async move { next.run(req).await })
4377 .await
4378 } else {
4379 next.run(req).await
4380 }
4381}
4382
4383#[cfg(test)]
4384mod tests {
4385 use super::*;
4386 use axum::body::Body;
4387 use axum::http::{Request, StatusCode};
4388 use tower::ServiceExt;
4389
4390 fn test_state() -> AppState {
4391 AppState {
4392 extensions: std::sync::Arc::new(std::sync::RwLock::new(
4393 std::collections::HashMap::new(),
4394 )),
4395 #[cfg(feature = "db")]
4396 pool: None,
4397 #[cfg(feature = "db")]
4398 replica_pool: None,
4399 #[cfg(feature = "db")]
4400 shards: None,
4401 profile: Some("test".to_owned()),
4402 role: crate::config::ProcessRole::Combined,
4403 started_at: std::time::Instant::now(),
4404 health_detailed: false,
4405 probes: crate::probe::ProbeState::ready_for_test(),
4406 metrics: crate::middleware::MetricsCollector::new(),
4407 log_levels: crate::actuator::LogLevels::new("info"),
4408 task_registry: crate::actuator::TaskRegistry::new(),
4409 job_registry: crate::actuator::JobRegistry::new(),
4410 config_props: crate::actuator::ConfigProperties::default(),
4411 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
4412 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
4413 #[cfg(feature = "ws")]
4414 channels: crate::channels::Channels::new(32),
4415 #[cfg(feature = "presence")]
4416 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
4417 #[cfg(feature = "ws")]
4418 shutdown: tokio_util::sync::CancellationToken::new(),
4419 policy_registry: crate::authorization::PolicyRegistry::default(),
4420 forbidden_response: crate::authorization::ForbiddenResponse::default(),
4421 auth_session_key: "user_id".to_owned(),
4422 shared_cache: None,
4423 clock: std::sync::Arc::new(crate::time::SystemClock),
4424 app_id: crate::state::AppState::next_app_id(),
4425 }
4426 }
4427
4428 #[test]
4431 fn submit_token_explicit_memory_in_production_fails_router_build() {
4432 let mut config = AutumnConfig::default();
4435 config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
4436 let err = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
4437 .expect_err("explicit memory submit-token backend in prod must fail router build");
4438 assert!(
4439 matches!(err, RouterBuildError::InvalidSubmitTokenBackend(_)),
4440 "expected InvalidSubmitTokenBackend, got {err:?}"
4441 );
4442 }
4443
4444 #[test]
4445 fn submit_token_inherited_memory_in_production_builds() {
4446 let mut config = AutumnConfig::default();
4449 config.security.submit_token.backend = None;
4450 config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
4451 let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
4452 .expect("inherited memory submit-token backend in prod must still build (warn only)");
4453 }
4454
4455 #[test]
4456 fn submit_token_memory_outside_production_builds() {
4457 let mut config = AutumnConfig::default();
4459 config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
4460 let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, false)
4461 .expect("memory submit-token backend outside production must build");
4462 }
4463
4464 #[tokio::test]
4465 async fn build_router_mounts_actuator_at_configured_prefix() {
4466 let mut config = AutumnConfig::default();
4467 config.actuator.prefix = "/ops".to_owned();
4468 config.actuator.sensitive = true;
4469
4470 let app = build_router(Vec::new(), &config, test_state());
4471
4472 let prefixed = app
4473 .clone()
4474 .oneshot(
4475 Request::builder()
4476 .uri("/ops/health")
4477 .body(Body::empty())
4478 .unwrap(),
4479 )
4480 .await
4481 .unwrap();
4482 assert_eq!(prefixed.status(), StatusCode::OK);
4483
4484 let legacy = app
4485 .oneshot(
4486 Request::builder()
4487 .uri("/actuator/health")
4488 .body(Body::empty())
4489 .unwrap(),
4490 )
4491 .await
4492 .unwrap();
4493 assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
4494 }
4495
4496 #[tokio::test]
4499 async fn probe_only_router_mounts_probes_and_actuator_but_no_user_routes() {
4500 let config = AutumnConfig::default();
4501 let app = try_build_probe_only_router(&config, test_state())
4502 .expect("probe-only router should build");
4503
4504 for path in [
4506 config.health.live_path.as_str(),
4507 config.health.ready_path.as_str(),
4508 config.health.startup_path.as_str(),
4509 config.health.path.as_str(),
4510 "/actuator/health",
4511 "/actuator/info",
4512 ] {
4513 let response = app
4514 .clone()
4515 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
4516 .await
4517 .unwrap();
4518 assert_ne!(
4519 response.status(),
4520 StatusCode::NOT_FOUND,
4521 "probe-only router should serve {path}"
4522 );
4523 }
4524
4525 let missing = app
4527 .oneshot(
4528 Request::builder()
4529 .uri("/definitely-not-a-user-route")
4530 .body(Body::empty())
4531 .unwrap(),
4532 )
4533 .await
4534 .unwrap();
4535 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
4536 }
4537
4538 #[tokio::test]
4544 async fn user_route_at_health_path_overrides_builtin_probe() {
4545 async fn user_health() -> &'static str {
4546 "user-health-handler"
4547 }
4548
4549 let config = AutumnConfig::default();
4550 assert_eq!(config.health.path, "/health");
4552
4553 let route = Route {
4554 method: http::Method::GET,
4555 path: "/health",
4556 handler: axum::routing::get(user_health),
4557 name: "user_health",
4558 api_doc: crate::openapi::ApiDoc {
4559 method: "GET",
4560 path: "/health",
4561 operation_id: "user_health",
4562 success_status: 200,
4563 ..Default::default()
4564 },
4565 repository: None,
4566 idempotency: crate::route::RouteIdempotency::Direct,
4567 timeout: crate::route::RouteTimeout::Inherit,
4568 api_version: None,
4569 sunset_opt_out: false,
4570 };
4571
4572 let app = build_router(vec![route], &config, test_state());
4576
4577 let response = app
4579 .clone()
4580 .oneshot(
4581 Request::builder()
4582 .uri("/health")
4583 .body(Body::empty())
4584 .unwrap(),
4585 )
4586 .await
4587 .unwrap();
4588 assert_eq!(response.status(), StatusCode::OK);
4589 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4590 .await
4591 .unwrap();
4592 assert_eq!(
4593 &body[..],
4594 b"user-health-handler",
4595 "user route must win at the health path"
4596 );
4597
4598 for path in ["/live", "/ready", "/startup"] {
4600 let resp = app
4601 .clone()
4602 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
4603 .await
4604 .unwrap();
4605 assert_ne!(
4606 resp.status(),
4607 StatusCode::NOT_FOUND,
4608 "built-in probe {path} should still be mounted"
4609 );
4610 }
4611 }
4612
4613 #[cfg(feature = "maud")]
4618 #[tokio::test]
4619 async fn widgets_css_route_serves_the_shared_stylesheet() {
4620 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4621
4622 let response = app
4623 .oneshot(
4624 Request::builder()
4625 .uri(crate::ui::WIDGETS_CSS_PATH)
4626 .body(Body::empty())
4627 .unwrap(),
4628 )
4629 .await
4630 .unwrap();
4631
4632 assert_eq!(response.status(), StatusCode::OK);
4633 let content_type = response
4634 .headers()
4635 .get(http::header::CONTENT_TYPE)
4636 .unwrap()
4637 .to_str()
4638 .unwrap();
4639 assert!(content_type.contains("text/css"), "{content_type}");
4640
4641 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4642 .await
4643 .unwrap();
4644 let body = String::from_utf8(body.to_vec()).unwrap();
4645 assert!(body.contains(".autumn-field"), "{body}");
4646 assert!(body.contains(":root"), "{body}");
4647 }
4648
4649 #[cfg(feature = "maud")]
4655 #[tokio::test]
4656 async fn widgets_css_route_supports_conditional_get() {
4657 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4658
4659 let first = app
4660 .clone()
4661 .oneshot(
4662 Request::builder()
4663 .uri(crate::ui::WIDGETS_CSS_PATH)
4664 .body(Body::empty())
4665 .unwrap(),
4666 )
4667 .await
4668 .unwrap();
4669 assert_eq!(first.status(), StatusCode::OK);
4670 let etag = first
4671 .headers()
4672 .get(http::header::ETAG)
4673 .expect("widget stylesheet response should carry an ETag")
4674 .clone();
4675
4676 let revalidated = app
4677 .oneshot(
4678 Request::builder()
4679 .uri(crate::ui::WIDGETS_CSS_PATH)
4680 .header(http::header::IF_NONE_MATCH, etag)
4681 .body(Body::empty())
4682 .unwrap(),
4683 )
4684 .await
4685 .unwrap();
4686 assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
4687 let revalidated_body = axum::body::to_bytes(revalidated.into_body(), usize::MAX)
4688 .await
4689 .unwrap();
4690 assert!(revalidated_body.is_empty());
4691 }
4692
4693 #[cfg(feature = "maud")]
4698 #[tokio::test]
4699 async fn widgets_css_route_etag_is_weak_not_strong() {
4700 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4701
4702 let response = app
4703 .oneshot(
4704 Request::builder()
4705 .uri(crate::ui::WIDGETS_CSS_PATH)
4706 .body(Body::empty())
4707 .unwrap(),
4708 )
4709 .await
4710 .unwrap();
4711
4712 let etag = response
4713 .headers()
4714 .get(http::header::ETAG)
4715 .expect("widget stylesheet response should carry an ETag")
4716 .to_str()
4717 .unwrap()
4718 .to_owned();
4719 assert!(
4720 etag.starts_with("W/\""),
4721 "ETag must be weak since encoded variants aren't byte-identical: {etag}"
4722 );
4723 }
4724
4725 #[cfg(feature = "maud")]
4729 #[tokio::test]
4730 async fn widgets_css_route_serves_precompressed_brotli_when_accepted() {
4731 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4732
4733 let response = app
4734 .oneshot(
4735 Request::builder()
4736 .uri(crate::ui::WIDGETS_CSS_PATH)
4737 .header(http::header::ACCEPT_ENCODING, "br")
4738 .body(Body::empty())
4739 .unwrap(),
4740 )
4741 .await
4742 .unwrap();
4743
4744 assert_eq!(response.status(), StatusCode::OK);
4745 assert_eq!(
4746 response
4747 .headers()
4748 .get(http::header::CONTENT_ENCODING)
4749 .unwrap(),
4750 "br"
4751 );
4752 assert_eq!(
4753 response.headers().get(http::header::VARY).unwrap(),
4754 "Accept-Encoding"
4755 );
4756
4757 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4758 .await
4759 .unwrap();
4760 let mut decoded = Vec::new();
4761 brotli::BrotliDecompress(&mut std::io::Cursor::new(body.as_ref()), &mut decoded)
4762 .expect("response body must be valid brotli");
4763 assert_eq!(String::from_utf8(decoded).unwrap(), crate::ui::WIDGETS_CSS);
4764 }
4765
4766 #[cfg(feature = "maud")]
4768 #[tokio::test]
4769 async fn widgets_css_route_serves_precompressed_gzip_when_accepted() {
4770 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4771
4772 let response = app
4773 .oneshot(
4774 Request::builder()
4775 .uri(crate::ui::WIDGETS_CSS_PATH)
4776 .header(http::header::ACCEPT_ENCODING, "gzip")
4777 .body(Body::empty())
4778 .unwrap(),
4779 )
4780 .await
4781 .unwrap();
4782
4783 assert_eq!(response.status(), StatusCode::OK);
4784 assert_eq!(
4785 response
4786 .headers()
4787 .get(http::header::CONTENT_ENCODING)
4788 .unwrap(),
4789 "gzip"
4790 );
4791
4792 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4793 .await
4794 .unwrap();
4795 let mut gz = flate2::read::GzDecoder::new(body.as_ref());
4796 let mut output = String::new();
4797 std::io::Read::read_to_string(&mut gz, &mut output)
4798 .expect("response body must be valid gzip");
4799 assert_eq!(output, crate::ui::WIDGETS_CSS);
4800 }
4801
4802 #[cfg(feature = "maud")]
4806 #[tokio::test]
4807 async fn widgets_css_route_honors_q_zero_opt_out() {
4808 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4809
4810 let response = app
4811 .oneshot(
4812 Request::builder()
4813 .uri(crate::ui::WIDGETS_CSS_PATH)
4814 .header(http::header::ACCEPT_ENCODING, "br;q=0, gzip")
4815 .body(Body::empty())
4816 .unwrap(),
4817 )
4818 .await
4819 .unwrap();
4820
4821 assert_eq!(response.status(), StatusCode::OK);
4822 assert_eq!(
4823 response
4824 .headers()
4825 .get(http::header::CONTENT_ENCODING)
4826 .unwrap(),
4827 "gzip"
4828 );
4829 }
4830
4831 #[cfg(feature = "maud")]
4835 #[tokio::test]
4836 async fn widgets_css_route_serves_identity_with_no_accept_encoding() {
4837 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4838
4839 let response = app
4840 .oneshot(
4841 Request::builder()
4842 .uri(crate::ui::WIDGETS_CSS_PATH)
4843 .body(Body::empty())
4844 .unwrap(),
4845 )
4846 .await
4847 .unwrap();
4848
4849 assert_eq!(response.status(), StatusCode::OK);
4850 assert!(
4851 !response
4852 .headers()
4853 .contains_key(http::header::CONTENT_ENCODING)
4854 );
4855 }
4856
4857 #[test]
4862 fn startup_barrier_503s_are_access_logged() {
4863 use tracing_subscriber::layer::SubscriberExt as _;
4864
4865 #[derive(Clone, Default)]
4866 struct Capture {
4867 events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
4868 }
4869 struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
4870 impl tracing::field::Visit for Visitor<'_> {
4871 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
4872 self.0.insert(field.name().to_owned(), format!("{value:?}"));
4873 }
4874 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
4875 self.0.insert(field.name().to_owned(), value.to_string());
4876 }
4877 }
4878 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
4879 fn on_event(
4880 &self,
4881 event: &tracing::Event<'_>,
4882 _ctx: tracing_subscriber::layer::Context<'_, S>,
4883 ) {
4884 if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
4885 return;
4886 }
4887 let mut fields = std::collections::BTreeMap::new();
4888 event.record(&mut Visitor(&mut fields));
4889 self.events.lock().unwrap().push(fields);
4890 }
4891 }
4892
4893 let capture = Capture::default();
4894 let events = Arc::clone(&capture.events);
4895 let subscriber = tracing_subscriber::registry().with(capture);
4896
4897 tracing::subscriber::with_default(subscriber, || {
4898 let state = AppState::for_test()
4901 .with_profile("test")
4902 .with_startup_complete(false);
4903 let app = build_router(Vec::new(), &AutumnConfig::default(), state);
4904 let rt = tokio::runtime::Builder::new_current_thread()
4905 .enable_all()
4906 .build()
4907 .unwrap();
4908
4909 let mut response = None;
4920 for attempt in 1..=5 {
4921 tracing::callsite::rebuild_interest_cache();
4922 let resp = rt.block_on(async {
4923 app.clone()
4924 .oneshot(
4925 Request::builder()
4926 .uri("/not-a-probe")
4927 .body(Body::empty())
4928 .unwrap(),
4929 )
4930 .await
4931 .unwrap()
4932 });
4933 let captured = !events.lock().unwrap().is_empty();
4934 response = Some(resp);
4935 if captured {
4936 break;
4937 }
4938 assert!(
4939 attempt < 5,
4940 "access-log event was not captured after {attempt} attempts \
4941 (tracing interest-cache race with a concurrent test)"
4942 );
4943 }
4944 assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
4945 });
4946
4947 let events = events.lock().unwrap().clone();
4948 assert_eq!(
4949 events.len(),
4950 1,
4951 "a barrier-rejected request should emit one access event: {events:?}"
4952 );
4953 assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
4954 assert!(
4955 !events[0].contains_key("request_id"),
4956 "barrier short-circuits before RequestIdLayer, so no request id"
4957 );
4958 }
4959
4960 #[tokio::test]
4966 async fn startup_barrier_503s_carry_server_timing_header() {
4967 let state = AppState::for_test()
4970 .with_profile("test")
4971 .with_startup_complete(false);
4972 let mut config = AutumnConfig::default();
4973 config.observability.server_timing = Some(true);
4974
4975 let app = build_router(Vec::new(), &config, state);
4976 let response = app
4977 .oneshot(
4978 Request::builder()
4979 .uri("/not-a-probe")
4980 .body(Body::empty())
4981 .unwrap(),
4982 )
4983 .await
4984 .unwrap();
4985
4986 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4987 let header = response
4988 .headers()
4989 .get("server-timing")
4990 .expect("startup 503 short-circuit should still carry Server-Timing via the fallback")
4991 .to_str()
4992 .expect("server-timing header should be valid ASCII");
4993 assert!(
4994 header.starts_with("total;dur="),
4995 "fallback should emit a `total` metric, got {header:?}"
4996 );
4997 assert_eq!(
5000 header.matches("total;dur=").count(),
5001 1,
5002 "short-circuit response must carry a single total metric: {header:?}"
5003 );
5004 }
5005
5006 #[test]
5007 fn try_build_router_rejects_invalid_session_backend_config() {
5008 let mut config = AutumnConfig::default();
5009 config.session.backend = crate::session::SessionBackend::Redis;
5010
5011 let error = try_build_router(Vec::new(), &config, test_state())
5012 .expect_err("missing redis config should fail checked router build");
5013
5014 assert!(matches!(
5015 error,
5016 RouterBuildError::InvalidSessionBackend(
5017 crate::session::SessionBackendConfigError::MissingRedisUrl
5018 )
5019 ));
5020 }
5021
5022 #[test]
5023 fn try_build_router_with_static_rejects_invalid_session_backend_config() {
5024 let mut config = AutumnConfig::default();
5025 config.session.backend = crate::session::SessionBackend::Redis;
5026
5027 let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
5028 .expect_err("missing redis config should fail checked static router build");
5029
5030 assert!(matches!(
5031 error,
5032 RouterBuildError::InvalidSessionBackend(
5033 crate::session::SessionBackendConfigError::MissingRedisUrl
5034 )
5035 ));
5036 }
5037
5038 #[test]
5039 fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
5040 let mut config = AutumnConfig::default();
5041 config.actuator.prefix = "/".to_owned();
5042
5043 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5044 try_build_router(Vec::new(), &config, test_state())
5045 }));
5046
5047 assert!(result.is_ok(), "try_build_router panicked on route overlap");
5048 assert!(
5049 result.unwrap().is_err(),
5050 "route overlap should be reported as a checked router build error"
5051 );
5052 }
5053
5054 #[test]
5062 fn probe_actuator_overlap_detected_when_user_route_owns_probe_path() {
5063 async fn user_health() -> &'static str {
5064 "user-health-handler"
5065 }
5066
5067 let mut config = AutumnConfig::default();
5068 config.actuator.prefix = "/".to_owned();
5069 assert_eq!(config.health.path, "/health");
5072
5073 let route = Route {
5074 method: http::Method::GET,
5075 path: "/health",
5076 handler: axum::routing::get(user_health),
5077 name: "user_health",
5078 api_doc: crate::openapi::ApiDoc {
5079 method: "GET",
5080 path: "/health",
5081 operation_id: "user_health",
5082 success_status: 200,
5083 ..Default::default()
5084 },
5085 repository: None,
5086 idempotency: crate::route::RouteIdempotency::Direct,
5087 timeout: crate::route::RouteTimeout::Inherit,
5088 api_version: None,
5089 sunset_opt_out: false,
5090 };
5091
5092 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5093 try_build_router(vec![route], &config, test_state())
5094 }));
5095
5096 assert!(
5097 result.is_ok(),
5098 "try_build_router panicked instead of returning a checked overlap error"
5099 );
5100 let build = result.unwrap();
5101 assert!(
5102 matches!(
5103 &build,
5104 Err(RouterBuildError::FrameworkRouteOverlap {
5105 path,
5106 incoming: "actuator endpoint",
5107 ..
5108 }) if path == "/health"
5109 ),
5110 "root-prefix actuator over a user-owned probe path must yield a checked \
5111 FrameworkRouteOverlap for /health, got: {:?}",
5112 build.as_ref().map(|_| "Ok(router)"),
5113 );
5114 }
5115
5116 #[tokio::test]
5117 async fn apply_cors_middleware_skipped_when_no_origins() {
5118 let config = AutumnConfig::default();
5119 assert!(config.cors.allowed_origins.is_empty());
5120
5121 let base: axum::Router<AppState> =
5122 axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
5123 let router = apply_cors_middleware(base, &config).with_state(test_state());
5124
5125 let response = router
5126 .oneshot(
5127 Request::builder()
5128 .uri("/test")
5129 .header("Origin", "https://example.com")
5130 .body(Body::empty())
5131 .unwrap(),
5132 )
5133 .await
5134 .unwrap();
5135
5136 assert_eq!(response.status(), StatusCode::OK);
5137 assert!(
5138 response
5139 .headers()
5140 .get("access-control-allow-origin")
5141 .is_none(),
5142 "CORS header must be absent when no origins are configured"
5143 );
5144 }
5145
5146 #[tokio::test]
5147 async fn apply_cors_middleware_present_when_origins_configured() {
5148 let mut config = AutumnConfig::default();
5149 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
5150
5151 let base: axum::Router<AppState> =
5152 axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
5153 let router = apply_cors_middleware(base, &config).with_state(test_state());
5154
5155 let response = router
5156 .oneshot(
5157 Request::builder()
5158 .uri("/test")
5159 .header("Origin", "https://example.com")
5160 .body(Body::empty())
5161 .unwrap(),
5162 )
5163 .await
5164 .unwrap();
5165
5166 assert_eq!(response.status(), StatusCode::OK);
5167 assert!(
5168 response
5169 .headers()
5170 .get("access-control-allow-origin")
5171 .is_some(),
5172 "CORS header must be present when origins are configured"
5173 );
5174 }
5175
5176 #[tokio::test]
5177 async fn apply_cors_middleware_handles_preflight_request() {
5178 let mut config = AutumnConfig::default();
5179 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
5180
5181 let base: axum::Router<AppState> =
5182 axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
5183 let router = apply_cors_middleware(base, &config).with_state(test_state());
5184
5185 let response = router
5186 .oneshot(
5187 Request::builder()
5188 .method("OPTIONS")
5189 .uri("/api/widgets")
5190 .header("Origin", "https://example.com")
5191 .header("Access-Control-Request-Method", "POST")
5192 .header("Access-Control-Request-Headers", "Content-Type")
5193 .body(Body::empty())
5194 .unwrap(),
5195 )
5196 .await
5197 .unwrap();
5198
5199 let headers = response.headers();
5200 assert_eq!(
5201 headers
5202 .get("access-control-allow-origin")
5203 .and_then(|v| v.to_str().ok()),
5204 Some("https://example.com"),
5205 "preflight must echo the allowed origin"
5206 );
5207 assert!(
5208 headers.get("access-control-allow-methods").is_some(),
5209 "preflight must advertise allowed methods"
5210 );
5211 assert!(
5212 headers.get("access-control-allow-headers").is_some(),
5213 "preflight must advertise allowed headers"
5214 );
5215 assert!(
5216 headers.get("access-control-max-age").is_some(),
5217 "preflight must advertise max-age so browsers can cache it"
5218 );
5219 }
5220
5221 #[tokio::test]
5222 async fn apply_csrf_middleware_skipped_when_disabled() {
5223 let config = AutumnConfig::default();
5224 assert!(!config.security.csrf.enabled);
5225
5226 let base: axum::Router<AppState> =
5227 axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
5228 let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
5229
5230 let response = router
5232 .oneshot(
5233 Request::builder()
5234 .method("POST")
5235 .uri("/form")
5236 .body(Body::empty())
5237 .unwrap(),
5238 )
5239 .await
5240 .unwrap();
5241
5242 assert_eq!(response.status(), StatusCode::OK);
5243 }
5244
5245 #[tokio::test]
5246 async fn apply_rate_limit_middleware_skipped_when_disabled() {
5247 let config = AutumnConfig::default();
5248 assert!(!config.security.rate_limit.enabled);
5249
5250 let base: axum::Router<AppState> =
5251 axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
5252 let state = test_state();
5253 let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
5254
5255 for _ in 0..5 {
5257 let response = router
5258 .clone()
5259 .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
5260 .await
5261 .unwrap();
5262 assert_eq!(response.status(), StatusCode::OK);
5263 }
5264 }
5265
5266 #[tokio::test]
5267 async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
5268 let mut config = AutumnConfig::default();
5269 config.security.rate_limit.enabled = true;
5270 config.security.rate_limit.requests_per_second = 0.1;
5271 config.security.rate_limit.burst = 1;
5272 config.security.rate_limit.trust_forwarded_headers = true;
5273
5274 let base: axum::Router<AppState> =
5275 axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
5276 let state = test_state();
5277 let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
5278
5279 let ok = router
5280 .clone()
5281 .oneshot(
5282 Request::builder()
5283 .uri("/ping")
5284 .header("X-Forwarded-For", "203.0.113.9")
5285 .body(Body::empty())
5286 .unwrap(),
5287 )
5288 .await
5289 .unwrap();
5290 assert_eq!(ok.status(), StatusCode::OK);
5291
5292 let blocked = router
5293 .oneshot(
5294 Request::builder()
5295 .uri("/ping")
5296 .header("X-Forwarded-For", "203.0.113.9")
5297 .body(Body::empty())
5298 .unwrap(),
5299 )
5300 .await
5301 .unwrap();
5302 assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
5303 assert!(blocked.headers().get("retry-after").is_some());
5304 }
5305
5306 #[cfg(feature = "mcp")]
5307 #[tokio::test]
5308 async fn mcp_envelope_is_gated_during_maintenance() {
5309 use crate::maintenance::{MaintenanceConfig, MaintenanceState};
5310
5311 let mut config = AutumnConfig::default();
5314 config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
5315
5316 let wiring = crate::mcp::McpWiring {
5317 cors: crate::config::CorsConfig::default(),
5318 trusted_hosts: TrustedHostPolicy::from_config(&config),
5319 tenant_header: None,
5320 csrf_header: "x-csrf-token".to_owned(),
5321 envelope_rate_limited: false,
5322 envelope_load_shed: false,
5323 };
5324 let mcp_router =
5325 crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
5326
5327 let initialize = || {
5328 Request::builder()
5329 .method("POST")
5330 .uri("/mcp")
5331 .header("host", "app.example")
5332 .header("content-type", "application/json")
5333 .body(Body::from(
5334 serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
5335 ))
5336 .unwrap()
5337 };
5338
5339 let state = test_state();
5342 let maintenance = MaintenanceState::new();
5343 maintenance.enable(MaintenanceConfig::default());
5344 state.insert_extension(maintenance);
5345 let gated = mcp_router
5346 .clone()
5347 .layer(build_maintenance_layer(&config, &state))
5348 .with_state(state);
5349 let resp = gated.oneshot(initialize()).await.unwrap();
5350 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
5351
5352 let state = test_state();
5355 let open = mcp_router
5356 .layer(build_maintenance_layer(&config, &state))
5357 .with_state(state);
5358 let resp = open.oneshot(initialize()).await.unwrap();
5359 assert_eq!(resp.status(), StatusCode::OK);
5360 }
5361
5362 #[cfg(feature = "mail")]
5363 fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
5364 let mut config = AutumnConfig {
5365 profile: Some("dev".to_owned()),
5366 mail: crate::mail::MailConfig {
5367 transport: crate::mail::Transport::File,
5368 file_dir: dir.to_path_buf(),
5369 ..Default::default()
5370 },
5371 ..Default::default()
5372 };
5373 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5374 config
5375 }
5376
5377 #[cfg(any(feature = "mail", feature = "maud"))]
5378 async fn response_text(response: axum::response::Response) -> String {
5379 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5380 .await
5381 .expect("body should collect");
5382 String::from_utf8(body.to_vec()).expect("body should be utf8")
5383 }
5384
5385 #[cfg(feature = "mail")]
5386 #[tokio::test]
5387 async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
5388 let dir = tempfile::tempdir().expect("tempdir");
5389 let config = dev_mail_preview_config(dir.path());
5390 let router = build_router(Vec::new(), &config, test_state());
5391
5392 let response = router
5393 .oneshot(
5394 Request::builder()
5395 .uri("/_autumn/mail")
5396 .header("host", "example.com")
5397 .body(Body::empty())
5398 .unwrap(),
5399 )
5400 .await
5401 .unwrap();
5402
5403 assert_eq!(response.status(), StatusCode::OK);
5404 let body = response_text(response).await;
5405 assert!(
5406 body.contains("No captured emails"),
5407 "missing empty state: {body}"
5408 );
5409 assert!(
5410 body.contains("mail.transport = "file""),
5411 "empty state should explain capture setup: {body}"
5412 );
5413 }
5414
5415 #[cfg(feature = "mail")]
5416 #[tokio::test]
5417 async fn build_router_lists_captured_mail_newest_first() {
5418 let dir = tempfile::tempdir().expect("tempdir");
5419 let older = dir.path().join("older.eml");
5420 let newer = dir.path().join("newer.eml");
5421 std::fs::write(
5422 &older,
5423 "To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
5424 )
5425 .expect("write older eml");
5426 std::fs::write(
5427 &newer,
5428 "To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
5429 )
5430 .expect("write newer eml");
5431 filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
5432 .expect("set older mtime");
5433 filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
5434 .expect("set newer mtime");
5435
5436 let config = dev_mail_preview_config(dir.path());
5437 let router = build_router(Vec::new(), &config, test_state());
5438 let response = router
5439 .oneshot(
5440 Request::builder()
5441 .uri("/_autumn/mail")
5442 .header("host", "example.com")
5443 .body(Body::empty())
5444 .unwrap(),
5445 )
5446 .await
5447 .unwrap();
5448
5449 assert_eq!(response.status(), StatusCode::OK);
5450 let body = response_text(response).await;
5451 let second = body.find("Second").expect("newer subject should render");
5452 let first = body.find("First").expect("older subject should render");
5453 assert!(second < first, "newest message should render first: {body}");
5454 assert!(
5455 body.contains("second@example.com"),
5456 "missing To column: {body}"
5457 );
5458 assert!(
5459 body.contains("Timestamp"),
5460 "missing timestamp column: {body}"
5461 );
5462 }
5463
5464 #[cfg(feature = "mail")]
5465 #[tokio::test]
5466 async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
5467 let dir = tempfile::tempdir().expect("tempdir");
5468 std::fs::write(
5469 dir.path().join("detail.eml"),
5470 "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",
5471 )
5472 .expect("write detail eml");
5473
5474 let config = dev_mail_preview_config(dir.path());
5475 let router = build_router(Vec::new(), &config, test_state());
5476 let response = router
5477 .oneshot(
5478 Request::builder()
5479 .uri("/_autumn/mail/messages/detail.eml")
5480 .header("host", "example.com")
5481 .body(Body::empty())
5482 .unwrap(),
5483 )
5484 .await
5485 .unwrap();
5486
5487 assert_eq!(response.status(), StatusCode::OK);
5488 let body = response_text(response).await;
5489 assert!(body.contains("<iframe"), "missing iframe: {body}");
5490 assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
5491 assert!(body.contains("Hello iframe"), "missing html body: {body}");
5492 assert!(body.contains("Plain text"), "missing text toggle: {body}");
5493 assert!(body.contains("Headers"), "missing headers toggle: {body}");
5494 assert!(
5495 body.contains("Raw .eml"),
5496 "missing raw source toggle: {body}"
5497 );
5498 assert!(
5499 body.contains("Message-Id"),
5500 "missing message id header: {body}"
5501 );
5502 }
5503
5504 #[cfg(feature = "mail")]
5505 #[tokio::test]
5506 async fn build_router_does_not_mount_mail_preview_outside_dev() {
5507 let dir = tempfile::tempdir().expect("tempdir");
5508 let mut config = dev_mail_preview_config(dir.path());
5509 config.profile = Some("prod".to_owned());
5510 let router = build_router(Vec::new(), &config, test_state());
5511
5512 let response = router
5513 .oneshot(
5514 Request::builder()
5515 .uri("/_autumn/mail")
5516 .header("host", "example.com")
5517 .body(Body::empty())
5518 .unwrap(),
5519 )
5520 .await
5521 .unwrap();
5522
5523 assert_eq!(response.status(), StatusCode::NOT_FOUND);
5524 }
5525
5526 #[cfg(feature = "maud")]
5534 fn story_gallery_config() -> AutumnConfig {
5535 let mut config = AutumnConfig::default();
5536 config.stories.enabled = true;
5537 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5538 config
5539 }
5540
5541 #[cfg(feature = "maud")]
5542 fn stories_state_with_builtin() -> AppState {
5543 let state = test_state();
5544 state.insert_extension(crate::stories::builtin());
5545 state
5546 }
5547
5548 #[cfg(feature = "maud")]
5549 async fn get_with_host(router: axum::Router, uri: &str) -> axum::response::Response {
5550 router
5551 .oneshot(
5552 Request::builder()
5553 .uri(uri)
5554 .header("host", "example.com")
5555 .body(Body::empty())
5556 .unwrap(),
5557 )
5558 .await
5559 .unwrap()
5560 }
5561
5562 #[cfg(feature = "maud")]
5566 #[tokio::test]
5567 async fn build_router_mounts_story_gallery_when_enabled() {
5568 let router = build_router(
5569 Vec::new(),
5570 &story_gallery_config(),
5571 stories_state_with_builtin(),
5572 );
5573
5574 let response = get_with_host(router, crate::stories::STORIES_PATH).await;
5575 assert_eq!(response.status(), StatusCode::OK);
5576 let body = response_text(response).await;
5577 assert!(
5578 body.contains("Data table"),
5579 "index should list builtin story names: {body}"
5580 );
5581 assert!(
5582 body.contains("autumn-widgets.css"),
5583 "index should link the framework widget stylesheet: {body}"
5584 );
5585 }
5586
5587 #[cfg(feature = "maud")]
5590 #[tokio::test]
5591 async fn story_detail_route_serves_render_source_and_html() {
5592 let router = build_router(
5593 Vec::new(),
5594 &story_gallery_config(),
5595 stories_state_with_builtin(),
5596 );
5597
5598 let response = get_with_host(router, "/_stories/data-table").await;
5599 assert_eq!(response.status(), StatusCode::OK);
5600 let body = response_text(response).await;
5601 assert!(
5602 body.contains("<table"),
5603 "detail page must contain the live data_table render: {body}"
5604 );
5605 assert!(
5606 body.contains("data_table("),
5607 "detail page must show the source snippet that produced the render: {body}"
5608 );
5609 assert!(
5610 body.contains("Rendered HTML"),
5611 "detail page must offer the rendered-HTML tab: {body}"
5612 );
5613 assert!(
5614 body.contains("Source"),
5615 "detail page must offer the source tab: {body}"
5616 );
5617 }
5618
5619 #[cfg(feature = "maud")]
5621 #[tokio::test]
5622 async fn story_detail_unknown_slug_is_404() {
5623 let router = build_router(
5624 Vec::new(),
5625 &story_gallery_config(),
5626 stories_state_with_builtin(),
5627 );
5628
5629 let missing = get_with_host(router.clone(), "/_stories/nope").await;
5630 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
5631
5632 let index = get_with_host(router, "/_stories").await;
5633 assert_eq!(
5634 index.status(),
5635 StatusCode::OK,
5636 "index route must exist even when a slug misses"
5637 );
5638 }
5639
5640 #[cfg(feature = "maud")]
5643 #[tokio::test]
5644 async fn build_router_omits_story_gallery_by_default() {
5645 let mut config = AutumnConfig::default();
5646 assert!(
5647 !config.stories.enabled,
5648 "stories gallery must be off by default"
5649 );
5650 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5651
5652 let router = build_router(Vec::new(), &config, stories_state_with_builtin());
5653 let response = get_with_host(router, "/_stories").await;
5654 assert_eq!(response.status(), StatusCode::NOT_FOUND);
5655 }
5656
5657 #[cfg(feature = "maud")]
5660 async fn stories_status_for_layered_profile(toml: &str, profile: &str) -> StatusCode {
5661 let dir = tempfile::tempdir().expect("tempdir");
5662 std::fs::write(dir.path().join("autumn.toml"), toml).expect("write autumn.toml");
5663 let env = crate::config::MockEnv::new()
5664 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
5665 .with("AUTUMN_ENV", profile);
5666 let mut config = AutumnConfig::load_with_env(&env).expect("layered config should load");
5667 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5668
5669 let router = build_router(Vec::new(), &config, stories_state_with_builtin());
5670 get_with_host(router, "/_stories").await.status()
5671 }
5672
5673 #[cfg(feature = "maud")]
5676 #[tokio::test]
5677 async fn story_routes_mount_iff_resolved_profile_flag() {
5678 let dev_only = r"
5680[stories]
5681enabled = false
5682
5683[profile.dev.stories]
5684enabled = true
5685";
5686 assert_eq!(
5687 stories_status_for_layered_profile(dev_only, "dev").await,
5688 StatusCode::OK,
5689 "dev profile override must mount the gallery"
5690 );
5691 assert_eq!(
5692 stories_status_for_layered_profile(dev_only, "prod").await,
5693 StatusCode::NOT_FOUND,
5694 "prod must not mount the gallery when only dev enables it"
5695 );
5696
5697 let public_showcase = r"
5699[stories]
5700enabled = false
5701
5702[profile.prod.stories]
5703enabled = true
5704";
5705 assert_eq!(
5706 stories_status_for_layered_profile(public_showcase, "prod").await,
5707 StatusCode::OK,
5708 "prod profile override must mount the gallery for a public showcase"
5709 );
5710 assert_eq!(
5711 stories_status_for_layered_profile(public_showcase, "dev").await,
5712 StatusCode::NOT_FOUND,
5713 "dev must not mount the gallery when only prod enables it"
5714 );
5715 }
5716
5717 #[cfg(feature = "maud")]
5720 #[tokio::test]
5721 async fn custom_story_served_alongside_builtins() {
5722 let custom = crate::stories::story! {
5723 "App",
5724 "Greeting",
5725 {
5726 maud::html! { span class="app-greeting" { "hi from the app" } }
5727 }
5728 };
5729 let state = test_state();
5730 state.insert_extension(
5731 crate::stories::StoryGallery::builtin()
5732 .extend([custom])
5733 .into_registry(),
5734 );
5735
5736 let router = build_router(Vec::new(), &story_gallery_config(), state);
5737
5738 let detail = get_with_host(router.clone(), "/_stories/greeting").await;
5739 assert_eq!(detail.status(), StatusCode::OK);
5740 let body = response_text(detail).await;
5741 assert!(
5742 body.contains("hi from the app"),
5743 "custom story must render at its slug: {body}"
5744 );
5745
5746 let index = get_with_host(router, "/_stories").await;
5747 let body = response_text(index).await;
5748 assert!(
5749 body.contains("Greeting"),
5750 "index must list the custom story: {body}"
5751 );
5752 assert!(
5753 body.contains("App"),
5754 "index must show the custom story's group: {body}"
5755 );
5756 assert!(
5757 body.contains("Data table"),
5758 "builtins must still be listed alongside the custom story: {body}"
5759 );
5760 }
5761
5762 #[cfg(feature = "maud")]
5768 #[tokio::test]
5769 async fn story_pages_inline_style_carries_csp_header_nonce() {
5770 let mut config = story_gallery_config();
5771 config.security.headers.csp_nonce.enabled = true;
5772
5773 let router = build_router(Vec::new(), &config, stories_state_with_builtin());
5774
5775 for uri in ["/_stories", "/_stories/data-table"] {
5776 let response = get_with_host(router.clone(), uri).await;
5777 assert_eq!(response.status(), StatusCode::OK);
5778
5779 let csp = response
5780 .headers()
5781 .get("content-security-policy")
5782 .expect("CSP header must be present")
5783 .to_str()
5784 .unwrap()
5785 .to_owned();
5786 let nonce = csp
5787 .split("'nonce-")
5788 .nth(1)
5789 .and_then(|rest| rest.split('\'').next())
5790 .unwrap_or_else(|| panic!("CSP header must advertise a nonce: {csp}"))
5791 .to_owned();
5792 assert!(!nonce.is_empty(), "advertised nonce must be non-empty");
5793
5794 let body = response_text(response).await;
5795 assert!(
5796 body.contains(&format!(r#"<style nonce="{nonce}">"#)),
5797 "{uri} inline style must carry the CSP header nonce {nonce}: {body}"
5798 );
5799 }
5800 }
5801
5802 #[cfg(feature = "maud")]
5805 #[tokio::test]
5806 async fn enabled_without_registry_shows_empty_state() {
5807 let router = build_router(Vec::new(), &story_gallery_config(), test_state());
5808
5809 let response = get_with_host(router, "/_stories").await;
5810 assert_eq!(response.status(), StatusCode::OK);
5811 let body = response_text(response).await;
5812 assert!(
5813 body.contains("with_story_gallery"),
5814 "empty state should point at AppBuilder::with_story_gallery: {body}"
5815 );
5816 }
5817
5818 #[tokio::test]
5819 async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
5820 let mut config = AutumnConfig::default();
5821 config.security.csrf.enabled = true;
5822
5823 let base: axum::Router<AppState> =
5824 axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
5825 let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
5826
5827 let response = router
5829 .oneshot(
5830 Request::builder()
5831 .method("POST")
5832 .uri("/form")
5833 .body(Body::empty())
5834 .unwrap(),
5835 )
5836 .await
5837 .unwrap();
5838
5839 assert_ne!(
5840 response.status(),
5841 StatusCode::OK,
5842 "POST without CSRF token should be rejected when CSRF is enabled"
5843 );
5844 }
5845
5846 #[test]
5847 fn join_nested_path_normalizes_like_axum() {
5848 assert_eq!(super::join_nested_path("/api", "/"), "/api");
5853 assert_eq!(super::join_nested_path("/api/", "/"), "/api/");
5859 assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
5861 assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
5864 assert_eq!(super::join_nested_path("", "/"), "/");
5866 assert_eq!(super::join_nested_path("", "/users"), "/users");
5867 }
5868
5869 #[tokio::test]
5875 async fn join_nested_path_matches_axum_matched_path() {
5876 use axum::routing::get;
5877 async fn matched(mp: Option<axum::extract::MatchedPath>) -> String {
5878 mp.map(|m| m.as_str().to_owned()).unwrap_or_default()
5879 }
5880 for (prefix, child, req) in [
5882 ("/api", "/", "/api"),
5883 ("/api/", "/", "/api/"),
5884 ("/api", "/users", "/api/users"),
5885 ("/api/", "/users", "/api/users"),
5886 ] {
5887 let sub = axum::Router::new().route(child, get(matched));
5888 let app: axum::Router = axum::Router::new().nest(prefix, sub);
5889 let resp = tower::ServiceExt::oneshot(
5890 app,
5891 axum::http::Request::builder()
5892 .uri(req)
5893 .body(axum::body::Body::empty())
5894 .unwrap(),
5895 )
5896 .await
5897 .unwrap();
5898 assert_eq!(resp.status(), http::StatusCode::OK, "{prefix} + {child}");
5899 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5900 .await
5901 .unwrap();
5902 let axum_matched = String::from_utf8(body.to_vec()).unwrap();
5903 assert_eq!(
5904 super::join_nested_path(prefix, child),
5905 axum_matched,
5906 "join_nested_path must equal axum MatchedPath for nest({prefix:?}, {child:?})"
5907 );
5908 }
5909 }
5910
5911 #[cfg(feature = "openapi")]
5912 #[tokio::test]
5913 async fn try_build_router_detects_scoped_root_collision() {
5914 use crate::openapi::{ApiDoc, OpenApiConfig};
5918 async fn child() -> &'static str {
5919 "inner"
5920 }
5921 let group = crate::app::ScopedGroup {
5922 prefix: "/api".to_owned(),
5923 routes: vec![Route {
5924 method: http::Method::GET,
5925 path: "/",
5926 handler: axum::routing::get(child),
5927 name: "root",
5928 api_doc: ApiDoc {
5929 method: "GET",
5930 path: "/",
5931 operation_id: "root",
5932 success_status: 200,
5933 ..Default::default()
5934 },
5935 repository: None,
5936 idempotency: crate::route::RouteIdempotency::Direct,
5937 timeout: crate::route::RouteTimeout::Inherit,
5938 api_version: None,
5939 sunset_opt_out: false,
5940 }],
5941 source: crate::route_listing::RouteSource::User,
5942 apply_layer: Box::new(|r| r),
5943 };
5944
5945 let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
5946 let config = AutumnConfig::default();
5947 let ctx = RouterContext {
5948 exception_filters: Vec::new(),
5949 scoped_groups: vec![group],
5950 merge_routers: Vec::new(),
5951 nest_routers: Vec::new(),
5952 custom_layers: Vec::new(),
5953 static_gate_layers: Vec::new(),
5954 #[cfg(feature = "maud")]
5955 error_page_renderer: None,
5956 session_store: None,
5957 openapi: Some(openapi),
5958 #[cfg(feature = "mcp")]
5959 mcp: None,
5960 };
5961 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
5962 .expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
5963 assert!(matches!(
5964 err,
5965 RouterBuildError::OpenApiPathCollision {
5966 field: "openapi_json_path",
5967 ..
5968 }
5969 ));
5970 }
5971
5972 #[cfg(all(feature = "openapi", feature = "maud"))]
5977 #[test]
5978 fn try_build_router_detects_widgets_css_path_collision() {
5979 use crate::openapi::OpenApiConfig;
5980
5981 let openapi =
5982 OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::ui::WIDGETS_CSS_PATH);
5983 let config = AutumnConfig::default();
5984 let ctx = RouterContext {
5985 exception_filters: Vec::new(),
5986 scoped_groups: Vec::new(),
5987 merge_routers: Vec::new(),
5988 nest_routers: Vec::new(),
5989 custom_layers: Vec::new(),
5990 static_gate_layers: Vec::new(),
5991 #[cfg(feature = "maud")]
5992 error_page_renderer: None,
5993 session_store: None,
5994 openapi: Some(openapi),
5995 #[cfg(feature = "mcp")]
5996 mcp: None,
5997 };
5998 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
5999 "openapi_json_path colliding with the widget stylesheet route should be rejected",
6000 );
6001 assert!(matches!(
6002 err,
6003 RouterBuildError::OpenApiPathCollision {
6004 field: "openapi_json_path",
6005 ..
6006 }
6007 ));
6008 }
6009
6010 #[cfg(all(feature = "openapi", feature = "flash"))]
6014 #[test]
6015 fn try_build_router_detects_flash_css_path_collision() {
6016 use crate::openapi::OpenApiConfig;
6017
6018 let openapi =
6019 OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::flash::FLASH_CSS_PATH);
6020 let config = AutumnConfig::default();
6021 let ctx = RouterContext {
6022 exception_filters: Vec::new(),
6023 scoped_groups: Vec::new(),
6024 merge_routers: Vec::new(),
6025 nest_routers: Vec::new(),
6026 custom_layers: Vec::new(),
6027 static_gate_layers: Vec::new(),
6028 #[cfg(feature = "maud")]
6029 error_page_renderer: None,
6030 session_store: None,
6031 openapi: Some(openapi),
6032 #[cfg(feature = "mcp")]
6033 mcp: None,
6034 };
6035 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
6036 "openapi_json_path colliding with the flash stylesheet route should be rejected",
6037 );
6038 assert!(matches!(
6039 err,
6040 RouterBuildError::OpenApiPathCollision {
6041 field: "openapi_json_path",
6042 ..
6043 }
6044 ));
6045 }
6046
6047 #[cfg(feature = "openapi")]
6048 #[test]
6049 fn extract_path_params_matches_macro_behavior() {
6050 assert_eq!(
6052 super::extract_path_params("/orgs/{org_id}/users/{id}"),
6053 vec!["org_id".to_owned(), "id".to_owned()]
6054 );
6055 assert_eq!(
6056 super::extract_path_params("/users/{id}/posts/{slug}"),
6057 vec!["id".to_owned(), "slug".to_owned()]
6058 );
6059 assert!(super::extract_path_params("/static").is_empty());
6060
6061 assert_eq!(
6063 super::extract_path_params("/users/{id:[0-9]+}"),
6064 vec!["id".to_owned()]
6065 );
6066 assert_eq!(
6068 super::extract_path_params("{id:[0-9]{1,3}}"),
6069 vec!["id".to_owned()]
6070 );
6071
6072 assert!(super::extract_path_params("{{hello}}").is_empty());
6075 assert_eq!(
6076 super::extract_path_params("{{literal}}/{id}"),
6077 vec!["id".to_owned()]
6078 );
6079
6080 assert!(super::extract_path_params("{{}").is_empty());
6082 assert!(super::extract_path_params("{a{b}").is_empty());
6083 assert!(super::extract_path_params("{").is_empty());
6084 assert!(super::extract_path_params("}").is_empty());
6085 assert!(super::extract_path_params("{}").is_empty());
6086 }
6087
6088 #[cfg(feature = "openapi")]
6089 #[test]
6090 fn extract_path_params_handles_unbalanced_braces() {
6091 for path in ["{{}", "{", "}", "{a{b}"] {
6097 for name in super::extract_path_params(path) {
6098 assert!(
6099 !name.contains('{') && !name.contains('}'),
6100 "param name should be brace-free for {path:?}: {name:?}"
6101 );
6102 assert!(
6103 !name.is_empty(),
6104 "param name should be non-empty for {path:?}"
6105 );
6106 }
6107 }
6108 assert!(super::extract_path_params("{{}").is_empty());
6111 assert!(super::extract_path_params("{a{b}").is_empty());
6114 }
6115
6116 #[cfg(feature = "openapi")]
6117 #[tokio::test]
6118 async fn openapi_merges_scoped_prefix_path_params() {
6119 use crate::openapi::{ApiDoc, OpenApiConfig};
6120
6121 async fn handler() -> &'static str {
6126 "ok"
6127 }
6128 let child = Route {
6129 method: http::Method::GET,
6130 path: "/users/{id}",
6131 handler: axum::routing::get(handler),
6132 name: "child",
6133 api_doc: ApiDoc {
6134 method: "GET",
6135 path: "/users/{id}",
6136 operation_id: "child",
6137 path_params: &["id"],
6138 success_status: 200,
6139 ..Default::default()
6140 },
6141 repository: None,
6142 idempotency: crate::route::RouteIdempotency::Direct,
6143 timeout: crate::route::RouteTimeout::Inherit,
6144 api_version: None,
6145 sunset_opt_out: false,
6146 };
6147 let group = crate::app::ScopedGroup {
6148 prefix: "/orgs/{org_id}".to_owned(),
6149 routes: vec![child],
6150 source: crate::route_listing::RouteSource::User,
6151 apply_layer: Box::new(|r| r),
6152 };
6153
6154 let config = OpenApiConfig::new("Demo", "1.0.0");
6155 let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
6156 .expect("openapi sub-router builds")
6157 .expect("openapi sub-router present when config is Some");
6158 let state = test_state();
6159 let router = router.with_state(state);
6160
6161 let response = router
6162 .oneshot(
6163 Request::builder()
6164 .uri("/openapi.json")
6165 .body(Body::empty())
6166 .unwrap(),
6167 )
6168 .await
6169 .unwrap();
6170 assert_eq!(response.status(), StatusCode::OK);
6171 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6172 .await
6173 .unwrap();
6174 let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
6175 let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
6176 let names: Vec<&str> = params
6177 .as_array()
6178 .unwrap()
6179 .iter()
6180 .map(|p| p["name"].as_str().unwrap())
6181 .collect();
6182 assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
6183 assert!(names.contains(&"id"), "missing id: {names:?}");
6184 }
6185
6186 #[cfg(feature = "openapi")]
6187 #[tokio::test]
6188 async fn openapi_documents_configured_session_cookie_name() {
6189 use crate::openapi::{ApiDoc, OpenApiConfig};
6190
6191 async fn handler() -> &'static str {
6192 "ok"
6193 }
6194
6195 let route = Route {
6196 method: http::Method::GET,
6197 path: "/protected",
6198 handler: axum::routing::get(handler),
6199 name: "protected",
6200 api_doc: ApiDoc {
6201 method: "GET",
6202 path: "/protected",
6203 operation_id: "protected",
6204 success_status: 200,
6205 secured: true,
6206 ..Default::default()
6207 },
6208 repository: None,
6209 idempotency: crate::route::RouteIdempotency::Direct,
6210 timeout: crate::route::RouteTimeout::Inherit,
6211 api_version: None,
6212 sunset_opt_out: false,
6213 };
6214
6215 let protected_routes = vec![route];
6216 let config = OpenApiConfig::new("Demo", "1.0.0");
6217 let docs_router =
6218 super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
6219 .expect("openapi sub-router builds")
6220 .expect("openapi sub-router present when config is Some");
6221 let docs_router = docs_router.with_state(test_state());
6222
6223 let response = docs_router
6224 .oneshot(
6225 Request::builder()
6226 .uri("/openapi.json")
6227 .body(Body::empty())
6228 .unwrap(),
6229 )
6230 .await
6231 .unwrap();
6232 assert_eq!(response.status(), StatusCode::OK);
6233 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6234 .await
6235 .unwrap();
6236 let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
6237 let schemes = &spec["components"]["securitySchemes"];
6238
6239 assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
6240 assert_eq!(schemes["SessionAuth"]["in"], "cookie");
6241 assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
6242 assert!(
6243 schemes.get("BearerAuth").is_none(),
6244 "secured routes must not be documented as bearer JWT routes"
6245 );
6246 }
6247
6248 #[cfg(feature = "openapi")]
6249 #[test]
6250 fn openapi_rejects_json_path_without_leading_slash() {
6251 let config =
6252 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
6253 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6254 .expect_err("non-slash path should be rejected");
6255 assert!(matches!(
6256 err,
6257 RouterBuildError::InvalidOpenApiPath {
6258 field: "openapi_json_path",
6259 ..
6260 }
6261 ));
6262 }
6263
6264 #[cfg(feature = "openapi")]
6265 #[test]
6266 fn openapi_rejects_path_with_captures() {
6267 let config =
6270 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
6271 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6272 .expect_err("captures should be rejected");
6273 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6274 }
6275
6276 #[cfg(feature = "openapi")]
6277 #[test]
6278 fn openapi_rejects_path_with_unbalanced_brace() {
6279 let config =
6280 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
6281 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6282 .expect_err("unbalanced brace should be rejected");
6283 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6284 }
6285
6286 #[cfg(feature = "openapi")]
6287 #[test]
6288 fn openapi_rejects_path_with_wildcard() {
6289 let config =
6290 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
6291 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6292 .expect_err("wildcard should be rejected");
6293 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6294 }
6295
6296 #[cfg(feature = "openapi")]
6297 #[test]
6298 fn openapi_rejects_path_with_double_slash() {
6299 let config =
6300 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
6301 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6302 .expect_err("double-slash should be rejected");
6303 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6304 }
6305
6306 #[cfg(feature = "openapi")]
6307 #[test]
6308 fn openapi_rejects_swagger_ui_path_without_leading_slash() {
6309 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6310 .swagger_ui_path(Some("docs".to_owned()));
6311 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6312 .expect_err("non-slash path should be rejected");
6313 assert!(matches!(
6314 err,
6315 RouterBuildError::InvalidOpenApiPath {
6316 field: "swagger_ui_path",
6317 ..
6318 }
6319 ));
6320 }
6321
6322 #[cfg(feature = "openapi")]
6323 #[test]
6324 fn openapi_rejects_empty_json_path() {
6325 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
6326 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6327 .expect_err("empty path should be rejected");
6328 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6329 }
6330
6331 #[cfg(feature = "openapi")]
6332 #[test]
6333 fn openapi_accepts_valid_paths() {
6334 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6335 .openapi_json_path("/api-docs")
6336 .swagger_ui_path(Some("/ui".to_owned()));
6337 let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6338 .expect("valid paths must not error");
6339 assert!(out.is_some());
6340 }
6341
6342 #[cfg(feature = "openapi")]
6343 #[test]
6344 fn openapi_rejects_duplicate_json_and_swagger_paths() {
6345 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6346 .openapi_json_path("/docs")
6347 .swagger_ui_path(Some("/docs".to_owned()));
6348 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6349 .expect_err("colliding paths should be rejected before axum panics");
6350 assert!(matches!(
6351 err,
6352 RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
6353 ));
6354 }
6355
6356 #[cfg(feature = "openapi")]
6357 async fn collision_test_handler() -> &'static str {
6358 "user"
6359 }
6360
6361 #[cfg(feature = "openapi")]
6362 #[tokio::test]
6363 async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
6364 let mut config = AutumnConfig::default();
6365 config.actuator.prefix = "/ops".to_owned();
6366 let openapi =
6367 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
6368
6369 let user_route = Route {
6370 method: http::Method::GET,
6371 path: "/my-api-docs",
6372 handler: axum::routing::get(collision_test_handler),
6373 name: "collides",
6374 api_doc: crate::openapi::ApiDoc {
6375 method: "GET",
6376 path: "/my-api-docs",
6377 operation_id: "collides",
6378 success_status: 200,
6379 ..Default::default()
6380 },
6381 repository: None,
6382 idempotency: crate::route::RouteIdempotency::Direct,
6383 timeout: crate::route::RouteTimeout::Inherit,
6384 api_version: None,
6385 sunset_opt_out: false,
6386 };
6387
6388 let ctx = RouterContext {
6389 exception_filters: Vec::new(),
6390 scoped_groups: Vec::new(),
6391 merge_routers: Vec::new(),
6392 nest_routers: Vec::new(),
6393 custom_layers: Vec::new(),
6394 static_gate_layers: Vec::new(),
6395 #[cfg(feature = "maud")]
6396 error_page_renderer: None,
6397 session_store: None,
6398 openapi: Some(openapi),
6399 #[cfg(feature = "mcp")]
6400 mcp: None,
6401 };
6402 let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
6403 .expect_err("user-owned path should prevent OpenAPI mount");
6404 assert!(matches!(
6405 err,
6406 RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
6407 ));
6408 }
6409
6410 #[cfg(feature = "openapi")]
6411 #[tokio::test]
6412 async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
6413 let config = AutumnConfig::default(); let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6415 .openapi_json_path("/actuator/health");
6416 let ctx = RouterContext {
6417 exception_filters: Vec::new(),
6418 scoped_groups: Vec::new(),
6419 merge_routers: Vec::new(),
6420 nest_routers: Vec::new(),
6421 custom_layers: Vec::new(),
6422 static_gate_layers: Vec::new(),
6423 #[cfg(feature = "maud")]
6424 error_page_renderer: None,
6425 session_store: None,
6426 openapi: Some(openapi),
6427 #[cfg(feature = "mcp")]
6428 mcp: None,
6429 };
6430 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6431 .expect_err("framework-owned path should prevent OpenAPI mount");
6432 assert!(matches!(
6433 err,
6434 RouterBuildError::OpenApiPathCollision {
6435 field: "openapi_json_path",
6436 ..
6437 }
6438 ));
6439 }
6440
6441 #[cfg(feature = "openapi")]
6442 #[tokio::test]
6443 async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
6444 let config = AutumnConfig::default();
6445 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
6446
6447 let user_route = Route {
6448 method: http::Method::GET,
6449 path: "/swagger-ui/swagger-ui.css",
6450 handler: axum::routing::get(collision_test_handler),
6451 name: "swagger-ui-asset-collides",
6452 api_doc: crate::openapi::ApiDoc {
6453 method: "GET",
6454 path: "/swagger-ui/swagger-ui.css",
6455 operation_id: "swagger_ui_asset_collides",
6456 success_status: 200,
6457 ..Default::default()
6458 },
6459 repository: None,
6460 idempotency: crate::route::RouteIdempotency::Direct,
6461 timeout: crate::route::RouteTimeout::Inherit,
6462 api_version: None,
6463 sunset_opt_out: false,
6464 };
6465
6466 let ctx = RouterContext {
6467 exception_filters: Vec::new(),
6468 scoped_groups: Vec::new(),
6469 merge_routers: Vec::new(),
6470 nest_routers: Vec::new(),
6471 custom_layers: Vec::new(),
6472 static_gate_layers: Vec::new(),
6473 #[cfg(feature = "maud")]
6474 error_page_renderer: None,
6475 session_store: None,
6476 openapi: Some(openapi),
6477 #[cfg(feature = "mcp")]
6478 mcp: None,
6479 };
6480 let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
6481 .expect_err("swagger ui asset path should be reserved");
6482 assert!(matches!(
6483 err,
6484 RouterBuildError::OpenApiPathCollision {
6485 field: "swagger_ui_path",
6486 ref path,
6487 } if path == "/swagger-ui/swagger-ui.css"
6488 ));
6489 }
6490
6491 #[cfg(all(feature = "openapi", feature = "htmx"))]
6492 #[tokio::test]
6493 async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
6494 let config = AutumnConfig::default();
6495 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6496 .openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
6497 let ctx = RouterContext {
6498 exception_filters: Vec::new(),
6499 scoped_groups: Vec::new(),
6500 merge_routers: Vec::new(),
6501 nest_routers: Vec::new(),
6502 custom_layers: Vec::new(),
6503 static_gate_layers: Vec::new(),
6504 #[cfg(feature = "maud")]
6505 error_page_renderer: None,
6506 session_store: None,
6507 openapi: Some(openapi),
6508 #[cfg(feature = "mcp")]
6509 mcp: None,
6510 };
6511 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6512 .expect_err("htmx csrf helper path should be reserved");
6513 assert!(matches!(
6514 err,
6515 RouterBuildError::OpenApiPathCollision {
6516 field: "openapi_json_path",
6517 ref path,
6518 } if path == crate::htmx::HTMX_CSRF_JS_PATH
6519 ));
6520 }
6521
6522 #[cfg(feature = "openapi")]
6523 #[tokio::test]
6524 async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
6525 let config = AutumnConfig::default();
6530 let openapi =
6531 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
6532 let nested = axum::Router::<AppState>::new()
6533 .route("/inner", axum::routing::get(|| async { "inner" }));
6534 let ctx = RouterContext {
6535 exception_filters: Vec::new(),
6536 scoped_groups: Vec::new(),
6537 merge_routers: Vec::new(),
6538 nest_routers: vec![("/api".to_owned(), nested)],
6539 custom_layers: Vec::new(),
6540 static_gate_layers: Vec::new(),
6541 #[cfg(feature = "maud")]
6542 error_page_renderer: None,
6543 session_store: None,
6544 openapi: Some(openapi),
6545 #[cfg(feature = "mcp")]
6546 mcp: None,
6547 };
6548 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6549 .expect_err("OpenAPI path under a nest prefix should collide");
6550 assert!(matches!(
6551 err,
6552 RouterBuildError::OpenApiPathCollision {
6553 field: "openapi_json_path",
6554 ref path,
6555 } if path == "/api/docs"
6556 ));
6557 }
6558
6559 #[cfg(all(feature = "openapi", feature = "mail"))]
6560 #[tokio::test]
6561 async fn try_build_router_rejects_openapi_path_on_unsubscribe_endpoint() {
6562 let mut config = AutumnConfig::default();
6567 config.mail.mount_unsubscribe_endpoint = true;
6568 config.mail.unsubscribe_base_url = Some("https://app.example.com".to_owned());
6569 assert!(config.mail.should_mount_unsubscribe_endpoint());
6570 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6571 .openapi_json_path(crate::mail::UNSUBSCRIBE_PATH);
6572 let ctx = RouterContext {
6573 exception_filters: Vec::new(),
6574 scoped_groups: Vec::new(),
6575 merge_routers: Vec::new(),
6576 nest_routers: Vec::new(),
6577 custom_layers: Vec::new(),
6578 static_gate_layers: Vec::new(),
6579 #[cfg(feature = "maud")]
6580 error_page_renderer: None,
6581 session_store: None,
6582 openapi: Some(openapi),
6583 #[cfg(feature = "mcp")]
6584 mcp: None,
6585 };
6586 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6587 .expect_err("unsubscribe endpoint path should be reserved");
6588 assert!(matches!(
6589 err,
6590 RouterBuildError::OpenApiPathCollision {
6591 field: "openapi_json_path",
6592 ref path,
6593 } if path == crate::mail::UNSUBSCRIBE_PATH
6594 ));
6595 }
6596
6597 #[cfg(feature = "openapi")]
6598 #[tokio::test]
6599 async fn try_build_router_rejects_openapi_path_on_job_status_endpoint() {
6600 let config = AutumnConfig::default();
6604 assert!(config.jobs.tracking.route_enabled);
6605 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6606 .openapi_json_path(crate::job_tracking::JOB_STATUS_ROUTE_PATH);
6607 let ctx = RouterContext {
6608 exception_filters: Vec::new(),
6609 scoped_groups: Vec::new(),
6610 merge_routers: Vec::new(),
6611 nest_routers: Vec::new(),
6612 custom_layers: Vec::new(),
6613 static_gate_layers: Vec::new(),
6614 #[cfg(feature = "maud")]
6615 error_page_renderer: None,
6616 session_store: None,
6617 openapi: Some(openapi),
6618 #[cfg(feature = "mcp")]
6619 mcp: None,
6620 };
6621 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6622 .expect_err("job status endpoint path should be reserved");
6623 assert!(matches!(
6624 err,
6625 RouterBuildError::OpenApiPathCollision {
6626 field: "openapi_json_path",
6627 ref path,
6628 } if path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
6629 ));
6630 }
6631
6632 #[cfg(all(feature = "openapi", feature = "maud"))]
6633 #[tokio::test]
6634 async fn try_build_router_rejects_openapi_path_on_story_gallery() {
6635 let mut config = AutumnConfig::default();
6641 config.stories.enabled = true;
6642 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6643 .openapi_json_path(crate::stories::STORIES_PATH);
6644 let ctx = RouterContext {
6645 exception_filters: Vec::new(),
6646 scoped_groups: Vec::new(),
6647 merge_routers: Vec::new(),
6648 nest_routers: Vec::new(),
6649 custom_layers: Vec::new(),
6650 static_gate_layers: Vec::new(),
6651 error_page_renderer: None,
6652 session_store: None,
6653 openapi: Some(openapi),
6654 #[cfg(feature = "mcp")]
6655 mcp: None,
6656 };
6657 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6658 .expect_err("story gallery path should be reserved while stories are enabled");
6659 assert!(matches!(
6660 err,
6661 RouterBuildError::OpenApiPathCollision {
6662 field: "openapi_json_path",
6663 ref path,
6664 } if path == crate::stories::STORIES_PATH
6665 ));
6666 }
6667
6668 #[cfg(feature = "openapi")]
6669 #[test]
6670 fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
6671 temp_env::with_vars(
6672 [
6673 ("AUTUMN_DEV_RELOAD", Some("1")),
6674 ("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
6675 ],
6676 || {
6677 let config = AutumnConfig::default();
6678 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6679 .openapi_json_path("/__autumn/live-reload");
6680 let ctx = RouterContext {
6681 exception_filters: Vec::new(),
6682 scoped_groups: Vec::new(),
6683 merge_routers: Vec::new(),
6684 nest_routers: Vec::new(),
6685 custom_layers: Vec::new(),
6686 static_gate_layers: Vec::new(),
6687 error_page_renderer: None,
6688 session_store: None,
6689 openapi: Some(openapi),
6690 #[cfg(feature = "mcp")]
6691 mcp: None,
6692 };
6693 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6694 .expect_err("dev reload path should be reserved");
6695 assert!(matches!(
6696 err,
6697 RouterBuildError::OpenApiPathCollision {
6698 field: "openapi_json_path",
6699 ..
6700 }
6701 ));
6702 },
6703 );
6704 }
6705
6706 async fn duplicate_route_handler() -> &'static str {
6709 "ok"
6710 }
6711
6712 fn duplicate_test_route(method: http::Method, path: &'static str, name: &'static str) -> Route {
6718 let handler = match method {
6719 http::Method::POST => axum::routing::post(duplicate_route_handler),
6720 http::Method::PUT => axum::routing::put(duplicate_route_handler),
6721 http::Method::PATCH => axum::routing::patch(duplicate_route_handler),
6722 http::Method::DELETE => axum::routing::delete(duplicate_route_handler),
6723 _ => axum::routing::get(duplicate_route_handler),
6724 };
6725 let method_str = if method == http::Method::POST {
6726 "POST"
6727 } else if method == http::Method::PUT {
6728 "PUT"
6729 } else if method == http::Method::PATCH {
6730 "PATCH"
6731 } else if method == http::Method::DELETE {
6732 "DELETE"
6733 } else {
6734 "GET"
6735 };
6736 Route {
6737 method,
6738 path,
6739 handler,
6740 name,
6741 api_doc: crate::openapi::ApiDoc {
6742 method: method_str,
6743 path,
6744 operation_id: name,
6745 success_status: 200,
6746 ..Default::default()
6747 },
6748 repository: None,
6749 idempotency: crate::route::RouteIdempotency::Direct,
6750 timeout: crate::route::RouteTimeout::Inherit,
6751 api_version: None,
6752 sunset_opt_out: false,
6753 }
6754 }
6755
6756 fn duplicate_test_ctx() -> RouterContext {
6757 RouterContext {
6758 exception_filters: Vec::new(),
6759 scoped_groups: Vec::new(),
6760 merge_routers: Vec::new(),
6761 nest_routers: Vec::new(),
6762 custom_layers: Vec::new(),
6763 static_gate_layers: Vec::new(),
6764 #[cfg(feature = "maud")]
6765 error_page_renderer: None,
6766 session_store: None,
6767 #[cfg(feature = "openapi")]
6768 openapi: None,
6769 #[cfg(feature = "mcp")]
6770 mcp: None,
6771 }
6772 }
6773
6774 #[tokio::test]
6779 async fn try_build_router_rejects_duplicate_user_route_paths() {
6780 let config = AutumnConfig::default();
6781 let a = duplicate_test_route(http::Method::GET, "/", "root_a");
6782 let b = duplicate_test_route(http::Method::GET, "/", "root_b");
6783 let err =
6784 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
6785 .expect_err("two GET / routes should be rejected before mount");
6786 let display = err.to_string();
6787 match err {
6788 RouterBuildError::DuplicateUserRoute {
6789 ref method,
6790 ref path,
6791 ref existing,
6792 ref incoming,
6793 } => {
6794 assert_eq!(method, "GET");
6795 assert_eq!(path, "/");
6796 assert_eq!(existing, "root_a");
6797 assert_eq!(incoming, "root_b");
6798 }
6799 other => panic!("expected DuplicateUserRoute, got {other:?}"),
6800 }
6801 assert!(
6802 display.contains("root_a"),
6803 "error message must name first handler; got: {display}"
6804 );
6805 assert!(
6806 display.contains("root_b"),
6807 "error message must name second handler; got: {display}"
6808 );
6809 assert!(
6810 display.contains("GET"),
6811 "error message must name the HTTP method; got: {display}"
6812 );
6813 assert!(
6814 display.contains('/'),
6815 "error message must contain the path; got: {display}"
6816 );
6817 }
6818
6819 #[tokio::test]
6823 async fn try_build_router_allows_distinct_methods_on_same_path() {
6824 let config = AutumnConfig::default();
6825 let get = duplicate_test_route(http::Method::GET, "/admin", "admin_index");
6826 let post = duplicate_test_route(http::Method::POST, "/admin", "admin_create");
6827 let _router = super::try_build_router_inner(
6828 vec![get, post],
6829 &config,
6830 test_state(),
6831 duplicate_test_ctx(),
6832 )
6833 .expect("GET + POST on the same path should build cleanly");
6834 }
6835
6836 #[tokio::test]
6840 async fn try_build_router_rejects_duplicate_across_scoped_group() {
6841 let config = AutumnConfig::default();
6842 let top = duplicate_test_route(http::Method::GET, "/api/posts", "top_posts");
6843 let scoped_child = duplicate_test_route(http::Method::GET, "/posts", "scoped_posts");
6844 let group = crate::app::ScopedGroup {
6845 prefix: "/api".to_owned(),
6846 routes: vec![scoped_child],
6847 source: crate::route_listing::RouteSource::User,
6848 apply_layer: Box::new(|r| r),
6849 };
6850 let mut ctx = duplicate_test_ctx();
6851 ctx.scoped_groups.push(group);
6852 let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
6853 .expect_err("top-level + scoped resolving to same path should be rejected");
6854 match err {
6855 RouterBuildError::DuplicateUserRoute {
6856 ref method,
6857 ref path,
6858 ..
6859 } => {
6860 assert_eq!(method, "GET");
6861 assert_eq!(path, "/api/posts");
6862 }
6863 other => panic!("expected DuplicateUserRoute, got {other:?}"),
6864 }
6865 }
6866
6867 #[tokio::test]
6870 async fn try_build_router_rejects_duplicate_within_scoped_groups() {
6871 let config = AutumnConfig::default();
6872 let a = crate::app::ScopedGroup {
6873 prefix: "/api".to_owned(),
6874 routes: vec![duplicate_test_route(
6875 http::Method::GET,
6876 "/posts",
6877 "user_posts",
6878 )],
6879 source: crate::route_listing::RouteSource::User,
6880 apply_layer: Box::new(|r| r),
6881 };
6882 let b = crate::app::ScopedGroup {
6883 prefix: "/api".to_owned(),
6884 routes: vec![duplicate_test_route(
6885 http::Method::GET,
6886 "/posts",
6887 "plugin_posts",
6888 )],
6889 source: crate::route_listing::RouteSource::Plugin("blog".to_owned()),
6890 apply_layer: Box::new(|r| r),
6891 };
6892 let mut ctx = duplicate_test_ctx();
6893 ctx.scoped_groups.push(a);
6894 ctx.scoped_groups.push(b);
6895 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6896 .expect_err("two scoped groups colliding on /api/posts should be rejected");
6897 assert!(matches!(
6898 err,
6899 RouterBuildError::DuplicateUserRoute { ref existing, ref incoming, .. }
6900 if existing == "user_posts" && incoming == "plugin_posts"
6901 ));
6902 }
6903
6904 #[tokio::test]
6909 async fn try_build_router_skips_duplicate_check_for_opaque_merge_router() {
6910 let config = AutumnConfig::default();
6911 let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
6912 let raw = axum::Router::<AppState>::new()
6913 .route("/raw", axum::routing::get(duplicate_route_handler));
6914 let mut ctx = duplicate_test_ctx();
6915 ctx.merge_routers.push(raw);
6916 let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
6917 .expect("opaque merge routers must not fail the duplicate preflight");
6918 }
6919
6920 #[tokio::test]
6922 async fn try_build_router_skips_duplicate_check_for_opaque_nest_router() {
6923 let config = AutumnConfig::default();
6924 let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
6925 let nested = axum::Router::<AppState>::new()
6926 .route("/child", axum::routing::get(duplicate_route_handler));
6927 let mut ctx = duplicate_test_ctx();
6928 ctx.nest_routers.push(("/plugin".to_owned(), nested));
6929 let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
6930 .expect("opaque nest routers must not fail the duplicate preflight");
6931 }
6932
6933 #[tokio::test]
6941 async fn try_build_router_rejects_duplicate_capture_name_paths() {
6942 let config = AutumnConfig::default();
6943 let a = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
6944 let b = duplicate_test_route(http::Method::GET, "/users/{slug}", "by_slug");
6945 let err =
6946 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
6947 .expect_err("capture-name-only difference must be rejected before mount");
6948 match err {
6949 RouterBuildError::ConflictingRouteShape {
6950 ref existing,
6951 ref existing_path,
6952 ref incoming,
6953 ref incoming_path,
6954 } => {
6955 assert_eq!(existing, "by_id");
6956 assert_eq!(existing_path, "/users/{id}");
6957 assert_eq!(incoming, "by_slug");
6958 assert_eq!(incoming_path, "/users/{slug}");
6959 }
6960 other => panic!("expected ConflictingRouteShape, got {other:?}"),
6961 }
6962 let display = err.to_string();
6964 assert!(
6965 display.contains("/users/{id}") && display.contains("/users/{slug}"),
6966 "error must show both original path templates; got: {display}"
6967 );
6968 }
6969
6970 #[tokio::test]
6974 async fn try_build_router_rejects_duplicate_capture_name_across_scoped_group() {
6975 let config = AutumnConfig::default();
6976 let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
6977 let scoped_child =
6978 duplicate_test_route(http::Method::GET, "/users/{slug}", "scoped_by_slug");
6979 let group = crate::app::ScopedGroup {
6980 prefix: "/api".to_owned(),
6981 routes: vec![scoped_child],
6982 source: crate::route_listing::RouteSource::User,
6983 apply_layer: Box::new(|r| r),
6984 };
6985 let mut ctx = duplicate_test_ctx();
6986 ctx.scoped_groups.push(group);
6987 let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
6988 .expect_err("scoped capture-name collision must be rejected before mount");
6989 assert!(
6990 matches!(
6991 err,
6992 RouterBuildError::ConflictingRouteShape {
6993 ref existing, ref incoming, ref existing_path, ref incoming_path
6994 }
6995 if existing == "top_by_id" && incoming == "scoped_by_slug"
6996 && existing_path == "/api/users/{id}"
6997 && incoming_path == "/api/users/{slug}"
6998 ),
6999 "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
7000 );
7001 }
7002
7003 #[tokio::test]
7007 async fn try_build_router_allows_distinct_route_shapes() {
7008 let config = AutumnConfig::default();
7009 let a = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
7010 let b = duplicate_test_route(http::Method::GET, "/users/{id}/posts", "posts");
7011 let _router =
7012 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7013 .expect("distinct route shapes must not be flagged as duplicates");
7014 }
7015
7016 #[tokio::test]
7027 async fn try_build_router_rejects_ws_get_collision() {
7028 let config = AutumnConfig::default();
7029 let get = duplicate_test_route(http::Method::GET, "/live", "live_poll");
7030 let ws = duplicate_test_route(
7031 http::Method::from_bytes(b"WS").unwrap(),
7032 "/live",
7033 "live_socket",
7034 );
7035 let err = super::try_build_router_inner(
7036 vec![get, ws],
7037 &config,
7038 test_state(),
7039 duplicate_test_ctx(),
7040 )
7041 .expect_err("GET + WS on the same path must be rejected before mount");
7042 match err {
7043 RouterBuildError::DuplicateUserRoute {
7044 ref method,
7045 ref path,
7046 ref existing,
7047 ref incoming,
7048 } => {
7049 assert_eq!(method, "GET", "WS must be normalized to its effective GET");
7050 assert_eq!(path, "/live");
7051 assert_eq!(existing, "live_poll");
7052 assert_eq!(incoming, "live_socket");
7053 }
7054 other => panic!("expected DuplicateUserRoute, got {other:?}"),
7055 }
7056 }
7057
7058 #[tokio::test]
7067 async fn try_build_router_rejects_cross_method_shape_conflict() {
7068 let config = AutumnConfig::default();
7069 let get = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
7070 let post = duplicate_test_route(http::Method::POST, "/users/{slug}", "by_slug");
7071 let err = super::try_build_router_inner(
7072 vec![get, post],
7073 &config,
7074 test_state(),
7075 duplicate_test_ctx(),
7076 )
7077 .expect_err("cross-method capture-name-only conflict must be rejected before mount");
7078 match err {
7079 RouterBuildError::ConflictingRouteShape {
7080 ref existing,
7081 ref existing_path,
7082 ref incoming,
7083 ref incoming_path,
7084 } => {
7085 assert_eq!(existing, "by_id");
7086 assert_eq!(existing_path, "/users/{id}");
7087 assert_eq!(incoming, "by_slug");
7088 assert_eq!(incoming_path, "/users/{slug}");
7089 }
7090 other => panic!("expected ConflictingRouteShape, got {other:?}"),
7091 }
7092 let display = err.to_string();
7093 assert!(
7094 display.contains("by_id") && display.contains("by_slug"),
7095 "error must name both handlers; got: {display}"
7096 );
7097 assert!(
7098 display.contains("/users/{id}") && display.contains("/users/{slug}"),
7099 "error must name both original templates; got: {display}"
7100 );
7101 }
7102
7103 #[tokio::test]
7107 async fn try_build_router_rejects_cross_method_shape_conflict_across_scoped_group() {
7108 let config = AutumnConfig::default();
7109 let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
7110 let scoped_child =
7111 duplicate_test_route(http::Method::POST, "/users/{slug}", "scoped_by_slug");
7112 let group = crate::app::ScopedGroup {
7113 prefix: "/api".to_owned(),
7114 routes: vec![scoped_child],
7115 source: crate::route_listing::RouteSource::User,
7116 apply_layer: Box::new(|r| r),
7117 };
7118 let mut ctx = duplicate_test_ctx();
7119 ctx.scoped_groups.push(group);
7120 let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
7121 .expect_err("scoped cross-method shape conflict must be rejected before mount");
7122 assert!(
7123 matches!(
7124 err,
7125 RouterBuildError::ConflictingRouteShape {
7126 ref existing, ref incoming, ref existing_path, ref incoming_path
7127 }
7128 if existing == "top_by_id" && incoming == "scoped_by_slug"
7129 && existing_path == "/api/users/{id}"
7130 && incoming_path == "/api/users/{slug}"
7131 ),
7132 "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
7133 );
7134 }
7135
7136 #[tokio::test]
7142 async fn try_build_router_allows_same_capture_template_distinct_methods() {
7143 let config = AutumnConfig::default();
7144 let get = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
7145 let post = duplicate_test_route(http::Method::POST, "/users/{id}", "update");
7146 let _router = super::try_build_router_inner(
7147 vec![get, post],
7148 &config,
7149 test_state(),
7150 duplicate_test_ctx(),
7151 )
7152 .expect("same capture template on GET + POST must build cleanly");
7153 }
7154
7155 #[tokio::test]
7161 async fn try_build_router_allows_escaped_brace_literals() {
7162 let config = AutumnConfig::default();
7163 let a = duplicate_test_route(http::Method::GET, "/{{foo}}", "lit_foo");
7164 let b = duplicate_test_route(http::Method::GET, "/{{bar}}", "lit_bar");
7165 let _router =
7166 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7167 .expect("distinct escaped-literal paths must not be flagged as duplicates");
7168 }
7169
7170 #[tokio::test]
7174 async fn try_build_router_allows_escaped_literal_prefix_with_capture() {
7175 let config = AutumnConfig::default();
7176 let a = duplicate_test_route(http::Method::GET, "/{{x}}/{id}", "x_show");
7177 let b = duplicate_test_route(http::Method::GET, "/{{y}}/{id}", "y_show");
7178 let _router =
7179 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7180 .expect("distinct escaped-literal prefixes with a shared capture must build");
7181 }
7182
7183 #[tokio::test]
7188 async fn try_build_router_rejects_mixed_literal_capture_shape_conflict() {
7189 let config = AutumnConfig::default();
7190 let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
7191 let b = duplicate_test_route(http::Method::GET, "/file.{kind}", "by_kind");
7192 let err =
7193 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7194 .expect_err("mixed literal+capture shape conflict must be rejected before mount");
7195 assert!(
7196 matches!(
7197 err,
7198 RouterBuildError::ConflictingRouteShape {
7199 ref existing_path, ref incoming_path, ..
7200 }
7201 if existing_path == "/file.{ext}" && incoming_path == "/file.{kind}"
7202 ),
7203 "expected ConflictingRouteShape naming both templates, got {err:?}"
7204 );
7205 }
7206
7207 #[tokio::test]
7212 async fn try_build_router_allows_mixed_capture_vs_static_segment() {
7213 let config = AutumnConfig::default();
7214 let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
7215 let b = duplicate_test_route(http::Method::GET, "/file.json", "static_json");
7216 let _router =
7217 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7218 .expect("a capture segment and a static segment must not be flagged as duplicates");
7219 }
7220
7221 #[tokio::test]
7226 async fn try_build_router_rejects_catch_all_vs_normal_capture() {
7227 let config = AutumnConfig::default();
7228 let a = duplicate_test_route(http::Method::GET, "/u/{id}", "one");
7229 let b = duplicate_test_route(http::Method::GET, "/u/{*rest}", "rest");
7230 let err =
7231 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7232 .expect_err("catch-all vs normal capture must be rejected before mount");
7233 assert!(
7234 matches!(
7235 err,
7236 RouterBuildError::ConflictingRouteShape {
7237 ref existing_path, ref incoming_path, ..
7238 }
7239 if existing_path == "/u/{id}" && incoming_path == "/u/{*rest}"
7240 ),
7241 "expected ConflictingRouteShape naming both templates, got {err:?}"
7242 );
7243 }
7244
7245 #[tokio::test]
7254 async fn try_build_router_rejects_catch_all_vs_dynamic_descendant() {
7255 let config = AutumnConfig::default();
7256 let a = duplicate_test_route(http::Method::GET, "/cmd/{tool}/{sub}", "cmd_sub");
7257 let b = duplicate_test_route(http::Method::POST, "/cmd/{*path}", "cmd_all");
7258 let err =
7259 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7260 .expect_err("catch-all vs dynamic descendant must be rejected before mount");
7261 match err {
7262 RouterBuildError::ConflictingRouteShape {
7263 ref existing,
7264 ref existing_path,
7265 ref incoming,
7266 ref incoming_path,
7267 } => {
7268 assert_eq!(existing, "cmd_sub");
7269 assert_eq!(existing_path, "/cmd/{tool}/{sub}");
7270 assert_eq!(incoming, "cmd_all");
7271 assert_eq!(incoming_path, "/cmd/{*path}");
7272 }
7273 other => panic!("expected ConflictingRouteShape, got {other:?}"),
7274 }
7275 let display = err.to_string();
7276 assert!(
7277 display.contains("/cmd/{tool}/{sub}") && display.contains("/cmd/{*path}"),
7278 "error must name both original templates; got: {display}"
7279 );
7280 }
7281
7282 #[tokio::test]
7288 async fn try_build_router_allows_static_vs_dynamic_segment() {
7289 let config = AutumnConfig::default();
7290 let a = duplicate_test_route(http::Method::GET, "/users/me", "me");
7291 let b = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
7292 let _router =
7293 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7294 .expect("a static segment and a dynamic capture must not be flagged as a conflict");
7295 }
7296
7297 #[test]
7305 fn matchit_agrees_with_axum_route_conflicts() {
7306 let matrix: &[(&str, &str, bool)] = &[
7308 ("/users/{id}", "/users/{slug}", true),
7309 ("/users/{id}", "/users/{id}/posts", false),
7310 ("/cmd/{tool}/{sub}", "/cmd/{*path}", true),
7311 ("/users/me", "/users/{id}", false),
7312 ("/{{foo}}", "/{{bar}}", false),
7313 ("/file.{ext}", "/file.{kind}", true),
7314 ("/file.{ext}", "/file.json", false),
7315 ("/u/{id}", "/u/{*rest}", true),
7316 ];
7317
7318 let prev_hook = std::panic::take_hook();
7321 std::panic::set_hook(Box::new(|_| {}));
7322
7323 let mut rows = Vec::new();
7324 let mut mismatches = Vec::new();
7325 for &(a, b, expect_conflict) in matrix {
7326 let axum_panics = std::panic::catch_unwind(|| {
7328 let _ = axum::Router::<()>::new()
7329 .route(a, axum::routing::get(|| async { "a" }))
7330 .route(b, axum::routing::get(|| async { "b" }));
7331 })
7332 .is_err();
7333
7334 let mut r: matchit::Router<()> = matchit::Router::new();
7336 r.insert(a, ()).expect("first template must insert cleanly");
7337 let matchit_conflicts =
7338 matches!(r.insert(b, ()), Err(matchit::InsertError::Conflict { .. }));
7339
7340 rows.push(format!(
7341 "{a:<20} vs {b:<20} axum={} matchit={} expected={}",
7342 if axum_panics { "PANIC" } else { "ok" },
7343 if matchit_conflicts { "Err" } else { "Ok" },
7344 if expect_conflict { "conflict" } else { "ok" },
7345 ));
7346
7347 if axum_panics != matchit_conflicts || axum_panics != expect_conflict {
7348 mismatches.push(rows.last().unwrap().clone());
7349 }
7350 }
7351
7352 std::panic::set_hook(prev_hook);
7353
7354 assert!(
7355 mismatches.is_empty(),
7356 "matchit must agree with axum 0.8.9 AND the expected outcome on every \
7357 case (oracle divergence => false positives/negatives at mount).\n\
7358 full matrix:\n{}\nmismatches:\n{}",
7359 rows.join("\n"),
7360 mismatches.join("\n"),
7361 );
7362 }
7363
7364 fn create_ssg_dist(entries: &[(&str, &str, &[u8])]) -> tempfile::TempDir {
7380 let dir = tempfile::tempdir().expect("tempdir");
7381 let dist = dir.path().join("dist");
7382 let mut routes = std::collections::HashMap::new();
7383 for (route, file, bytes) in entries {
7384 let path = dist.join(file);
7385 if let Some(parent) = path.parent() {
7386 std::fs::create_dir_all(parent).expect("mkdir");
7387 }
7388 std::fs::write(&path, bytes).expect("write file");
7389 routes.insert(
7390 (*route).to_owned(),
7391 crate::static_gen::ManifestEntry {
7392 file: (*file).to_owned(),
7393 revalidate: None,
7394 },
7395 );
7396 }
7397 let manifest = crate::static_gen::StaticManifest {
7398 generated_at: "2026-07-12T00:00:00Z".to_owned(),
7399 autumn_version: "0.6.0".to_owned(),
7400 routes,
7401 };
7402 std::fs::write(
7403 dist.join("manifest.json"),
7404 serde_json::to_string(&manifest).unwrap(),
7405 )
7406 .unwrap();
7407 dir
7408 }
7409
7410 fn compression_enabled_config() -> AutumnConfig {
7411 let mut config = AutumnConfig::default();
7412 config.compression.enabled = true;
7413 config
7414 }
7415
7416 #[tokio::test]
7420 async fn ssg_html_hit_is_gzip_compressed() {
7421 let html = format!(
7422 "<html><body>{}</body></html>",
7423 "Lorem ipsum dolor sit amet. ".repeat(64)
7424 );
7425 let tmp = create_ssg_dist(&[("/", "index.html", html.as_bytes())]);
7426 let dist = tmp.path().join("dist");
7427
7428 let router = try_build_router_with_static(
7429 Vec::new(),
7430 &compression_enabled_config(),
7431 test_state(),
7432 Some(&dist),
7433 )
7434 .expect("router builds");
7435 let response = router
7436 .oneshot(
7437 Request::builder()
7438 .uri("/")
7439 .header("accept-encoding", "gzip")
7440 .body(Body::empty())
7441 .unwrap(),
7442 )
7443 .await
7444 .unwrap();
7445
7446 assert_eq!(response.status(), StatusCode::OK);
7447 assert_eq!(
7448 response
7449 .headers()
7450 .get(http::header::CONTENT_ENCODING)
7451 .and_then(|v| v.to_str().ok()),
7452 Some("gzip"),
7453 "manifest-backed SSG HTML page must be gzip-compressed"
7454 );
7455 let vary = response
7456 .headers()
7457 .get(http::header::VARY)
7458 .and_then(|v| v.to_str().ok())
7459 .unwrap_or("");
7460 assert!(
7461 vary.to_lowercase().contains("accept-encoding"),
7462 "Vary must advertise Accept-Encoding, got {vary:?}"
7463 );
7464 assert_eq!(
7465 response
7466 .headers()
7467 .get(http::header::CONTENT_TYPE)
7468 .and_then(|v| v.to_str().ok()),
7469 Some("text/html; charset=utf-8"),
7470 "HTML page keeps its text/html content type"
7471 );
7472 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
7474 .await
7475 .unwrap();
7476 assert_ne!(
7477 body.as_ref(),
7478 html.as_bytes(),
7479 "compressed body must differ from the raw HTML"
7480 );
7481 }
7482
7483 #[tokio::test]
7487 async fn ssg_binary_asset_is_not_compressed_and_keeps_mime() {
7488 let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
7491 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
7492 let tmp = create_ssg_dist(&[("/logo", "logo.png", &bytes)]);
7493 let dist = tmp.path().join("dist");
7494
7495 let router = try_build_router_with_static(
7496 Vec::new(),
7497 &compression_enabled_config(),
7498 test_state(),
7499 Some(&dist),
7500 )
7501 .expect("router builds");
7502 let response = router
7503 .oneshot(
7504 Request::builder()
7505 .uri("/logo")
7506 .header("accept-encoding", "gzip")
7507 .body(Body::empty())
7508 .unwrap(),
7509 )
7510 .await
7511 .unwrap();
7512
7513 assert_eq!(response.status(), StatusCode::OK);
7514 assert_eq!(
7515 response
7516 .headers()
7517 .get(http::header::CONTENT_TYPE)
7518 .and_then(|v| v.to_str().ok()),
7519 Some("image/png"),
7520 "binary manifest asset must keep its real MIME type, not text/html"
7521 );
7522 assert_eq!(
7523 response.headers().get(http::header::CONTENT_ENCODING),
7524 None,
7525 "binary asset must not be blindly compressed"
7526 );
7527 }
7528
7529 #[tokio::test]
7534 async fn ssg_woff2_font_is_not_compressed_and_keeps_mime() {
7535 let mut bytes = b"wOF2".to_vec();
7538 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
7539 let tmp = create_ssg_dist(&[("/inter", "fonts/inter.woff2", &bytes)]);
7540 let dist = tmp.path().join("dist");
7541
7542 let router = try_build_router_with_static(
7543 Vec::new(),
7544 &compression_enabled_config(),
7545 test_state(),
7546 Some(&dist),
7547 )
7548 .expect("router builds");
7549 let response = router
7550 .oneshot(
7551 Request::builder()
7552 .uri("/inter")
7553 .header("accept-encoding", "gzip")
7554 .body(Body::empty())
7555 .unwrap(),
7556 )
7557 .await
7558 .unwrap();
7559
7560 assert_eq!(response.status(), StatusCode::OK);
7561 assert_eq!(
7562 response
7563 .headers()
7564 .get(http::header::CONTENT_TYPE)
7565 .and_then(|v| v.to_str().ok()),
7566 Some("font/woff2"),
7567 "woff2 manifest asset must keep its font/woff2 MIME type"
7568 );
7569 assert_eq!(
7570 response.headers().get(http::header::CONTENT_ENCODING),
7571 None,
7572 "pre-compressed woff2 font must not be re-compressed"
7573 );
7574 }
7575
7576 #[tokio::test]
7582 async fn ssg_nested_multidot_asset_resolves_js_mime() {
7583 let js = format!("console.log({:?});", "x".repeat(256));
7584 let tmp = create_ssg_dist(&[("/app.js", "assets/js/app.min.js", js.as_bytes())]);
7585 let dist = tmp.path().join("dist");
7586
7587 let router = try_build_router_with_static(
7588 Vec::new(),
7589 &compression_enabled_config(),
7590 test_state(),
7591 Some(&dist),
7592 )
7593 .expect("router builds");
7594 let response = router
7595 .oneshot(
7596 Request::builder()
7597 .uri("/app.js")
7598 .header("accept-encoding", "gzip")
7599 .body(Body::empty())
7600 .unwrap(),
7601 )
7602 .await
7603 .unwrap();
7604
7605 assert_eq!(response.status(), StatusCode::OK);
7606 assert_eq!(
7607 response
7608 .headers()
7609 .get(http::header::CONTENT_TYPE)
7610 .and_then(|v| v.to_str().ok()),
7611 Some("text/javascript; charset=utf-8"),
7612 "nested multi-dot JS asset must resolve to the JavaScript MIME type"
7613 );
7614 assert_eq!(
7616 response
7617 .headers()
7618 .get(http::header::CONTENT_ENCODING)
7619 .and_then(|v| v.to_str().ok()),
7620 Some("gzip"),
7621 "compressible JS asset must be gzip-compressed"
7622 );
7623 }
7624
7625 #[tokio::test]
7631 async fn ssg_generated_html_page_keeps_text_html_and_is_compressed() {
7632 let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
7633 let tmp = create_ssg_dist(&[("/about", "about/index.html", html.as_bytes())]);
7634 let dist = tmp.path().join("dist");
7635
7636 let router = try_build_router_with_static(
7637 Vec::new(),
7638 &compression_enabled_config(),
7639 test_state(),
7640 Some(&dist),
7641 )
7642 .expect("router builds");
7643 let response = router
7644 .oneshot(
7645 Request::builder()
7646 .uri("/about")
7647 .header("accept-encoding", "gzip")
7648 .body(Body::empty())
7649 .unwrap(),
7650 )
7651 .await
7652 .unwrap();
7653
7654 assert_eq!(response.status(), StatusCode::OK);
7655 assert_eq!(
7656 response
7657 .headers()
7658 .get(http::header::CONTENT_TYPE)
7659 .and_then(|v| v.to_str().ok()),
7660 Some("text/html; charset=utf-8"),
7661 "extensionless generated page must stay text/html, not octet-stream"
7662 );
7663 assert_eq!(
7664 response
7665 .headers()
7666 .get(http::header::CONTENT_ENCODING)
7667 .and_then(|v| v.to_str().ok()),
7668 Some("gzip"),
7669 "generated HTML page must be gzip-compressed"
7670 );
7671 }
7672
7673 #[tokio::test]
7679 async fn ssg_generated_txt_route_is_text_plain_and_compressed() {
7680 let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128));
7681 let tmp =
7682 create_ssg_dist(&[("/robots.txt", "robots.txt/index.html", body_text.as_bytes())]);
7683 let dist = tmp.path().join("dist");
7684
7685 let router = try_build_router_with_static(
7686 Vec::new(),
7687 &compression_enabled_config(),
7688 test_state(),
7689 Some(&dist),
7690 )
7691 .expect("router builds");
7692 let response = router
7693 .oneshot(
7694 Request::builder()
7695 .uri("/robots.txt")
7696 .header("accept-encoding", "gzip")
7697 .body(Body::empty())
7698 .unwrap(),
7699 )
7700 .await
7701 .unwrap();
7702
7703 assert_eq!(response.status(), StatusCode::OK);
7704 assert_eq!(
7705 response
7706 .headers()
7707 .get(http::header::CONTENT_TYPE)
7708 .and_then(|v| v.to_str().ok()),
7709 Some("text/plain; charset=utf-8"),
7710 "generated .txt route must be text/plain, derived from the route extension"
7711 );
7712 assert_eq!(
7713 response
7714 .headers()
7715 .get(http::header::CONTENT_ENCODING)
7716 .and_then(|v| v.to_str().ok()),
7717 Some("gzip"),
7718 "compressible text/plain route must be gzip-compressed"
7719 );
7720 }
7721
7722 #[tokio::test]
7726 async fn ssg_generated_xml_route_is_xml_mime() {
7727 let xml = format!(
7728 "<?xml version=\"1.0\"?><urlset>{}</urlset>",
7729 "<url><loc>https://example.com/</loc></url>".repeat(64)
7730 );
7731 let tmp = create_ssg_dist(&[("/sitemap.xml", "sitemap.xml/index.html", xml.as_bytes())]);
7732 let dist = tmp.path().join("dist");
7733
7734 let router = try_build_router_with_static(
7735 Vec::new(),
7736 &compression_enabled_config(),
7737 test_state(),
7738 Some(&dist),
7739 )
7740 .expect("router builds");
7741 let response = router
7742 .oneshot(
7743 Request::builder()
7744 .uri("/sitemap.xml")
7745 .header("accept-encoding", "gzip")
7746 .body(Body::empty())
7747 .unwrap(),
7748 )
7749 .await
7750 .unwrap();
7751
7752 assert_eq!(response.status(), StatusCode::OK);
7753 assert_eq!(
7754 response
7755 .headers()
7756 .get(http::header::CONTENT_TYPE)
7757 .and_then(|v| v.to_str().ok()),
7758 Some("application/xml"),
7759 "generated .xml route must be application/xml, derived from the route extension"
7760 );
7761 }
7762
7763 #[tokio::test]
7770 async fn ssg_dotted_slug_generated_page_stays_html_and_compressed() {
7771 let html = format!(
7772 "<html><body>{}</body></html>",
7773 "Release notes. ".repeat(128)
7774 );
7775 let tmp = create_ssg_dist(&[(
7776 "/posts/release.v1",
7777 "release.v1/index.html",
7778 html.as_bytes(),
7779 )]);
7780 let dist = tmp.path().join("dist");
7781
7782 let router = try_build_router_with_static(
7783 Vec::new(),
7784 &compression_enabled_config(),
7785 test_state(),
7786 Some(&dist),
7787 )
7788 .expect("router builds");
7789 let response = router
7790 .oneshot(
7791 Request::builder()
7792 .uri("/posts/release.v1")
7793 .header("accept-encoding", "gzip")
7794 .body(Body::empty())
7795 .unwrap(),
7796 )
7797 .await
7798 .unwrap();
7799
7800 assert_eq!(response.status(), StatusCode::OK);
7801 assert_eq!(
7802 response
7803 .headers()
7804 .get(http::header::CONTENT_TYPE)
7805 .and_then(|v| v.to_str().ok()),
7806 Some("text/html; charset=utf-8"),
7807 "dotted-slug generated page must stay text/html, not octet-stream"
7808 );
7809 assert_eq!(
7810 response
7811 .headers()
7812 .get(http::header::CONTENT_ENCODING)
7813 .and_then(|v| v.to_str().ok()),
7814 Some("gzip"),
7815 "dotted-slug generated HTML page must be gzip-compressed"
7816 );
7817 }
7818
7819 #[tokio::test]
7824 async fn ssg_email_slug_generated_page_stays_html() {
7825 let html = format!("<html><body>{}</body></html>", "Profile. ".repeat(64));
7826 let tmp = create_ssg_dist(&[(
7827 "/users/alice@example.com",
7828 "alice@example.com/index.html",
7829 html.as_bytes(),
7830 )]);
7831 let dist = tmp.path().join("dist");
7832
7833 let router = try_build_router_with_static(
7834 Vec::new(),
7835 &compression_enabled_config(),
7836 test_state(),
7837 Some(&dist),
7838 )
7839 .expect("router builds");
7840 let response = router
7841 .oneshot(
7842 Request::builder()
7843 .uri("/users/alice@example.com")
7844 .header("accept-encoding", "gzip")
7845 .body(Body::empty())
7846 .unwrap(),
7847 )
7848 .await
7849 .unwrap();
7850
7851 assert_eq!(response.status(), StatusCode::OK);
7852 assert_eq!(
7853 response
7854 .headers()
7855 .get(http::header::CONTENT_TYPE)
7856 .and_then(|v| v.to_str().ok()),
7857 Some("text/html; charset=utf-8"),
7858 "email-like dotted-slug generated page must stay text/html"
7859 );
7860 }
7861
7862 #[tokio::test]
7866 async fn ssg_dynamic_fallback_route_is_gzip_compressed() {
7867 async fn dynamic() -> impl axum::response::IntoResponse {
7868 (
7869 [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7870 format!(
7871 "<html><body>{}</body></html>",
7872 "dynamic content ".repeat(64)
7873 ),
7874 )
7875 }
7876 let route = Route {
7877 method: http::Method::GET,
7878 path: "/dynamic",
7879 handler: axum::routing::get(dynamic),
7880 name: "dynamic",
7881 api_doc: crate::openapi::ApiDoc {
7882 method: "GET",
7883 path: "/dynamic",
7884 operation_id: "dynamic",
7885 success_status: 200,
7886 ..Default::default()
7887 },
7888 api_version: None,
7889 sunset_opt_out: false,
7890 repository: None,
7891 idempotency: crate::route::RouteIdempotency::default(),
7892 timeout: crate::route::RouteTimeout::default(),
7893 };
7894
7895 let tmp = create_ssg_dist(&[("/", "index.html", b"<h1>home</h1>")]);
7898 let dist = tmp.path().join("dist");
7899
7900 let router = try_build_router_with_static(
7901 vec![route],
7902 &compression_enabled_config(),
7903 test_state(),
7904 Some(&dist),
7905 )
7906 .expect("router builds");
7907 let response = router
7908 .oneshot(
7909 Request::builder()
7910 .uri("/dynamic")
7911 .header("accept-encoding", "gzip")
7912 .body(Body::empty())
7913 .unwrap(),
7914 )
7915 .await
7916 .unwrap();
7917
7918 assert_eq!(response.status(), StatusCode::OK);
7919 assert_eq!(
7920 response
7921 .headers()
7922 .get(http::header::CONTENT_ENCODING)
7923 .and_then(|v| v.to_str().ok()),
7924 Some("gzip"),
7925 "dynamic fallback route must be compressed just like SSG pages"
7926 );
7927 }
7928
7929 fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
7930 let dir = tempfile::tempdir().expect("tempdir");
7931 let dist = dir.path().join("dist");
7932 std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
7933 std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
7934 std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
7935
7936 let mut routes = std::collections::HashMap::new();
7937 routes.insert(
7938 "/".to_owned(),
7939 crate::static_gen::ManifestEntry {
7940 file: "index.html".to_owned(),
7941 revalidate: None,
7942 },
7943 );
7944 routes.insert(
7945 "/about".to_owned(),
7946 crate::static_gen::ManifestEntry {
7947 file: "about/index.html".to_owned(),
7948 revalidate,
7949 },
7950 );
7951
7952 let manifest = crate::static_gen::StaticManifest {
7953 generated_at: "2026-05-18T00:00:00Z".to_owned(),
7954 autumn_version: "0.5.0".to_owned(),
7955 routes,
7956 };
7957 let json = serde_json::to_string(&manifest).expect("serialize manifest");
7958 std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
7959 dir
7960 }
7961
7962 #[tokio::test]
7963 async fn static_serving_serves_get_request_inside_user_layers() {
7964 let tmp = create_static_dist(None);
7965 let dist = tmp.path().join("dist");
7966 let config = AutumnConfig::default();
7967
7968 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
7969 .expect("router builds");
7970
7971 let response = router
7972 .oneshot(
7973 Request::builder()
7974 .uri("/about")
7975 .body(Body::empty())
7976 .unwrap(),
7977 )
7978 .await
7979 .unwrap();
7980
7981 assert_eq!(response.status(), StatusCode::OK);
7982 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
7983 .await
7984 .unwrap();
7985 assert_eq!(body.as_ref(), b"<h1>About</h1>");
7986 }
7987
7988 #[tokio::test]
7989 async fn static_serving_serves_head_request() {
7990 let tmp = create_static_dist(None);
7991 let dist = tmp.path().join("dist");
7992 let config = AutumnConfig::default();
7993
7994 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
7995 .expect("router builds");
7996
7997 let response = router
7998 .oneshot(
7999 Request::builder()
8000 .method("HEAD")
8001 .uri("/about")
8002 .body(Body::empty())
8003 .unwrap(),
8004 )
8005 .await
8006 .unwrap();
8007
8008 assert_eq!(response.status(), StatusCode::OK);
8009 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8010 .await
8011 .unwrap();
8012 assert!(body.is_empty(), "HEAD response body should be empty");
8013 }
8014
8015 #[tokio::test]
8016 async fn static_serving_normalizes_trailing_slash() {
8017 let tmp = create_static_dist(None);
8018 let dist = tmp.path().join("dist");
8019 let config = AutumnConfig::default();
8020
8021 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8022 .expect("router builds");
8023
8024 let response = router
8025 .oneshot(
8026 Request::builder()
8027 .uri("/about/")
8028 .body(Body::empty())
8029 .unwrap(),
8030 )
8031 .await
8032 .unwrap();
8033
8034 assert_eq!(response.status(), StatusCode::OK);
8035 }
8036
8037 #[tokio::test]
8038 async fn static_serving_falls_through_for_unknown_route() {
8039 let tmp = create_static_dist(None);
8040 let dist = tmp.path().join("dist");
8041 let config = AutumnConfig::default();
8042
8043 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8044 .expect("router builds");
8045
8046 let response = router
8047 .oneshot(
8048 Request::builder()
8049 .uri("/not-in-manifest")
8050 .body(Body::empty())
8051 .unwrap(),
8052 )
8053 .await
8054 .unwrap();
8055
8056 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8057 }
8058
8059 #[tokio::test]
8060 async fn static_serving_skipped_when_no_manifest() {
8061 let tmp = tempfile::tempdir().expect("tempdir");
8062 let dist = tmp.path().join("dist");
8063 std::fs::create_dir_all(&dist).expect("mkdir dist");
8064 let config = AutumnConfig::default();
8065
8066 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8067 .expect("router builds even without manifest");
8068
8069 let response = router
8070 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
8071 .await
8072 .unwrap();
8073
8074 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8075 }
8076
8077 #[tokio::test]
8078 async fn static_serving_with_isr_manifest_builds_successfully() {
8079 let tmp = create_static_dist(Some(3600));
8080 let dist = tmp.path().join("dist");
8081 let config = AutumnConfig::default();
8082
8083 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8084 .expect("router with ISR manifest should build");
8085
8086 let response = router
8087 .oneshot(
8088 Request::builder()
8089 .uri("/about")
8090 .body(Body::empty())
8091 .unwrap(),
8092 )
8093 .await
8094 .unwrap();
8095
8096 assert_eq!(response.status(), StatusCode::OK);
8097 }
8098}
8099
8100#[cfg(test)]
8101mod trusted_host_tests {
8102 use super::*;
8103 use axum::body::Body;
8104 use http::Request;
8105 use tower::util::ServiceExt;
8106
8107 #[tokio::test]
8108 async fn trusted_host_allows_matching_and_blocks_nonmatching() {
8109 let mut cfg = AutumnConfig::default();
8110 cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
8111 let state = crate::state::AppState::for_test();
8112 let router = build_router(vec![], &cfg, state);
8113
8114 let ok = router
8115 .clone()
8116 .oneshot(
8117 Request::builder()
8118 .uri("/nope")
8119 .header("host", "api.example.com")
8120 .body(Body::empty())
8121 .unwrap(),
8122 )
8123 .await
8124 .unwrap();
8125 assert_eq!(ok.status(), StatusCode::NOT_FOUND);
8126
8127 let blocked = router
8128 .oneshot(
8129 Request::builder()
8130 .uri("/nope")
8131 .header("host", "evil.com")
8132 .body(Body::empty())
8133 .unwrap(),
8134 )
8135 .await
8136 .unwrap();
8137 assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
8138 }
8139
8140 #[tokio::test]
8141 async fn trusted_host_wildcard_allows_any_host() {
8142 let mut cfg = AutumnConfig::default();
8143 cfg.security.trusted_hosts.hosts = vec!["*".into()];
8144 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8145 let response = router
8146 .oneshot(
8147 Request::builder()
8148 .uri("/nope")
8149 .header("host", "anything.example")
8150 .body(Body::empty())
8151 .expect("request should build"),
8152 )
8153 .await
8154 .expect("request should complete");
8155 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8156 }
8157
8158 #[tokio::test]
8159 async fn trusted_host_bypasses_probe_paths() {
8160 let mut cfg = AutumnConfig::default();
8161 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8162 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8163 let response = router
8164 .oneshot(
8165 Request::builder()
8166 .uri("/actuator/health")
8167 .header("host", "evil.com")
8168 .body(Body::empty())
8169 .expect("request should build"),
8170 )
8171 .await
8172 .expect("request should complete");
8173 assert_eq!(response.status(), StatusCode::OK);
8174 }
8175
8176 #[tokio::test]
8177 async fn trusted_host_bypasses_actuator_health_path() {
8178 let mut cfg = AutumnConfig::default();
8179 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8180 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8181 let response = router
8182 .oneshot(
8183 Request::builder()
8184 .uri("/actuator/health")
8185 .header("host", "evil.com")
8186 .body(Body::empty())
8187 .expect("request should build"),
8188 )
8189 .await
8190 .expect("request should complete");
8191 assert_eq!(response.status(), StatusCode::OK);
8192 }
8193
8194 #[test]
8201 fn probe_bypass_paths_is_the_single_source_for_trusted_host_and_startup_barrier() {
8202 let mut cfg = AutumnConfig::default();
8203 cfg.health.path = "/custom-health-check".into();
8204 let expected = probe_bypass_paths(&cfg);
8205 assert!(expected.contains(&"/custom-health-check".to_string()));
8206
8207 let trusted_host = TrustedHostPolicy::from_config(&cfg);
8208 for path in &expected {
8209 assert!(
8210 trusted_host.probe_bypass_paths.contains(path),
8211 "TrustedHostPolicy must derive its bypass set from probe_bypass_paths(): missing {path}"
8212 );
8213 }
8214
8215 let state = crate::state::AppState::for_test();
8216 let barrier = StartupBarrierState::from_config(&cfg, &state);
8217 for path in &expected {
8218 assert!(
8219 barrier.allows_path(path),
8220 "StartupBarrierState must derive its bypass set from probe_bypass_paths(): missing {path}"
8221 );
8222 }
8223 }
8224
8225 #[cfg(feature = "http-client")]
8232 #[test]
8233 fn startup_barrier_allows_webhook_replay_post_path() {
8234 let mut cfg = AutumnConfig::default();
8235 cfg.actuator.sensitive = true;
8236 let state = crate::state::AppState::for_test();
8237 let barrier = StartupBarrierState::from_config(&cfg, &state);
8238 let replay_path =
8239 crate::actuator::actuator_route_path(&cfg.actuator.prefix, "/webhooks/replay");
8240 assert!(
8241 barrier.allows_path(&replay_path),
8242 "startup barrier must allow {replay_path} to bypass admission"
8243 );
8244 }
8245
8246 #[tokio::test]
8247 async fn trusted_host_release_rejects_loopback_unless_listed() {
8248 let mut cfg = AutumnConfig {
8249 profile: Some("prod".into()),
8250 ..AutumnConfig::default()
8251 };
8252 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8253 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8254 let response = router
8255 .oneshot(
8256 Request::builder()
8257 .uri("/nope")
8258 .header("host", "localhost")
8259 .body(Body::empty())
8260 .expect("request should build"),
8261 )
8262 .await
8263 .expect("request should complete");
8264 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8265 }
8266
8267 #[tokio::test]
8268 async fn trusted_host_uses_uri_authority_when_host_header_missing() {
8269 let mut cfg = AutumnConfig::default();
8270 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8271 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8272 let response = router
8273 .oneshot(
8274 Request::builder()
8275 .uri("http://EXAMPLE.COM/nope")
8276 .body(Body::empty())
8277 .expect("request should build"),
8278 )
8279 .await
8280 .expect("request should complete");
8281 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8282 }
8283
8284 #[tokio::test]
8285 async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
8286 let cfg = AutumnConfig::default();
8287 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8288 let response = router
8289 .oneshot(
8290 Request::builder()
8291 .uri("/nope")
8292 .header("host", "[::1]:3000")
8293 .body(Body::empty())
8294 .expect("request should build"),
8295 )
8296 .await
8297 .expect("request should complete");
8298 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8299 }
8300
8301 #[tokio::test]
8302 async fn trusted_host_matching_is_case_insensitive() {
8303 let mut cfg = AutumnConfig::default();
8304 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8305 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8306 let response = router
8307 .oneshot(
8308 Request::builder()
8309 .uri("/nope")
8310 .header("host", "EXAMPLE.COM")
8311 .body(Body::empty())
8312 .expect("request should build"),
8313 )
8314 .await
8315 .expect("request should complete");
8316 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8317 }
8318
8319 #[tokio::test]
8320 async fn trusted_host_rejects_malformed_port() {
8321 let mut cfg = AutumnConfig::default();
8322 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8323 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8324 let response = router
8325 .oneshot(
8326 Request::builder()
8327 .uri("/nope")
8328 .header("host", "example.com:abc")
8329 .body(Body::empty())
8330 .expect("request should build"),
8331 )
8332 .await
8333 .expect("request should complete");
8334 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8335 }
8336
8337 #[tokio::test]
8338 async fn trusted_host_rejects_empty_port_suffix() {
8339 let mut cfg = AutumnConfig::default();
8340 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8341 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8342 let response = router
8343 .oneshot(
8344 Request::builder()
8345 .uri("/nope")
8346 .header("host", "example.com:")
8347 .body(Body::empty())
8348 .expect("request should build"),
8349 )
8350 .await
8351 .expect("request should complete");
8352 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8353 }
8354
8355 #[tokio::test]
8356 async fn trusted_host_rejects_bracketed_reg_name() {
8357 let mut cfg = AutumnConfig::default();
8358 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8359 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8360 let response = router
8361 .oneshot(
8362 Request::builder()
8363 .uri("/nope")
8364 .header("host", "[example.com]")
8365 .body(Body::empty())
8366 .expect("request should build"),
8367 )
8368 .await
8369 .expect("request should complete");
8370 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8371 }
8372 #[tokio::test]
8373 async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
8374 let mut cfg = AutumnConfig::default();
8375 cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
8376 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8377 let response = router
8378 .oneshot(
8379 Request::builder()
8380 .uri("/nope")
8381 .header("host", "example.com")
8382 .body(Body::empty())
8383 .expect("request should build"),
8384 )
8385 .await
8386 .expect("request should complete");
8387 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8388 }
8389
8390 #[tokio::test]
8391 async fn trusted_host_accepts_trailing_dot_fqdn() {
8392 let mut cfg = AutumnConfig::default();
8393 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8394 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8395 let response = router
8396 .oneshot(
8397 Request::builder()
8398 .uri("/nope")
8399 .header("host", "example.com.")
8400 .body(Body::empty())
8401 .expect("request should build"),
8402 )
8403 .await
8404 .expect("request should complete");
8405 assert_eq!(response.status(), StatusCode::NOT_FOUND);
8406 }
8407
8408 #[tokio::test]
8409 async fn trusted_host_bypasses_custom_probe_path_only() {
8410 let mut cfg = AutumnConfig::default();
8411 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8412 cfg.health.path = "/healthz".into();
8413 cfg.health.startup_path = "/startupz".into();
8414 cfg.health.ready_path = "/readyz".into();
8415 cfg.health.live_path = "/livez".into();
8416 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8417
8418 let bypassed = router
8419 .clone()
8420 .oneshot(
8421 Request::builder()
8422 .uri("/healthz")
8423 .header("host", "evil.com")
8424 .body(Body::empty())
8425 .expect("request should build"),
8426 )
8427 .await
8428 .expect("request should complete");
8429 assert_eq!(bypassed.status(), StatusCode::OK);
8430
8431 let not_bypassed = router
8432 .oneshot(
8433 Request::builder()
8434 .uri("/health")
8435 .header("host", "evil.com")
8436 .body(Body::empty())
8437 .expect("request should build"),
8438 )
8439 .await
8440 .expect("request should complete");
8441 assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
8442 }
8443
8444 #[tokio::test]
8445 async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
8446 let mut cfg = AutumnConfig::default();
8447 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8448 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8449 let response = router
8450 .oneshot(
8451 Request::builder()
8452 .method("POST")
8453 .uri("/health")
8454 .header("host", "evil.com")
8455 .body(Body::empty())
8456 .expect("request should build"),
8457 )
8458 .await
8459 .expect("request should complete");
8460 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8461 }
8462
8463 #[tokio::test]
8466 async fn apply_upload_middleware_rejects_oversized_json_body() {
8467 let mut config = AutumnConfig::default();
8468 config.security.upload.max_request_size_bytes = 100; let base: axum::Router<AppState> = axum::Router::new().route(
8471 "/data",
8472 axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
8473 );
8474 let router =
8475 apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
8476
8477 let big_body = "x".repeat(200);
8479 let response = router
8480 .oneshot(
8481 Request::builder()
8482 .method("POST")
8483 .uri("/data")
8484 .header("content-type", "application/json")
8485 .body(Body::from(big_body))
8486 .unwrap(),
8487 )
8488 .await
8489 .unwrap();
8490
8491 assert_eq!(
8492 response.status(),
8493 StatusCode::PAYLOAD_TOO_LARGE,
8494 "oversized body must be rejected with 413 regardless of content type"
8495 );
8496 }
8497
8498 #[tokio::test]
8499 async fn apply_upload_middleware_accepts_body_within_limit() {
8500 let mut config = AutumnConfig::default();
8501 config.security.upload.max_request_size_bytes = 1024;
8502
8503 let base: axum::Router<AppState> = axum::Router::new().route(
8504 "/data",
8505 axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
8506 );
8507 let router =
8508 apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
8509
8510 let response = router
8511 .oneshot(
8512 Request::builder()
8513 .method("POST")
8514 .uri("/data")
8515 .header("content-type", "application/json")
8516 .body(Body::from("hello"))
8517 .unwrap(),
8518 )
8519 .await
8520 .unwrap();
8521
8522 assert_eq!(response.status(), StatusCode::OK);
8523 }
8524
8525 fn no_route_timeouts() -> RouteTimeoutTable {
8529 std::sync::Arc::new(std::collections::HashMap::new())
8530 }
8531
8532 fn get_route_timeouts(path: &str, timeout: crate::route::RouteTimeout) -> RouteTimeoutTable {
8535 let mut by_method = std::collections::HashMap::new();
8536 by_method.insert(http::Method::GET, timeout);
8537 let mut table = std::collections::HashMap::new();
8538 table.insert(path.to_owned(), by_method);
8539 std::sync::Arc::new(table)
8540 }
8541
8542 #[tokio::test(start_paused = true)]
8543 async fn request_timeout_returns_503_when_exceeded() {
8544 let mut config = AutumnConfig::default();
8545 config.server.timeouts.request_timeout_ms = Some(100);
8546
8547 let state = crate::state::AppState::for_test();
8548 let router: axum::Router<AppState> = axum::Router::new().route(
8549 "/slow",
8550 axum::routing::get(|| async {
8551 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8553 "ok"
8554 }),
8555 );
8556
8557 let router = apply_request_timeout_middleware(
8559 router,
8560 &config,
8561 state.metrics.clone(),
8562 no_route_timeouts(),
8563 false,
8564 )
8565 .layer(RequestIdLayer)
8566 .with_state(state);
8567
8568 let response = router
8569 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8570 .await
8571 .unwrap();
8572
8573 assert_eq!(
8574 response.status(),
8575 StatusCode::SERVICE_UNAVAILABLE,
8576 "a slow handler must trigger 503"
8577 );
8578 assert_eq!(
8579 response
8580 .headers()
8581 .get("content-type")
8582 .and_then(|v| v.to_str().ok()),
8583 Some("application/problem+json"),
8584 "timeout response must use Problem Details content type"
8585 );
8586 }
8587
8588 #[tokio::test(start_paused = true)]
8589 async fn request_timeout_increments_metric() {
8590 let mut config = AutumnConfig::default();
8591 config.server.timeouts.request_timeout_ms = Some(100);
8592
8593 let state = crate::state::AppState::for_test();
8594 let router: axum::Router<AppState> = axum::Router::new().route(
8595 "/slow",
8596 axum::routing::get(|| async {
8597 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8598 "ok"
8599 }),
8600 );
8601
8602 let router = apply_request_timeout_middleware(
8603 router,
8604 &config,
8605 state.metrics.clone(),
8606 no_route_timeouts(),
8607 false,
8608 )
8609 .layer(RequestIdLayer)
8610 .with_state(state.clone());
8611
8612 router
8613 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8614 .await
8615 .unwrap();
8616
8617 let snap = state.metrics.snapshot();
8618 assert_eq!(
8619 snap.http.request_timeouts_total, 1,
8620 "autumn_request_timeouts_total must be incremented on timeout"
8621 );
8622 }
8623
8624 #[tokio::test(start_paused = true)]
8625 async fn render_deadline_exempt_marker_skips_timeout() {
8626 let mut config = AutumnConfig::default();
8627 config.server.timeouts.request_timeout_ms = Some(100);
8628
8629 let state = crate::state::AppState::for_test();
8630 let router: axum::Router<AppState> = axum::Router::new().route(
8631 "/slow",
8632 axum::routing::get(|| async {
8633 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8636 "ok"
8637 }),
8638 );
8639
8640 let router = apply_request_timeout_middleware(
8641 router,
8642 &config,
8643 state.metrics.clone(),
8644 no_route_timeouts(),
8645 false,
8646 )
8647 .layer(RequestIdLayer)
8648 .with_state(state);
8649
8650 let live = router
8652 .clone()
8653 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8654 .await
8655 .unwrap();
8656 assert_eq!(
8657 live.status(),
8658 StatusCode::SERVICE_UNAVAILABLE,
8659 "a live request to a slow handler must still time out"
8660 );
8661
8662 let exempt = router
8665 .oneshot(
8666 Request::builder()
8667 .uri("/slow")
8668 .extension(crate::static_gen::RenderDeadlineExempt)
8669 .body(Body::empty())
8670 .unwrap(),
8671 )
8672 .await
8673 .unwrap();
8674 assert_eq!(
8675 exempt.status(),
8676 StatusCode::OK,
8677 "the build/ISR render marker must exempt the request from the deadline"
8678 );
8679 }
8680
8681 #[tokio::test(start_paused = true)]
8682 async fn request_timeout_503_mirrors_cors_headers() {
8683 let mut config = AutumnConfig::default();
8684 config.server.timeouts.request_timeout_ms = Some(100);
8685 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
8687 config.cors.allow_credentials = true;
8688
8689 let state = crate::state::AppState::for_test();
8690 let router: axum::Router<AppState> = axum::Router::new().route(
8691 "/slow",
8692 axum::routing::get(|| async {
8693 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8694 "ok"
8695 }),
8696 );
8697
8698 let router = apply_request_timeout_middleware(
8701 router,
8702 &config,
8703 state.metrics.clone(),
8704 no_route_timeouts(),
8705 true,
8706 )
8707 .layer(RequestIdLayer)
8708 .with_state(state);
8709
8710 let response = router
8711 .oneshot(
8712 Request::builder()
8713 .uri("/slow")
8714 .header("origin", "https://app.example.com")
8715 .body(Body::empty())
8716 .unwrap(),
8717 )
8718 .await
8719 .unwrap();
8720
8721 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8722 assert_eq!(
8723 response
8724 .headers()
8725 .get("access-control-allow-origin")
8726 .and_then(|v| v.to_str().ok()),
8727 Some("https://app.example.com"),
8728 "an allowed origin must be reflected on the timeout 503 so browsers can read it"
8729 );
8730 assert_eq!(
8731 response
8732 .headers()
8733 .get("access-control-allow-credentials")
8734 .and_then(|v| v.to_str().ok()),
8735 Some("true"),
8736 "credentials flag must be mirrored when configured"
8737 );
8738 assert!(
8739 response
8740 .headers()
8741 .get_all("vary")
8742 .iter()
8743 .any(|v| v.to_str().is_ok_and(|s| s.eq_ignore_ascii_case("origin"))),
8744 "a reflected origin must carry Vary: origin"
8745 );
8746 }
8747
8748 #[tokio::test(start_paused = true)]
8749 async fn request_timeout_503_omits_cors_for_disallowed_origin() {
8750 let mut config = AutumnConfig::default();
8751 config.server.timeouts.request_timeout_ms = Some(100);
8752 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
8753
8754 let state = crate::state::AppState::for_test();
8755 let router: axum::Router<AppState> = axum::Router::new().route(
8756 "/slow",
8757 axum::routing::get(|| async {
8758 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8759 "ok"
8760 }),
8761 );
8762
8763 let router = apply_request_timeout_middleware(
8764 router,
8765 &config,
8766 state.metrics.clone(),
8767 no_route_timeouts(),
8768 true,
8769 )
8770 .layer(RequestIdLayer)
8771 .with_state(state);
8772
8773 let response = router
8774 .oneshot(
8775 Request::builder()
8776 .uri("/slow")
8777 .header("origin", "https://evil.example.com")
8778 .body(Body::empty())
8779 .unwrap(),
8780 )
8781 .await
8782 .unwrap();
8783
8784 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8785 assert!(
8786 response
8787 .headers()
8788 .get("access-control-allow-origin")
8789 .is_none(),
8790 "a disallowed origin must not be reflected, mirroring CorsLayer"
8791 );
8792 }
8793
8794 #[tokio::test(start_paused = true)]
8795 async fn request_timeout_response_includes_request_id_header() {
8796 let mut config = AutumnConfig::default();
8797 config.server.timeouts.request_timeout_ms = Some(100);
8798
8799 let state = crate::state::AppState::for_test();
8800 let router: axum::Router<AppState> = axum::Router::new().route(
8801 "/slow",
8802 axum::routing::get(|| async {
8803 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8804 "ok"
8805 }),
8806 );
8807
8808 let router = apply_request_timeout_middleware(
8809 router,
8810 &config,
8811 state.metrics.clone(),
8812 no_route_timeouts(),
8813 false,
8814 )
8815 .layer(RequestIdLayer)
8816 .with_state(state);
8817
8818 let response = router
8819 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8820 .await
8821 .unwrap();
8822
8823 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8824 assert!(
8826 response.headers().contains_key("x-request-id"),
8827 "503 response must carry the X-Request-Id header"
8828 );
8829
8830 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
8832 .await
8833 .unwrap();
8834 let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
8835 assert_eq!(body["status"], 503);
8836 }
8837
8838 #[tokio::test]
8839 async fn request_timeout_disabled_when_none() {
8840 let config = AutumnConfig::default(); let state = crate::state::AppState::for_test();
8843 let router: axum::Router<AppState> =
8844 axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
8845
8846 let router = apply_request_timeout_middleware(
8847 router,
8848 &config,
8849 state.metrics.clone(),
8850 no_route_timeouts(),
8851 false,
8852 )
8853 .with_state(state);
8854
8855 let response = router
8856 .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
8857 .await
8858 .unwrap();
8859
8860 assert_eq!(response.status(), StatusCode::OK);
8861 }
8862
8863 #[tokio::test]
8864 async fn request_timeout_zero_treated_as_disabled() {
8865 let mut config = AutumnConfig::default();
8866 config.server.timeouts.request_timeout_ms = Some(0); let state = crate::state::AppState::for_test();
8869 let router: axum::Router<AppState> =
8870 axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
8871
8872 let router = apply_request_timeout_middleware(
8873 router,
8874 &config,
8875 state.metrics.clone(),
8876 no_route_timeouts(),
8877 false,
8878 )
8879 .with_state(state);
8880
8881 let response = router
8882 .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
8883 .await
8884 .unwrap();
8885
8886 assert_eq!(response.status(), StatusCode::OK);
8887 }
8888
8889 #[tokio::test(start_paused = true)]
8892 async fn request_timeout_503_without_request_id_layer() {
8893 let mut config = AutumnConfig::default();
8894 config.server.timeouts.request_timeout_ms = Some(100);
8895
8896 let state = crate::state::AppState::for_test();
8897 let router: axum::Router<AppState> = axum::Router::new().route(
8898 "/slow",
8899 axum::routing::get(|| async {
8900 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8901 "ok"
8902 }),
8903 );
8904
8905 let router = apply_request_timeout_middleware(
8907 router,
8908 &config,
8909 state.metrics.clone(),
8910 no_route_timeouts(),
8911 false,
8912 )
8913 .with_state(state);
8914
8915 let response = router
8916 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8917 .await
8918 .unwrap();
8919
8920 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8921 }
8922
8923 #[tokio::test(start_paused = true)]
8926 async fn request_timeout_per_route_override_extends_deadline() {
8927 let mut config = AutumnConfig::default();
8928 config.server.timeouts.request_timeout_ms = Some(100); let state = crate::state::AppState::for_test();
8931 let router: axum::Router<AppState> = axum::Router::new().route(
8932 "/export",
8933 axum::routing::get(|| async {
8934 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
8936 "report"
8937 }),
8938 );
8939
8940 let table = get_route_timeouts(
8941 "/export",
8942 crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10)),
8943 );
8944 let router =
8945 apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
8946 .with_state(state);
8947
8948 let response = router
8949 .oneshot(
8950 Request::builder()
8951 .uri("/export")
8952 .body(Body::empty())
8953 .unwrap(),
8954 )
8955 .await
8956 .unwrap();
8957
8958 assert_eq!(
8959 response.status(),
8960 StatusCode::OK,
8961 "the override must let the slow route complete past the global deadline"
8962 );
8963 }
8964
8965 #[tokio::test(start_paused = true)]
8967 async fn request_timeout_per_route_disabled_exempts_route() {
8968 let mut config = AutumnConfig::default();
8969 config.server.timeouts.request_timeout_ms = Some(100);
8970
8971 let state = crate::state::AppState::for_test();
8972 let router: axum::Router<AppState> = axum::Router::new().route(
8973 "/stream",
8974 axum::routing::get(|| async {
8975 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
8976 "done"
8977 }),
8978 );
8979
8980 let table = get_route_timeouts("/stream", crate::route::RouteTimeout::Disabled);
8981 let router =
8982 apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
8983 .with_state(state.clone());
8984
8985 let response = router
8986 .oneshot(
8987 Request::builder()
8988 .uri("/stream")
8989 .body(Body::empty())
8990 .unwrap(),
8991 )
8992 .await
8993 .unwrap();
8994
8995 assert_eq!(response.status(), StatusCode::OK);
8996 assert_eq!(
8997 state.metrics.snapshot().http.request_timeouts_total,
8998 0,
8999 "an exempt route must not record a timeout"
9000 );
9001 }
9002
9003 #[tokio::test(start_paused = true)]
9005 async fn request_timeout_override_active_when_global_disabled() {
9006 let config = AutumnConfig::default(); let state = crate::state::AppState::for_test();
9009 let router: axum::Router<AppState> = axum::Router::new().route(
9010 "/export",
9011 axum::routing::get(|| async {
9012 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
9013 "report"
9014 }),
9015 );
9016
9017 let table = get_route_timeouts(
9018 "/export",
9019 crate::route::RouteTimeout::Override(std::time::Duration::from_millis(100)),
9020 );
9021 let router =
9022 apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
9023 .with_state(state);
9024
9025 let response = router
9026 .oneshot(
9027 Request::builder()
9028 .uri("/export")
9029 .body(Body::empty())
9030 .unwrap(),
9031 )
9032 .await
9033 .unwrap();
9034
9035 assert_eq!(
9036 response.status(),
9037 StatusCode::SERVICE_UNAVAILABLE,
9038 "a per-route override must be enforced even with the global timeout off"
9039 );
9040 }
9041
9042 #[test]
9043 fn build_route_timeout_table_is_empty_without_routes() {
9044 let table = build_route_timeout_table(&[], &[]);
9048 assert!(table.is_empty(), "no routes ⇒ empty override table");
9049 }
9050
9051 fn timeout_route(
9054 method: http::Method,
9055 path: &'static str,
9056 timeout: crate::route::RouteTimeout,
9057 ) -> Route {
9058 async fn noop() -> &'static str {
9059 "ok"
9060 }
9061 Route {
9062 method,
9063 path,
9064 handler: axum::routing::get(noop),
9065 name: "noop",
9066 api_doc: crate::openapi::ApiDoc::default(),
9067 repository: None,
9068 idempotency: crate::route::RouteIdempotency::Direct,
9069 timeout,
9070 api_version: None,
9071 sunset_opt_out: false,
9072 }
9073 }
9074
9075 #[test]
9076 fn build_route_timeout_table_normalizes_method_aliases() {
9077 let override_10s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10));
9078 let routes = vec![
9079 timeout_route(http::Method::GET, "/export", override_10s),
9081 timeout_route(
9084 http::Method::from_bytes(b"WS").unwrap(),
9085 "/live",
9086 crate::route::RouteTimeout::Disabled,
9087 ),
9088 timeout_route(http::Method::POST, "/submit", override_10s),
9090 ];
9091
9092 let table = build_route_timeout_table(&routes, &[]);
9093
9094 let export = table.get("/export").expect("/export keyed");
9096 assert_eq!(export.get(&http::Method::GET), Some(&override_10s));
9097 assert_eq!(
9098 export.get(&http::Method::HEAD),
9099 Some(&override_10s),
9100 "a GET override must also cover the HEAD alias axum serves"
9101 );
9102
9103 let live = table.get("/live").expect("/live keyed");
9106 assert_eq!(
9107 live.get(&http::Method::GET),
9108 Some(&crate::route::RouteTimeout::Disabled),
9109 "a WS override must be keyed under the GET the upgrade arrives as"
9110 );
9111 assert!(
9112 live.get(&http::Method::from_bytes(b"WS").unwrap())
9113 .is_none(),
9114 "the synthetic WS method is never seen at lookup time"
9115 );
9116
9117 let submit = table.get("/submit").expect("/submit keyed");
9119 assert_eq!(submit.get(&http::Method::POST), Some(&override_10s));
9120 assert!(submit.get(&http::Method::HEAD).is_none());
9121 }
9122
9123 #[test]
9124 fn build_route_timeout_table_keys_scoped_root_by_axum_matched_path() {
9125 let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
9131 let make_group = |prefix: &str| crate::app::ScopedGroup {
9132 prefix: prefix.to_owned(),
9133 routes: vec![timeout_route(http::Method::GET, "/", override_5s)],
9134 source: crate::route_listing::RouteSource::User,
9135 apply_layer: Box::new(|r| r),
9136 };
9137
9138 let table = build_route_timeout_table(&[], &[make_group("/api/")]);
9139 assert_eq!(
9140 table.get("/api/").and_then(|m| m.get(&http::Method::GET)),
9141 Some(&override_5s),
9142 "trailing-slash scoped root must key the override at /api/"
9143 );
9144 assert!(
9145 table.get("/api").is_none(),
9146 "the stripped /api key would never match the runtime lookup"
9147 );
9148
9149 let table = build_route_timeout_table(&[], &[make_group("/api")]);
9151 assert_eq!(
9152 table.get("/api").and_then(|m| m.get(&http::Method::GET)),
9153 Some(&override_5s),
9154 );
9155 }
9156
9157 fn redirect_gate_registration() -> crate::app::CustomLayerRegistration {
9164 let gate = axum::middleware::from_fn(
9165 |req: axum::extract::Request, next: axum::middleware::Next| async move {
9166 if req.headers().contains_key("x-authed") {
9167 next.run(req).await
9168 } else {
9169 http::Response::builder()
9170 .status(StatusCode::FOUND)
9171 .header(http::header::LOCATION, "/login")
9172 .body(Body::empty())
9173 .unwrap()
9174 }
9175 },
9176 );
9177 crate::app::CustomLayerRegistration {
9178 type_id: std::any::TypeId::of::<()>(),
9179 type_name: "redirect_gate",
9180 apply: Box::new(move |router| router.layer(gate)),
9181 }
9182 }
9183
9184 fn build_cached_dist(marker: &str) -> (tempfile::TempDir, std::path::PathBuf) {
9188 let tmp = tempfile::tempdir().expect("tempdir");
9189 let dist = tmp.path().join("dist");
9190 std::fs::create_dir_all(&dist).expect("create dist");
9191 std::fs::write(dist.join("index.html"), marker).expect("write index.html");
9192 let mut routes = std::collections::HashMap::new();
9193 routes.insert(
9194 "/".to_owned(),
9195 crate::static_gen::ManifestEntry {
9196 file: "index.html".to_owned(),
9197 revalidate: None,
9198 },
9199 );
9200 let manifest = crate::static_gen::StaticManifest {
9201 generated_at: "2026-06-14T00:00:00Z".to_owned(),
9202 autumn_version: "0.3.0".to_owned(),
9203 routes,
9204 };
9205 std::fs::write(
9206 dist.join("manifest.json"),
9207 serde_json::to_string(&manifest).expect("serialize manifest"),
9208 )
9209 .expect("write manifest");
9210 (tmp, dist)
9211 }
9212
9213 fn ctx_with_static_gate(gate: crate::app::CustomLayerRegistration) -> RouterContext {
9214 RouterContext {
9215 exception_filters: Vec::new(),
9216 scoped_groups: Vec::new(),
9217 merge_routers: Vec::new(),
9218 nest_routers: Vec::new(),
9219 custom_layers: Vec::new(),
9220 static_gate_layers: vec![gate],
9221 #[cfg(feature = "maud")]
9222 error_page_renderer: None,
9223 session_store: None,
9224 #[cfg(feature = "openapi")]
9225 openapi: None,
9226 #[cfg(feature = "mcp")]
9227 mcp: None,
9228 }
9229 }
9230
9231 #[tokio::test]
9232 async fn static_gate_runs_before_cached_static_page() {
9233 let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
9237 let config = AutumnConfig::default();
9238 let ctx = ctx_with_static_gate(redirect_gate_registration());
9239
9240 let app = super::try_build_router_with_static_inner(
9241 Vec::new(),
9242 &config,
9243 crate::state::AppState::for_test(),
9244 Some(dist.as_path()),
9245 ctx,
9246 )
9247 .expect("router builds");
9248
9249 let unauthed = app
9251 .clone()
9252 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9253 .await
9254 .unwrap();
9255 assert_eq!(
9256 unauthed.status(),
9257 StatusCode::FOUND,
9258 "static_gate must redirect before the cached page is served"
9259 );
9260 assert_eq!(
9261 unauthed.headers().get(http::header::LOCATION).unwrap(),
9262 "/login"
9263 );
9264
9265 let authed = app
9267 .oneshot(
9268 Request::builder()
9269 .uri("/")
9270 .header("x-authed", "1")
9271 .body(Body::empty())
9272 .unwrap(),
9273 )
9274 .await
9275 .unwrap();
9276 assert_eq!(authed.status(), StatusCode::OK);
9277 let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
9278 .await
9279 .unwrap();
9280 assert!(
9281 String::from_utf8_lossy(&body).contains("cached"),
9282 "authenticated request should receive the cached page"
9283 );
9284 }
9285
9286 #[tokio::test]
9287 async fn static_gate_runs_in_dynamic_mode() {
9288 async fn dynamic_handler() -> &'static str {
9292 "dynamic"
9293 }
9294 let route = Route {
9295 method: http::Method::GET,
9296 path: "/",
9297 handler: axum::routing::get(dynamic_handler),
9298 name: "root",
9299 api_doc: crate::openapi::ApiDoc {
9300 method: "GET",
9301 path: "/",
9302 operation_id: "root",
9303 success_status: 200,
9304 ..Default::default()
9305 },
9306 repository: None,
9307 idempotency: crate::route::RouteIdempotency::Direct,
9308 timeout: crate::route::RouteTimeout::Inherit,
9309 api_version: None,
9310 sunset_opt_out: false,
9311 };
9312 let config = AutumnConfig::default();
9313 let ctx = ctx_with_static_gate(redirect_gate_registration());
9314
9315 let app = super::try_build_router_with_static_inner(
9316 vec![route],
9317 &config,
9318 crate::state::AppState::for_test(),
9319 None,
9320 ctx,
9321 )
9322 .expect("router builds");
9323
9324 let unauthed = app
9325 .clone()
9326 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9327 .await
9328 .unwrap();
9329 assert_eq!(unauthed.status(), StatusCode::FOUND);
9330
9331 let authed = app
9332 .oneshot(
9333 Request::builder()
9334 .uri("/")
9335 .header("x-authed", "1")
9336 .body(Body::empty())
9337 .unwrap(),
9338 )
9339 .await
9340 .unwrap();
9341 assert_eq!(authed.status(), StatusCode::OK);
9342 let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
9343 .await
9344 .unwrap();
9345 assert_eq!(String::from_utf8_lossy(&body), "dynamic");
9346 }
9347
9348 #[tokio::test]
9349 async fn static_gate_redirect_carries_security_headers_ssg() {
9350 let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
9353 let config = AutumnConfig::default();
9354 let ctx = ctx_with_static_gate(redirect_gate_registration());
9355
9356 let app = super::try_build_router_with_static_inner(
9357 Vec::new(),
9358 &config,
9359 crate::state::AppState::for_test(),
9360 Some(dist.as_path()),
9361 ctx,
9362 )
9363 .expect("router builds");
9364
9365 let unauthed = app
9366 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9367 .await
9368 .unwrap();
9369 assert_eq!(unauthed.status(), StatusCode::FOUND);
9370 assert_eq!(
9373 unauthed
9374 .headers()
9375 .get("x-content-type-options")
9376 .expect("gate redirect must carry security headers"),
9377 "nosniff"
9378 );
9379 }
9380
9381 #[tokio::test]
9382 async fn static_gate_redirect_carries_security_headers_dynamic() {
9383 async fn dynamic_handler() -> &'static str {
9387 "dynamic"
9388 }
9389 let route = Route {
9390 method: http::Method::GET,
9391 path: "/",
9392 handler: axum::routing::get(dynamic_handler),
9393 name: "root",
9394 api_doc: crate::openapi::ApiDoc {
9395 method: "GET",
9396 path: "/",
9397 operation_id: "root",
9398 success_status: 200,
9399 ..Default::default()
9400 },
9401 repository: None,
9402 idempotency: crate::route::RouteIdempotency::Direct,
9403 timeout: crate::route::RouteTimeout::Inherit,
9404 api_version: None,
9405 sunset_opt_out: false,
9406 };
9407 let config = AutumnConfig::default();
9408 let ctx = ctx_with_static_gate(redirect_gate_registration());
9409
9410 let app = super::try_build_router_with_static_inner(
9411 vec![route],
9412 &config,
9413 crate::state::AppState::for_test(),
9414 None,
9415 ctx,
9416 )
9417 .expect("router builds");
9418
9419 let unauthed = app
9420 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9421 .await
9422 .unwrap();
9423 assert_eq!(unauthed.status(), StatusCode::FOUND);
9424 assert_eq!(
9425 unauthed
9426 .headers()
9427 .get("x-content-type-options")
9428 .expect("dynamic gate redirect must carry security headers"),
9429 "nosniff"
9430 );
9431 }
9432
9433 #[test]
9434 fn static_gate_layer_requires_fail_closed_idempotency() {
9435 let gate = vec![redirect_gate_registration()];
9439 assert!(super::custom_layers_require_fail_closed_idempotency(&gate));
9440 assert!(!super::custom_layers_require_fail_closed_idempotency(&[]));
9442 }
9443}
9444#[derive(Clone, Debug)]
9445pub struct TrustedHostPolicy {
9446 rules: Arc<Vec<String>>,
9447 allow_any: bool,
9448 allow_missing_host: bool,
9449 probe_bypass_paths: Arc<std::collections::HashSet<String>>,
9450}
9451
9452impl TrustedHostPolicy {
9453 pub fn from_config(config: &AutumnConfig) -> Self {
9454 let mut rules: Vec<String> = config
9455 .security
9456 .trusted_hosts
9457 .hosts
9458 .iter()
9459 .map(|h| h.trim().to_ascii_lowercase())
9460 .map(|h| h.trim_end_matches('.').to_owned())
9461 .filter(|h| !h.is_empty())
9462 .collect();
9463 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
9464 if !is_production {
9465 rules.extend(
9466 ["localhost", "127.0.0.1", "::1"]
9467 .into_iter()
9468 .map(std::borrow::ToOwned::to_owned),
9469 );
9470 }
9471 let allow_any = rules.iter().any(|h| h == "*");
9472 let probe_bypass_paths = probe_bypass_paths(config).into_iter().collect();
9473 Self {
9474 rules: Arc::new(rules),
9475 allow_any,
9476 allow_missing_host: !is_production,
9477 probe_bypass_paths: Arc::new(probe_bypass_paths),
9478 }
9479 }
9480
9481 #[cfg(feature = "mcp")]
9488 pub const fn allows_missing_host(&self) -> bool {
9489 self.allow_missing_host
9490 }
9491
9492 pub fn allows_host(&self, host: &str) -> bool {
9493 if self.allow_any {
9494 return true;
9495 }
9496 self.rules.iter().any(|rule| {
9497 rule.strip_prefix('.').map_or_else(
9498 || host == rule,
9499 |suffix| {
9500 host == suffix
9501 || host
9502 .strip_suffix(suffix)
9503 .is_some_and(|prefix| prefix.ends_with('.'))
9504 },
9505 )
9506 })
9507 }
9508}
9509
9510#[derive(Clone, Debug)]
9512pub struct RouteVersionMetadata {
9513 pub version: String,
9514 pub sunset_opt_out: bool,
9515 pub secured: bool,
9516 pub required_roles: &'static [&'static str],
9517 pub has_policy: bool,
9518}
9519
9520async fn api_versioning_middleware(
9522 state: axum::extract::State<AppState>,
9523 route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
9524 request: axum::http::Request<axum::body::Body>,
9525 next: axum::middleware::Next,
9526) -> axum::response::Response {
9527 let Some(axum::extract::Extension(meta)) = route_version else {
9528 return next.run(request).await;
9529 };
9530
9531 let clock = state.clock();
9532 let now = clock.now();
9533
9534 let versions = state.extension::<crate::app::RegisteredApiVersions>();
9535 let matching_version = versions
9536 .as_ref()
9537 .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
9538
9539 let Some(version) = matching_version else {
9540 return next.run(request).await;
9541 };
9542
9543 let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
9544 let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
9545
9546 if is_sunset && !meta.sunset_opt_out {
9547 if meta.has_policy {
9548 return next.run(request).await;
9549 }
9550 if meta.secured {
9551 let session = request.extensions().get::<crate::session::Session>();
9552 let mut auth_failed = false;
9553 let mut auth_error = None;
9554 if let Some(session) = session {
9555 if let Err(err) = crate::auth::__check_secured_with_key(
9556 session,
9557 state.auth_session_key(),
9558 meta.required_roles,
9559 )
9560 .await
9561 {
9562 auth_failed = true;
9563 auth_error = Some(err);
9564 }
9565 } else {
9566 auth_failed = true;
9567 auth_error = Some(crate::error::AutumnError::unauthorized_msg(
9568 "authentication required",
9569 ));
9570 }
9571 if auth_failed {
9572 return auth_error.unwrap().into_response();
9573 }
9574 }
9575
9576 let err = crate::error::AutumnError::gone_msg(format!(
9577 "API version '{}' has been sunsetted.",
9578 meta.version
9579 ));
9580 let mut response = err.into_response();
9581 if let Some(sunset) = version.sunset_at {
9582 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
9583 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
9584 response.headers_mut().insert("Sunset", val);
9585 }
9586 }
9587 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
9588 (Some(d), Some(s)) => Some(d.min(s)),
9589 (d, s) => d.or(s),
9590 };
9591 if let Some(date) = deprecation_date {
9592 let timestamp = date.timestamp();
9593 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
9594 response.headers_mut().insert("Deprecation", val);
9595 }
9596 }
9597 return response;
9598 }
9599
9600 let mut response = next.run(request).await;
9601
9602 if is_deprecated || is_sunset {
9603 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
9604 (Some(d), Some(s)) => Some(d.min(s)),
9605 (d, s) => d.or(s),
9606 };
9607 if let Some(date) = deprecation_date {
9608 let timestamp = date.timestamp();
9609 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
9610 response.headers_mut().insert("Deprecation", val);
9611 }
9612 }
9613 }
9614 if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
9615 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
9616 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
9617 response.headers_mut().insert("Sunset", val);
9618 }
9619 }
9620
9621 response
9622}
9623
9624#[must_use]
9627pub fn check_sunset(
9628 state: &crate::state::AppState,
9629 meta: &RouteVersionMetadata,
9630) -> Option<axum::response::Response> {
9631 let clock = state.clock();
9632 let now = clock.now();
9633
9634 let versions = state.extension::<crate::app::RegisteredApiVersions>();
9635 let matching_version = versions
9636 .as_ref()
9637 .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
9638
9639 let version = matching_version?;
9640 let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
9641
9642 if is_sunset && !meta.sunset_opt_out {
9643 let err = crate::error::AutumnError::gone_msg(format!(
9644 "API version '{}' has been sunsetted.",
9645 meta.version
9646 ));
9647 let mut response = axum::response::IntoResponse::into_response(err);
9648 if let Some(sunset) = version.sunset_at {
9649 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
9650 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
9651 response.headers_mut().insert("Sunset", val);
9652 }
9653 }
9654 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
9655 (Some(d), Some(s)) => Some(d.min(s)),
9656 (d, s) => d.or(s),
9657 };
9658 if let Some(date) = deprecation_date {
9659 let timestamp = date.timestamp();
9660 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
9661 response.headers_mut().insert("Deprecation", val);
9662 }
9663 }
9664 return Some(response);
9665 }
9666
9667 None
9668}
9669
9670#[cfg(all(test, feature = "htmx"))]
9671mod idiomorph_tests {
9672 use super::*;
9673 use http::StatusCode;
9674 use http_body_util::BodyExt;
9675
9676 #[tokio::test]
9677 async fn idiomorph_handler_returns_js_with_correct_headers() {
9678 let response = idiomorph_handler().await;
9679
9680 assert_eq!(response.status(), StatusCode::OK);
9681
9682 let ct = response
9683 .headers()
9684 .get(http::header::CONTENT_TYPE)
9685 .and_then(|v| v.to_str().ok())
9686 .unwrap_or("");
9687 assert_eq!(ct, "application/javascript");
9688
9689 let cc = response
9690 .headers()
9691 .get(http::header::CACHE_CONTROL)
9692 .and_then(|v| v.to_str().ok())
9693 .unwrap_or("");
9694 assert!(
9699 cc.contains("must-revalidate"),
9700 "expected revalidating cache-control, got: {cc}"
9701 );
9702 assert!(
9703 !cc.contains("immutable"),
9704 "cache-control must not be immutable for a non-fingerprinted URL, got: {cc}"
9705 );
9706
9707 let etag = response
9713 .headers()
9714 .get(http::header::ETAG)
9715 .and_then(|v| v.to_str().ok())
9716 .unwrap_or("");
9717 assert!(
9718 etag.starts_with("W/\"idiomorph-") && etag.ends_with('"'),
9719 "expected a weak quoted idiomorph ETag, got: {etag}"
9720 );
9721
9722 let body = response.into_body().collect().await.unwrap().to_bytes();
9723 assert!(!body.is_empty(), "idiomorph JS body must be non-empty");
9724 }
9725}
9726
9727#[cfg(test)]
9728mod proptests {
9729 use super::*;
9734 use proptest::prelude::*;
9735
9736 proptest! {
9737 #![proptest_config(ProptestConfig::with_cases(256))]
9738
9739 #[test]
9743 fn join_nested_path_root_child_is_identity(prefix in "/?[a-z0-9/]{0,20}", root in prop::sample::select(vec!["/", ""])) {
9744 let once = join_nested_path(&prefix, root);
9745 let expected = if prefix.is_empty() { "/".to_owned() } else { prefix };
9746 prop_assert_eq!(&once, &expected);
9747 let twice = join_nested_path(&once, root);
9748 prop_assert_eq!(once, twice);
9749 }
9750
9751 #[test]
9754 fn join_nested_path_no_double_slash_at_seam(prefix in "/[a-z0-9]{1,8}/?", child in "/[a-z0-9]{1,8}") {
9755 let joined = join_nested_path(&prefix, &child);
9756 prop_assert!(!joined.contains("//"), "unexpected `//` in {joined:?}");
9757 }
9758
9759 #[test]
9764 fn extract_host_without_port_never_panics(header in ".*") {
9765 if let Some(host) = extract_host_without_port(&header) {
9766 prop_assert!(header.contains(host));
9767 }
9768 }
9769
9770 #[test]
9773 fn path_matches_route_prefix_reflexive(path in ".*") {
9774 prop_assert!(path_matches_route_prefix(&path, &path));
9775 }
9776
9777 #[test]
9781 fn path_matches_route_prefix_boundary(path in "/?[a-z0-9/]{0,24}", prefix in "/?[a-z0-9/]{0,24}") {
9782 if path_matches_route_prefix(&path, &prefix) {
9783 let boundary_ok = path == prefix
9784 || path.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('/'));
9785 prop_assert!(boundary_ok, "match without boundary: path={path:?} prefix={prefix:?}");
9786 }
9787 }
9788 }
9789
9790 #[cfg(feature = "openapi")]
9793 proptest! {
9794 #![proptest_config(ProptestConfig::with_cases(256))]
9795
9796 #[test]
9797 fn extract_path_params_never_panics(path in ".*") {
9798 for name in extract_path_params(&path) {
9799 prop_assert!(!name.is_empty());
9800 let has_brace = name.contains('{') || name.contains('}');
9801 prop_assert!(!has_brace, "param name should be brace-free: {name:?}");
9802 }
9803 }
9804
9805 #[test]
9814 fn extract_path_params_brace_inputs_are_brace_free(path in "[{}a-z:]{0,6}") {
9815 for name in extract_path_params(&path) {
9816 prop_assert!(!name.is_empty());
9817 let has_brace = name.contains('{') || name.contains('}');
9818 prop_assert!(!has_brace, "param name should be brace-free for {path:?}: {name:?}");
9819 }
9820 }
9821 }
9822}