Skip to main content

autumn_web/
route_listing.rs

1//! Route listing types and collection logic for `autumn routes`.
2//!
3//! Collects route metadata from an [`AppBuilder`](crate::app::AppBuilder) into
4//! serializable [`RouteInfo`] values that the CLI can consume without booting
5//! the full HTTP server.
6
7// autumn-determinism-gate: production code in this module must read time and
8// mint identifiers through the framework's injected seams (ClockSource /
9// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
10// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
11// (issue #1797). Justify exceptions with
12// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
13#![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
22/// Machine-readable stderr marker for omitted (unenumerable) raw routers.
23///
24/// Emitted by the `AUTUMN_DUMP_ROUTES` dump when raw routers registered via
25/// [`AppBuilder::merge`](crate::app::AppBuilder::merge) /
26/// [`AppBuilder::nest`](crate::app::AppBuilder::nest) are omitted from the JSON
27/// route listing (they are opaque and cannot be enumerated). The decimal count
28/// of omitted routers follows the marker on the same line.
29///
30/// This is a process-boundary protocol: `autumn routes audit` runs the built
31/// binary as a child, scans its stderr for this marker, and hard-fails the
32/// coverage gate when the count is non-zero — omitted routes can carry unproven
33/// auth posture, so a manifest that silently drops them would defeat the gate.
34pub const OMITTED_ROUTES_MARKER: &str = "[autumn:omitted-routes] ";
35
36/// Machine-readable stderr marker carrying the resolved security configuration
37/// (CSRF + headers) for the `declared` manifest dimensions built by
38/// `autumn routes audit`.
39///
40/// Emitted by the `AUTUMN_DUMP_ROUTES` dump only when `AUTUMN_DUMP_SECURITY=1`
41/// is *also* set — which `autumn routes audit` sets, but the plain
42/// `autumn routes` listing does not. A single compact JSON [`SecurityDump`]
43/// follows the marker on the same line. Kept off stdout so the routes-only JSON
44/// parse path (`autumn routes`, and older audit builds) stays byte-compatible.
45pub const SECURITY_CONFIG_MARKER: &str = "[autumn:security-config] ";
46
47/// Resolved CSRF configuration carried across the dump boundary for the
48/// `declared` CSRF manifest dimension.
49///
50/// Mirrors the runtime-relevant subset of
51/// [`CsrfConfig`](crate::security::config::CsrfConfig): the enable flag plus the
52/// two inputs to the runtime safe/exempt predicate.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct CsrfDump {
55    /// Whether CSRF enforcement is enabled.
56    pub enabled: bool,
57    /// Methods that never require a CSRF token (sorted).
58    pub safe_methods: Vec<String>,
59    /// Path prefixes exempt from CSRF validation (sorted).
60    pub exempt_paths: Vec<String>,
61}
62
63/// Resolved security-headers configuration carried across the dump boundary for
64/// the `declared` security-headers manifest dimension.
65///
66/// Every string is the *effective* declared value — in particular
67/// `content_security_policy` is the resolved template
68/// ([`default_content_security_policy`](crate::security::config::default_content_security_policy)
69/// when unset), not an unresolved sentinel. When per-request CSP nonce injection
70/// is active it is the nonce-aware template (with a stable `AUTUMN_CSP_NONCE`
71/// placeholder), matching what responses actually send.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[allow(clippy::struct_excessive_bools)]
74pub struct HeadersDump {
75    /// `X-Frame-Options` value (empty = header not emitted).
76    pub x_frame_options: String,
77    /// Whether `X-Content-Type-Options: nosniff` is sent.
78    pub x_content_type_options: bool,
79    /// Whether `X-XSS-Protection: 1; mode=block` is sent.
80    pub xss_protection: bool,
81    /// Resolved `Content-Security-Policy` value (empty = header not emitted).
82    pub content_security_policy: String,
83    /// `Referrer-Policy` value (empty = header not emitted).
84    pub referrer_policy: String,
85    /// `Permissions-Policy` value (empty = header not emitted).
86    pub permissions_policy: String,
87    /// Whether `Strict-Transport-Security` (HSTS) is sent.
88    pub strict_transport_security: bool,
89    /// HSTS `max-age` in seconds.
90    pub hsts_max_age_secs: u64,
91    /// Whether HSTS includes subdomains.
92    pub hsts_include_subdomains: bool,
93    /// Whether per-request CSP nonce injection is enabled.
94    pub csp_nonce: bool,
95}
96
97/// Resolved mTLS client-auth configuration carried across the dump boundary for
98/// the `declared` mTLS manifest dimension (issue #1640).
99///
100/// Mirrors the runtime-relevant subset of
101/// [`ClientAuthConfig`](crate::config::ClientAuthConfig): the listener mode plus
102/// the route prefixes that demand a certificate. The trust-store *paths* are
103/// deliberately absent — rotating a bundle is not a posture change, and a path
104/// in the manifest would make every filesystem-layout change a finding.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ClientAuthDump {
107    /// Listener requirement level: `off`, `optional`, or `required`.
108    pub mode: String,
109    /// Route prefixes that demand a verified client certificate (sorted).
110    pub required_paths: Vec<String>,
111}
112
113/// Resolved security configuration snapshot emitted after
114/// [`SECURITY_CONFIG_MARKER`] for the manifest's `declared` dimensions.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SecurityDump {
117    /// CSRF configuration.
118    pub csrf: CsrfDump,
119    /// Security-headers configuration.
120    pub headers: HeadersDump,
121    /// mTLS client-certificate configuration (issue #1640). Defaults to `off`
122    /// with no required paths, so a dump from a build without
123    /// `[server.tls.client_auth]` reads as "no route requires mTLS".
124    #[serde(default = "ClientAuthDump::off")]
125    pub client_auth: ClientAuthDump,
126}
127
128impl ClientAuthDump {
129    /// The dump for a deployment with no client-certificate verification.
130    #[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    /// Snapshot the security-relevant configuration for the manifest dump.
141    ///
142    /// Every emitted list is sorted so the serialized snapshot is
143    /// byte-deterministic across runs, regardless of config source ordering.
144    #[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        // Runtime's `apply_csrf_middleware` exempts every configured webhook
154        // endpoint path via `CsrfLayer::with_exempt_path`, so a webhook POST
155        // route not also listed in `csrf.exempt_paths` still skips CSRF at
156        // runtime. Fold those paths into the exempt set so the manifest matches.
157        for endpoint in &config.security.webhooks.endpoints {
158            exempt_paths.push(endpoint.path.clone());
159        }
160        // Runtime also exempts the framework-owned RFC 8058 one-click unsubscribe
161        // endpoint from CSRF (`apply_csrf_middleware`, `router.rs`) under the same
162        // `mail`-feature gate and `should_mount_unsubscribe_endpoint()` predicate,
163        // so fold that path in too when it applies.
164        #[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/// Where a route was registered: by the user application, by a named plugin,
210/// or by the framework itself (probes, actuator, htmx assets, dev reload).
211#[derive(Debug, Clone, PartialEq, Eq, Default)]
212pub enum RouteSource {
213    /// Registered directly by the user application.
214    #[default]
215    User,
216    /// Registered by a named autumn plugin (e.g. `"admin"` for autumn-admin-plugin).
217    Plugin(String),
218    /// Registered by the framework (probes, actuator, htmx assets, dev reload).
219    Framework,
220}
221
222/// Security posture of a route.
223///
224/// Derived at build time from the handler's macro-expanded
225/// [`ApiDoc`](crate::openapi::ApiDoc) and its [`RouteSource`]. Emitted alongside
226/// every route in the `autumn routes audit` manifest so route authentication
227/// coverage can be gated in CI.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
229pub enum RouteClassification {
230    /// Owned by the framework (probes, actuator, htmx assets, docs). Exempt
231    /// from the audit gate — these are pre-attributed and never require an
232    /// explicit `#[secured]`/`#[public]` declaration.
233    Framework,
234    /// Guarded by authentication (`#[secured]`) and/or dynamic policy
235    /// authorization (`#[authorize]`).
236    Gated,
237    /// Explicitly declared unauthenticated via `#[public]`.
238    Public,
239    /// No security posture could be proven for this route. This is the state
240    /// the audit gate fails on.
241    #[default]
242    Unclassified,
243}
244
245impl RouteClassification {
246    /// Stable lowercase tag used in serialized manifests.
247    #[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/// One record-level authorization binding declared by `#[authorize]`.
312///
313/// Wire twin of [`AuthorizeBinding`](crate::openapi::AuthorizeBinding): the
314/// same `("action", resource = Type)` pair in owned form, so it round-trips
315/// through the routes-dump JSON. (The `*Info` suffix follows [`RouteInfo`].)
316///
317/// [`Self::resource`] is the identifier exactly as written at the use site,
318/// not a resolved path, and never the `impl Policy<_>` that serves the check:
319/// that binding is established from the `PolicyRegistry` at boot
320/// (`AppBuilder::policy::<R, _>(...)`) and is not knowable at build time.
321#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
322pub struct AuthorizeBindingInfo {
323    /// Action verb passed to the policy check — the first `#[authorize]`
324    /// argument, recorded verbatim.
325    pub action: String,
326    /// Resource type identifier exactly as written in `resource = Type`.
327    pub resource: String,
328}
329
330/// Metadata for a single mounted route, suitable for display and JSON export.
331#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332pub struct RouteInfo {
333    /// HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `WS`, etc.).
334    pub method: String,
335    /// Full mounted URL path (e.g. `/api/posts/{id}`), reflecting the
336    /// final URL after any scope prefix is applied.
337    pub path: String,
338    /// Handler function name (e.g. `"posts::show"`).
339    pub handler: String,
340    /// Registration origin: user application, a named plugin, or the framework.
341    pub source: RouteSource,
342    /// Active middleware on this route (compact labels, e.g. `"secured"`, `"cached(60s)"`).
343    pub middleware: Vec<String>,
344    /// API version of the route (e.g. "v1")
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub api_version: Option<String>,
347    /// Status of the version ("active", "deprecated", "sunset")
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub status: Option<String>,
350    /// Whether this route opts out of sunset 410 Gone response
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub sunset_opt_out: Option<bool>,
353    /// Build-time security classification derived from the handler's auth
354    /// posture and registration source. Consumed by `autumn routes audit`.
355    #[serde(default)]
356    pub classification: RouteClassification,
357    /// Roles required by `#[secured("role")]`, carried for `Gated` routes.
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub roles: Vec<String>,
360    /// Scopes required by `#[secured(scopes = [...])]`, carried for `Gated` routes.
361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
362    pub scopes: Vec<String>,
363    /// Whether the route is guarded by dynamic policy authorization
364    /// (`#[authorize]`), carried for `Gated` routes.
365    #[serde(default, skip_serializing_if = "is_false")]
366    pub policy: bool,
367    /// The route is `Gated` only because its group uses
368    /// `AppBuilder::scoped_guarded`. The guard is declared, not proven.
369    #[serde(default, skip_serializing_if = "is_false")]
370    pub layer_guarded: bool,
371    /// Module path of the handler (from `module_path!()`), used to name a
372    /// route in audit diagnostics. `None` for routes without a known module.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub module: Option<String>,
375    /// `file:line` source location of the handler (from `file!()`/`line!()`),
376    /// used to point an audit diagnostic straight at the offending handler.
377    /// `None` for routes without a known location.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub location: Option<String>,
380    /// Record-level authorization bindings proven from the handler's
381    /// `#[authorize]` attributes — one `(action, resource)` entry per binding,
382    /// sorted and deduplicated. Empty for routes that declare none.
383    ///
384    /// This is the provable *subset* of [`Self::policy`], never a replacement
385    /// for it: that boolean is also `true` for a hand-written `__check_policy`
386    /// call in the body and for a `policy = ...` repository auto-API, neither
387    /// of which carries a binding a macro can recover. A route with
388    /// `policy: true` and no bindings is therefore normal, not a defect.
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub authorize_bindings: Vec<AuthorizeBindingInfo>,
391    /// Name of the authority grant proved for this handler by
392    /// `#[agent_operable(grant = ...)]` (issue #1691), so `autumn routes` shows
393    /// which endpoints an agent may drive and under what envelope.
394    ///
395    /// `None` means the handler declares no grant. For a *mutating*
396    /// MCP-exposed route that is the "ungoverned tool" case the authority
397    /// manifest reports; for everything else it simply means the endpoint is
398    /// not agent-operable.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub agent_grant: Option<String>,
401    /// Statically derived resource character of the route — the capacity
402    /// contract's per-route shape (issue #1733). Computed from [`Self::pools`]
403    /// by [`ResourceShape::from_pools`], so the classification rule lives in
404    /// exactly one place.
405    #[serde(default)]
406    pub resource_shape: ResourceShape,
407    /// Pool tags this route provably touches, sorted and deduplicated.
408    ///
409    /// A **provable subset**, never a complete accounting: it is read off the
410    /// handler's declared extractors at macro-expansion time, so a pool
411    /// reached through an application-held `State` value is invisible here and
412    /// the route reads as compute-bound. A route with no pools is therefore
413    /// normal, not a defect — the same "provable" caveat the security
414    /// dimensions of the routes manifest carry.
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub pools: Vec<String>,
417}
418
419/// `skip_serializing_if` helper: elide `false` booleans from JSON output.
420#[allow(clippy::trivially_copy_pass_by_ref)]
421const fn is_false(b: &bool) -> bool {
422    !*b
423}
424
425impl RouteInfo {
426    /// Construct a framework-owned `GET` route entry. Framework routes are
427    /// pre-classified and exempt from the audit gate.
428    pub(crate) fn framework_get(path: String, handler: &str) -> Self {
429        Self::framework_route("GET", path, handler)
430    }
431
432    /// Construct a framework-owned route entry for an arbitrary HTTP method.
433    /// Framework routes are pre-classified and exempt from the audit gate.
434    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
446/// Classify a route in a scoped group. Only an app route with no other
447/// classification takes the group's declared layer guard.
448fn 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    // A plugin cannot vouch for its own routes.
455    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
464/// Derive a route's security classification (and carried posture) from its
465/// registration source and macro-expanded [`ApiDoc`](crate::openapi::ApiDoc).
466///
467/// Precedence: framework routes are always [`RouteClassification::Framework`];
468/// otherwise a route guarded by `#[secured]` or `#[authorize]` is
469/// [`RouteClassification::Gated`] (carrying its roles/scopes/policy); an
470/// explicit `#[public]` is [`RouteClassification::Public`]; anything left is
471/// [`RouteClassification::Unclassified`].
472///
473/// Repository auto-API routes (`#[repository(api = ..., policy = ...)]`) carry
474/// their record-level authorization guard on the route's
475/// [`RepositoryApiMeta`](crate::route::RepositoryApiMeta), *not* on the
476/// handler's [`ApiDoc`](crate::openapi::ApiDoc): the generated CRUD handlers
477/// build their `ApiDoc` with `..Default::default()` and never set
478/// `secured`/`has_policy`. So a `policy = ...` repository must be classified
479/// from `repository.has_policy` — otherwise a policy-protected CRUD endpoint
480/// would fail the audit gate as `Unclassified` despite enforcing authorization.
481fn 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    // A repository auto-API declared with `scope = ...` (but no `policy = ...`)
496    // enforces the registered scope on its generated list handler at runtime,
497    // yet leaves both the handler `ApiDoc` and `RepositoryApiMeta::has_policy`
498    // at their defaults. Treat the presence of a scope guard as gated too, so
499    // these scope-protected `GET /<api>` routes are not false-failed as
500    // `Unclassified`. No scope *name* is recorded on the meta (only a
501    // type-erased registry probe), so there is nothing to carry into `scopes`.
502    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
532/// The handler's module path, when the route macros captured one.
533fn 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
537/// The handler's `file:line` source location, when the route macros captured
538/// one (`ApiDoc::source_file`/`ApiDoc::source_line`, from `file!()`/`line!()`
539/// at the handler's definition site). `None` for routes constructed without
540/// the route macros, so an audit diagnostic can point straight at the
541/// offending handler instead of only naming its module.
542fn 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
547/// The route's `#[authorize]` bindings, canonicalized for the wire.
548///
549/// Framework routes are pre-classified and carry no handler metadata, so —
550/// like [`classify`], which short-circuits before it reads the `ApiDoc` at all
551/// — they never report a binding.
552///
553/// The list is sorted by `(action, resource)` and then deduplicated. That
554/// diverges deliberately from `roles`/`scopes`, which preserve `#[secured]`
555/// attribute source order: the audit's falsifiability claim over these
556/// bindings is a *set* claim ("removing an `#[authorize]` removes exactly that
557/// binding"), so a stable set serves it better than a stable sequence, and the
558/// manifest diff stays byte-deterministic — the same contract
559/// [`SecurityDump::from_config`] holds every list it emits to. Sorting must run
560/// *first*: [`Vec::dedup`] only collapses adjacent duplicates, so two equal
561/// bindings written apart in the source would otherwise both survive.
562fn 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
582/// Name of the authority grant proved for a handler by `#[agent_operable]`.
583///
584/// Read straight off [`crate::openapi::ApiDoc::agent_authority`], so the grant
585/// shown by `autumn routes` disappears exactly when the attribute does.
586fn 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
592/// Helper type alias representing version name, status string, and sunset opt-out flag.
593type RouteVersionInfo = (Option<String>, Option<String>, Option<bool>);
594
595/// Pool tags proven for one route, sorted and deduplicated.
596///
597/// Repository auto-API routes are database-backed *by construction*: the
598/// generated CRUD handlers own their pool checkout internally, so no extractor
599/// appears in a signature the route macro could read. Adding the database tag
600/// here keeps the contract honest about the routes an app never wrote by hand.
601fn 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
614/// Collect [`RouteInfo`] entries from user routes and scoped groups.
615///
616/// `route_sources` is a parallel slice to `routes`; each element is the
617/// [`RouteSource`] for the corresponding route. If the slice is shorter than
618/// `routes`, remaining routes are attributed to [`RouteSource::User`].
619///
620/// Does not include framework-internal routes (probes, actuator, htmx).
621/// Call `append_framework_routes` with a loaded config to add those.
622///
623/// # Errors
624///
625/// Returns `RouterBuildError::UnregisteredApiVersion` if a route's version is not registered.
626pub 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
737/// Append the routes a worker replica serves: the probes and the actuator.
738///
739/// This matches `router::try_build_probe_only_router`. It adds no htmx asset,
740/// docs UI, static route or dev route.
741pub(crate) fn append_probe_and_actuator_routes(
742    infos: &mut Vec<RouteInfo>,
743    config: &crate::config::AutumnConfig,
744) {
745    // List a probe only where `mount_probe_endpoints` mounts it: never when
746    // `health.enabled = false`, and never on a path a user `GET` (or `WS`)
747    // owns (#1971). A second entry for one route corrupts the posture
748    // manifest (#2472).
749    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    // Mutating (non-GET) actuator routes are enumerated separately so they
768    // carry their real HTTP method (e.g. `PUT /actuator/loggers/{name}`,
769    // `POST /actuator/webhooks/replay`) rather than a phantom GET.
770    let mutating_routes = crate::actuator::actuator_mutating_routes(
771        &config.actuator.prefix,
772        config.actuator.sensitive,
773    );
774    // Some entries in `actuator_endpoint_paths` exist only to seed the runtime
775    // startup-barrier allow-list (`StartupBarrierState::from_config`) and are
776    // actually mounted with a mutating method (e.g. `/webhooks/replay` is a
777    // `POST`). Skip any path already covered by a mutating-method entry so the
778    // GET-only listing does not emit a phantom GET for it.
779    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
804/// Append framework-internal routes (probes, actuator, assets, dev routes).
805///
806/// Paths are taken from `config` so custom probe/actuator prefix settings
807/// are reflected accurately. Dev routes follow the process environment.
808pub(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/// [`append_framework_routes`] with an explicit environment for the dev gate.
816#[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    // Flash and widget stylesheets: mounted whenever their feature is on.
843    #[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    // Framework-owned RFC 8058 one-click unsubscribe endpoint. Runtime merges
877    // `unsubscribe_router()` — a GET and a POST at `UNSUBSCRIBE_PATH` — under the
878    // same `mail`-feature gate and `should_mount_unsubscribe_endpoint()` predicate
879    // that folds the path into `csrf.exempt_paths`, so list both methods here to
880    // keep the manifest in lockstep. Without the POST listed, `build_csrf_dimension`
881    // would silently under-report the exempt (not-enforced) mutating route.
882    #[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    // Widget story gallery routes (#1526), listed iff the resolved config
894    // enables them (same gating condition as the router mount).
895    #[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            // Live demo backends for the Active search / Autocomplete /
901            // Infinite feed stories (review follow-up — route-dump
902            // consumers couldn't see these three without an entry here).
903            ("/_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    // Dev request inspector routes.
918    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    // Tracked-job status endpoint (#1627): runtime mounts
930    // `GET /_autumn/jobs/{token}` when `jobs.tracking.route_enabled` (default
931    // true), gated identically to the router mount. List it so the manifest
932    // reflects the mounted route.
933    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    // Static file serving is unconditionally mounted at /static.
941    infos.push(RouteInfo::framework_get(
942        "/static/{*path}".to_owned(),
943        "static_files",
944    ));
945}
946
947/// Append `OpenAPI` documentation routes (`/openapi.json`, `/swagger-ui`).
948///
949/// Only compiled when the `openapi` feature is enabled.
950#[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
967/// Append dev live-reload routes (`/__autumn/live-reload`, `/__autumn/live-reload.js`).
968///
969/// Routes are only appended when the dev server sets the live-reload
970/// variables, the same gate the router uses.
971fn 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
985/// Stable-sort route infos: primary key is path (lexicographic), secondary
986/// key is HTTP method (lexicographic). This makes output diff-friendly.
987pub(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
991/// Join a scope prefix with a child route path, mirroring axum's nest
992/// normalization: `/api` + `/` → `/api`, `/api` + `/posts` → `/api/posts`.
993fn 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    /// Shorthand for an expected wire-side `#[authorize]` binding.
1030    fn binding(action: &str, resource: &str) -> AuthorizeBindingInfo {
1031        AuthorizeBindingInfo {
1032            action: action.to_owned(),
1033            resource: resource.to_owned(),
1034        }
1035    }
1036
1037    /// Build a [`RepositoryApiMeta`](crate::route::RepositoryApiMeta) with the
1038    /// given `has_policy`, mirroring what the `#[repository]` macro emits for a
1039    /// `policy = ...` (or bare) auto-API.
1040    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    /// Build a [`RepositoryApiMeta`](crate::route::RepositoryApiMeta) for a
1051    /// `scope = ...` (but no `policy = ...`) auto-API: `has_policy` stays
1052    /// `false`, but the list handler carries a `scope_check` probe.
1053    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    // ── collect_route_infos ────────────────────────────────────────────────
1103
1104    #[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    /// Acceptance criterion for issue #605: `autumn routes` /
1135    /// `/actuator/routes` must keep reporting the declared effective
1136    /// method (`PUT`, `PATCH`, `DELETE`) even though HTML browser
1137    /// submissions transport those mutations as `POST` with a hidden
1138    /// `_method` override.
1139    ///
1140    /// Route metadata is collected at registration time from
1141    /// `Route::method`, never from any per-request rewrite, so the
1142    /// listing stays semantically honest regardless of how clients
1143    /// reach the route.
1144    #[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        // The transport method browsers actually use must never appear in
1156        // the listing for these routes.
1157        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        // empty sources slice — should fall back to User
1223        let infos = collect_route_infos(&routes, &[], &[], &[]).unwrap();
1224        assert_eq!(infos[0].source, RouteSource::User);
1225    }
1226
1227    // ── classification (#1604) ──────────────────────────────────────────────
1228
1229    #[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    /// A repository CRUD route generated by `#[repository(api = ..., policy =
1279    /// ...)]` carries its record-level authorization guard on
1280    /// [`RepositoryApiMeta::has_policy`](crate::route::RepositoryApiMeta),
1281    /// while its handler `ApiDoc` is left at defaults (never `secured`/
1282    /// `has_policy`). Such a route must classify `Gated` — not
1283    /// `Unclassified` — so the audit gate does not flag a policy-protected
1284    /// endpoint as unauthenticated. (#1604)
1285    #[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    /// A repository auto-API declared with `scope = ...` (but no `policy =
1299    /// ...`) enforces the registered scope on its generated list handler,
1300    /// recorded as `RepositoryApiMeta::scope_check = Some(..)` while
1301    /// `has_policy` stays `false`. Such a route must classify `Gated` — not
1302    /// `Unclassified` — so the audit gate does not flag a scope-protected list
1303    /// endpoint as unauthenticated. No scope *name* is recorded on the meta, so
1304    /// `scopes` stays empty and `policy` stays `false` (a scope is not a
1305    /// policy). (#1604)
1306    #[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    /// A repository route without a policy (`#[repository(api = ...)]`, no
1323    /// `policy = ...`) has `has_policy == false` and stays `Unclassified`:
1324    /// that unauthenticated CRUD form is exactly what the audit gate should
1325    /// keep flagging. (#1604)
1326    #[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    /// Keystone falsifiability check (#1604) at the library level: an
1335    /// unannotated mutating handler classifies as `Unclassified` (the audit
1336    /// gate's failure state), and adding *either* a `#[secured]` guard or a
1337    /// `#[public]` marker flips it to a passing classification.
1338    #[test]
1339    fn unclassified_route_turns_green_when_guarded_or_public() {
1340        // Red: no auth posture declared.
1341        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        // Green via #[secured]: carries the declared role.
1346        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        // Green via #[public].
1357        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    /// Group helper for the layer-guard tests (#2468).
1367    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    /// An unclassified route in a layer-guarded group is `Gated` and flagged
1378    /// as declared. The macros cannot see the layer.
1379    #[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    /// Without the declaration, the same route still fails the audit.
1389    #[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    /// An explicit `#[public]` wins over the group. The manifest must not hide it.
1398    #[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    /// A plugin cannot declare a guard for its own routes.
1411    #[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    /// The wire contract with `autumn routes audit`: `true` is sent, `false` is not.
1422    #[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    /// A handler guard is proven. It keeps its stronger claim.
1434    #[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    /// End-to-end through `collect_route_infos`: a policy-protected repository
1449    /// CRUD route classifies `Gated` (carrying `policy = true`) rather than
1450    /// `Unclassified`, so it does not fail the audit gate. A bare repository
1451    /// route (no policy) stays `Unclassified`. (#1604)
1452    #[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    // ── #[authorize] bindings (#1627) ──────────────────────────────────────
1488
1489    /// The wire list is a *set* of bindings, not a transcript of the source:
1490    /// unlike `roles`/`scopes` (which keep `#[secured]` attribute order), it is
1491    /// sorted by `(action, resource)` and deduplicated so the manifest diff is
1492    /// byte-deterministic and the audit's "removing an `#[authorize]` removes
1493    /// exactly that binding" claim is order-independent.
1494    #[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    /// `Vec::dedup` only collapses *adjacent* equal elements, so a helper that
1533    /// deduped before sorting would leave the duplicate pair in place. Pin the
1534    /// order of the two operations with duplicates that are separated in the
1535    /// source.
1536    #[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    /// Framework routes are pre-classified and carry no handler metadata, so
1563    /// they never report a binding — mirroring `classify`, which short-circuits
1564    /// on [`RouteSource::Framework`] before reading the `ApiDoc` at all.
1565    #[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    /// End-to-end through `collect_route_infos`: the bindings recorded by the
1583    /// route macros reach the `RouteInfo` the dump serializes.
1584    #[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    // ── `agent_grant` (#1691) ───────────────────────────────────────────
1602
1603    /// One hand-built authority, standing in for what `#[agent_operable]`
1604    /// emits. `autumn routes` only ever reads the grant's *name* off it.
1605    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        // `None` is the signal the authority manifest keys `ungoverned_tools`
1647        // off, so inventing one here would hide the gap the listing exists to
1648        // show.
1649        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        // Same compatibility convention as the fields before it: elided when
1658        // absent, so an existing dump entry stays byte-stable for an older
1659        // consumer.
1660        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    /// Field 15 follows the field 9-14 convention: elided when empty, so the
1683    /// first fourteen keys of every existing dump entry stay byte-stable and an
1684    /// *old* consumer sees the JSON it already knows.
1685    #[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    /// The other half of the compatibility contract: `#[serde(default)]` lets a
1713    /// *new* consumer read a dump produced before the field existed, rather
1714    /// than failing the whole parse on a missing key.
1715    #[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    // ── sort_route_infos ───────────────────────────────────────────────────
1758
1759    #[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    // ── RouteSource serialization ──────────────────────────────────────────
1836
1837    #[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    // ── append_framework_routes ────────────────────────────────────────────
1880
1881    #[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    /// A user `GET` on a probe path wins, and the router does not mount the
1906    /// probe (#1971). So the listing must not list it either: two entries for
1907    /// one route make the posture manifest corrupt (#2472).
1908    #[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    /// `health.enabled = false` mounts no probe, so none is listed.
1932    #[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    /// Codex P2 (issue #1627): mutating actuator routes must be enumerated with
1981    /// their real HTTP method so the `csrf` posture dimension can see them.
1982    /// `PUT {prefix}/loggers/{name}` is mounted at runtime when
1983    /// `actuator.sensitive = true` but was previously absent from the listing.
1984    #[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        // The `/webhooks/replay` route is mounted as POST only; it must never
2001        // appear as a phantom GET.
2002        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    /// Companion to the above, guarded on `http-client`: the DLQ replay endpoint
2012    /// is mounted as `POST` and must be enumerated as such.
2013    #[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        // `/webhooks/dlq` stays a GET listing.
2030        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    /// Regression guard (#1627): `/webhooks/replay` must stay in the runtime
2040    /// path set (`actuator_endpoint_paths`) that seeds the startup-barrier
2041    /// allow-list, while the GET-only route listing must NOT emit a phantom
2042    /// GET for it (only the real `POST`). A prior fix dropped the path from
2043    /// `actuator_endpoint_paths` entirely to kill the phantom GET, which also
2044    /// silently removed it from the barrier bypass set — this asserts both
2045    /// halves are decoupled: complete runtime set, clean listing.
2046    #[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        // Runtime set (barrier allow-list source) still contains the path.
2055        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        // Listing: no phantom GET, but the real POST is present.
2067        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    /// #1627: the tracked-job status endpoint (`GET /_autumn/jobs/{token}`) is
2084    /// mounted by default and must be enumerated; disabling
2085    /// `jobs.tracking.route_enabled` (the same gate as the router mount) drops
2086    /// both the mount and the listing.
2087    #[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    /// T18 (AC5, issue #1526): `autumn routes` introspection lists the story
2116    /// gallery endpoints exactly when the resolved config enables them.
2117    #[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        // Review follow-up: the Active search / Autocomplete / Infinite feed
2134        // stories' live demo backends must be listed too, or route-dump
2135        // consumers can't see them and the OpenAPI/MCP collision preflight
2136        // (`collect_framework_get_paths` in router.rs) can't reserve them.
2137        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    /// Codex P2 (issue #1627): when the RFC 8058 one-click unsubscribe endpoint
2163    /// is mounted, runtime merges `unsubscribe_router()` — a GET and a POST at
2164    /// `UNSUBSCRIBE_PATH` — and exempts the path from CSRF. `append_framework_routes`
2165    /// must list both methods (matching the same `should_mount_unsubscribe_endpoint()`
2166    /// predicate the exempt fold uses) so the csrf posture dimension can see the
2167    /// mutating POST. When the endpoint is not mounted, neither route is listed.
2168    #[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        // When the endpoint is NOT mounted, neither route is listed.
2197        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    // ── join_scope_path ────────────────────────────────────────────────────
2210
2211    #[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    // ── RouteInfo JSON roundtrip ───────────────────────────────────────────
2242
2243    #[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    // ── append_openapi_routes ──────────────────────────────────────────────
2266
2267    #[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        // Only the JSON endpoint; no swagger-ui entry.
2314        assert_eq!(infos.len(), 1);
2315        assert_eq!(infos[0].path, "/openapi.json");
2316    }
2317
2318    // ── append_framework_routes static ────────────────────────────────────
2319
2320    #[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    // ── append_dev_reload_routes ───────────────────────────────────────────
2337
2338    #[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    /// Framework routes list the dev live-reload paths under the router's
2353    /// gate, so `autumn routes` and the graph both see them (#2520 item 7).
2354    #[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    /// The widget runtime script comes from `framework_scripts`; it must be
2403    /// listed once, not once there and once as a separate asset entry.
2404    #[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    /// A worker mounts the probes and the actuator only (#2520 item 2).
2419    #[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    // ── SecurityDump::from_config (declared-vs-runtime honesty) ─────────────
2438
2439    /// Finding A: when CSP nonce injection is enabled and the CSP is the
2440    /// framework default, runtime emits the nonce-aware template (not the raw
2441    /// default), so the dumped CSP must be that template with the stable
2442    /// `AUTUMN_CSP_NONCE` placeholder — never the raw default.
2443    #[test]
2444    fn security_dump_reports_nonce_aware_csp_when_nonce_enabled() {
2445        let mut config = AutumnConfig::default();
2446        // Default CSP is left in place; only the nonce flag flips.
2447        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        // With the nonce disabled the raw default is reported (no placeholder).
2462        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    /// Finding B: runtime's `apply_csrf_middleware` exempts every configured
2475    /// webhook endpoint path, so the dumped `csrf.exempt_paths` must include
2476    /// them even when they are not duplicated in `security.csrf.exempt_paths`.
2477    #[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        // Determinism: sorted and deduped.
2495        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    /// Runtime's `apply_csrf_middleware` exempts the framework-owned RFC 8058
2505    /// one-click unsubscribe endpoint when the app opts in with a base URL, so
2506    /// the dumped `csrf.exempt_paths` must include `UNSUBSCRIBE_PATH` for that
2507    /// configuration and a mutating route there must read as CSRF-unenforced.
2508    #[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        // When the endpoint is NOT mounted, the path must not be folded in.
2530        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    // ── capacity contract: per-route resource shape (#1733) ──────────────
2545
2546    #[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        // The generated CRUD handlers own their pool checkout internally, so no
2594        // extractor appears in a signature the macro could read — but the route
2595        // is database-backed by construction.
2596        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}