use std::process::Command;
use autumn_web::route_listing::{
AuthorizeBindingInfo, ClientAuthDump, CsrfDump, HeadersDump, OMITTED_ROUTES_MARKER,
SECURITY_CONFIG_MARKER, SecurityDump,
};
use serde::{Deserialize, Serialize};
use crate::routes;
pub const MANIFEST_SCHEMA_VERSION: u32 = 6;
pub struct AuditOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub manifest: Option<&'a str>,
pub json: bool,
pub strict: bool,
pub features: routes::CargoFeatures,
pub profile: routes::CargoProfile,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuditRoute {
pub method: String,
pub path: String,
pub handler: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub classification: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub policy: bool,
#[serde(default)]
pub layer_guarded: bool,
#[serde(default)]
pub module: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub authorize_bindings: Vec<AuthorizeBindingInfo>,
}
impl AuditRoute {
#[must_use]
pub fn is_unclassified(&self) -> bool {
!matches!(
self.classification.as_str(),
"framework" | "gated" | "public"
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provenance {
Provable,
Declared,
#[serde(rename = "runtime-only")]
RuntimeOnly,
}
#[derive(Debug, Serialize)]
pub struct Manifest {
pub schema_version: u32,
pub build: BuildConfig,
pub dimensions: Dimensions,
pub excluded: Vec<ExcludedDimension>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BuildConfig {
pub profile: String,
pub features: Vec<String>,
pub all_features: bool,
pub no_default_features: bool,
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
profile: "dev".to_owned(),
features: Vec::new(),
all_features: false,
no_default_features: false,
}
}
}
impl BuildConfig {
#[must_use]
pub fn new(features: &routes::CargoFeatures, profile: &routes::CargoProfile) -> Self {
let profile = if profile.release {
"release".to_owned()
} else {
profile.profile.clone().unwrap_or_else(|| "dev".to_owned())
};
Self {
profile,
features: normalize_features(&features.features),
all_features: features.all,
no_default_features: features.no_default,
}
}
}
#[must_use]
pub fn normalize_features(values: &[String]) -> Vec<String> {
let mut features: Vec<String> = values
.iter()
.flat_map(|v| v.split([',', ' ']))
.filter(|f| !f.is_empty())
.map(str::to_owned)
.collect();
features.sort();
features.dedup();
features
}
#[derive(Debug, Serialize)]
pub struct Dimensions {
pub routes: RoutesDimension,
pub csrf: CsrfDimension,
pub security_headers: HeadersDimension,
pub authorization_policies: AuthorizationPoliciesDimension,
pub mtls: MtlsDimension,
}
#[derive(Debug, Serialize)]
pub struct MtlsDimension {
pub provenance: Provenance,
pub source: &'static str,
pub mode: String,
pub required_paths: Vec<String>,
pub entries: Vec<MtlsEntry>,
}
#[derive(Debug, Serialize)]
pub struct MtlsEntry {
pub path: String,
pub method: String,
pub mtls_required: bool,
}
#[derive(Debug, Serialize)]
pub struct RoutesDimension {
pub provenance: Provenance,
pub source: &'static str,
pub entries: Vec<ManifestRoute>,
}
#[derive(Debug, Serialize)]
pub struct ManifestRoute {
pub path: String,
pub method: String,
pub name: String,
pub classification: String,
pub roles: Vec<String>,
pub scopes: Vec<String>,
pub policy: bool,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
pub provenance: &'static str,
}
#[derive(Debug, Serialize)]
pub struct CsrfDimension {
pub provenance: Provenance,
pub source: &'static str,
pub exempt_paths: Vec<String>,
pub entries: Vec<CsrfEntry>,
}
#[derive(Debug, Serialize)]
pub struct CsrfEntry {
pub path: String,
pub method: String,
pub csrf_enforced: bool,
pub exempt: bool,
}
#[derive(Debug, Serialize)]
pub struct HeadersDimension {
pub provenance: Provenance,
pub source: &'static str,
pub entries: Vec<HeaderEntry>,
}
#[derive(Debug, Serialize)]
pub struct HeaderEntry {
pub header: String,
pub value: String,
pub emitted: bool,
}
#[derive(Debug, Serialize)]
pub struct AuthorizationPoliciesDimension {
pub provenance: Provenance,
pub source: &'static str,
pub runtime_caveat: &'static str,
pub entries: Vec<AuthorizationPolicyEntry>,
}
#[derive(Debug, Serialize)]
pub struct AuthorizationPolicyEntry {
pub path: String,
pub method: String,
pub name: String,
pub action: String,
pub resource: String,
pub provenance: &'static str,
}
#[derive(Debug, Serialize)]
pub struct ExcludedDimension {
pub dimension: &'static str,
pub eventual_provenance: Provenance,
pub reason: &'static str,
}
fn normalize_method(method: &str) -> &str {
if method == "WS" { "GET" } else { method }
}
fn method_is_safe(method: &str, safe_methods: &[String]) -> bool {
let method = normalize_method(method);
safe_methods.iter().any(|m| m == method)
}
fn path_is_exempt(path: &str, exempt_paths: &[String]) -> bool {
exempt_paths.iter().any(|prefix| {
if path == prefix {
true
} else if let Some(stripped) = path.strip_prefix(prefix.as_str()) {
prefix.ends_with('/') || stripped.starts_with('/')
} else {
false
}
})
}
fn build_routes_dimension(routes: &[AuditRoute]) -> RoutesDimension {
let mut entries: Vec<ManifestRoute> = routes
.iter()
.map(|r| ManifestRoute {
path: r.path.clone(),
method: r.method.clone(),
name: r.handler.clone(),
classification: r.classification.clone(),
roles: r.roles.clone(),
scopes: r.scopes.clone(),
policy: r.policy,
source: r.source.clone(),
location: r.location.clone(),
provenance: if r.layer_guarded {
"declared"
} else {
"provable"
},
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
let declared = routes.iter().any(|r| r.layer_guarded);
RoutesDimension {
provenance: if declared {
Provenance::Declared
} else {
Provenance::Provable
},
source: if declared {
"macro:#[secured]/#[authorize]/#[public]; declared:scoped_guarded"
} else {
"macro:#[secured]/#[authorize]/#[public]"
},
entries,
}
}
fn build_csrf_dimension(routes: &[AuditRoute], csrf: &CsrfDump) -> CsrfDimension {
let mut exempt_paths = csrf.exempt_paths.clone();
exempt_paths.sort();
exempt_paths.dedup();
let mut entries: Vec<CsrfEntry> = routes
.iter()
.filter(|r| !method_is_safe(&r.method, &csrf.safe_methods))
.map(|r| {
let exempt = path_is_exempt(&r.path, &exempt_paths);
CsrfEntry {
path: r.path.clone(),
method: r.method.clone(),
csrf_enforced: csrf.enabled && !exempt,
exempt,
}
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
CsrfDimension {
provenance: Provenance::Declared,
source: "config:security.csrf",
exempt_paths,
entries,
}
}
fn build_headers_dimension(h: &HeadersDump) -> HeadersDimension {
let hsts_value = if h.strict_transport_security {
let mut v = format!("max-age={}", h.hsts_max_age_secs);
if h.hsts_include_subdomains {
v.push_str("; includeSubDomains");
}
v
} else {
String::new()
};
let bool_header = |on: bool, val: &str| if on { val.to_owned() } else { String::new() };
let mut entries = vec![
header_entry("content_security_policy", h.content_security_policy.clone()),
header_entry("permissions_policy", h.permissions_policy.clone()),
header_entry("referrer_policy", h.referrer_policy.clone()),
header_entry("strict_transport_security", hsts_value),
header_entry(
"x_content_type_options",
bool_header(h.x_content_type_options, "nosniff"),
),
header_entry("x_frame_options", h.x_frame_options.clone()),
header_entry(
"xss_protection",
bool_header(h.xss_protection, "1; mode=block"),
),
];
entries.sort_by(|a, b| a.header.cmp(&b.header));
HeadersDimension {
provenance: Provenance::Declared,
source: "config:security.headers",
entries,
}
}
fn header_entry(header: &str, value: String) -> HeaderEntry {
use autumn_web::reexports::http::HeaderValue;
let emitted = !value.is_empty() && HeaderValue::from_str(&value).is_ok();
HeaderEntry {
header: header.to_owned(),
emitted,
value,
}
}
fn build_authorization_policies_dimension(routes: &[AuditRoute]) -> AuthorizationPoliciesDimension {
let mut entries: Vec<AuthorizationPolicyEntry> = routes
.iter()
.filter(|r| r.source != "framework")
.flat_map(|r| {
r.authorize_bindings
.iter()
.map(move |b| AuthorizationPolicyEntry {
path: r.path.clone(),
method: r.method.clone(),
name: r.handler.clone(),
action: b.action.clone(),
resource: b.resource.clone(),
provenance: "provable",
})
})
.collect();
entries.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.method.cmp(&b.method))
.then_with(|| a.action.cmp(&b.action))
.then_with(|| a.resource.cmp(&b.resource))
});
AuthorizationPoliciesDimension {
provenance: Provenance::Provable,
source: "macro:#[authorize]",
runtime_caveat: "the route→action→resource binding is proven from macro-expanded code; \
which impl Policy<Resource> serves it is resolved from the \
PolicyRegistry at boot (AppBuilder::policy::<R, _>(...)). A missing \
registration is not visible at build time — see the policy_registration \
entry in `excluded`.",
entries,
}
}
fn excluded_dimensions() -> Vec<ExcludedDimension> {
vec![
ExcludedDimension {
dimension: "repository_policy_bindings",
eventual_provenance: Provenance::Provable,
reason: "#[repository(api = ..., policy = ...)] auto-API handlers enforce fixed \
show/create/update/delete policy actions, but the macro discards the policy \
type at expansion and RepositoryApiMeta carries only a type-erased probe; \
their presence still shows as routes.entries[].policy",
},
ExcludedDimension {
dimension: "sessions",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "secrets",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "encryption",
eventual_provenance: Provenance::Provable,
reason: "later phase (#[encrypted] fields provable; key config declared)",
},
ExcludedDimension {
dimension: "rate_limit",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "submit_token",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "outbound_http",
eventual_provenance: Provenance::Declared,
reason: "later phase; named-client base_urls allowlist is config, nothing proves every \
outbound call routes through a named client",
},
ExcludedDimension {
dimension: "policy_registration",
eventual_provenance: Provenance::RuntimeOnly,
reason: "boot-time fact; excluded from a build-time manifest by design — the \
authorization_policies dimension proves bindings and carries this as its \
runtime_caveat",
},
ExcludedDimension {
dimension: "serve_path_routers",
eventual_provenance: Provenance::Provable,
reason: "opt-in serve-path HTTP surfaces (MCP, inbound-mail, storage, SEO) are \
injected only on the serve path, after the dump early-exit, so they are not \
enumerated by `autumn routes` at build time; their mutating POSTs (MCP, \
inbound-mail) run outside the framework CSRF layer and self-protect (MCP: \
origin + csrf_header + trusted-host; inbound-mail: provider signature \
verification). Enumerating them is deferred to a dump-plumbing follow-up",
},
]
}
const fn empty_csrf_dimension() -> CsrfDimension {
CsrfDimension {
provenance: Provenance::Declared,
source: "config:security.csrf",
exempt_paths: Vec::new(),
entries: Vec::new(),
}
}
const fn empty_headers_dimension() -> HeadersDimension {
HeadersDimension {
provenance: Provenance::Declared,
source: "config:security.headers",
entries: Vec::new(),
}
}
fn empty_mtls_dimension() -> MtlsDimension {
MtlsDimension {
provenance: Provenance::Declared,
source: MTLS_DIMENSION_SOURCE,
mode: "off".to_owned(),
required_paths: Vec::new(),
entries: Vec::new(),
}
}
const MTLS_DIMENSION_SOURCE: &str = "config:server.tls.client_auth";
fn path_requires_mtls(path: &str, required_paths: &[String]) -> bool {
required_paths.iter().any(|prefix| {
let bare = prefix.strip_suffix('/').unwrap_or(prefix);
if path == bare {
true
} else if let Some(rest) = path.strip_prefix(bare) {
rest.starts_with('/')
} else {
false
}
})
}
fn build_mtls_dimension(routes: &[AuditRoute], client_auth: &ClientAuthDump) -> MtlsDimension {
let mut required_paths = client_auth.required_paths.clone();
required_paths.sort();
required_paths.dedup();
let listener_requests = client_auth.mode != "off";
let mut entries: Vec<MtlsEntry> = routes
.iter()
.map(|r| MtlsEntry {
path: r.path.clone(),
method: r.method.clone(),
mtls_required: listener_requests && path_requires_mtls(&r.path, &required_paths),
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
MtlsDimension {
provenance: Provenance::Declared,
source: MTLS_DIMENSION_SOURCE,
mode: client_auth.mode.clone(),
required_paths,
entries,
}
}
#[must_use]
pub fn build_manifest(routes: &[AuditRoute], security: Option<&SecurityDump>) -> Manifest {
let routes_dim = build_routes_dimension(routes);
let (csrf, security_headers, mtls) = security.map_or_else(
|| {
(
empty_csrf_dimension(),
empty_headers_dimension(),
empty_mtls_dimension(),
)
},
|s| {
(
build_csrf_dimension(routes, &s.csrf),
build_headers_dimension(&s.headers),
build_mtls_dimension(routes, &s.client_auth),
)
},
);
Manifest {
schema_version: MANIFEST_SCHEMA_VERSION,
build: BuildConfig::default(),
dimensions: Dimensions {
routes: routes_dim,
csrf,
security_headers,
authorization_policies: build_authorization_policies_dimension(routes),
mtls,
},
excluded: excluded_dimensions(),
}
}
pub fn audited_manifest(
routes: &[AuditRoute],
security: Option<&SecurityDump>,
build: BuildConfig,
) -> Result<Manifest, String> {
let manifest = Manifest {
build,
..build_manifest(routes, security)
};
crate::posture::model::PostureManifest::parse(&manifest_json(&manifest), "the manifest")
.map_err(|e| e.to_string())?;
Ok(manifest)
}
#[must_use]
pub fn parse_security_config(stderr: &str) -> Option<SecurityDump> {
stderr
.lines()
.filter_map(|line| line.strip_prefix(SECURITY_CONFIG_MARKER))
.filter_map(|json| serde_json::from_str::<SecurityDump>(json.trim()).ok())
.next_back()
}
#[must_use]
pub fn manifest_json(manifest: &Manifest) -> String {
serde_json::to_string_pretty(manifest).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
#[must_use]
pub fn unclassified_routes(routes: &[AuditRoute]) -> Vec<&AuditRoute> {
routes.iter().filter(|r| r.is_unclassified()).collect()
}
#[must_use]
pub fn audit_exit_code(routes: &[AuditRoute]) -> i32 {
i32::from(routes.iter().any(AuditRoute::is_unclassified))
}
#[must_use]
pub fn parse_omitted_count(stderr: &str) -> usize {
let marker = OMITTED_ROUTES_MARKER.trim();
stderr
.lines()
.filter_map(|line| line.trim().strip_prefix(marker))
.filter_map(|rest| rest.trim().parse::<usize>().ok())
.next_back()
.unwrap_or(0)
}
#[must_use]
pub fn format_omitted_diagnostic(count: usize) -> String {
format!(
"\u{2717} {count} raw router(s) added via `AppBuilder::merge()`/`nest()` \
are not enumerable and were omitted from the route listing.\n\
Route auth coverage can't be proven while these exist. Mount routes via \
`routes![]` (or a plugin's `declare_plugin_routes`) so they are visible \
and classifiable.\n"
)
}
#[must_use]
pub fn format_diagnostic(unresolved: &[&AuditRoute]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"\u{2717} {} route(s) have no proven auth posture:",
unresolved.len()
);
for r in unresolved {
let module = r
.module
.as_deref()
.map(|m| format!(" [{m}]"))
.unwrap_or_default();
let location = r
.location
.as_deref()
.map(|l| format!(" at {l}"))
.unwrap_or_default();
let _ = writeln!(
out,
" {method:<6} {path} (handler `{handler}`{module}{location})",
method = r.method,
path = r.path,
handler = r.handler,
);
}
out.push_str(
"\nAdd a guard (`#[secured]` / `#[authorize]`) or mark the route \
deliberately open with `#[public]`.\n",
);
out
}
#[must_use]
pub fn format_summary(routes: &[AuditRoute]) -> String {
let mut framework = 0usize;
let mut gated = 0usize;
let mut public = 0usize;
let mut unclassified = 0usize;
for r in routes {
match r.classification.as_str() {
"framework" => framework += 1,
"gated" => gated += 1,
"public" => public += 1,
_ => unclassified += 1,
}
}
format!(
"{total} route(s): {gated} gated, {public} public, {framework} framework, \
{unclassified} unclassified",
total = routes.len(),
)
}
pub fn run(opts: &AuditOptions<'_>) {
eprintln!("\u{1F342} autumn routes audit\n");
if !opts.features.is_default() || !opts.profile.is_default() {
let mut build_flags = opts.features.to_args();
build_flags.extend(opts.profile.to_args());
eprintln!("Building with {}\n", build_flags.join(" "));
}
routes::compile_binary_with(opts.package, opts.bin, &opts.features, &opts.profile);
let binary = routes::find_binary_in_profile(opts.package, opts.bin, &opts.profile);
let mut command = Command::new(&binary);
command.env(routes::DUMP_ENV, "1");
command.env("AUTUMN_DUMP_SECURITY", "1");
routes::clear_other_dump_modes(&mut command, routes::DUMP_ENV);
let output = command
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
std::process::exit(1);
});
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.starts_with(SECURITY_CONFIG_MARKER) {
eprintln!("{line}");
}
}
let omitted = parse_omitted_count(&stderr);
let security = parse_security_config(&stderr);
if !output.status.success() {
eprintln!(
"\u{2717} Binary exited with status {} while dumping routes",
output.status
);
std::process::exit(output.status.code().unwrap_or(1));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let routes: Vec<AuditRoute> = serde_json::from_str(&stdout).unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to parse route listing JSON: {e}");
eprintln!("Raw output: {stdout}");
std::process::exit(1);
});
let build = BuildConfig::new(&opts.features, &opts.profile);
let manifest = audited_manifest(&routes, security.as_ref(), build).unwrap_or_else(|e| {
eprintln!("\u{2717} {e}");
std::process::exit(1);
});
let json = manifest_json(&manifest);
if let Some(path) = opts.manifest {
if let Err(e) = std::fs::write(path, format!("{json}\n")) {
eprintln!("\u{2717} Failed to write manifest to {path}: {e}");
std::process::exit(1);
}
eprintln!("\u{2713} Wrote security manifest \u{2192} {path}");
}
if opts.json {
println!("{json}");
} else {
println!("{}", format_summary(&routes));
}
let unresolved = unclassified_routes(&routes);
if unresolved.is_empty() {
if !opts.json && omitted == 0 {
eprintln!("\u{2713} All routes are classified.");
}
} else {
eprint!("\n{}", format_diagnostic(&unresolved));
if opts.strict {
eprintln!("(strict mode)");
}
}
if omitted > 0 {
eprint!("\n{}", format_omitted_diagnostic(omitted));
}
let failed = omitted > 0 || audit_exit_code(&routes) != 0;
std::process::exit(i32::from(failed));
}
#[cfg(test)]
mod tests {
use super::*;
fn route(method: &str, path: &str, handler: &str, classification: &str) -> AuditRoute {
AuditRoute {
method: method.to_owned(),
path: path.to_owned(),
handler: handler.to_owned(),
source: "user".to_owned(),
classification: classification.to_owned(),
roles: vec![],
scopes: vec![],
policy: false,
layer_guarded: false,
module: None,
location: None,
authorize_bindings: Vec::new(),
}
}
fn route_with_bindings(
method: &str,
path: &str,
handler: &str,
classification: &str,
bindings: &[(&str, &str)],
) -> AuditRoute {
let mut r = route(method, path, handler, classification);
r.policy = true;
r.authorize_bindings = bindings
.iter()
.map(|(action, resource)| AuthorizeBindingInfo {
action: (*action).to_owned(),
resource: (*resource).to_owned(),
})
.collect();
r
}
#[test]
fn is_unclassified_true_for_unclassified_and_unknown() {
assert!(route("POST", "/w", "create", "unclassified").is_unclassified());
assert!(route("POST", "/w", "create", "").is_unclassified());
assert!(route("POST", "/w", "create", "bogus").is_unclassified());
}
#[test]
fn is_unclassified_false_for_known_postures() {
assert!(!route("GET", "/w", "list", "gated").is_unclassified());
assert!(!route("GET", "/w", "list", "public").is_unclassified());
assert!(!route("GET", "/health", "health", "framework").is_unclassified());
}
#[test]
fn exit_code_is_nonzero_iff_any_route_unclassified() {
let clean = vec![
route("GET", "/a", "a", "public"),
route("GET", "/b", "b", "gated"),
route("GET", "/health", "health", "framework"),
];
assert_eq!(audit_exit_code(&clean), 0);
let mut dirty = clean;
dirty.push(route("POST", "/widgets", "create_widget", "unclassified"));
assert_eq!(audit_exit_code(&dirty), 1);
}
#[test]
fn unclassified_routes_returns_only_the_offenders() {
let routes = vec![
route("GET", "/a", "a", "public"),
route("POST", "/widgets", "create_widget", "unclassified"),
route("DELETE", "/things/{id}", "delete_thing", ""),
];
let unresolved = unclassified_routes(&routes);
assert_eq!(unresolved.len(), 2);
assert!(unresolved.iter().any(|r| r.handler == "create_widget"));
assert!(unresolved.iter().any(|r| r.handler == "delete_thing"));
}
#[test]
fn diagnostic_names_the_offending_route() {
let mut r = route("POST", "/widgets", "create_widget", "unclassified");
r.module = Some("myapp::widgets".to_owned());
let unresolved = vec![&r];
let msg = format_diagnostic(&unresolved);
assert!(msg.contains("POST"), "{msg}");
assert!(msg.contains("/widgets"), "{msg}");
assert!(msg.contains("create_widget"), "{msg}");
assert!(msg.contains("myapp::widgets"), "{msg}");
assert!(msg.contains("#[public]"), "{msg}");
}
#[test]
fn diagnostic_includes_source_location_when_known() {
let mut r = route("POST", "/widgets", "create_widget", "unclassified");
r.location = Some("src/routes/widgets.rs:42".to_owned());
let unresolved = vec![&r];
let msg = format_diagnostic(&unresolved);
assert!(msg.contains("src/routes/widgets.rs:42"), "{msg}");
}
#[test]
fn diagnostic_omits_location_when_unknown() {
let r = route("POST", "/widgets", "create_widget", "unclassified");
let unresolved = vec![&r];
let msg = format_diagnostic(&unresolved);
assert!(!msg.contains(" at "), "{msg}");
}
#[test]
fn manifest_is_schema_shaped_and_provable() {
let routes = vec![route("GET", "/a", "a", "gated")];
let manifest = build_manifest(&routes, None);
assert_eq!(manifest.schema_version, MANIFEST_SCHEMA_VERSION);
assert_eq!(manifest.dimensions.routes.entries.len(), 1);
assert_eq!(manifest.dimensions.routes.entries[0].provenance, "provable");
let json: serde_json::Value = serde_json::from_str(&manifest_json(&manifest)).unwrap();
assert_eq!(json["schema_version"], 6);
let entry = &json["dimensions"]["routes"]["entries"][0];
for key in [
"path",
"method",
"name",
"classification",
"roles",
"scopes",
"policy",
"source",
"provenance",
] {
assert!(entry.get(key).is_some(), "manifest route missing `{key}`");
}
}
#[test]
fn layer_guarded_route_is_declared_not_provable() {
let mut guarded = route("GET", "/api/todos", "list", "gated");
guarded.layer_guarded = true;
let proven = route("GET", "/admin", "admin", "gated");
let manifest = build_manifest(&[guarded, proven], None);
let entries = &manifest.dimensions.routes.entries;
assert_eq!(entries[0].path, "/admin");
assert_eq!(entries[0].provenance, "provable");
assert_eq!(entries[1].path, "/api/todos");
assert_eq!(entries[1].provenance, "declared");
let dim = &manifest.dimensions.routes;
assert!(matches!(dim.provenance, Provenance::Declared));
assert!(dim.source.contains("scoped_guarded"), "{}", dim.source);
}
#[test]
fn proven_routes_keep_a_provable_dimension() {
let manifest = build_manifest(&[route("GET", "/a", "a", "gated")], None);
let dim = &manifest.dimensions.routes;
assert!(matches!(dim.provenance, Provenance::Provable));
assert_eq!(dim.source, "macro:#[secured]/#[authorize]/#[public]");
}
#[test]
fn audit_route_reads_layer_guarded_from_the_dump() {
let with: AuditRoute = serde_json::from_str(
r#"{"method":"GET","path":"/a","handler":"h","classification":"gated","layer_guarded":true}"#,
)
.unwrap();
assert!(with.layer_guarded);
let without: AuditRoute =
serde_json::from_str(r#"{"method":"GET","path":"/a","handler":"h"}"#).unwrap();
assert!(!without.layer_guarded);
}
#[test]
fn manifest_routes_are_stable_ordered_by_path_then_method() {
let routes = vec![
route("POST", "/posts", "create", "gated"),
route("GET", "/posts", "list", "public"),
route("GET", "/about", "about", "public"),
];
let manifest = build_manifest(&routes, None);
let order: Vec<(&str, &str)> = manifest
.dimensions
.routes
.entries
.iter()
.map(|r| (r.path.as_str(), r.method.as_str()))
.collect();
assert_eq!(
order,
vec![("/about", "GET"), ("/posts", "GET"), ("/posts", "POST")]
);
}
#[test]
fn manifest_carries_roles_scopes_policy() {
let mut r = route("POST", "/admin", "admin_action", "gated");
r.roles = vec!["admin".to_owned()];
r.scopes = vec!["posts:write".to_owned()];
r.policy = true;
let manifest = build_manifest(&[r], None);
let entry = &manifest.dimensions.routes.entries[0];
assert_eq!(entry.roles, vec!["admin"]);
assert_eq!(entry.scopes, vec!["posts:write"]);
assert!(entry.policy);
}
fn security_dump(enabled: bool, exempt_paths: &[&str]) -> SecurityDump {
SecurityDump {
csrf: CsrfDump {
enabled,
safe_methods: vec![
"GET".to_owned(),
"HEAD".to_owned(),
"OPTIONS".to_owned(),
"TRACE".to_owned(),
],
exempt_paths: exempt_paths.iter().map(|s| (*s).to_owned()).collect(),
},
headers: HeadersDump {
x_frame_options: "DENY".to_owned(),
x_content_type_options: true,
xss_protection: true,
content_security_policy: "default-src 'self'".to_owned(),
referrer_policy: "strict-origin-when-cross-origin".to_owned(),
permissions_policy: String::new(),
strict_transport_security: false,
hsts_max_age_secs: 31_536_000,
hsts_include_subdomains: true,
csp_nonce: false,
},
client_auth: ClientAuthDump::off(),
}
}
fn manifest_value(m: &Manifest) -> serde_json::Value {
serde_json::from_str(&manifest_json(m)).unwrap()
}
#[test]
fn manifest_envelope_shape_and_provenance_labels() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", "gated"),
];
let sec = security_dump(true, &[]);
let json = manifest_value(&build_manifest(&routes, Some(&sec)));
assert_eq!(json["schema_version"], 6);
assert_eq!(json["dimensions"]["routes"]["provenance"], "provable");
assert_eq!(
json["dimensions"]["routes"]["source"],
"macro:#[secured]/#[authorize]/#[public]"
);
assert_eq!(json["dimensions"]["csrf"]["provenance"], "declared");
assert_eq!(json["dimensions"]["csrf"]["source"], "config:security.csrf");
assert_eq!(
json["dimensions"]["security_headers"]["provenance"],
"declared"
);
assert_eq!(
json["dimensions"]["security_headers"]["source"],
"config:security.headers"
);
assert_eq!(json["dimensions"]["mtls"]["provenance"], "declared");
assert_eq!(
json["dimensions"]["mtls"]["source"],
"config:server.tls.client_auth"
);
assert_eq!(
json["dimensions"]["authorization_policies"]["provenance"],
"provable"
);
assert_eq!(
json["dimensions"]["authorization_policies"]["source"],
"macro:#[authorize]"
);
assert!(
!json["dimensions"]["authorization_policies"]["runtime_caveat"]
.as_str()
.expect("runtime_caveat string")
.is_empty(),
"the authorization_policies dimension must carry a non-empty runtime_caveat"
);
let excluded = json["excluded"].as_array().expect("excluded array");
assert_eq!(excluded.len(), 9);
assert_eq!(excluded[0]["dimension"], "repository_policy_bindings");
assert_eq!(excluded[0]["eventual_provenance"], "provable");
assert!(
!excluded
.iter()
.any(|e| e["dimension"] == "authorization_policies"),
"authorization_policies is emitted now and must not also be listed as excluded"
);
let runtime_only = excluded
.iter()
.find(|e| e["dimension"] == "policy_registration")
.expect("policy_registration excluded");
assert_eq!(runtime_only["eventual_provenance"], "runtime-only");
let serve_path = excluded
.iter()
.find(|e| e["dimension"] == "serve_path_routers")
.expect("serve_path_routers excluded");
assert_eq!(serve_path["eventual_provenance"], "provable");
}
fn security_dump_with_mtls(mode: &str, required_paths: &[&str]) -> SecurityDump {
let mut dump = security_dump(true, &[]);
dump.client_auth = ClientAuthDump {
mode: mode.to_owned(),
required_paths: required_paths.iter().map(|s| (*s).to_owned()).collect(),
};
dump
}
#[test]
fn mtls_dimension_is_declared_and_names_its_source() {
let routes = vec![route("GET", "/internal/keys", "keys", "gated")];
let json = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("required", &["/internal/"])),
));
let dim = &json["dimensions"]["mtls"];
assert_eq!(dim["provenance"], "declared");
assert_eq!(dim["source"], "config:server.tls.client_auth");
assert_eq!(dim["mode"], "required");
assert_eq!(dim["required_paths"][0], "/internal/");
}
#[test]
fn mtls_entry_shape_is_exactly_route_plus_requirement() {
let routes = vec![route("GET", "/internal/keys", "keys", "gated")];
let json = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("required", &["/internal/"])),
));
let entry = &json["dimensions"]["mtls"]["entries"][0];
let obj = entry.as_object().expect("entry object");
let keys = ["path", "method", "mtls_required"];
for key in keys {
assert!(obj.contains_key(key), "entry missing `{key}`: {entry}");
}
assert_eq!(obj.len(), keys.len(), "unexpected extra keys: {entry}");
assert_eq!(entry["mtls_required"], true);
}
#[test]
fn mtls_flags_only_routes_under_a_required_prefix() {
let routes = vec![
route("GET", "/internal/keys", "keys", "gated"),
route("GET", "/internal-tools", "tools", "public"),
route("GET", "/health", "health", "framework"),
];
let json = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("optional", &["/internal"])),
));
let entries = json["dimensions"]["mtls"]["entries"]
.as_array()
.expect("entries");
let required: Vec<&str> = entries
.iter()
.filter(|e| e["mtls_required"] == true)
.map(|e| e["path"].as_str().expect("path"))
.collect();
assert_eq!(required, vec!["/internal/keys"]);
assert_eq!(entries.len(), 3);
}
#[test]
fn mtls_off_requires_nothing_even_with_configured_prefixes() {
let routes = vec![route("GET", "/internal/keys", "keys", "gated")];
let json = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("off", &["/internal/"])),
));
assert_eq!(
json["dimensions"]["mtls"]["entries"][0]["mtls_required"],
false
);
}
#[test]
fn a_trailing_slash_prefix_covers_the_bare_route_like_the_runtime() {
let routes = vec![
route("GET", "/internal", "index", "gated"),
route("GET", "/internal/keys", "keys", "gated"),
route("GET", "/internal-tools", "tools", "public"),
];
let json = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("optional", &["/internal/"])),
));
let required: Vec<&str> = json["dimensions"]["mtls"]["entries"]
.as_array()
.expect("entries")
.iter()
.filter(|e| e["mtls_required"] == true)
.map(|e| e["path"].as_str().expect("path"))
.collect();
assert_eq!(required, vec!["/internal", "/internal/keys"]);
}
#[test]
fn mtls_dimension_defaults_to_off_without_a_security_dump() {
let routes = vec![route("GET", "/a", "a", "gated")];
let json = manifest_value(&build_manifest(&routes, None));
assert_eq!(json["dimensions"]["mtls"]["mode"], "off");
assert!(
json["dimensions"]["mtls"]["entries"]
.as_array()
.expect("entries")
.is_empty()
);
}
#[test]
fn dropping_a_required_prefix_flips_only_the_mtls_dimension() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("GET", "/internal/keys", "keys", "gated"),
];
let locked = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("optional", &["/internal/"])),
));
let unlocked = manifest_value(&build_manifest(
&routes,
Some(&security_dump_with_mtls("optional", &[])),
));
for dimension in [
"routes",
"csrf",
"security_headers",
"authorization_policies",
] {
assert_eq!(
locked["dimensions"][dimension], unlocked["dimensions"][dimension],
"`{dimension}` must not move when only the mTLS requirement does"
);
}
assert_ne!(
locked["dimensions"]["mtls"], unlocked["dimensions"]["mtls"],
"the mtls dimension must record the dropped requirement"
);
}
#[test]
fn manifest_authorization_policies_entry_shape() {
let routes = vec![route_with_bindings(
"POST",
"/notes/{id}",
"update_note",
"gated",
&[("update", "Note")],
)];
let json = manifest_value(&build_manifest(&routes, None));
let entry = &json["dimensions"]["authorization_policies"]["entries"][0];
let obj = entry.as_object().expect("entry object");
let keys = ["path", "method", "name", "action", "resource", "provenance"];
for key in keys {
assert!(obj.contains_key(key), "entry missing `{key}`: {entry}");
}
assert_eq!(obj.len(), keys.len(), "unexpected extra keys: {entry}");
assert_eq!(entry["path"], "/notes/{id}");
assert_eq!(entry["method"], "POST");
assert_eq!(entry["name"], "update_note");
assert_eq!(entry["action"], "update");
assert_eq!(entry["resource"], "Note");
assert_eq!(entry["provenance"], "provable");
}
#[test]
fn removing_an_authorize_binding_flips_only_that_entry() {
let dump = |bindings: &[(&str, &str)]| {
vec![
route("GET", "/health", "health", "framework"),
route_with_bindings("POST", "/notes/{id}", "update_note", "gated", bindings),
]
};
let sec = security_dump(true, &[]);
let both = manifest_value(&build_manifest(
&dump(&[("delete", "Note"), ("update", "Note")]),
Some(&sec),
));
let one = manifest_value(&build_manifest(&dump(&[("update", "Note")]), Some(&sec)));
assert_eq!(both["dimensions"]["routes"], one["dimensions"]["routes"]);
assert_eq!(both["dimensions"]["csrf"], one["dimensions"]["csrf"]);
assert_eq!(
both["dimensions"]["security_headers"],
one["dimensions"]["security_headers"]
);
let entries = |v: &serde_json::Value| -> Vec<serde_json::Value> {
v["dimensions"]["authorization_policies"]["entries"]
.as_array()
.expect("entries array")
.clone()
};
let (both_entries, one_entries) = (entries(&both), entries(&one));
assert_eq!(both_entries.len(), 2, "{both_entries:?}");
assert_eq!(one_entries.len(), 1, "{one_entries:?}");
let removed: Vec<&serde_json::Value> = both_entries
.iter()
.filter(|e| !one_entries.contains(e))
.collect();
assert_eq!(removed.len(), 1, "exactly one entry may disappear");
assert_eq!(removed[0]["action"], "delete");
assert_eq!(removed[0]["resource"], "Note");
assert_eq!(one_entries[0]["action"], "update");
}
#[test]
fn reclassifying_a_route_flips_only_the_routes_dimension() {
let dump = |cls: &str, roles: &[&str]| {
let mut reports = route("GET", "/reports", "reports", cls);
reports.roles = roles.iter().map(|r| (*r).to_owned()).collect();
vec![
reports,
route_with_bindings(
"POST",
"/notes/{id}",
"update_note",
"gated",
&[("update", "Note")],
),
]
};
let sec = security_dump(true, &[]);
let before = manifest_value(&build_manifest(&dump("unclassified", &[]), Some(&sec)));
let after = manifest_value(&build_manifest(&dump("gated", &["admin"]), Some(&sec)));
assert_ne!(
before["dimensions"]["routes"], after["dimensions"]["routes"],
"reclassifying a route must be visible in the routes dimension"
);
assert_eq!(before["dimensions"]["csrf"], after["dimensions"]["csrf"]);
assert_eq!(
before["dimensions"]["security_headers"],
after["dimensions"]["security_headers"]
);
assert!(
!before["dimensions"]["authorization_policies"]["entries"]
.as_array()
.expect("entries array")
.is_empty(),
"the fixture must seed at least one binding"
);
assert_eq!(
before["dimensions"]["authorization_policies"],
after["dimensions"]["authorization_policies"]
);
}
#[test]
fn authorization_policies_entries_are_sorted() {
let routes = vec![
route_with_bindings(
"POST",
"/notes/{id}",
"update_note",
"gated",
&[("update", "Note"), ("delete", "Note")],
),
route_with_bindings(
"GET",
"/notes/{id}",
"show_note",
"gated",
&[("show", "Note")],
),
route_with_bindings(
"POST",
"/albums/{id}",
"update_album",
"gated",
&[("update", "Photo"), ("update", "Album")],
),
];
let manifest = build_manifest(&routes, None);
let order: Vec<(&str, &str, &str, &str)> = manifest
.dimensions
.authorization_policies
.entries
.iter()
.map(|e| {
(
e.path.as_str(),
e.method.as_str(),
e.action.as_str(),
e.resource.as_str(),
)
})
.collect();
assert_eq!(
order,
vec![
("/albums/{id}", "POST", "update", "Album"),
("/albums/{id}", "POST", "update", "Photo"),
("/notes/{id}", "GET", "show", "Note"),
("/notes/{id}", "POST", "delete", "Note"),
("/notes/{id}", "POST", "update", "Note"),
],
"entries sort by (path, method, action, resource)"
);
}
#[test]
fn policy_true_without_bindings_produces_no_authorization_entry() {
let mut r = route("POST", "/api/notes", "notes::create", "gated");
r.policy = true;
let json = manifest_value(&build_manifest(&[r], None));
assert_eq!(json["dimensions"]["routes"]["entries"][0]["policy"], true);
assert!(
json["dimensions"]["authorization_policies"]["entries"]
.as_array()
.expect("entries array")
.is_empty(),
"a bare `policy: true` proves no (action, resource) binding"
);
let excluded = json["excluded"].as_array().expect("excluded array");
let repo = excluded
.iter()
.find(|e| e["dimension"] == "repository_policy_bindings")
.expect("repository_policy_bindings excluded");
assert_eq!(repo["eventual_provenance"], "provable");
assert!(
repo["reason"]
.as_str()
.expect("reason string")
.contains("routes.entries[].policy"),
"the excluded entry must point readers at the superset boolean: {repo}"
);
}
#[test]
fn framework_routes_never_contribute_authorization_entries() {
let mut framework = route_with_bindings(
"POST",
"/_autumn/unsubscribe",
"unsubscribe",
"framework",
&[("update", "Subscriber")],
);
framework.source = "framework".to_owned();
let routes = vec![
framework,
route_with_bindings(
"POST",
"/notes/{id}",
"update_note",
"gated",
&[("update", "Note")],
),
];
let manifest = build_manifest(&routes, None);
let entries = &manifest.dimensions.authorization_policies.entries;
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0].path, "/notes/{id}");
assert!(
!entries.iter().any(|e| e.path == "/_autumn/unsubscribe"),
"no framework route may appear: {entries:?}"
);
}
#[test]
fn csrf_dimension_only_covers_mutating_routes() {
let routes = vec![
route("GET", "/widgets", "list_widgets", "public"),
route("POST", "/widgets", "create_widget", "gated"),
route("DELETE", "/widgets/{id}", "delete_widget", "gated"),
];
let sec = security_dump(true, &[]);
let manifest = build_manifest(&routes, Some(&sec));
let paths: Vec<(&str, &str)> = manifest
.dimensions
.csrf
.entries
.iter()
.map(|e| (e.path.as_str(), e.method.as_str()))
.collect();
assert_eq!(
paths,
vec![("/widgets", "POST"), ("/widgets/{id}", "DELETE")],
"only mutating routes, ordered by (path, method)"
);
assert!(
manifest
.dimensions
.csrf
.entries
.iter()
.all(|e| e.csrf_enforced)
);
}
#[test]
fn csrf_dimension_covers_mutating_framework_routes() {
let routes = vec![
route("GET", "/actuator/loggers", "loggers_get", "framework"),
route(
"PUT",
"/actuator/loggers/{name}",
"loggers_put",
"framework",
),
];
let dim = build_csrf_dimension(&routes, &security_dump(true, &[]).csrf);
let entry = dim
.entries
.iter()
.find(|e| e.method == "PUT" && e.path == "/actuator/loggers/{name}")
.expect("mutating framework route must appear in csrf.entries");
assert!(
entry.csrf_enforced && !entry.exempt,
"PUT loggers must be CSRF-enforced: {entry:?}"
);
assert!(
!dim.entries.iter().any(|e| e.method == "GET"),
"safe-method routes must not appear: {:?}",
dim.entries
);
}
#[test]
fn disabling_csrf_flips_only_csrf_entries() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", "gated"),
];
let on = manifest_value(&build_manifest(&routes, Some(&security_dump(true, &[]))));
let off = manifest_value(&build_manifest(&routes, Some(&security_dump(false, &[]))));
assert_eq!(on["dimensions"]["routes"], off["dimensions"]["routes"]);
assert_eq!(
on["dimensions"]["security_headers"],
off["dimensions"]["security_headers"]
);
assert_eq!(
on["dimensions"]["csrf"]["entries"][0]["csrf_enforced"],
true
);
assert_eq!(
off["dimensions"]["csrf"]["entries"][0]["csrf_enforced"],
false
);
}
#[test]
fn adding_exempt_path_flips_only_affected_csrf_entries() {
let routes = vec![
route("POST", "/api/widgets", "api_create", "gated"),
route("POST", "/widgets", "create_widget", "gated"),
];
let base = manifest_value(&build_manifest(&routes, Some(&security_dump(true, &[]))));
let exempt = manifest_value(&build_manifest(
&routes,
Some(&security_dump(true, &["/api/"])),
));
assert_eq!(base["dimensions"]["routes"], exempt["dimensions"]["routes"]);
assert_eq!(
base["dimensions"]["security_headers"],
exempt["dimensions"]["security_headers"]
);
let e = &exempt["dimensions"]["csrf"]["entries"];
assert_eq!(e[0]["path"], "/api/widgets");
assert_eq!(e[0]["exempt"], true);
assert_eq!(e[0]["csrf_enforced"], false);
assert_eq!(e[1]["path"], "/widgets");
assert_eq!(e[1]["exempt"], false);
assert_eq!(e[1]["csrf_enforced"], true);
assert_eq!(exempt["dimensions"]["csrf"]["exempt_paths"][0], "/api/");
}
#[test]
fn weakening_a_header_changes_only_that_entry() {
let routes = vec![route("POST", "/widgets", "create_widget", "gated")];
let base_sec = security_dump(true, &[]);
let base = manifest_value(&build_manifest(&routes, Some(&base_sec)));
let mut weak = security_dump(true, &[]);
weak.headers.x_frame_options = "SAMEORIGIN".to_owned();
let weak = manifest_value(&build_manifest(&routes, Some(&weak)));
assert_eq!(base["dimensions"]["routes"], weak["dimensions"]["routes"]);
assert_eq!(base["dimensions"]["csrf"], weak["dimensions"]["csrf"]);
let find = |v: &serde_json::Value, key: &str| -> serde_json::Value {
v["dimensions"]["security_headers"]["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["header"] == key)
.unwrap()
.clone()
};
assert_eq!(find(&base, "x_frame_options")["value"], "DENY");
assert_eq!(find(&weak, "x_frame_options")["value"], "SAMEORIGIN");
for key in ["content_security_policy", "referrer_policy"] {
assert_eq!(
find(&base, key),
find(&weak, key),
"{key} must be untouched"
);
}
let mut no_csp = security_dump(true, &[]);
no_csp.headers.content_security_policy = String::new();
let no_csp = manifest_value(&build_manifest(&routes, Some(&no_csp)));
assert_eq!(find(&base, "content_security_policy")["emitted"], true);
assert_eq!(find(&no_csp, "content_security_policy")["emitted"], false);
assert_eq!(find(&no_csp, "content_security_policy")["value"], "");
}
#[test]
fn ws_routes_are_csrf_safe_when_get_is_a_safe_method() {
let routes = vec![
route("WS", "/live", "live_socket", "public"),
route("POST", "/widgets", "create_widget", "gated"),
];
let manifest = build_manifest(&routes, Some(&security_dump(true, &[])));
let entries: Vec<(&str, &str)> = manifest
.dimensions
.csrf
.entries
.iter()
.map(|e| (e.path.as_str(), e.method.as_str()))
.collect();
assert_eq!(
entries,
vec![("/widgets", "POST")],
"WS routes mount as GET; with GET safe they must not be listed as mutating"
);
assert!(
!manifest
.dimensions
.csrf
.entries
.iter()
.any(|e| e.method == "WS"),
"no WS entry may appear in the csrf dimension when GET is safe"
);
}
#[test]
fn ws_routes_are_csrf_enforced_when_get_is_not_a_safe_method() {
let routes = vec![
route("WS", "/live", "live_socket", "public"),
route("WS", "/api/live", "api_socket", "public"),
route("POST", "/widgets", "create_widget", "gated"),
];
let mut sec = security_dump(true, &["/api/"]);
sec.csrf.safe_methods = vec!["HEAD".to_owned(), "OPTIONS".to_owned()];
let manifest = build_manifest(&routes, Some(&sec));
let entries: Vec<(&str, &str, bool, bool)> = manifest
.dimensions
.csrf
.entries
.iter()
.map(|e| {
(
e.path.as_str(),
e.method.as_str(),
e.csrf_enforced,
e.exempt,
)
})
.collect();
assert_eq!(
entries,
vec![
("/api/live", "WS", false, true),
("/live", "WS", true, false),
("/widgets", "POST", true, false),
],
"WS routes appear as GET-mutating entries with the exempt-path predicate applied, \
while keeping their listed `WS` method for display"
);
}
#[test]
fn invalid_header_value_is_not_emitted() {
let routes = vec![route("POST", "/widgets", "create_widget", "gated")];
let mut sec = security_dump(true, &[]);
sec.headers.referrer_policy = "no-referrer\r\ninjected: 1".to_owned();
let json = manifest_value(&build_manifest(&routes, Some(&sec)));
let entry = json["dimensions"]["security_headers"]["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["header"] == "referrer_policy")
.unwrap()
.clone();
assert_eq!(
entry["emitted"], false,
"an invalid (unparseable) header value must not be reported as emitted"
);
}
#[test]
fn csp_nonce_is_not_reported_as_an_emitted_header() {
let routes = vec![route("GET", "/page", "page", "public")];
let mut sec = security_dump(true, &[]);
sec.headers.csp_nonce = true;
sec.headers.content_security_policy =
"style-src 'self' 'nonce-AUTUMN_CSP_NONCE'; script-src 'self' 'nonce-AUTUMN_CSP_NONCE'"
.to_owned();
let json = manifest_value(&build_manifest(&routes, Some(&sec)));
let entries = json["dimensions"]["security_headers"]["entries"]
.as_array()
.unwrap();
assert!(
!entries.iter().any(|e| e["header"] == "csp_nonce"),
"csp_nonce is not a response header and must not appear in security_headers"
);
let csp = entries
.iter()
.find(|e| e["header"] == "content_security_policy")
.expect("content_security_policy entry present");
assert_eq!(csp["emitted"], true);
assert!(
csp["value"]
.as_str()
.unwrap()
.contains("'nonce-AUTUMN_CSP_NONCE'"),
"the nonce-aware CSP template must remain visible in the CSP value: {csp}"
);
}
#[test]
fn webhook_exempt_path_marks_route_unenforced() {
let routes = vec![route("POST", "/webhooks/stripe", "stripe_hook", "public")];
let sec = security_dump(true, &["/webhooks/stripe"]);
let manifest = build_manifest(&routes, Some(&sec));
let entry = &manifest.dimensions.csrf.entries[0];
assert_eq!(entry.path, "/webhooks/stripe");
assert!(entry.exempt, "webhook path must be exempt");
assert!(
!entry.csrf_enforced,
"runtime skips CSRF on webhook endpoints, so csrf_enforced must be false"
);
assert!(
manifest
.dimensions
.csrf
.exempt_paths
.iter()
.any(|p| p == "/webhooks/stripe"),
"dimension exempt_paths must record the webhook path"
);
}
#[test]
fn unsubscribe_exempt_path_marks_post_unenforced() {
let routes = vec![route(
"POST",
"/_autumn/unsubscribe",
"unsubscribe",
"framework",
)];
let sec = security_dump(true, &["/_autumn/unsubscribe"]);
let manifest = build_manifest(&routes, Some(&sec));
let entry = &manifest.dimensions.csrf.entries[0];
assert_eq!(entry.path, "/_autumn/unsubscribe");
assert_eq!(entry.method, "POST");
assert!(entry.exempt, "mounted unsubscribe path must be exempt");
assert!(
!entry.csrf_enforced,
"runtime exempts the unsubscribe endpoint from CSRF, so csrf_enforced must be false"
);
assert!(
manifest
.dimensions
.csrf
.exempt_paths
.iter()
.any(|p| p == "/_autumn/unsubscribe"),
"dimension exempt_paths must record the unsubscribe path"
);
}
#[test]
fn manifest_records_the_default_build() {
let m = build_manifest(&[route("GET", "/a", "a", "public")], None);
let v = manifest_value(&m);
assert_eq!(v["schema_version"], 6);
assert_eq!(
v["build"],
serde_json::json!({
"profile": "dev",
"features": [],
"all_features": false,
"no_default_features": false,
})
);
let json = manifest_json(&m);
assert!(
json.find("\"build\"") < json.find("\"dimensions\""),
"{json}"
);
}
#[test]
fn audited_manifest_records_the_requested_build() {
let build = BuildConfig::new(
&routes::CargoFeatures::default(),
&routes::CargoProfile::from_release(true),
);
let m = audited_manifest(&[route("GET", "/a", "a", "public")], None, build)
.expect("a valid manifest");
assert_eq!(manifest_value(&m)["build"]["profile"], "release");
}
#[test]
fn audited_manifest_refuses_a_route_listed_twice() {
let twice = [
route("GET", "/health", "mine", "public"),
route("GET", "/health", "health", "framework"),
];
let err = audited_manifest(&twice, None, BuildConfig::default())
.expect_err("a duplicate route must fail the audit");
assert!(err.contains("more than once"), "{err}");
}
#[test]
fn build_config_names_the_cargo_build() {
let features = routes::CargoFeatures {
features: vec!["embed-assets,b".to_owned(), "a b".to_owned()],
all: true,
no_default: true,
};
let build = BuildConfig::new(&features, &routes::CargoProfile::from_release(true));
assert_eq!(build.profile, "release");
assert_eq!(build.features, ["a", "b", "embed-assets"]);
assert!(build.all_features && build.no_default_features);
let named = |name: &str| {
BuildConfig::new(
&routes::CargoFeatures::default(),
&routes::CargoProfile {
release: false,
profile: Some(name.to_owned()),
},
)
.profile
};
assert_eq!(named("dist"), "dist");
assert_eq!(named("release"), "release");
assert_eq!(named("dev"), "dev");
assert_eq!(
BuildConfig::new(
&routes::CargoFeatures::default(),
&routes::CargoProfile::default()
),
BuildConfig::default()
);
}
#[test]
fn manifest_rebuild_is_byte_identical() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", "gated"),
route_with_bindings(
"DELETE",
"/widgets/{id}",
"delete_widget",
"gated",
&[("delete", "Widget"), ("update", "Widget")],
),
];
let sec = security_dump(true, &["/api/", "/webhooks/"]);
let a = manifest_json(&build_manifest(&routes, Some(&sec)));
let b = manifest_json(&build_manifest(&routes, Some(&sec)));
assert_eq!(a, b, "manifest must be reproducible byte-for-byte");
assert!(
a.contains("\"resource\": \"Widget\""),
"the seeded bindings must appear in the manifest: {a}"
);
}
#[test]
fn parse_security_config_reads_the_marker() {
let sec = security_dump(true, &["/api/"]);
let line = serde_json::to_string(&sec).unwrap();
let stderr = format!(
"\u{1F342} autumn routes audit\n{SECURITY_CONFIG_MARKER}{line}\nsome trailing warning\n"
);
let parsed = parse_security_config(&stderr).expect("marker parsed");
assert!(parsed.csrf.enabled);
assert_eq!(parsed.csrf.exempt_paths, vec!["/api/"]);
assert!(parse_security_config("no marker here\n").is_none());
}
#[test]
fn audit_gate_red_then_green() {
let dump = |cls: &str| {
vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", cls),
]
};
let red = dump("unclassified");
assert_eq!(audit_exit_code(&red), 1);
let unresolved = unclassified_routes(&red);
assert_eq!(unresolved.len(), 1);
assert!(format_diagnostic(&unresolved).contains("create_widget"));
assert_eq!(audit_exit_code(&dump("gated")), 0);
assert_eq!(audit_exit_code(&dump("public")), 0);
}
#[test]
fn parse_omitted_count_reads_the_marker() {
let stderr = format!(
"\u{1F342} autumn routes\n\
[autumn routes] warning: 2 raw router(s) added via .merge()/.nest() ...\n\
{OMITTED_ROUTES_MARKER}2\n"
);
assert_eq!(parse_omitted_count(&stderr), 2);
}
#[test]
fn parse_omitted_count_zero_when_no_marker() {
assert_eq!(parse_omitted_count(""), 0);
assert_eq!(parse_omitted_count("some unrelated warning\n"), 0);
}
#[test]
fn omitted_routes_fail_the_gate_even_when_visible_routes_are_clean() {
let visible = vec![
route("GET", "/health", "health", "framework"),
route("GET", "/posts", "list", "public"),
];
assert_eq!(audit_exit_code(&visible), 0);
let stderr = format!("{OMITTED_ROUTES_MARKER}1\n");
let omitted = parse_omitted_count(&stderr);
assert_eq!(omitted, 1);
let failed = omitted > 0 || audit_exit_code(&visible) != 0;
assert!(failed, "omitted routes must hard-fail the audit gate");
let diag = format_omitted_diagnostic(omitted);
assert!(diag.contains("merge()"), "{diag}");
assert!(diag.contains("routes!["), "{diag}");
}
#[test]
fn declared_nest_emits_no_marker_and_passes_the_gate() {
let visible = vec![
route("GET", "/health", "health", "framework"),
route("GET", "/admin", "admin::index", "gated"),
route("POST", "/admin/users", "admin::create", "gated"),
];
assert_eq!(audit_exit_code(&visible), 0);
let stderr = "\u{1F342} autumn routes\n";
let omitted = parse_omitted_count(stderr);
assert_eq!(
omitted, 0,
"a declared-covered nest must not emit the marker"
);
let failed = omitted > 0 || audit_exit_code(&visible) != 0;
assert!(
!failed,
"a declared-covered admin nest must not false-fail the audit gate"
);
}
}