use serde::{Deserialize, Serialize};
use crate::app::ScopedGroup;
use crate::route::Route;
pub const OMITTED_ROUTES_MARKER: &str = "[autumn:omitted-routes] ";
pub const SECURITY_CONFIG_MARKER: &str = "[autumn:security-config] ";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CsrfDump {
pub enabled: bool,
pub safe_methods: Vec<String>,
pub exempt_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct HeadersDump {
pub x_frame_options: String,
pub x_content_type_options: bool,
pub xss_protection: bool,
pub content_security_policy: String,
pub referrer_policy: String,
pub permissions_policy: String,
pub strict_transport_security: bool,
pub hsts_max_age_secs: u64,
pub hsts_include_subdomains: bool,
pub csp_nonce: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityDump {
pub csrf: CsrfDump,
pub headers: HeadersDump,
}
impl SecurityDump {
#[must_use]
pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
let csrf = &config.security.csrf;
let headers = &config.security.headers;
let mut safe_methods = csrf.safe_methods.clone();
safe_methods.sort();
safe_methods.dedup();
let mut exempt_paths = csrf.exempt_paths.clone();
for endpoint in &config.security.webhooks.endpoints {
exempt_paths.push(endpoint.path.clone());
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
exempt_paths.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
}
exempt_paths.sort();
exempt_paths.dedup();
Self {
csrf: CsrfDump {
enabled: csrf.enabled,
safe_methods,
exempt_paths,
},
headers: HeadersDump {
x_frame_options: headers.x_frame_options.clone(),
x_content_type_options: headers.x_content_type_options,
xss_protection: headers.xss_protection,
content_security_policy: crate::security::headers::resolved_content_security_policy(
headers,
),
referrer_policy: headers.referrer_policy.clone(),
permissions_policy: headers.permissions_policy.clone(),
strict_transport_security: headers.strict_transport_security,
hsts_max_age_secs: headers.hsts_max_age_secs,
hsts_include_subdomains: headers.hsts_include_subdomains,
csp_nonce: headers.csp_nonce.enabled,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RouteSource {
#[default]
User,
Plugin(String),
Framework,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RouteClassification {
Framework,
Gated,
Public,
#[default]
Unclassified,
}
impl RouteClassification {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Framework => "framework",
Self::Gated => "gated",
Self::Public => "public",
Self::Unclassified => "unclassified",
}
}
}
impl std::fmt::Display for RouteClassification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for RouteClassification {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for RouteClassification {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"framework" => Self::Framework,
"gated" => Self::Gated,
"public" => Self::Public,
_ => Self::Unclassified,
})
}
}
impl std::fmt::Display for RouteSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User => write!(f, "user"),
Self::Plugin(name) => write!(f, "plugin:{name}"),
Self::Framework => write!(f, "framework"),
}
}
}
impl Serialize for RouteSource {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for RouteSource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(if s == "framework" {
Self::Framework
} else if let Some(name) = s.strip_prefix("plugin:") {
Self::Plugin(name.to_owned())
} else {
Self::User
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AuthorizeBindingInfo {
pub action: String,
pub resource: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RouteInfo {
pub method: String,
pub path: String,
pub handler: String,
pub source: RouteSource,
pub middleware: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset_opt_out: Option<bool>,
#[serde(default)]
pub classification: RouteClassification,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub policy: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authorize_bindings: Vec<AuthorizeBindingInfo>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_false(b: &bool) -> bool {
!*b
}
impl RouteInfo {
fn framework_get(path: String, handler: &str) -> Self {
Self::framework_route("GET", path, handler)
}
fn framework_route(method: &str, path: String, handler: &str) -> Self {
Self {
method: method.to_owned(),
path,
handler: handler.to_owned(),
source: RouteSource::Framework,
classification: RouteClassification::Framework,
..Self::default()
}
}
}
fn classify(
source: &RouteSource,
api_doc: &crate::openapi::ApiDoc,
repository: Option<&crate::route::RepositoryApiMeta>,
) -> (RouteClassification, Vec<String>, Vec<String>, bool) {
if matches!(source, RouteSource::Framework) {
return (
RouteClassification::Framework,
Vec::new(),
Vec::new(),
false,
);
}
let repo_has_policy = repository.is_some_and(|r| r.has_policy);
let repo_has_scope = repository.is_some_and(|r| r.scope_check.is_some());
if api_doc.secured || api_doc.has_policy || repo_has_policy || repo_has_scope {
let roles = api_doc
.required_roles
.iter()
.map(|s| (*s).to_owned())
.collect();
let scopes = api_doc
.required_scopes
.iter()
.map(|s| (*s).to_owned())
.collect();
return (
RouteClassification::Gated,
roles,
scopes,
api_doc.has_policy || repo_has_policy,
);
}
if api_doc.public {
return (RouteClassification::Public, Vec::new(), Vec::new(), false);
}
(
RouteClassification::Unclassified,
Vec::new(),
Vec::new(),
false,
)
}
fn module_of(api_doc: &crate::openapi::ApiDoc) -> Option<String> {
(!api_doc.module_path.is_empty()).then(|| api_doc.module_path.to_owned())
}
fn source_location_of(api_doc: &crate::openapi::ApiDoc) -> Option<String> {
(!api_doc.source_file.is_empty())
.then(|| format!("{}:{}", api_doc.source_file, api_doc.source_line))
}
fn authorize_bindings_of(
source: &RouteSource,
api_doc: &crate::openapi::ApiDoc,
) -> Vec<AuthorizeBindingInfo> {
if matches!(source, RouteSource::Framework) {
return Vec::new();
}
let mut bindings: Vec<AuthorizeBindingInfo> = api_doc
.authorize_bindings
.iter()
.map(|binding| AuthorizeBindingInfo {
action: binding.action.to_owned(),
resource: binding.resource.to_owned(),
})
.collect();
bindings.sort();
bindings.dedup();
bindings
}
type RouteVersionInfo = (Option<String>, Option<String>, Option<bool>);
pub fn collect_route_infos(
routes: &[Route],
route_sources: &[RouteSource],
scoped_groups: &[ScopedGroup],
api_versions: &[crate::app::ApiVersion],
) -> Result<Vec<RouteInfo>, crate::router::RouterBuildError> {
let mut infos = Vec::with_capacity(routes.len());
let now = chrono::Utc::now();
let resolve_status = |route_name: &str,
api_version: Option<&str>,
sunset_opt_out: bool|
-> Result<RouteVersionInfo, crate::router::RouterBuildError> {
let Some(ver) = api_version else {
return Ok((None, None, None));
};
api_versions
.iter()
.find(|av| av.version == ver)
.map_or_else(
|| {
Err(crate::router::RouterBuildError::UnregisteredApiVersion {
route_name: route_name.to_string(),
version: ver.to_string(),
})
},
|av| {
let is_sunset = av.sunset_at.is_some_and(|s| now >= s);
let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
let status = if is_sunset {
"sunset"
} else if is_dep {
"deprecated"
} else {
"active"
};
Ok((
Some(ver.to_string()),
Some(status.to_string()),
Some(sunset_opt_out),
))
},
)
};
for (i, route) in routes.iter().enumerate() {
let source = route_sources.get(i).cloned().unwrap_or(RouteSource::User);
let (api_version, status, sunset_opt_out) =
resolve_status(route.name, route.api_version, route.sunset_opt_out)?;
let (classification, roles, scopes, policy) =
classify(&source, &route.api_doc, route.repository.as_ref());
let authorize_bindings = authorize_bindings_of(&source, &route.api_doc);
infos.push(RouteInfo {
method: route.method.to_string(),
path: route.path.to_owned(),
handler: route.name.to_owned(),
source,
middleware: Vec::new(),
api_version,
status,
sunset_opt_out,
classification,
roles,
scopes,
policy,
module: module_of(&route.api_doc),
location: source_location_of(&route.api_doc),
authorize_bindings,
});
}
for group in scoped_groups {
for route in &group.routes {
let full_path = join_scope_path(&group.prefix, route.path);
let (api_version, status, sunset_opt_out) =
resolve_status(route.name, route.api_version, route.sunset_opt_out)?;
let (classification, roles, scopes, policy) =
classify(&group.source, &route.api_doc, route.repository.as_ref());
infos.push(RouteInfo {
method: route.method.to_string(),
path: full_path,
handler: route.name.to_owned(),
source: group.source.clone(),
middleware: Vec::new(),
api_version,
status,
sunset_opt_out,
classification,
roles,
scopes,
policy,
module: module_of(&route.api_doc),
location: source_location_of(&route.api_doc),
authorize_bindings: authorize_bindings_of(&group.source, &route.api_doc),
});
}
}
Ok(infos)
}
#[allow(clippy::too_many_lines)]
pub(crate) fn append_framework_routes(
infos: &mut Vec<RouteInfo>,
config: &crate::config::AutumnConfig,
) {
let mut probe_paths = std::collections::HashSet::new();
for (path, name) in [
(config.health.live_path.as_str(), "live"),
(config.health.ready_path.as_str(), "ready"),
(config.health.startup_path.as_str(), "startup"),
(config.health.path.as_str(), "health"),
] {
if probe_paths.insert(path) {
infos.push(RouteInfo::framework_get(path.to_owned(), name));
}
}
let mutating_routes = crate::actuator::actuator_mutating_routes(
&config.actuator.prefix,
config.actuator.sensitive,
);
let mutating_paths: std::collections::HashSet<&str> = mutating_routes
.iter()
.map(|(_, path)| path.as_str())
.collect();
for path in crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
) {
if mutating_paths.contains(path.as_str()) {
continue;
}
infos.push(RouteInfo::framework_get(path, "actuator"));
}
for (route_method, route_path) in &mutating_routes {
infos.push(RouteInfo::framework_route(
route_method,
route_path.clone(),
"actuator",
));
}
#[cfg(feature = "htmx")]
{
infos.push(RouteInfo::framework_get(
crate::htmx::HTMX_JS_PATH.to_owned(),
"htmx",
));
infos.push(RouteInfo::framework_get(
crate::htmx::HTMX_CSRF_JS_PATH.to_owned(),
"htmx_csrf",
));
infos.push(RouteInfo::framework_get(
crate::htmx::IDIOMORPH_JS_PATH.to_owned(),
"idiomorph",
));
infos.push(RouteInfo::framework_get(
crate::htmx::HTMX_SSE_JS_PATH.to_owned(),
"htmx_sse",
));
}
#[cfg(feature = "mail")]
if config
.mail
.preview_routes_enabled(config.profile.as_deref())
{
for (path, handler) in [
(crate::mail::MAIL_PREVIEW_PATH, "mail_preview"),
(
"/_autumn/mail/messages/{message_id}",
"mail_preview_message",
),
(
"/_autumn/mail/previews/{mailer}/{method}",
"mail_preview_template",
),
] {
infos.push(RouteInfo::framework_get(path.to_owned(), handler));
}
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
for http_method in ["GET", "POST"] {
infos.push(RouteInfo::framework_route(
http_method,
crate::mail::UNSUBSCRIBE_PATH.to_owned(),
"unsubscribe",
));
}
}
#[cfg(feature = "maud")]
if config.stories.enabled {
for (path, handler) in [
(crate::stories::STORIES_PATH, "story_gallery_index"),
("/_stories/{slug}", "story_gallery_story"),
] {
infos.push(RouteInfo::framework_get(path.to_owned(), handler));
}
}
if matches!(config.profile.as_deref(), Some("dev" | "development")) {
let inspector_path = &config.dev.inspector_path;
let inspector_detail_path = format!("{inspector_path}/requests/{{id}}");
for (path, handler) in [
(inspector_path.as_str(), "inspector_index"),
(inspector_detail_path.as_str(), "inspector_detail"),
] {
infos.push(RouteInfo::framework_get(path.to_owned(), handler));
}
}
if config.jobs.tracking.route_enabled {
infos.push(RouteInfo::framework_get(
crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned(),
"job_status",
));
}
infos.push(RouteInfo::framework_get(
"/static/{*path}".to_owned(),
"static_files",
));
}
#[cfg(feature = "openapi")]
pub(crate) fn append_openapi_routes(
infos: &mut Vec<RouteInfo>,
openapi: &crate::openapi::OpenApiConfig,
) {
infos.push(RouteInfo::framework_get(
openapi.openapi_json_path.clone(),
"openapi_json",
));
if let Some(ui_path) = &openapi.swagger_ui_path {
infos.push(RouteInfo::framework_get(ui_path.clone(), "swagger_ui"));
}
}
pub(crate) fn append_dev_reload_routes(infos: &mut Vec<RouteInfo>) {
if crate::middleware::dev::is_enabled_with_env(&crate::config::OsEnv) {
for (path, handler) in [
(crate::middleware::dev::LIVE_RELOAD_PATH, "dev_live_reload"),
(
crate::middleware::dev::LIVE_RELOAD_SCRIPT_PATH,
"dev_live_reload_js",
),
] {
infos.push(RouteInfo::framework_get(path.to_owned(), handler));
}
}
}
pub(crate) fn sort_route_infos(infos: &mut [RouteInfo]) {
infos.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
}
fn join_scope_path(prefix: &str, path: &str) -> String {
let prefix = prefix.trim_end_matches('/');
if path == "/" || path.is_empty() {
if prefix.is_empty() {
"/".to_owned()
} else {
prefix.to_owned()
}
} else if path.starts_with('/') {
format!("{prefix}{path}")
} else {
format!("{prefix}/{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AutumnConfig;
use axum::routing::get;
use http::Method;
fn dummy_api_doc() -> crate::openapi::ApiDoc {
crate::openapi::ApiDoc {
method: "GET",
path: "/dummy",
operation_id: "dummy",
success_status: 200,
..Default::default()
}
}
fn make_route(method: Method, path: &'static str, name: &'static str) -> Route {
make_route_with(method, path, name, dummy_api_doc())
}
fn binding(action: &str, resource: &str) -> AuthorizeBindingInfo {
AuthorizeBindingInfo {
action: action.to_owned(),
resource: resource.to_owned(),
}
}
fn repo_meta(has_policy: bool) -> crate::route::RepositoryApiMeta {
crate::route::RepositoryApiMeta {
resource_type_name: "Post",
api_path: "/api/posts",
has_policy,
policy_check: None,
scope_check: None,
}
}
fn repo_meta_scope_only() -> crate::route::RepositoryApiMeta {
fn probe(_: &crate::authorization::PolicyRegistry) -> bool {
true
}
crate::route::RepositoryApiMeta {
resource_type_name: "Post",
api_path: "/api/posts",
has_policy: false,
policy_check: None,
scope_check: Some(probe),
}
}
fn make_repo_route(
method: Method,
path: &'static str,
name: &'static str,
repository: Option<crate::route::RepositoryApiMeta>,
) -> Route {
let mut route = make_route_with(method, path, name, dummy_api_doc());
route.repository = repository;
route
}
fn make_route_with(
method: Method,
path: &'static str,
name: &'static str,
api_doc: crate::openapi::ApiDoc,
) -> Route {
async fn handler() -> &'static str {
"ok"
}
Route {
method,
path,
handler: get(handler),
name,
api_doc,
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
#[test]
fn collect_route_infos_empty_produces_empty() {
let infos = collect_route_infos(&[], &[], &[], &[]).unwrap();
assert!(infos.is_empty());
}
#[test]
fn collect_route_infos_single_user_route() {
let routes = vec![make_route(Method::GET, "/posts", "list_posts")];
let sources = vec![RouteSource::User];
let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].method, "GET");
assert_eq!(infos[0].path, "/posts");
assert_eq!(infos[0].handler, "list_posts");
assert_eq!(infos[0].source, RouteSource::User);
assert!(infos[0].middleware.is_empty());
}
#[test]
fn collect_route_infos_multiple_methods_same_path() {
let routes = vec![
make_route(Method::GET, "/posts", "list_posts"),
make_route(Method::POST, "/posts", "create_post"),
];
let sources = vec![RouteSource::User, RouteSource::User];
let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
assert_eq!(infos.len(), 2);
}
#[test]
fn collect_route_infos_reports_declared_method_for_overridable_routes() {
let routes = vec![
make_route(Method::PUT, "/posts/{id}", "update_post"),
make_route(Method::PATCH, "/posts/{id}", "patch_post"),
make_route(Method::DELETE, "/posts/{id}", "delete_post"),
];
let sources = vec![RouteSource::User; 3];
let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
let methods: Vec<&str> = infos.iter().map(|i| i.method.as_str()).collect();
assert_eq!(methods, vec!["PUT", "PATCH", "DELETE"]);
assert!(infos.iter().all(|i| i.method != "POST"), "{infos:?}");
}
#[test]
fn collect_route_infos_scoped_group_prepends_prefix() {
let group = ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![make_route(Method::GET, "/posts", "api_list_posts")],
source: RouteSource::User,
apply_layer: Box::new(|r| r),
};
let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].path, "/api/posts");
assert_eq!(infos[0].handler, "api_list_posts");
}
#[test]
fn collect_route_infos_scoped_root_child() {
let group = ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![make_route(Method::GET, "/", "api_root")],
source: RouteSource::User,
apply_layer: Box::new(|r| r),
};
let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].path, "/api");
}
#[test]
fn collect_route_infos_marks_user_source() {
let routes = vec![make_route(Method::POST, "/items", "create_item")];
let sources = vec![RouteSource::User];
let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
assert_eq!(infos[0].source, RouteSource::User);
}
#[test]
fn collect_route_infos_plugin_source_from_parallel_slice() {
let routes = vec![make_route(Method::GET, "/admin", "admin_index")];
let sources = vec![RouteSource::Plugin("admin".to_owned())];
let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
assert_eq!(infos[0].source, RouteSource::Plugin("admin".to_owned()));
}
#[test]
fn collect_route_infos_plugin_source_on_scoped_group() {
let group = ScopedGroup {
prefix: "/admin".to_owned(),
routes: vec![make_route(Method::GET, "/users", "admin_users")],
source: RouteSource::Plugin("admin".to_owned()),
apply_layer: Box::new(|r| r),
};
let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
assert_eq!(infos[0].source, RouteSource::Plugin("admin".to_owned()));
assert_eq!(infos[0].path, "/admin/users");
}
#[test]
fn collect_route_infos_missing_source_defaults_to_user() {
let routes = vec![make_route(Method::GET, "/x", "x")];
let infos = collect_route_infos(&routes, &[], &[], &[]).unwrap();
assert_eq!(infos[0].source, RouteSource::User);
}
#[test]
fn classify_framework_source_is_framework() {
let (c, roles, scopes, policy) = classify(&RouteSource::Framework, &dummy_api_doc(), None);
assert_eq!(c, RouteClassification::Framework);
assert!(roles.is_empty() && scopes.is_empty() && !policy);
}
#[test]
fn classify_secured_is_gated_and_carries_posture() {
let api_doc = crate::openapi::ApiDoc {
secured: true,
required_roles: &["admin"],
required_scopes: &["posts:write"],
..dummy_api_doc()
};
let (c, roles, scopes, policy) = classify(&RouteSource::User, &api_doc, None);
assert_eq!(c, RouteClassification::Gated);
assert_eq!(roles, vec!["admin"]);
assert_eq!(scopes, vec!["posts:write"]);
assert!(!policy);
}
#[test]
fn classify_policy_is_gated_and_carries_policy_flag() {
let api_doc = crate::openapi::ApiDoc {
has_policy: true,
..dummy_api_doc()
};
let (c, _roles, _scopes, policy) = classify(&RouteSource::User, &api_doc, None);
assert_eq!(c, RouteClassification::Gated);
assert!(policy);
}
#[test]
fn classify_public_is_public() {
let api_doc = crate::openapi::ApiDoc {
public: true,
..dummy_api_doc()
};
let (c, _, _, _) = classify(&RouteSource::User, &api_doc, None);
assert_eq!(c, RouteClassification::Public);
}
#[test]
fn classify_unannotated_is_unclassified() {
let (c, _, _, _) = classify(&RouteSource::User, &dummy_api_doc(), None);
assert_eq!(c, RouteClassification::Unclassified);
}
#[test]
fn classify_repository_policy_is_gated() {
let repo = repo_meta(true);
let (c, roles, scopes, policy) =
classify(&RouteSource::User, &dummy_api_doc(), Some(&repo));
assert_eq!(c, RouteClassification::Gated);
assert!(
policy,
"repository has_policy must surface as policy = true"
);
assert!(roles.is_empty() && scopes.is_empty());
}
#[test]
fn classify_repository_scope_only_is_gated() {
let repo = repo_meta_scope_only();
let (c, roles, scopes, policy) =
classify(&RouteSource::User, &dummy_api_doc(), Some(&repo));
assert_eq!(c, RouteClassification::Gated);
assert!(
!policy,
"a repository scope guard is not a policy: policy must stay false"
);
assert!(
roles.is_empty() && scopes.is_empty(),
"no scope name is recorded on the meta, so scopes stays empty"
);
}
#[test]
fn classify_repository_without_policy_is_unclassified() {
let repo = repo_meta(false);
let (c, _, _, policy) = classify(&RouteSource::User, &dummy_api_doc(), Some(&repo));
assert_eq!(c, RouteClassification::Unclassified);
assert!(!policy);
}
#[test]
fn unclassified_route_turns_green_when_guarded_or_public() {
let route = make_route_with(Method::POST, "/widgets", "create_widget", dummy_api_doc());
let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].classification, RouteClassification::Unclassified);
let secured_doc = crate::openapi::ApiDoc {
secured: true,
required_roles: &["admin"],
..dummy_api_doc()
};
let route = make_route_with(Method::POST, "/widgets", "create_widget", secured_doc);
let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].classification, RouteClassification::Gated);
assert_eq!(infos[0].roles, vec!["admin"]);
let public_doc = crate::openapi::ApiDoc {
public: true,
..dummy_api_doc()
};
let route = make_route_with(Method::POST, "/widgets", "create_widget", public_doc);
let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].classification, RouteClassification::Public);
}
#[test]
fn collect_repository_policy_route_is_gated() {
let route = make_repo_route(
Method::POST,
"/api/posts",
"posts_create",
Some(repo_meta(true)),
);
let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].classification, RouteClassification::Gated);
assert!(infos[0].policy);
let bare = make_repo_route(
Method::POST,
"/api/posts",
"posts_create",
Some(repo_meta(false)),
);
let infos = collect_route_infos(&[bare], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].classification, RouteClassification::Unclassified);
assert!(!infos[0].policy);
}
#[test]
fn collect_carries_handler_module_when_present() {
let api_doc = crate::openapi::ApiDoc {
public: true,
module_path: "myapp::widgets",
..dummy_api_doc()
};
let route = make_route_with(Method::GET, "/widgets", "list_widgets", api_doc);
let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].module.as_deref(), Some("myapp::widgets"));
}
#[test]
fn authorize_bindings_are_sorted_and_deduped() {
let api_doc = crate::openapi::ApiDoc {
has_policy: true,
authorize_bindings: &[
crate::openapi::AuthorizeBinding {
action: "update",
resource: "Note",
},
crate::openapi::AuthorizeBinding {
action: "delete",
resource: "Note",
},
crate::openapi::AuthorizeBinding {
action: "update",
resource: "Note",
},
crate::openapi::AuthorizeBinding {
action: "update",
resource: "Comment",
},
],
..dummy_api_doc()
};
let bindings = authorize_bindings_of(&RouteSource::User, &api_doc);
assert_eq!(
bindings,
vec![
binding("delete", "Note"),
binding("update", "Comment"),
binding("update", "Note"),
],
"bindings must be sorted by (action, resource) and deduplicated"
);
}
#[test]
fn authorize_bindings_dedup_runs_after_sorting() {
let api_doc = crate::openapi::ApiDoc {
authorize_bindings: &[
crate::openapi::AuthorizeBinding {
action: "show",
resource: "Note",
},
crate::openapi::AuthorizeBinding {
action: "delete",
resource: "Note",
},
crate::openapi::AuthorizeBinding {
action: "show",
resource: "Note",
},
],
..dummy_api_doc()
};
assert_eq!(
authorize_bindings_of(&RouteSource::User, &api_doc),
vec![binding("delete", "Note"), binding("show", "Note")],
);
}
#[test]
fn framework_routes_carry_no_authorize_bindings() {
let api_doc = crate::openapi::ApiDoc {
has_policy: true,
authorize_bindings: &[crate::openapi::AuthorizeBinding {
action: "update",
resource: "Note",
}],
..dummy_api_doc()
};
assert!(
authorize_bindings_of(&RouteSource::Framework, &api_doc).is_empty(),
"a framework route must never report an #[authorize] binding"
);
}
#[test]
fn collect_carries_authorize_bindings() {
let api_doc = crate::openapi::ApiDoc {
has_policy: true,
authorize_bindings: &[crate::openapi::AuthorizeBinding {
action: "update",
resource: "Note",
}],
..dummy_api_doc()
};
let route = make_route_with(Method::POST, "/notes/{id}", "update_note", api_doc);
let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
assert_eq!(infos[0].authorize_bindings, vec![binding("update", "Note")]);
assert!(infos[0].policy, "a bound route is still policy-guarded");
}
#[test]
fn route_info_elides_empty_authorize_bindings() {
let info = RouteInfo {
method: "GET".to_owned(),
path: "/health".to_owned(),
handler: "health".to_owned(),
..Default::default()
};
let value = serde_json::to_value(&info).unwrap();
assert!(
value.get("authorize_bindings").is_none(),
"an empty binding list must not be serialized: {value}"
);
let bound = RouteInfo {
authorize_bindings: vec![binding("update", "Note")],
..info
};
let value = serde_json::to_value(&bound).unwrap();
assert_eq!(
value["authorize_bindings"],
serde_json::json!([{ "action": "update", "resource": "Note" }]),
);
let decoded: RouteInfo = serde_json::from_value(value).unwrap();
assert_eq!(decoded.authorize_bindings, vec![binding("update", "Note")]);
}
#[test]
fn route_info_deserializes_old_dump_without_bindings() {
let old = serde_json::json!({
"method": "POST",
"path": "/notes/{id}",
"handler": "update_note",
"source": "user",
"middleware": [],
"classification": "gated",
"policy": true,
});
let decoded: RouteInfo = serde_json::from_value(old).unwrap();
assert!(
decoded.authorize_bindings.is_empty(),
"a dump without the key must decode to an empty list"
);
assert!(decoded.policy, "the pre-existing keys must still decode");
}
#[test]
fn framework_get_helper_is_exempt() {
let info = RouteInfo::framework_get("/actuator/health".to_owned(), "actuator");
assert_eq!(info.classification, RouteClassification::Framework);
assert_eq!(info.source, RouteSource::Framework);
assert_eq!(info.method, "GET");
}
#[test]
fn route_classification_serializes_to_lowercase_tag() {
assert_eq!(
serde_json::to_string(&RouteClassification::Gated).unwrap(),
"\"gated\""
);
assert_eq!(
serde_json::to_string(&RouteClassification::Unclassified).unwrap(),
"\"unclassified\""
);
let decoded: RouteClassification = serde_json::from_str("\"public\"").unwrap();
assert_eq!(decoded, RouteClassification::Public);
}
#[test]
fn sort_route_infos_by_path_then_method() {
let mut infos = vec![
RouteInfo {
method: "POST".to_owned(),
path: "/posts".to_owned(),
handler: "create".to_owned(),
source: RouteSource::User,
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
},
RouteInfo {
method: "GET".to_owned(),
path: "/posts".to_owned(),
handler: "list".to_owned(),
source: RouteSource::User,
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
},
RouteInfo {
method: "GET".to_owned(),
path: "/about".to_owned(),
handler: "about".to_owned(),
source: RouteSource::User,
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
},
];
sort_route_infos(&mut infos);
assert_eq!(infos[0].path, "/about");
assert_eq!(infos[1].path, "/posts");
assert_eq!(infos[1].method, "GET");
assert_eq!(infos[2].path, "/posts");
assert_eq!(infos[2].method, "POST");
}
#[test]
fn sort_route_infos_stable_on_equal() {
let mut infos = vec![
RouteInfo {
method: "GET".to_owned(),
path: "/z".to_owned(),
handler: "z".to_owned(),
source: RouteSource::User,
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
},
RouteInfo {
method: "GET".to_owned(),
path: "/a".to_owned(),
handler: "a".to_owned(),
source: RouteSource::User,
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
},
];
sort_route_infos(&mut infos);
assert_eq!(infos[0].path, "/a");
assert_eq!(infos[1].path, "/z");
}
#[test]
fn route_source_user_serializes_to_string() {
let s = serde_json::to_string(&RouteSource::User).unwrap();
assert_eq!(s, "\"user\"");
}
#[test]
fn route_source_framework_serializes_to_string() {
let s = serde_json::to_string(&RouteSource::Framework).unwrap();
assert_eq!(s, "\"framework\"");
}
#[test]
fn route_source_plugin_serializes_with_name() {
let s = serde_json::to_string(&RouteSource::Plugin("admin".to_owned())).unwrap();
assert_eq!(s, "\"plugin:admin\"");
}
#[test]
fn route_source_roundtrips_user() {
let original = RouteSource::User;
let json = serde_json::to_string(&original).unwrap();
let decoded: RouteSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn route_source_roundtrips_plugin() {
let original = RouteSource::Plugin("harvest".to_owned());
let json = serde_json::to_string(&original).unwrap();
let decoded: RouteSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn route_source_roundtrips_framework() {
let original = RouteSource::Framework;
let json = serde_json::to_string(&original).unwrap();
let decoded: RouteSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn append_framework_routes_includes_probe_paths() {
let config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&config.health.path.as_str()),
"health path missing: {paths:?}"
);
assert!(
paths.contains(&config.health.live_path.as_str()),
"live path missing: {paths:?}"
);
assert!(
paths.contains(&config.health.ready_path.as_str()),
"ready path missing: {paths:?}"
);
assert!(
paths.contains(&config.health.startup_path.as_str()),
"startup path missing: {paths:?}"
);
}
#[test]
fn append_framework_routes_marks_framework_source() {
let config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
for info in &infos {
assert_eq!(
info.source,
RouteSource::Framework,
"expected Framework source for {}: {:?}",
info.path,
info.source
);
}
}
#[test]
fn append_framework_routes_custom_health_path() {
let mut config = AutumnConfig::default();
config.health.path = "/ping".to_owned();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&"/ping"),
"custom health path missing: {paths:?}"
);
}
#[test]
fn append_framework_routes_includes_mutating_actuator_routes() {
let mut config = AutumnConfig::default();
config.actuator.sensitive = true;
let prefix = config.actuator.prefix.clone();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let loggers_path = format!("{prefix}/loggers/{{name}}");
assert!(
infos.iter().any(|i| i.method == "PUT"
&& i.path == loggers_path
&& i.classification == RouteClassification::Framework),
"expected PUT {loggers_path} framework route: {infos:?}"
);
let replay_path = format!("{prefix}/webhooks/replay");
assert!(
!infos
.iter()
.any(|i| i.method == "GET" && i.path == replay_path),
"phantom GET {replay_path} must not be listed: {infos:?}"
);
}
#[cfg(feature = "http-client")]
#[test]
fn append_framework_routes_includes_webhook_replay_post() {
let mut config = AutumnConfig::default();
config.actuator.sensitive = true;
let prefix = config.actuator.prefix.clone();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let replay_path = format!("{prefix}/webhooks/replay");
assert!(
infos.iter().any(|i| i.method == "POST"
&& i.path == replay_path
&& i.classification == RouteClassification::Framework),
"expected POST {replay_path} framework route: {infos:?}"
);
let dlq_path = format!("{prefix}/webhooks/dlq");
assert!(
infos
.iter()
.any(|i| i.method == "GET" && i.path == dlq_path),
"expected GET {dlq_path} framework route: {infos:?}"
);
}
#[cfg(feature = "http-client")]
#[test]
fn webhook_replay_in_runtime_set_but_not_a_phantom_get_listing() {
let mut config = AutumnConfig::default();
config.actuator.sensitive = true;
let prefix = config.actuator.prefix.clone();
let replay_path = format!("{prefix}/webhooks/replay");
let runtime_paths = crate::actuator::actuator_endpoint_paths(
&prefix,
config.actuator.sensitive,
config.actuator.prometheus,
);
assert!(
runtime_paths.contains(&replay_path),
"runtime actuator_endpoint_paths must contain {replay_path} so the \
startup barrier bypasses the POST: {runtime_paths:?}"
);
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
assert!(
!infos
.iter()
.any(|i| i.method == "GET" && i.path == replay_path),
"listing must not contain a phantom GET {replay_path}: {infos:?}"
);
assert!(
infos
.iter()
.any(|i| i.method == "POST" && i.path == replay_path),
"listing must contain POST {replay_path}: {infos:?}"
);
}
#[test]
fn framework_routes_include_job_status_when_enabled() {
let mut config = AutumnConfig::default();
assert!(
config.jobs.tracking.route_enabled,
"job status route should default to enabled"
);
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
assert!(
infos.iter().any(|i| i.method == "GET"
&& i.path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
&& i.classification == RouteClassification::Framework),
"expected GET {} framework route when enabled: {infos:?}",
crate::job_tracking::JOB_STATUS_ROUTE_PATH
);
config.jobs.tracking.route_enabled = false;
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
assert!(
!infos
.iter()
.any(|i| i.path == crate::job_tracking::JOB_STATUS_ROUTE_PATH),
"job status route must be absent when disabled: {infos:?}"
);
}
#[cfg(feature = "maud")]
#[test]
fn framework_routes_include_stories_when_enabled() {
let mut config = AutumnConfig::default();
config.stories.enabled = true;
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&crate::stories::STORIES_PATH),
"enabled stories must list the index route: {paths:?}"
);
assert!(
paths.contains(&"/_stories/{slug}"),
"enabled stories must list the detail route: {paths:?}"
);
let default_config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &default_config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
!paths.contains(&"/_stories"),
"disabled stories must not be listed: {paths:?}"
);
assert!(
!paths.contains(&"/_stories/{slug}"),
"disabled stories must not list the detail route: {paths:?}"
);
}
#[cfg(feature = "mail")]
#[test]
fn append_framework_routes_includes_mounted_unsubscribe_routes() {
let mut config = AutumnConfig::default();
config.mail.mount_unsubscribe_endpoint = true;
config.mail.unsubscribe_base_url = Some("https://example.com".to_owned());
assert!(
config.mail.should_mount_unsubscribe_endpoint(),
"test precondition: unsubscribe endpoint must be mounted"
);
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
assert!(
infos.iter().any(|i| i.method == "POST"
&& i.path == crate::mail::UNSUBSCRIBE_PATH
&& i.classification == RouteClassification::Framework),
"expected POST {} framework route: {infos:?}",
crate::mail::UNSUBSCRIBE_PATH
);
assert!(
infos.iter().any(|i| i.method == "GET"
&& i.path == crate::mail::UNSUBSCRIBE_PATH
&& i.classification == RouteClassification::Framework),
"expected GET {} framework route: {infos:?}",
crate::mail::UNSUBSCRIBE_PATH
);
let plain = AutumnConfig::default();
assert!(!plain.mail.should_mount_unsubscribe_endpoint());
let mut infos = Vec::new();
append_framework_routes(&mut infos, &plain);
assert!(
!infos
.iter()
.any(|i| i.path == crate::mail::UNSUBSCRIBE_PATH),
"unmounted unsubscribe path must not be listed: {infos:?}"
);
}
#[test]
fn join_scope_path_normal() {
assert_eq!(join_scope_path("/api", "/posts"), "/api/posts");
}
#[test]
fn join_scope_path_root_child() {
assert_eq!(join_scope_path("/api", "/"), "/api");
}
#[test]
fn join_scope_path_empty_child() {
assert_eq!(join_scope_path("/api", ""), "/api");
}
#[test]
fn join_scope_path_trailing_slash_on_prefix() {
assert_eq!(join_scope_path("/api/", "/posts"), "/api/posts");
}
#[test]
fn join_scope_path_empty_prefix() {
assert_eq!(join_scope_path("", "/posts"), "/posts");
}
#[test]
fn join_scope_path_root_prefix_root_child() {
assert_eq!(join_scope_path("", "/"), "/");
}
#[test]
fn route_info_roundtrips_json() {
let info = RouteInfo {
method: "GET".to_owned(),
path: "/posts/{id}".to_owned(),
handler: "posts::show".to_owned(),
source: RouteSource::User,
middleware: vec!["secured".to_owned()],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
};
let json = serde_json::to_string(&info).unwrap();
let decoded: RouteInfo = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.method, "GET");
assert_eq!(decoded.path, "/posts/{id}");
assert_eq!(decoded.handler, "posts::show");
assert_eq!(decoded.source, RouteSource::User);
assert_eq!(decoded.middleware, vec!["secured"]);
}
#[cfg(feature = "openapi")]
#[test]
fn append_openapi_routes_adds_json_and_ui_paths() {
let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0");
let mut infos = Vec::new();
append_openapi_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&"/openapi.json"),
"openapi json path missing: {paths:?}"
);
assert!(
paths.contains(&"/swagger-ui"),
"swagger ui path missing: {paths:?}"
);
for info in &infos {
assert_eq!(info.source, RouteSource::Framework);
assert_eq!(info.method, "GET");
}
}
#[cfg(feature = "openapi")]
#[test]
fn append_openapi_routes_custom_paths() {
let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0")
.openapi_json_path("/docs/openapi.json")
.swagger_ui_path(Some("/docs/ui".to_owned()));
let mut infos = Vec::new();
append_openapi_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(paths.contains(&"/docs/openapi.json"));
assert!(paths.contains(&"/docs/ui"));
}
#[cfg(feature = "openapi")]
#[test]
fn append_openapi_routes_no_swagger_ui_when_none() {
let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0").swagger_ui_path(None);
let mut infos = Vec::new();
append_openapi_routes(&mut infos, &config);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].path, "/openapi.json");
}
#[test]
fn append_framework_routes_includes_static_catch_all() {
let config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let static_route = infos.iter().find(|r| r.path == "/static/{*path}");
assert!(
static_route.is_some(),
"framework routes should include /static/{{*path}}"
);
let r = static_route.unwrap();
assert_eq!(r.method, "GET");
assert_eq!(r.handler, "static_files");
assert_eq!(r.source, RouteSource::Framework);
}
#[test]
fn append_dev_reload_routes_empty_when_dev_disabled() {
let guard = std::env::var("AUTUMN_DEV");
if guard.is_ok() {
return;
}
let mut infos = Vec::new();
append_dev_reload_routes(&mut infos);
assert!(
infos.is_empty(),
"expected no dev routes when AUTUMN_DEV unset"
);
}
#[test]
fn security_dump_reports_nonce_aware_csp_when_nonce_enabled() {
let mut config = AutumnConfig::default();
config.security.headers.csp_nonce.enabled = true;
let dump = SecurityDump::from_config(&config);
let csp = &dump.headers.content_security_policy;
assert!(
csp.contains("'nonce-AUTUMN_CSP_NONCE'"),
"nonce-enabled default CSP must report the nonce-aware template: {csp}"
);
assert_eq!(
*csp,
crate::security::headers::resolved_content_security_policy(&config.security.headers),
"dump must mirror the runtime CSP resolution verbatim"
);
let mut plain = AutumnConfig::default();
plain.security.headers.csp_nonce.enabled = false;
let plain_dump = SecurityDump::from_config(&plain);
assert!(
!plain_dump
.headers
.content_security_policy
.contains("AUTUMN_CSP_NONCE"),
"nonce-disabled CSP must not carry the placeholder"
);
}
#[test]
fn security_dump_exempts_configured_webhook_paths() {
let mut config = AutumnConfig::default();
config.security.webhooks.endpoints = vec![crate::webhook::WebhookEndpointConfig {
path: "/webhooks/stripe".to_owned(),
..Default::default()
}];
let dump = SecurityDump::from_config(&config);
assert!(
dump.csrf
.exempt_paths
.iter()
.any(|p| p == "/webhooks/stripe"),
"configured webhook path must be a CSRF exempt path: {:?}",
dump.csrf.exempt_paths
);
let mut sorted = dump.csrf.exempt_paths.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
dump.csrf.exempt_paths, sorted,
"exempt_paths must stay sorted and deduped"
);
}
#[cfg(feature = "mail")]
#[test]
fn security_dump_exempts_mounted_unsubscribe_path() {
let mut config = AutumnConfig::default();
config.mail.mount_unsubscribe_endpoint = true;
config.mail.unsubscribe_base_url = Some("https://example.com".to_owned());
assert!(
config.mail.should_mount_unsubscribe_endpoint(),
"test precondition: unsubscribe endpoint must be mounted"
);
let dump = SecurityDump::from_config(&config);
assert!(
dump.csrf
.exempt_paths
.iter()
.any(|p| p == crate::mail::UNSUBSCRIBE_PATH),
"mounted unsubscribe path must be a CSRF exempt path: {:?}",
dump.csrf.exempt_paths
);
let mut plain = AutumnConfig::default();
plain.mail.mount_unsubscribe_endpoint = false;
let plain_dump = SecurityDump::from_config(&plain);
assert!(
!plain_dump
.csrf
.exempt_paths
.iter()
.any(|p| p == crate::mail::UNSUBSCRIBE_PATH),
"unmounted unsubscribe path must not be exempt: {:?}",
plain_dump.csrf.exempt_paths
);
}
}