1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14
15use serde::{Deserialize, Serialize};
16
17use crate::capacity::{POOL_DB, ResourceShape};
18
19use crate::app::ScopedGroup;
20use crate::route::Route;
21
22pub const OMITTED_ROUTES_MARKER: &str = "[autumn:omitted-routes] ";
35
36pub const SECURITY_CONFIG_MARKER: &str = "[autumn:security-config] ";
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct CsrfDump {
55 pub enabled: bool,
57 pub safe_methods: Vec<String>,
59 pub exempt_paths: Vec<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[allow(clippy::struct_excessive_bools)]
74pub struct HeadersDump {
75 pub x_frame_options: String,
77 pub x_content_type_options: bool,
79 pub xss_protection: bool,
81 pub content_security_policy: String,
83 pub referrer_policy: String,
85 pub permissions_policy: String,
87 pub strict_transport_security: bool,
89 pub hsts_max_age_secs: u64,
91 pub hsts_include_subdomains: bool,
93 pub csp_nonce: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ClientAuthDump {
107 pub mode: String,
109 pub required_paths: Vec<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SecurityDump {
117 pub csrf: CsrfDump,
119 pub headers: HeadersDump,
121 #[serde(default = "ClientAuthDump::off")]
125 pub client_auth: ClientAuthDump,
126}
127
128impl ClientAuthDump {
129 #[must_use]
131 pub fn off() -> Self {
132 Self {
133 mode: crate::config::ClientAuthMode::Off.as_str().to_owned(),
134 required_paths: Vec::new(),
135 }
136 }
137}
138
139impl SecurityDump {
140 #[must_use]
145 pub fn from_config(config: &crate::config::AutumnConfig) -> Self {
146 let csrf = &config.security.csrf;
147 let headers = &config.security.headers;
148
149 let mut safe_methods = csrf.safe_methods.clone();
150 safe_methods.sort();
151 safe_methods.dedup();
152 let mut exempt_paths = csrf.exempt_paths.clone();
153 for endpoint in &config.security.webhooks.endpoints {
158 exempt_paths.push(endpoint.path.clone());
159 }
160 #[cfg(feature = "mail")]
165 if config.mail.should_mount_unsubscribe_endpoint() {
166 exempt_paths.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
167 }
168 exempt_paths.sort();
169 exempt_paths.dedup();
170
171 Self {
172 csrf: CsrfDump {
173 enabled: csrf.enabled,
174 safe_methods,
175 exempt_paths,
176 },
177 headers: HeadersDump {
178 x_frame_options: headers.x_frame_options.clone(),
179 x_content_type_options: headers.x_content_type_options,
180 xss_protection: headers.xss_protection,
181 content_security_policy: crate::security::headers::resolved_content_security_policy(
182 headers,
183 ),
184 referrer_policy: headers.referrer_policy.clone(),
185 permissions_policy: headers.permissions_policy.clone(),
186 strict_transport_security: headers.strict_transport_security,
187 hsts_max_age_secs: headers.hsts_max_age_secs,
188 hsts_include_subdomains: headers.hsts_include_subdomains,
189 csp_nonce: headers.csp_nonce.enabled,
190 },
191 client_auth: config
192 .server
193 .tls
194 .as_ref()
195 .and_then(|tls| tls.client_auth.as_ref())
196 .map_or_else(ClientAuthDump::off, |ca| {
197 let mut required_paths = ca.required_paths.clone();
198 required_paths.sort();
199 required_paths.dedup();
200 ClientAuthDump {
201 mode: ca.mode.as_str().to_owned(),
202 required_paths,
203 }
204 }),
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Default)]
212pub enum RouteSource {
213 #[default]
215 User,
216 Plugin(String),
218 Framework,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
229pub enum RouteClassification {
230 Framework,
234 Gated,
237 Public,
239 #[default]
242 Unclassified,
243}
244
245impl RouteClassification {
246 #[must_use]
248 pub const fn as_str(self) -> &'static str {
249 match self {
250 Self::Framework => "framework",
251 Self::Gated => "gated",
252 Self::Public => "public",
253 Self::Unclassified => "unclassified",
254 }
255 }
256}
257
258impl std::fmt::Display for RouteClassification {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 f.write_str(self.as_str())
261 }
262}
263
264impl Serialize for RouteClassification {
265 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
266 serializer.serialize_str(self.as_str())
267 }
268}
269
270impl<'de> Deserialize<'de> for RouteClassification {
271 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
272 let s = String::deserialize(deserializer)?;
273 Ok(match s.as_str() {
274 "framework" => Self::Framework,
275 "gated" => Self::Gated,
276 "public" => Self::Public,
277 _ => Self::Unclassified,
278 })
279 }
280}
281
282impl std::fmt::Display for RouteSource {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 match self {
285 Self::User => write!(f, "user"),
286 Self::Plugin(name) => write!(f, "plugin:{name}"),
287 Self::Framework => write!(f, "framework"),
288 }
289 }
290}
291
292impl Serialize for RouteSource {
293 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
294 serializer.serialize_str(&self.to_string())
295 }
296}
297
298impl<'de> Deserialize<'de> for RouteSource {
299 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
300 let s = String::deserialize(deserializer)?;
301 Ok(if s == "framework" {
302 Self::Framework
303 } else if let Some(name) = s.strip_prefix("plugin:") {
304 Self::Plugin(name.to_owned())
305 } else {
306 Self::User
307 })
308 }
309}
310
311#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
322pub struct AuthorizeBindingInfo {
323 pub action: String,
326 pub resource: String,
328}
329
330#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332pub struct RouteInfo {
333 pub method: String,
335 pub path: String,
338 pub handler: String,
340 pub source: RouteSource,
342 pub middleware: Vec<String>,
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub api_version: Option<String>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub status: Option<String>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub sunset_opt_out: Option<bool>,
353 #[serde(default)]
356 pub classification: RouteClassification,
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
359 pub roles: Vec<String>,
360 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub scopes: Vec<String>,
363 #[serde(default, skip_serializing_if = "is_false")]
366 pub policy: bool,
367 #[serde(default, skip_serializing_if = "is_false")]
370 pub layer_guarded: bool,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub module: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub location: Option<String>,
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
390 pub authorize_bindings: Vec<AuthorizeBindingInfo>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub agent_grant: Option<String>,
401 #[serde(default)]
406 pub resource_shape: ResourceShape,
407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub pools: Vec<String>,
417}
418
419#[allow(clippy::trivially_copy_pass_by_ref)]
421const fn is_false(b: &bool) -> bool {
422 !*b
423}
424
425impl RouteInfo {
426 pub(crate) fn framework_get(path: String, handler: &str) -> Self {
429 Self::framework_route("GET", path, handler)
430 }
431
432 fn framework_route(method: &str, path: String, handler: &str) -> Self {
435 Self {
436 method: method.to_owned(),
437 path,
438 handler: handler.to_owned(),
439 source: RouteSource::Framework,
440 classification: RouteClassification::Framework,
441 ..Self::default()
442 }
443 }
444}
445
446fn classify_in_group(
449 group: &ScopedGroup,
450 route: &Route,
451) -> (RouteClassification, Vec<String>, Vec<String>, bool, bool) {
452 let (mut classification, roles, scopes, policy) =
453 classify(&group.source, &route.api_doc, route.repository.as_ref());
454 let layer_guarded = group.layer_guarded
456 && group.source == RouteSource::User
457 && classification == RouteClassification::Unclassified;
458 if layer_guarded {
459 classification = RouteClassification::Gated;
460 }
461 (classification, roles, scopes, policy, layer_guarded)
462}
463
464fn classify(
482 source: &RouteSource,
483 api_doc: &crate::openapi::ApiDoc,
484 repository: Option<&crate::route::RepositoryApiMeta>,
485) -> (RouteClassification, Vec<String>, Vec<String>, bool) {
486 if matches!(source, RouteSource::Framework) {
487 return (
488 RouteClassification::Framework,
489 Vec::new(),
490 Vec::new(),
491 false,
492 );
493 }
494 let repo_has_policy = repository.is_some_and(|r| r.has_policy);
495 let repo_has_scope = repository.is_some_and(|r| r.scope_check.is_some());
503 if api_doc.secured || api_doc.has_policy || repo_has_policy || repo_has_scope {
504 let roles = api_doc
505 .required_roles
506 .iter()
507 .map(|s| (*s).to_owned())
508 .collect();
509 let scopes = api_doc
510 .required_scopes
511 .iter()
512 .map(|s| (*s).to_owned())
513 .collect();
514 return (
515 RouteClassification::Gated,
516 roles,
517 scopes,
518 api_doc.has_policy || repo_has_policy,
519 );
520 }
521 if api_doc.public {
522 return (RouteClassification::Public, Vec::new(), Vec::new(), false);
523 }
524 (
525 RouteClassification::Unclassified,
526 Vec::new(),
527 Vec::new(),
528 false,
529 )
530}
531
532fn module_of(api_doc: &crate::openapi::ApiDoc) -> Option<String> {
534 (!api_doc.module_path.is_empty()).then(|| api_doc.module_path.to_owned())
535}
536
537fn source_location_of(api_doc: &crate::openapi::ApiDoc) -> Option<String> {
543 (!api_doc.source_file.is_empty())
544 .then(|| format!("{}:{}", api_doc.source_file, api_doc.source_line))
545}
546
547fn authorize_bindings_of(
563 source: &RouteSource,
564 api_doc: &crate::openapi::ApiDoc,
565) -> Vec<AuthorizeBindingInfo> {
566 if matches!(source, RouteSource::Framework) {
567 return Vec::new();
568 }
569 let mut bindings: Vec<AuthorizeBindingInfo> = api_doc
570 .authorize_bindings
571 .iter()
572 .map(|binding| AuthorizeBindingInfo {
573 action: binding.action.to_owned(),
574 resource: binding.resource.to_owned(),
575 })
576 .collect();
577 bindings.sort();
578 bindings.dedup();
579 bindings
580}
581
582fn agent_grant_of(api_doc: &crate::openapi::ApiDoc) -> Option<String> {
587 api_doc
588 .agent_authority
589 .map(|authority| authority.grant.name.to_owned())
590}
591
592type RouteVersionInfo = (Option<String>, Option<String>, Option<bool>);
594
595fn pools_of(
602 api_doc: &crate::openapi::ApiDoc,
603 repository: Option<&crate::route::RepositoryApiMeta>,
604) -> Vec<String> {
605 let mut pools: Vec<String> = api_doc.pools.iter().map(|p| (*p).to_owned()).collect();
606 if repository.is_some() && !pools.iter().any(|p| p == POOL_DB) {
607 pools.push(POOL_DB.to_owned());
608 }
609 pools.sort();
610 pools.dedup();
611 pools
612}
613
614pub fn collect_route_infos(
627 routes: &[Route],
628 route_sources: &[RouteSource],
629 scoped_groups: &[ScopedGroup],
630 api_versions: &[crate::app::ApiVersion],
631) -> Result<Vec<RouteInfo>, crate::router::RouterBuildError> {
632 let mut infos = Vec::with_capacity(routes.len());
633 let now = crate::time::ambient_now();
634
635 let resolve_status = |route_name: &str,
636 api_version: Option<&str>,
637 sunset_opt_out: bool|
638 -> Result<RouteVersionInfo, crate::router::RouterBuildError> {
639 let Some(ver) = api_version else {
640 return Ok((None, None, None));
641 };
642 api_versions
643 .iter()
644 .find(|av| av.version == ver)
645 .map_or_else(
646 || {
647 Err(crate::router::RouterBuildError::UnregisteredApiVersion {
648 route_name: route_name.to_string(),
649 version: ver.to_string(),
650 })
651 },
652 |av| {
653 let is_sunset = av.sunset_at.is_some_and(|s| now >= s);
654 let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
655 let status = if is_sunset {
656 "sunset"
657 } else if is_dep {
658 "deprecated"
659 } else {
660 "active"
661 };
662 Ok((
663 Some(ver.to_string()),
664 Some(status.to_string()),
665 Some(sunset_opt_out),
666 ))
667 },
668 )
669 };
670
671 for (i, route) in routes.iter().enumerate() {
672 let source = route_sources.get(i).cloned().unwrap_or(RouteSource::User);
673 let (api_version, status, sunset_opt_out) =
674 resolve_status(route.name, route.api_version, route.sunset_opt_out)?;
675 let (classification, roles, scopes, policy) =
676 classify(&source, &route.api_doc, route.repository.as_ref());
677 let authorize_bindings = authorize_bindings_of(&source, &route.api_doc);
678 let pools = pools_of(&route.api_doc, route.repository.as_ref());
679 infos.push(RouteInfo {
680 method: route.method.to_string(),
681 path: route.path.to_owned(),
682 handler: route.name.to_owned(),
683 source,
684 middleware: Vec::new(),
685 api_version,
686 status,
687 sunset_opt_out,
688 classification,
689 roles,
690 scopes,
691 policy,
692 layer_guarded: false,
693 module: module_of(&route.api_doc),
694 location: source_location_of(&route.api_doc),
695 authorize_bindings,
696 agent_grant: agent_grant_of(&route.api_doc),
697 resource_shape: ResourceShape::from_pools(&pools),
698 pools,
699 });
700 }
701
702 for group in scoped_groups {
703 for route in &group.routes {
704 let full_path = join_scope_path(&group.prefix, route.path);
705 let (api_version, status, sunset_opt_out) =
706 resolve_status(route.name, route.api_version, route.sunset_opt_out)?;
707 let (classification, roles, scopes, policy, layer_guarded) =
708 classify_in_group(group, route);
709 let pools = pools_of(&route.api_doc, route.repository.as_ref());
710 infos.push(RouteInfo {
711 method: route.method.to_string(),
712 path: full_path,
713 handler: route.name.to_owned(),
714 source: group.source.clone(),
715 middleware: Vec::new(),
716 api_version,
717 status,
718 sunset_opt_out,
719 classification,
720 roles,
721 scopes,
722 policy,
723 layer_guarded,
724 module: module_of(&route.api_doc),
725 location: source_location_of(&route.api_doc),
726 authorize_bindings: authorize_bindings_of(&group.source, &route.api_doc),
727 agent_grant: agent_grant_of(&route.api_doc),
728 resource_shape: ResourceShape::from_pools(&pools),
729 pools,
730 });
731 }
732 }
733
734 Ok(infos)
735}
736
737pub(crate) fn append_probe_and_actuator_routes(
742 infos: &mut Vec<RouteInfo>,
743 config: &crate::config::AutumnConfig,
744) {
745 let mut probe_paths: std::collections::HashSet<String> = infos
750 .iter()
751 .filter(|i| i.method == "GET" || i.method == "WS")
752 .map(|i| i.path.clone())
753 .collect();
754 if config.health.enabled {
755 for (path, name) in [
756 (config.health.live_path.as_str(), "live"),
757 (config.health.ready_path.as_str(), "ready"),
758 (config.health.startup_path.as_str(), "startup"),
759 (config.health.path.as_str(), "health"),
760 ] {
761 if probe_paths.insert(path.to_owned()) {
762 infos.push(RouteInfo::framework_get(path.to_owned(), name));
763 }
764 }
765 }
766
767 let mutating_routes = crate::actuator::actuator_mutating_routes(
771 &config.actuator.prefix,
772 config.actuator.sensitive,
773 );
774 let mutating_paths: std::collections::HashSet<&str> = mutating_routes
780 .iter()
781 .map(|(_, path)| path.as_str())
782 .collect();
783
784 for path in crate::actuator::actuator_endpoint_paths(
785 &config.actuator.prefix,
786 config.actuator.sensitive,
787 config.actuator.prometheus,
788 ) {
789 if mutating_paths.contains(path.as_str()) {
790 continue;
791 }
792 infos.push(RouteInfo::framework_get(path, "actuator"));
793 }
794
795 for (route_method, route_path) in &mutating_routes {
796 infos.push(RouteInfo::framework_route(
797 route_method,
798 route_path.clone(),
799 "actuator",
800 ));
801 }
802}
803
804pub(crate) fn append_framework_routes(
809 infos: &mut Vec<RouteInfo>,
810 config: &crate::config::AutumnConfig,
811) {
812 append_framework_routes_with_env(infos, config, &crate::config::OsEnv);
813}
814
815#[allow(clippy::too_many_lines)]
817pub(crate) fn append_framework_routes_with_env(
818 infos: &mut Vec<RouteInfo>,
819 config: &crate::config::AutumnConfig,
820 env: &dyn crate::config::Env,
821) {
822 append_probe_and_actuator_routes(infos, config);
823
824 #[cfg(feature = "htmx")]
825 for asset in crate::htmx::framework_scripts() {
826 let handler = match asset.plain_url() {
827 crate::htmx::HTMX_JS_PATH => "htmx",
828 crate::htmx::HTMX_CSRF_JS_PATH => "htmx_csrf",
829 crate::htmx::IDIOMORPH_JS_PATH => "idiomorph",
830 crate::htmx::HTMX_SSE_JS_PATH => "htmx_sse",
831 #[cfg(feature = "webmcp")]
832 crate::webmcp::SHIM_PATH => "webmcp",
833 _ => "autumn_widgets",
834 };
835 infos.push(RouteInfo::framework_get(
836 asset.plain_url().to_owned(),
837 handler,
838 ));
839 infos.push(RouteInfo::framework_get(asset.url().to_owned(), handler));
840 }
841
842 #[cfg(feature = "flash")]
844 infos.push(RouteInfo::framework_get(
845 crate::flash::FLASH_CSS_PATH.to_owned(),
846 "flash_css",
847 ));
848 #[cfg(feature = "maud")]
849 infos.push(RouteInfo::framework_get(
850 crate::ui::WIDGETS_CSS_PATH.to_owned(),
851 "widgets_css",
852 ));
853
854 append_dev_reload_routes_with_env(infos, env);
855
856 #[cfg(feature = "mail")]
857 if config
858 .mail
859 .preview_routes_enabled(config.profile.as_deref())
860 {
861 for (path, handler) in [
862 (crate::mail::MAIL_PREVIEW_PATH, "mail_preview"),
863 (
864 "/_autumn/mail/messages/{message_id}",
865 "mail_preview_message",
866 ),
867 (
868 "/_autumn/mail/previews/{mailer}/{method}",
869 "mail_preview_template",
870 ),
871 ] {
872 infos.push(RouteInfo::framework_get(path.to_owned(), handler));
873 }
874 }
875
876 #[cfg(feature = "mail")]
883 if config.mail.should_mount_unsubscribe_endpoint() {
884 for http_method in ["GET", "POST"] {
885 infos.push(RouteInfo::framework_route(
886 http_method,
887 crate::mail::UNSUBSCRIBE_PATH.to_owned(),
888 "unsubscribe",
889 ));
890 }
891 }
892
893 #[cfg(feature = "maud")]
896 if config.stories.enabled {
897 for (path, handler) in [
898 (crate::stories::STORIES_PATH, "story_gallery_index"),
899 ("/_stories/{slug}", "story_gallery_story"),
900 ("/_stories/demo/search", "story_gallery_demo_search"),
904 (
905 "/_stories/demo/tags/search",
906 "story_gallery_demo_tag_search",
907 ),
908 (
909 "/_stories/demo/posts/feed",
910 "story_gallery_demo_infinite_feed",
911 ),
912 ] {
913 infos.push(RouteInfo::framework_get(path.to_owned(), handler));
914 }
915 }
916
917 if crate::config::profile_is_dev(config.profile.as_deref()) {
919 let inspector_path = &config.dev.inspector_path;
920 let inspector_detail_path = format!("{inspector_path}/requests/{{id}}");
921 for (path, handler) in [
922 (inspector_path.as_str(), "inspector_index"),
923 (inspector_detail_path.as_str(), "inspector_detail"),
924 ] {
925 infos.push(RouteInfo::framework_get(path.to_owned(), handler));
926 }
927 }
928
929 if config.jobs.tracking.route_enabled {
934 infos.push(RouteInfo::framework_get(
935 crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned(),
936 "job_status",
937 ));
938 }
939
940 infos.push(RouteInfo::framework_get(
942 "/static/{*path}".to_owned(),
943 "static_files",
944 ));
945}
946
947#[cfg(feature = "openapi")]
951pub(crate) fn append_openapi_routes(
952 infos: &mut Vec<RouteInfo>,
953 openapi: &crate::openapi::OpenApiConfig,
954) {
955 infos.push(RouteInfo::framework_get(
956 openapi.openapi_json_path.clone(),
957 "openapi_json",
958 ));
959 if let Some(ui_path) = &openapi.swagger_ui_path {
960 infos.push(RouteInfo::framework_get(ui_path.clone(), "swagger_ui"));
961 for asset in crate::openapi::swagger_ui_asset_paths(ui_path) {
962 infos.push(RouteInfo::framework_get(asset, "swagger_ui_asset"));
963 }
964 }
965}
966
967fn append_dev_reload_routes_with_env(infos: &mut Vec<RouteInfo>, env: &dyn crate::config::Env) {
972 if crate::middleware::dev::is_enabled_with_env(env) {
973 for (path, handler) in [
974 (crate::middleware::dev::LIVE_RELOAD_PATH, "dev_live_reload"),
975 (
976 crate::middleware::dev::LIVE_RELOAD_SCRIPT_PATH,
977 "dev_live_reload_js",
978 ),
979 ] {
980 infos.push(RouteInfo::framework_get(path.to_owned(), handler));
981 }
982 }
983}
984
985pub(crate) fn sort_route_infos(infos: &mut [RouteInfo]) {
988 infos.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
989}
990
991fn join_scope_path(prefix: &str, path: &str) -> String {
994 let prefix = prefix.trim_end_matches('/');
995 if path == "/" || path.is_empty() {
996 if prefix.is_empty() {
997 "/".to_owned()
998 } else {
999 prefix.to_owned()
1000 }
1001 } else if path.starts_with('/') {
1002 format!("{prefix}{path}")
1003 } else {
1004 format!("{prefix}/{path}")
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011 use crate::config::AutumnConfig;
1012 use axum::routing::get;
1013 use http::Method;
1014
1015 fn dummy_api_doc() -> crate::openapi::ApiDoc {
1016 crate::openapi::ApiDoc {
1017 method: "GET",
1018 path: "/dummy",
1019 operation_id: "dummy",
1020 success_status: 200,
1021 ..Default::default()
1022 }
1023 }
1024
1025 fn make_route(method: Method, path: &'static str, name: &'static str) -> Route {
1026 make_route_with(method, path, name, dummy_api_doc())
1027 }
1028
1029 fn binding(action: &str, resource: &str) -> AuthorizeBindingInfo {
1031 AuthorizeBindingInfo {
1032 action: action.to_owned(),
1033 resource: resource.to_owned(),
1034 }
1035 }
1036
1037 fn repo_meta(has_policy: bool) -> crate::route::RepositoryApiMeta {
1041 crate::route::RepositoryApiMeta {
1042 resource_type_name: "Post",
1043 api_path: "/api/posts",
1044 has_policy,
1045 policy_check: None,
1046 scope_check: None,
1047 }
1048 }
1049
1050 fn repo_meta_scope_only() -> crate::route::RepositoryApiMeta {
1054 fn probe(_: &crate::authorization::PolicyRegistry) -> bool {
1055 true
1056 }
1057 crate::route::RepositoryApiMeta {
1058 resource_type_name: "Post",
1059 api_path: "/api/posts",
1060 has_policy: false,
1061 policy_check: None,
1062 scope_check: Some(probe),
1063 }
1064 }
1065
1066 fn make_repo_route(
1067 method: Method,
1068 path: &'static str,
1069 name: &'static str,
1070 repository: Option<crate::route::RepositoryApiMeta>,
1071 ) -> Route {
1072 let mut route = make_route_with(method, path, name, dummy_api_doc());
1073 route.repository = repository;
1074 route
1075 }
1076
1077 fn make_route_with(
1078 method: Method,
1079 path: &'static str,
1080 name: &'static str,
1081 api_doc: crate::openapi::ApiDoc,
1082 ) -> Route {
1083 async fn handler() -> &'static str {
1084 "ok"
1085 }
1086 Route {
1087 method,
1088 path,
1089 handler: get(handler),
1090 name,
1091 api_doc,
1092 repository: None,
1093 idempotency: crate::route::RouteIdempotency::Direct,
1094 timeout: crate::route::RouteTimeout::Inherit,
1095 criticality: crate::admission::Criticality::Default,
1096 seo: crate::seo::SeoRouteDefaults::EMPTY,
1097 api_version: None,
1098 sunset_opt_out: false,
1099 }
1100 }
1101
1102 #[test]
1105 fn collect_route_infos_empty_produces_empty() {
1106 let infos = collect_route_infos(&[], &[], &[], &[]).unwrap();
1107 assert!(infos.is_empty());
1108 }
1109
1110 #[test]
1111 fn collect_route_infos_single_user_route() {
1112 let routes = vec![make_route(Method::GET, "/posts", "list_posts")];
1113 let sources = vec![RouteSource::User];
1114 let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
1115 assert_eq!(infos.len(), 1);
1116 assert_eq!(infos[0].method, "GET");
1117 assert_eq!(infos[0].path, "/posts");
1118 assert_eq!(infos[0].handler, "list_posts");
1119 assert_eq!(infos[0].source, RouteSource::User);
1120 assert!(infos[0].middleware.is_empty());
1121 }
1122
1123 #[test]
1124 fn collect_route_infos_multiple_methods_same_path() {
1125 let routes = vec![
1126 make_route(Method::GET, "/posts", "list_posts"),
1127 make_route(Method::POST, "/posts", "create_post"),
1128 ];
1129 let sources = vec![RouteSource::User, RouteSource::User];
1130 let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
1131 assert_eq!(infos.len(), 2);
1132 }
1133
1134 #[test]
1145 fn collect_route_infos_reports_declared_method_for_overridable_routes() {
1146 let routes = vec![
1147 make_route(Method::PUT, "/posts/{id}", "update_post"),
1148 make_route(Method::PATCH, "/posts/{id}", "patch_post"),
1149 make_route(Method::DELETE, "/posts/{id}", "delete_post"),
1150 ];
1151 let sources = vec![RouteSource::User; 3];
1152 let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
1153 let methods: Vec<&str> = infos.iter().map(|i| i.method.as_str()).collect();
1154 assert_eq!(methods, vec!["PUT", "PATCH", "DELETE"]);
1155 assert!(infos.iter().all(|i| i.method != "POST"), "{infos:?}");
1158 }
1159
1160 #[test]
1161 fn collect_route_infos_scoped_group_prepends_prefix() {
1162 let group = ScopedGroup {
1163 prefix: "/api".to_owned(),
1164 routes: vec![make_route(Method::GET, "/posts", "api_list_posts")],
1165 source: RouteSource::User,
1166 layer_guarded: false,
1167 apply_layer: Box::new(|r| r),
1168 };
1169 let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
1170 assert_eq!(infos.len(), 1);
1171 assert_eq!(infos[0].path, "/api/posts");
1172 assert_eq!(infos[0].handler, "api_list_posts");
1173 }
1174
1175 #[test]
1176 fn collect_route_infos_scoped_root_child() {
1177 let group = ScopedGroup {
1178 prefix: "/api".to_owned(),
1179 routes: vec![make_route(Method::GET, "/", "api_root")],
1180 source: RouteSource::User,
1181 layer_guarded: false,
1182 apply_layer: Box::new(|r| r),
1183 };
1184 let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
1185 assert_eq!(infos.len(), 1);
1186 assert_eq!(infos[0].path, "/api");
1187 }
1188
1189 #[test]
1190 fn collect_route_infos_marks_user_source() {
1191 let routes = vec![make_route(Method::POST, "/items", "create_item")];
1192 let sources = vec![RouteSource::User];
1193 let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
1194 assert_eq!(infos[0].source, RouteSource::User);
1195 }
1196
1197 #[test]
1198 fn collect_route_infos_plugin_source_from_parallel_slice() {
1199 let routes = vec![make_route(Method::GET, "/admin", "admin_index")];
1200 let sources = vec![RouteSource::Plugin("admin".to_owned())];
1201 let infos = collect_route_infos(&routes, &sources, &[], &[]).unwrap();
1202 assert_eq!(infos[0].source, RouteSource::Plugin("admin".to_owned()));
1203 }
1204
1205 #[test]
1206 fn collect_route_infos_plugin_source_on_scoped_group() {
1207 let group = ScopedGroup {
1208 prefix: "/admin".to_owned(),
1209 routes: vec![make_route(Method::GET, "/users", "admin_users")],
1210 source: RouteSource::Plugin("admin".to_owned()),
1211 layer_guarded: false,
1212 apply_layer: Box::new(|r| r),
1213 };
1214 let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
1215 assert_eq!(infos[0].source, RouteSource::Plugin("admin".to_owned()));
1216 assert_eq!(infos[0].path, "/admin/users");
1217 }
1218
1219 #[test]
1220 fn collect_route_infos_missing_source_defaults_to_user() {
1221 let routes = vec![make_route(Method::GET, "/x", "x")];
1222 let infos = collect_route_infos(&routes, &[], &[], &[]).unwrap();
1224 assert_eq!(infos[0].source, RouteSource::User);
1225 }
1226
1227 #[test]
1230 fn classify_framework_source_is_framework() {
1231 let (c, roles, scopes, policy) = classify(&RouteSource::Framework, &dummy_api_doc(), None);
1232 assert_eq!(c, RouteClassification::Framework);
1233 assert!(roles.is_empty() && scopes.is_empty() && !policy);
1234 }
1235
1236 #[test]
1237 fn classify_secured_is_gated_and_carries_posture() {
1238 let api_doc = crate::openapi::ApiDoc {
1239 secured: true,
1240 required_roles: &["admin"],
1241 required_scopes: &["posts:write"],
1242 ..dummy_api_doc()
1243 };
1244 let (c, roles, scopes, policy) = classify(&RouteSource::User, &api_doc, None);
1245 assert_eq!(c, RouteClassification::Gated);
1246 assert_eq!(roles, vec!["admin"]);
1247 assert_eq!(scopes, vec!["posts:write"]);
1248 assert!(!policy);
1249 }
1250
1251 #[test]
1252 fn classify_policy_is_gated_and_carries_policy_flag() {
1253 let api_doc = crate::openapi::ApiDoc {
1254 has_policy: true,
1255 ..dummy_api_doc()
1256 };
1257 let (c, _roles, _scopes, policy) = classify(&RouteSource::User, &api_doc, None);
1258 assert_eq!(c, RouteClassification::Gated);
1259 assert!(policy);
1260 }
1261
1262 #[test]
1263 fn classify_public_is_public() {
1264 let api_doc = crate::openapi::ApiDoc {
1265 public: true,
1266 ..dummy_api_doc()
1267 };
1268 let (c, _, _, _) = classify(&RouteSource::User, &api_doc, None);
1269 assert_eq!(c, RouteClassification::Public);
1270 }
1271
1272 #[test]
1273 fn classify_unannotated_is_unclassified() {
1274 let (c, _, _, _) = classify(&RouteSource::User, &dummy_api_doc(), None);
1275 assert_eq!(c, RouteClassification::Unclassified);
1276 }
1277
1278 #[test]
1286 fn classify_repository_policy_is_gated() {
1287 let repo = repo_meta(true);
1288 let (c, roles, scopes, policy) =
1289 classify(&RouteSource::User, &dummy_api_doc(), Some(&repo));
1290 assert_eq!(c, RouteClassification::Gated);
1291 assert!(
1292 policy,
1293 "repository has_policy must surface as policy = true"
1294 );
1295 assert!(roles.is_empty() && scopes.is_empty());
1296 }
1297
1298 #[test]
1307 fn classify_repository_scope_only_is_gated() {
1308 let repo = repo_meta_scope_only();
1309 let (c, roles, scopes, policy) =
1310 classify(&RouteSource::User, &dummy_api_doc(), Some(&repo));
1311 assert_eq!(c, RouteClassification::Gated);
1312 assert!(
1313 !policy,
1314 "a repository scope guard is not a policy: policy must stay false"
1315 );
1316 assert!(
1317 roles.is_empty() && scopes.is_empty(),
1318 "no scope name is recorded on the meta, so scopes stays empty"
1319 );
1320 }
1321
1322 #[test]
1327 fn classify_repository_without_policy_is_unclassified() {
1328 let repo = repo_meta(false);
1329 let (c, _, _, policy) = classify(&RouteSource::User, &dummy_api_doc(), Some(&repo));
1330 assert_eq!(c, RouteClassification::Unclassified);
1331 assert!(!policy);
1332 }
1333
1334 #[test]
1339 fn unclassified_route_turns_green_when_guarded_or_public() {
1340 let route = make_route_with(Method::POST, "/widgets", "create_widget", dummy_api_doc());
1342 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1343 assert_eq!(infos[0].classification, RouteClassification::Unclassified);
1344
1345 let secured_doc = crate::openapi::ApiDoc {
1347 secured: true,
1348 required_roles: &["admin"],
1349 ..dummy_api_doc()
1350 };
1351 let route = make_route_with(Method::POST, "/widgets", "create_widget", secured_doc);
1352 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1353 assert_eq!(infos[0].classification, RouteClassification::Gated);
1354 assert_eq!(infos[0].roles, vec!["admin"]);
1355
1356 let public_doc = crate::openapi::ApiDoc {
1358 public: true,
1359 ..dummy_api_doc()
1360 };
1361 let route = make_route_with(Method::POST, "/widgets", "create_widget", public_doc);
1362 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1363 assert_eq!(infos[0].classification, RouteClassification::Public);
1364 }
1365
1366 fn group_of(layer_guarded: bool, routes: Vec<Route>) -> ScopedGroup {
1368 ScopedGroup {
1369 prefix: "/api".to_owned(),
1370 routes,
1371 source: RouteSource::User,
1372 layer_guarded,
1373 apply_layer: Box::new(|r| r),
1374 }
1375 }
1376
1377 #[test]
1380 fn layer_guarded_group_gates_an_unclassified_route() {
1381 let route = make_route_with(Method::GET, "/todos", "list", dummy_api_doc());
1382 let infos = collect_route_infos(&[], &[], &[group_of(true, vec![route])], &[]).unwrap();
1383 assert_eq!(infos[0].classification, RouteClassification::Gated);
1384 assert!(infos[0].layer_guarded);
1385 assert!(infos[0].roles.is_empty() && !infos[0].policy);
1386 }
1387
1388 #[test]
1390 fn plain_scoped_group_stays_unclassified() {
1391 let route = make_route_with(Method::GET, "/todos", "list", dummy_api_doc());
1392 let infos = collect_route_infos(&[], &[], &[group_of(false, vec![route])], &[]).unwrap();
1393 assert_eq!(infos[0].classification, RouteClassification::Unclassified);
1394 assert!(!infos[0].layer_guarded);
1395 }
1396
1397 #[test]
1399 fn layer_guard_does_not_override_public() {
1400 let doc = crate::openapi::ApiDoc {
1401 public: true,
1402 ..dummy_api_doc()
1403 };
1404 let route = make_route_with(Method::GET, "/todos", "list", doc);
1405 let infos = collect_route_infos(&[], &[], &[group_of(true, vec![route])], &[]).unwrap();
1406 assert_eq!(infos[0].classification, RouteClassification::Public);
1407 assert!(!infos[0].layer_guarded);
1408 }
1409
1410 #[test]
1412 fn layer_guard_is_ignored_for_plugin_groups() {
1413 let route = make_route_with(Method::GET, "/todos", "list", dummy_api_doc());
1414 let mut group = group_of(true, vec![route]);
1415 group.source = RouteSource::Plugin("x".to_owned());
1416 let infos = collect_route_infos(&[], &[], &[group], &[]).unwrap();
1417 assert_eq!(infos[0].classification, RouteClassification::Unclassified);
1418 assert!(!infos[0].layer_guarded);
1419 }
1420
1421 #[test]
1423 fn layer_guarded_serialises_only_when_true() {
1424 let route = make_route_with(Method::GET, "/todos", "list", dummy_api_doc());
1425 let mut infos = collect_route_infos(&[], &[], &[group_of(true, vec![route])], &[]).unwrap();
1426 let json = serde_json::to_string(&infos[0]).unwrap();
1427 assert!(json.contains(r#""layer_guarded":true"#), "{json}");
1428 infos[0].layer_guarded = false;
1429 let json = serde_json::to_string(&infos[0]).unwrap();
1430 assert!(!json.contains("layer_guarded"), "{json}");
1431 }
1432
1433 #[test]
1435 fn layer_guard_does_not_weaken_a_handler_guard() {
1436 let doc = crate::openapi::ApiDoc {
1437 secured: true,
1438 required_roles: &["admin"],
1439 ..dummy_api_doc()
1440 };
1441 let route = make_route_with(Method::GET, "/todos", "list", doc);
1442 let infos = collect_route_infos(&[], &[], &[group_of(true, vec![route])], &[]).unwrap();
1443 assert_eq!(infos[0].classification, RouteClassification::Gated);
1444 assert_eq!(infos[0].roles, vec!["admin"]);
1445 assert!(!infos[0].layer_guarded);
1446 }
1447
1448 #[test]
1453 fn collect_repository_policy_route_is_gated() {
1454 let route = make_repo_route(
1455 Method::POST,
1456 "/api/posts",
1457 "posts_create",
1458 Some(repo_meta(true)),
1459 );
1460 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1461 assert_eq!(infos[0].classification, RouteClassification::Gated);
1462 assert!(infos[0].policy);
1463
1464 let bare = make_repo_route(
1465 Method::POST,
1466 "/api/posts",
1467 "posts_create",
1468 Some(repo_meta(false)),
1469 );
1470 let infos = collect_route_infos(&[bare], &[RouteSource::User], &[], &[]).unwrap();
1471 assert_eq!(infos[0].classification, RouteClassification::Unclassified);
1472 assert!(!infos[0].policy);
1473 }
1474
1475 #[test]
1476 fn collect_carries_handler_module_when_present() {
1477 let api_doc = crate::openapi::ApiDoc {
1478 public: true,
1479 module_path: "myapp::widgets",
1480 ..dummy_api_doc()
1481 };
1482 let route = make_route_with(Method::GET, "/widgets", "list_widgets", api_doc);
1483 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1484 assert_eq!(infos[0].module.as_deref(), Some("myapp::widgets"));
1485 }
1486
1487 #[test]
1495 fn authorize_bindings_are_sorted_and_deduped() {
1496 let api_doc = crate::openapi::ApiDoc {
1497 has_policy: true,
1498 authorize_bindings: &[
1499 crate::openapi::AuthorizeBinding {
1500 action: "update",
1501 resource: "Note",
1502 },
1503 crate::openapi::AuthorizeBinding {
1504 action: "delete",
1505 resource: "Note",
1506 },
1507 crate::openapi::AuthorizeBinding {
1508 action: "update",
1509 resource: "Note",
1510 },
1511 crate::openapi::AuthorizeBinding {
1512 action: "update",
1513 resource: "Comment",
1514 },
1515 ],
1516 ..dummy_api_doc()
1517 };
1518
1519 let bindings = authorize_bindings_of(&RouteSource::User, &api_doc);
1520
1521 assert_eq!(
1522 bindings,
1523 vec![
1524 binding("delete", "Note"),
1525 binding("update", "Comment"),
1526 binding("update", "Note"),
1527 ],
1528 "bindings must be sorted by (action, resource) and deduplicated"
1529 );
1530 }
1531
1532 #[test]
1537 fn authorize_bindings_dedup_runs_after_sorting() {
1538 let api_doc = crate::openapi::ApiDoc {
1539 authorize_bindings: &[
1540 crate::openapi::AuthorizeBinding {
1541 action: "show",
1542 resource: "Note",
1543 },
1544 crate::openapi::AuthorizeBinding {
1545 action: "delete",
1546 resource: "Note",
1547 },
1548 crate::openapi::AuthorizeBinding {
1549 action: "show",
1550 resource: "Note",
1551 },
1552 ],
1553 ..dummy_api_doc()
1554 };
1555
1556 assert_eq!(
1557 authorize_bindings_of(&RouteSource::User, &api_doc),
1558 vec![binding("delete", "Note"), binding("show", "Note")],
1559 );
1560 }
1561
1562 #[test]
1566 fn framework_routes_carry_no_authorize_bindings() {
1567 let api_doc = crate::openapi::ApiDoc {
1568 has_policy: true,
1569 authorize_bindings: &[crate::openapi::AuthorizeBinding {
1570 action: "update",
1571 resource: "Note",
1572 }],
1573 ..dummy_api_doc()
1574 };
1575
1576 assert!(
1577 authorize_bindings_of(&RouteSource::Framework, &api_doc).is_empty(),
1578 "a framework route must never report an #[authorize] binding"
1579 );
1580 }
1581
1582 #[test]
1585 fn collect_carries_authorize_bindings() {
1586 let api_doc = crate::openapi::ApiDoc {
1587 has_policy: true,
1588 authorize_bindings: &[crate::openapi::AuthorizeBinding {
1589 action: "update",
1590 resource: "Note",
1591 }],
1592 ..dummy_api_doc()
1593 };
1594 let route = make_route_with(Method::POST, "/notes/{id}", "update_note", api_doc);
1595 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1596
1597 assert_eq!(infos[0].authorize_bindings, vec![binding("update", "Note")]);
1598 assert!(infos[0].policy, "a bound route is still policy-guarded");
1599 }
1600
1601 fn refund_authority() -> &'static crate::agent_authority::AgentAuthority {
1606 static GRANT: crate::agent_authority::Grant = crate::agent_authority::Grant {
1607 name: "RefundDrafter",
1608 writes: &["Refund"],
1609 unbounded_writes: &[],
1610 tenant_scope: crate::agent_authority::TenantScope::Scoped,
1611 outbound: &[],
1612 webhooks: &[],
1613 jobs: &[],
1614 rate: None,
1615 spend: None,
1616 reversibility: crate::agent_authority::Reversibility::Compensable,
1617 location: "autumn/src/route_listing.rs",
1618 };
1619 static AUTHORITY: crate::agent_authority::AgentAuthority =
1620 crate::agent_authority::AgentAuthority {
1621 action: "draft_refund",
1622 module_path: "autumn_web::route_listing::tests",
1623 location: "autumn/src/route_listing.rs",
1624 grant: &GRANT,
1625 effects: &[],
1626 asserted_effect_free_sites: 0,
1627 asserted_effect_free: &[],
1628 };
1629 &AUTHORITY
1630 }
1631
1632 #[test]
1633 fn collect_carries_the_agent_grant_name() {
1634 let api_doc = crate::openapi::ApiDoc {
1635 agent_authority: Some(refund_authority()),
1636 ..dummy_api_doc()
1637 };
1638 let route = make_route_with(Method::POST, "/api/refunds", "draft_refund", api_doc);
1639 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1640
1641 assert_eq!(infos[0].agent_grant.as_deref(), Some("RefundDrafter"));
1642 }
1643
1644 #[test]
1645 fn collect_leaves_an_ungoverned_route_without_a_grant() {
1646 let route = make_route_with(Method::POST, "/api/notes", "write_note", dummy_api_doc());
1650 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap();
1651
1652 assert_eq!(infos[0].agent_grant, None);
1653 }
1654
1655 #[test]
1656 fn route_info_elides_an_absent_agent_grant() {
1657 let info = RouteInfo {
1661 method: "POST".to_owned(),
1662 path: "/api/notes".to_owned(),
1663 handler: "write_note".to_owned(),
1664 ..Default::default()
1665 };
1666 let value = serde_json::to_value(&info).unwrap();
1667 assert!(
1668 value.get("agent_grant").is_none(),
1669 "an absent grant must not be serialized: {value}"
1670 );
1671
1672 let governed = RouteInfo {
1673 agent_grant: Some("RefundDrafter".to_owned()),
1674 ..info
1675 };
1676 let value = serde_json::to_value(&governed).unwrap();
1677 assert_eq!(value["agent_grant"], "RefundDrafter");
1678 let decoded: RouteInfo = serde_json::from_value(value).unwrap();
1679 assert_eq!(decoded.agent_grant.as_deref(), Some("RefundDrafter"));
1680 }
1681
1682 #[test]
1686 fn route_info_elides_empty_authorize_bindings() {
1687 let info = RouteInfo {
1688 method: "GET".to_owned(),
1689 path: "/health".to_owned(),
1690 handler: "health".to_owned(),
1691 ..Default::default()
1692 };
1693 let value = serde_json::to_value(&info).unwrap();
1694 assert!(
1695 value.get("authorize_bindings").is_none(),
1696 "an empty binding list must not be serialized: {value}"
1697 );
1698
1699 let bound = RouteInfo {
1700 authorize_bindings: vec![binding("update", "Note")],
1701 ..info
1702 };
1703 let value = serde_json::to_value(&bound).unwrap();
1704 assert_eq!(
1705 value["authorize_bindings"],
1706 serde_json::json!([{ "action": "update", "resource": "Note" }]),
1707 );
1708 let decoded: RouteInfo = serde_json::from_value(value).unwrap();
1709 assert_eq!(decoded.authorize_bindings, vec![binding("update", "Note")]);
1710 }
1711
1712 #[test]
1716 fn route_info_deserializes_old_dump_without_bindings() {
1717 let old = serde_json::json!({
1718 "method": "POST",
1719 "path": "/notes/{id}",
1720 "handler": "update_note",
1721 "source": "user",
1722 "middleware": [],
1723 "classification": "gated",
1724 "policy": true,
1725 });
1726
1727 let decoded: RouteInfo = serde_json::from_value(old).unwrap();
1728 assert!(
1729 decoded.authorize_bindings.is_empty(),
1730 "a dump without the key must decode to an empty list"
1731 );
1732 assert!(decoded.policy, "the pre-existing keys must still decode");
1733 }
1734
1735 #[test]
1736 fn framework_get_helper_is_exempt() {
1737 let info = RouteInfo::framework_get("/actuator/health".to_owned(), "actuator");
1738 assert_eq!(info.classification, RouteClassification::Framework);
1739 assert_eq!(info.source, RouteSource::Framework);
1740 assert_eq!(info.method, "GET");
1741 }
1742
1743 #[test]
1744 fn route_classification_serializes_to_lowercase_tag() {
1745 assert_eq!(
1746 serde_json::to_string(&RouteClassification::Gated).unwrap(),
1747 "\"gated\""
1748 );
1749 assert_eq!(
1750 serde_json::to_string(&RouteClassification::Unclassified).unwrap(),
1751 "\"unclassified\""
1752 );
1753 let decoded: RouteClassification = serde_json::from_str("\"public\"").unwrap();
1754 assert_eq!(decoded, RouteClassification::Public);
1755 }
1756
1757 #[test]
1760 fn sort_route_infos_by_path_then_method() {
1761 let mut infos = vec![
1762 RouteInfo {
1763 method: "POST".to_owned(),
1764 path: "/posts".to_owned(),
1765 handler: "create".to_owned(),
1766 source: RouteSource::User,
1767 middleware: vec![],
1768 api_version: None,
1769 status: None,
1770 sunset_opt_out: None,
1771 ..Default::default()
1772 },
1773 RouteInfo {
1774 method: "GET".to_owned(),
1775 path: "/posts".to_owned(),
1776 handler: "list".to_owned(),
1777 source: RouteSource::User,
1778 middleware: vec![],
1779 api_version: None,
1780 status: None,
1781 sunset_opt_out: None,
1782 ..Default::default()
1783 },
1784 RouteInfo {
1785 method: "GET".to_owned(),
1786 path: "/about".to_owned(),
1787 handler: "about".to_owned(),
1788 source: RouteSource::User,
1789 middleware: vec![],
1790 api_version: None,
1791 status: None,
1792 sunset_opt_out: None,
1793 ..Default::default()
1794 },
1795 ];
1796 sort_route_infos(&mut infos);
1797 assert_eq!(infos[0].path, "/about");
1798 assert_eq!(infos[1].path, "/posts");
1799 assert_eq!(infos[1].method, "GET");
1800 assert_eq!(infos[2].path, "/posts");
1801 assert_eq!(infos[2].method, "POST");
1802 }
1803
1804 #[test]
1805 fn sort_route_infos_stable_on_equal() {
1806 let mut infos = vec![
1807 RouteInfo {
1808 method: "GET".to_owned(),
1809 path: "/z".to_owned(),
1810 handler: "z".to_owned(),
1811 source: RouteSource::User,
1812 middleware: vec![],
1813 api_version: None,
1814 status: None,
1815 sunset_opt_out: None,
1816 ..Default::default()
1817 },
1818 RouteInfo {
1819 method: "GET".to_owned(),
1820 path: "/a".to_owned(),
1821 handler: "a".to_owned(),
1822 source: RouteSource::User,
1823 middleware: vec![],
1824 api_version: None,
1825 status: None,
1826 sunset_opt_out: None,
1827 ..Default::default()
1828 },
1829 ];
1830 sort_route_infos(&mut infos);
1831 assert_eq!(infos[0].path, "/a");
1832 assert_eq!(infos[1].path, "/z");
1833 }
1834
1835 #[test]
1838 fn route_source_user_serializes_to_string() {
1839 let s = serde_json::to_string(&RouteSource::User).unwrap();
1840 assert_eq!(s, "\"user\"");
1841 }
1842
1843 #[test]
1844 fn route_source_framework_serializes_to_string() {
1845 let s = serde_json::to_string(&RouteSource::Framework).unwrap();
1846 assert_eq!(s, "\"framework\"");
1847 }
1848
1849 #[test]
1850 fn route_source_plugin_serializes_with_name() {
1851 let s = serde_json::to_string(&RouteSource::Plugin("admin".to_owned())).unwrap();
1852 assert_eq!(s, "\"plugin:admin\"");
1853 }
1854
1855 #[test]
1856 fn route_source_roundtrips_user() {
1857 let original = RouteSource::User;
1858 let json = serde_json::to_string(&original).unwrap();
1859 let decoded: RouteSource = serde_json::from_str(&json).unwrap();
1860 assert_eq!(decoded, original);
1861 }
1862
1863 #[test]
1864 fn route_source_roundtrips_plugin() {
1865 let original = RouteSource::Plugin("harvest".to_owned());
1866 let json = serde_json::to_string(&original).unwrap();
1867 let decoded: RouteSource = serde_json::from_str(&json).unwrap();
1868 assert_eq!(decoded, original);
1869 }
1870
1871 #[test]
1872 fn route_source_roundtrips_framework() {
1873 let original = RouteSource::Framework;
1874 let json = serde_json::to_string(&original).unwrap();
1875 let decoded: RouteSource = serde_json::from_str(&json).unwrap();
1876 assert_eq!(decoded, original);
1877 }
1878
1879 #[test]
1882 fn append_framework_routes_includes_probe_paths() {
1883 let config = AutumnConfig::default();
1884 let mut infos = Vec::new();
1885 append_framework_routes(&mut infos, &config);
1886 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
1887 assert!(
1888 paths.contains(&config.health.path.as_str()),
1889 "health path missing: {paths:?}"
1890 );
1891 assert!(
1892 paths.contains(&config.health.live_path.as_str()),
1893 "live path missing: {paths:?}"
1894 );
1895 assert!(
1896 paths.contains(&config.health.ready_path.as_str()),
1897 "ready path missing: {paths:?}"
1898 );
1899 assert!(
1900 paths.contains(&config.health.startup_path.as_str()),
1901 "startup path missing: {paths:?}"
1902 );
1903 }
1904
1905 #[test]
1909 fn append_framework_routes_skips_a_probe_a_user_route_owns() {
1910 let config = AutumnConfig::default();
1911 let mut infos = vec![RouteInfo {
1912 method: "GET".to_owned(),
1913 path: config.health.path.clone(),
1914 handler: "my_health".to_owned(),
1915 source: RouteSource::User,
1916 ..RouteInfo::default()
1917 }];
1918 append_framework_routes(&mut infos, &config);
1919 let at_health: Vec<&RouteInfo> = infos
1920 .iter()
1921 .filter(|i| i.method == "GET" && i.path == config.health.path)
1922 .collect();
1923 assert_eq!(at_health.len(), 1, "{at_health:?}");
1924 assert_eq!(at_health[0].handler, "my_health");
1925 assert!(
1926 infos.iter().any(|i| i.path == config.health.live_path),
1927 "the other probes stay listed"
1928 );
1929 }
1930
1931 #[test]
1933 fn append_framework_routes_lists_no_probe_when_health_is_off() {
1934 let mut config = AutumnConfig::default();
1935 config.health.enabled = false;
1936 let mut infos = Vec::new();
1937 append_framework_routes(&mut infos, &config);
1938 for probe in [
1939 &config.health.path,
1940 &config.health.live_path,
1941 &config.health.ready_path,
1942 &config.health.startup_path,
1943 ] {
1944 assert!(
1945 !infos.iter().any(|i| &i.path == probe),
1946 "{probe} is not mounted, so it must not be listed"
1947 );
1948 }
1949 }
1950
1951 #[test]
1952 fn append_framework_routes_marks_framework_source() {
1953 let config = AutumnConfig::default();
1954 let mut infos = Vec::new();
1955 append_framework_routes(&mut infos, &config);
1956 for info in &infos {
1957 assert_eq!(
1958 info.source,
1959 RouteSource::Framework,
1960 "expected Framework source for {}: {:?}",
1961 info.path,
1962 info.source
1963 );
1964 }
1965 }
1966
1967 #[test]
1968 fn append_framework_routes_custom_health_path() {
1969 let mut config = AutumnConfig::default();
1970 config.health.path = "/ping".to_owned();
1971 let mut infos = Vec::new();
1972 append_framework_routes(&mut infos, &config);
1973 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
1974 assert!(
1975 paths.contains(&"/ping"),
1976 "custom health path missing: {paths:?}"
1977 );
1978 }
1979
1980 #[test]
1985 fn append_framework_routes_includes_mutating_actuator_routes() {
1986 let mut config = AutumnConfig::default();
1987 config.actuator.sensitive = true;
1988 let prefix = config.actuator.prefix.clone();
1989 let mut infos = Vec::new();
1990 append_framework_routes(&mut infos, &config);
1991
1992 let loggers_path = format!("{prefix}/loggers/{{name}}");
1993 assert!(
1994 infos.iter().any(|i| i.method == "PUT"
1995 && i.path == loggers_path
1996 && i.classification == RouteClassification::Framework),
1997 "expected PUT {loggers_path} framework route: {infos:?}"
1998 );
1999
2000 let replay_path = format!("{prefix}/webhooks/replay");
2003 assert!(
2004 !infos
2005 .iter()
2006 .any(|i| i.method == "GET" && i.path == replay_path),
2007 "phantom GET {replay_path} must not be listed: {infos:?}"
2008 );
2009 }
2010
2011 #[cfg(feature = "http-client")]
2014 #[test]
2015 fn append_framework_routes_includes_webhook_replay_post() {
2016 let mut config = AutumnConfig::default();
2017 config.actuator.sensitive = true;
2018 let prefix = config.actuator.prefix.clone();
2019 let mut infos = Vec::new();
2020 append_framework_routes(&mut infos, &config);
2021
2022 let replay_path = format!("{prefix}/webhooks/replay");
2023 assert!(
2024 infos.iter().any(|i| i.method == "POST"
2025 && i.path == replay_path
2026 && i.classification == RouteClassification::Framework),
2027 "expected POST {replay_path} framework route: {infos:?}"
2028 );
2029 let dlq_path = format!("{prefix}/webhooks/dlq");
2031 assert!(
2032 infos
2033 .iter()
2034 .any(|i| i.method == "GET" && i.path == dlq_path),
2035 "expected GET {dlq_path} framework route: {infos:?}"
2036 );
2037 }
2038
2039 #[cfg(feature = "http-client")]
2047 #[test]
2048 fn webhook_replay_in_runtime_set_but_not_a_phantom_get_listing() {
2049 let mut config = AutumnConfig::default();
2050 config.actuator.sensitive = true;
2051 let prefix = config.actuator.prefix.clone();
2052 let replay_path = format!("{prefix}/webhooks/replay");
2053
2054 let runtime_paths = crate::actuator::actuator_endpoint_paths(
2056 &prefix,
2057 config.actuator.sensitive,
2058 config.actuator.prometheus,
2059 );
2060 assert!(
2061 runtime_paths.contains(&replay_path),
2062 "runtime actuator_endpoint_paths must contain {replay_path} so the \
2063 startup barrier bypasses the POST: {runtime_paths:?}"
2064 );
2065
2066 let mut infos = Vec::new();
2068 append_framework_routes(&mut infos, &config);
2069 assert!(
2070 !infos
2071 .iter()
2072 .any(|i| i.method == "GET" && i.path == replay_path),
2073 "listing must not contain a phantom GET {replay_path}: {infos:?}"
2074 );
2075 assert!(
2076 infos
2077 .iter()
2078 .any(|i| i.method == "POST" && i.path == replay_path),
2079 "listing must contain POST {replay_path}: {infos:?}"
2080 );
2081 }
2082
2083 #[test]
2088 fn framework_routes_include_job_status_when_enabled() {
2089 let mut config = AutumnConfig::default();
2090 assert!(
2091 config.jobs.tracking.route_enabled,
2092 "job status route should default to enabled"
2093 );
2094 let mut infos = Vec::new();
2095 append_framework_routes(&mut infos, &config);
2096 assert!(
2097 infos.iter().any(|i| i.method == "GET"
2098 && i.path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
2099 && i.classification == RouteClassification::Framework),
2100 "expected GET {} framework route when enabled: {infos:?}",
2101 crate::job_tracking::JOB_STATUS_ROUTE_PATH
2102 );
2103
2104 config.jobs.tracking.route_enabled = false;
2105 let mut infos = Vec::new();
2106 append_framework_routes(&mut infos, &config);
2107 assert!(
2108 !infos
2109 .iter()
2110 .any(|i| i.path == crate::job_tracking::JOB_STATUS_ROUTE_PATH),
2111 "job status route must be absent when disabled: {infos:?}"
2112 );
2113 }
2114
2115 #[cfg(feature = "maud")]
2118 #[test]
2119 fn framework_routes_include_stories_when_enabled() {
2120 let mut config = AutumnConfig::default();
2121 config.stories.enabled = true;
2122 let mut infos = Vec::new();
2123 append_framework_routes(&mut infos, &config);
2124 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
2125 assert!(
2126 paths.contains(&crate::stories::STORIES_PATH),
2127 "enabled stories must list the index route: {paths:?}"
2128 );
2129 assert!(
2130 paths.contains(&"/_stories/{slug}"),
2131 "enabled stories must list the detail route: {paths:?}"
2132 );
2133 for demo_path in [
2138 "/_stories/demo/search",
2139 "/_stories/demo/tags/search",
2140 "/_stories/demo/posts/feed",
2141 ] {
2142 assert!(
2143 paths.contains(&demo_path),
2144 "enabled stories must list the demo route {demo_path}: {paths:?}"
2145 );
2146 }
2147
2148 let default_config = AutumnConfig::default();
2149 let mut infos = Vec::new();
2150 append_framework_routes(&mut infos, &default_config);
2151 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
2152 assert!(
2153 !paths.contains(&"/_stories"),
2154 "disabled stories must not be listed: {paths:?}"
2155 );
2156 assert!(
2157 !paths.contains(&"/_stories/{slug}"),
2158 "disabled stories must not list the detail route: {paths:?}"
2159 );
2160 }
2161
2162 #[cfg(feature = "mail")]
2169 #[test]
2170 fn append_framework_routes_includes_mounted_unsubscribe_routes() {
2171 let mut config = AutumnConfig::default();
2172 config.mail.mount_unsubscribe_endpoint = true;
2173 config.mail.unsubscribe_base_url = Some("https://example.com".to_owned());
2174 assert!(
2175 config.mail.should_mount_unsubscribe_endpoint(),
2176 "test precondition: unsubscribe endpoint must be mounted"
2177 );
2178 let mut infos = Vec::new();
2179 append_framework_routes(&mut infos, &config);
2180
2181 assert!(
2182 infos.iter().any(|i| i.method == "POST"
2183 && i.path == crate::mail::UNSUBSCRIBE_PATH
2184 && i.classification == RouteClassification::Framework),
2185 "expected POST {} framework route: {infos:?}",
2186 crate::mail::UNSUBSCRIBE_PATH
2187 );
2188 assert!(
2189 infos.iter().any(|i| i.method == "GET"
2190 && i.path == crate::mail::UNSUBSCRIBE_PATH
2191 && i.classification == RouteClassification::Framework),
2192 "expected GET {} framework route: {infos:?}",
2193 crate::mail::UNSUBSCRIBE_PATH
2194 );
2195
2196 let plain = AutumnConfig::default();
2198 assert!(!plain.mail.should_mount_unsubscribe_endpoint());
2199 let mut infos = Vec::new();
2200 append_framework_routes(&mut infos, &plain);
2201 assert!(
2202 !infos
2203 .iter()
2204 .any(|i| i.path == crate::mail::UNSUBSCRIBE_PATH),
2205 "unmounted unsubscribe path must not be listed: {infos:?}"
2206 );
2207 }
2208
2209 #[test]
2212 fn join_scope_path_normal() {
2213 assert_eq!(join_scope_path("/api", "/posts"), "/api/posts");
2214 }
2215
2216 #[test]
2217 fn join_scope_path_root_child() {
2218 assert_eq!(join_scope_path("/api", "/"), "/api");
2219 }
2220
2221 #[test]
2222 fn join_scope_path_empty_child() {
2223 assert_eq!(join_scope_path("/api", ""), "/api");
2224 }
2225
2226 #[test]
2227 fn join_scope_path_trailing_slash_on_prefix() {
2228 assert_eq!(join_scope_path("/api/", "/posts"), "/api/posts");
2229 }
2230
2231 #[test]
2232 fn join_scope_path_empty_prefix() {
2233 assert_eq!(join_scope_path("", "/posts"), "/posts");
2234 }
2235
2236 #[test]
2237 fn join_scope_path_root_prefix_root_child() {
2238 assert_eq!(join_scope_path("", "/"), "/");
2239 }
2240
2241 #[test]
2244 fn route_info_roundtrips_json() {
2245 let info = RouteInfo {
2246 method: "GET".to_owned(),
2247 path: "/posts/{id}".to_owned(),
2248 handler: "posts::show".to_owned(),
2249 source: RouteSource::User,
2250 middleware: vec!["secured".to_owned()],
2251 api_version: None,
2252 status: None,
2253 sunset_opt_out: None,
2254 ..Default::default()
2255 };
2256 let json = serde_json::to_string(&info).unwrap();
2257 let decoded: RouteInfo = serde_json::from_str(&json).unwrap();
2258 assert_eq!(decoded.method, "GET");
2259 assert_eq!(decoded.path, "/posts/{id}");
2260 assert_eq!(decoded.handler, "posts::show");
2261 assert_eq!(decoded.source, RouteSource::User);
2262 assert_eq!(decoded.middleware, vec!["secured"]);
2263 }
2264
2265 #[cfg(feature = "openapi")]
2268 #[test]
2269 fn append_openapi_routes_adds_json_and_ui_paths() {
2270 let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0");
2271 let mut infos = Vec::new();
2272 append_openapi_routes(&mut infos, &config);
2273 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
2274 assert!(
2275 paths.contains(&"/openapi.json"),
2276 "openapi json path missing: {paths:?}"
2277 );
2278 assert!(
2279 paths.contains(&"/swagger-ui"),
2280 "swagger ui path missing: {paths:?}"
2281 );
2282 for asset in crate::openapi::swagger_ui_asset_paths("/swagger-ui") {
2283 assert!(
2284 paths.contains(&asset.as_str()),
2285 "swagger ui asset missing: {asset} in {paths:?}"
2286 );
2287 }
2288 for info in &infos {
2289 assert_eq!(info.source, RouteSource::Framework);
2290 assert_eq!(info.method, "GET");
2291 }
2292 }
2293
2294 #[cfg(feature = "openapi")]
2295 #[test]
2296 fn append_openapi_routes_custom_paths() {
2297 let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0")
2298 .openapi_json_path("/docs/openapi.json")
2299 .swagger_ui_path(Some("/docs/ui".to_owned()));
2300 let mut infos = Vec::new();
2301 append_openapi_routes(&mut infos, &config);
2302 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
2303 assert!(paths.contains(&"/docs/openapi.json"));
2304 assert!(paths.contains(&"/docs/ui"));
2305 }
2306
2307 #[cfg(feature = "openapi")]
2308 #[test]
2309 fn append_openapi_routes_no_swagger_ui_when_none() {
2310 let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0").swagger_ui_path(None);
2311 let mut infos = Vec::new();
2312 append_openapi_routes(&mut infos, &config);
2313 assert_eq!(infos.len(), 1);
2315 assert_eq!(infos[0].path, "/openapi.json");
2316 }
2317
2318 #[test]
2321 fn append_framework_routes_includes_static_catch_all() {
2322 let config = AutumnConfig::default();
2323 let mut infos = Vec::new();
2324 append_framework_routes(&mut infos, &config);
2325 let static_route = infos.iter().find(|r| r.path == "/static/{*path}");
2326 assert!(
2327 static_route.is_some(),
2328 "framework routes should include /static/{{*path}}"
2329 );
2330 let r = static_route.unwrap();
2331 assert_eq!(r.method, "GET");
2332 assert_eq!(r.handler, "static_files");
2333 assert_eq!(r.source, RouteSource::Framework);
2334 }
2335
2336 #[test]
2339 fn append_dev_reload_routes_empty_when_dev_disabled() {
2340 let env = crate::config::MockEnv::new();
2341 let mut infos = Vec::new();
2342 append_dev_reload_routes_with_env(&mut infos, &env);
2343 assert!(infos.is_empty(), "expected no dev routes without dev env");
2344 }
2345
2346 fn dev_env() -> crate::config::MockEnv {
2347 crate::config::MockEnv::new()
2348 .with("AUTUMN_DEV_RELOAD", "1")
2349 .with("AUTUMN_DEV_RELOAD_STATE", "state.json")
2350 }
2351
2352 #[test]
2355 fn append_framework_routes_lists_dev_reload_when_dev_env_is_set() {
2356 let config = AutumnConfig::default();
2357 let mut infos = Vec::new();
2358 append_framework_routes_with_env(&mut infos, &config, &dev_env());
2359 for path in [
2360 crate::middleware::dev::LIVE_RELOAD_PATH,
2361 crate::middleware::dev::LIVE_RELOAD_SCRIPT_PATH,
2362 ] {
2363 assert_eq!(infos.iter().filter(|i| i.path == path).count(), 1, "{path}");
2364 }
2365 let mut quiet = Vec::new();
2366 append_framework_routes_with_env(&mut quiet, &config, &crate::config::MockEnv::new());
2367 assert!(
2368 !quiet
2369 .iter()
2370 .any(|i| i.path == crate::middleware::dev::LIVE_RELOAD_PATH)
2371 );
2372 }
2373
2374 #[cfg(feature = "flash")]
2375 #[test]
2376 fn append_framework_routes_lists_flash_css() {
2377 let mut infos = Vec::new();
2378 append_framework_routes(&mut infos, &AutumnConfig::default());
2379 assert_eq!(
2380 infos
2381 .iter()
2382 .filter(|i| i.path == crate::flash::FLASH_CSS_PATH)
2383 .count(),
2384 1
2385 );
2386 }
2387
2388 #[cfg(feature = "maud")]
2389 #[test]
2390 fn append_framework_routes_lists_widgets_css() {
2391 let mut infos = Vec::new();
2392 append_framework_routes(&mut infos, &AutumnConfig::default());
2393 assert_eq!(
2394 infos
2395 .iter()
2396 .filter(|i| i.path == crate::ui::WIDGETS_CSS_PATH)
2397 .count(),
2398 1
2399 );
2400 }
2401
2402 #[cfg(feature = "htmx")]
2405 #[test]
2406 fn append_framework_routes_lists_widgets_js_once() {
2407 let mut infos = Vec::new();
2408 append_framework_routes(&mut infos, &AutumnConfig::default());
2409 assert_eq!(
2410 infos
2411 .iter()
2412 .filter(|i| i.path == crate::htmx::AUTUMN_WIDGETS_JS_PATH)
2413 .count(),
2414 1
2415 );
2416 }
2417
2418 #[test]
2420 fn append_probe_and_actuator_routes_lists_only_those() {
2421 let config = AutumnConfig::default();
2422 let mut infos = Vec::new();
2423 append_probe_and_actuator_routes(&mut infos, &config);
2424 let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
2425 assert!(paths.contains(&config.health.path.as_str()), "{paths:?}");
2426 assert!(
2427 paths.iter().any(|p| p.starts_with(&config.actuator.prefix)),
2428 "{paths:?}"
2429 );
2430 assert!(
2431 infos.iter().all(|i| !i.path.starts_with("/static/")),
2432 "no asset or static routes: {paths:?}"
2433 );
2434 assert!(!paths.contains(&"/static/{*path}"), "{paths:?}");
2435 }
2436
2437 #[test]
2444 fn security_dump_reports_nonce_aware_csp_when_nonce_enabled() {
2445 let mut config = AutumnConfig::default();
2446 config.security.headers.csp_nonce.enabled = true;
2448
2449 let dump = SecurityDump::from_config(&config);
2450 let csp = &dump.headers.content_security_policy;
2451 assert!(
2452 csp.contains("'nonce-AUTUMN_CSP_NONCE'"),
2453 "nonce-enabled default CSP must report the nonce-aware template: {csp}"
2454 );
2455 assert_eq!(
2456 *csp,
2457 crate::security::headers::resolved_content_security_policy(&config.security.headers),
2458 "dump must mirror the runtime CSP resolution verbatim"
2459 );
2460
2461 let mut plain = AutumnConfig::default();
2463 plain.security.headers.csp_nonce.enabled = false;
2464 let plain_dump = SecurityDump::from_config(&plain);
2465 assert!(
2466 !plain_dump
2467 .headers
2468 .content_security_policy
2469 .contains("AUTUMN_CSP_NONCE"),
2470 "nonce-disabled CSP must not carry the placeholder"
2471 );
2472 }
2473
2474 #[test]
2478 fn security_dump_exempts_configured_webhook_paths() {
2479 let mut config = AutumnConfig::default();
2480 config.security.webhooks.endpoints = vec![crate::webhook::WebhookEndpointConfig {
2481 path: "/webhooks/stripe".to_owned(),
2482 ..Default::default()
2483 }];
2484
2485 let dump = SecurityDump::from_config(&config);
2486 assert!(
2487 dump.csrf
2488 .exempt_paths
2489 .iter()
2490 .any(|p| p == "/webhooks/stripe"),
2491 "configured webhook path must be a CSRF exempt path: {:?}",
2492 dump.csrf.exempt_paths
2493 );
2494 let mut sorted = dump.csrf.exempt_paths.clone();
2496 sorted.sort();
2497 sorted.dedup();
2498 assert_eq!(
2499 dump.csrf.exempt_paths, sorted,
2500 "exempt_paths must stay sorted and deduped"
2501 );
2502 }
2503
2504 #[cfg(feature = "mail")]
2509 #[test]
2510 fn security_dump_exempts_mounted_unsubscribe_path() {
2511 let mut config = AutumnConfig::default();
2512 config.mail.mount_unsubscribe_endpoint = true;
2513 config.mail.unsubscribe_base_url = Some("https://example.com".to_owned());
2514 assert!(
2515 config.mail.should_mount_unsubscribe_endpoint(),
2516 "test precondition: unsubscribe endpoint must be mounted"
2517 );
2518
2519 let dump = SecurityDump::from_config(&config);
2520 assert!(
2521 dump.csrf
2522 .exempt_paths
2523 .iter()
2524 .any(|p| p == crate::mail::UNSUBSCRIBE_PATH),
2525 "mounted unsubscribe path must be a CSRF exempt path: {:?}",
2526 dump.csrf.exempt_paths
2527 );
2528
2529 let mut plain = AutumnConfig::default();
2531 plain.mail.mount_unsubscribe_endpoint = false;
2532 let plain_dump = SecurityDump::from_config(&plain);
2533 assert!(
2534 !plain_dump
2535 .csrf
2536 .exempt_paths
2537 .iter()
2538 .any(|p| p == crate::mail::UNSUBSCRIBE_PATH),
2539 "unmounted unsubscribe path must not be exempt: {:?}",
2540 plain_dump.csrf.exempt_paths
2541 );
2542 }
2543
2544 #[test]
2547 fn a_route_declaring_a_database_pool_is_db_bound() {
2548 let mut api_doc = dummy_api_doc();
2549 api_doc.pools = &["db"];
2550 let route = make_route_with(Method::GET, "/posts", "index", api_doc);
2551
2552 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[])
2553 .expect("route listing should collect");
2554
2555 assert_eq!(
2556 infos[0].resource_shape,
2557 crate::capacity::ResourceShape::DbBound
2558 );
2559 assert_eq!(infos[0].pools, vec!["db".to_owned()]);
2560 }
2561
2562 #[test]
2563 fn a_route_declaring_only_non_database_pools_is_io_bound() {
2564 let mut api_doc = dummy_api_doc();
2565 api_doc.pools = &["mail"];
2566 let route = make_route_with(Method::POST, "/invite", "invite", api_doc);
2567
2568 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[])
2569 .expect("route listing should collect");
2570
2571 assert_eq!(
2572 infos[0].resource_shape,
2573 crate::capacity::ResourceShape::IoBound
2574 );
2575 }
2576
2577 #[test]
2578 fn a_route_proving_no_pool_is_compute_bound() {
2579 let route = make_route(Method::GET, "/about", "about");
2580
2581 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[])
2582 .expect("route listing should collect");
2583
2584 assert_eq!(
2585 infos[0].resource_shape,
2586 crate::capacity::ResourceShape::ComputeBound
2587 );
2588 assert!(infos[0].pools.is_empty());
2589 }
2590
2591 #[test]
2592 fn a_repository_auto_api_route_is_db_bound_without_a_declared_extractor() {
2593 let route = make_repo_route(Method::GET, "/api/posts", "list", Some(repo_meta(true)));
2597
2598 let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[])
2599 .expect("route listing should collect");
2600
2601 assert_eq!(
2602 infos[0].resource_shape,
2603 crate::capacity::ResourceShape::DbBound
2604 );
2605 assert_eq!(infos[0].pools, vec!["db".to_owned()]);
2606 }
2607}