use std::process::Command;
use serde::{Deserialize, Serialize};
use autumn_web::plugin_contract::{
ContractVerdict, PLUGIN_CONTRACT_MARKER, PluginContract, SurfaceTier, evaluate, surface,
};
use crate::routes::{RouteInfo, compile_binary, find_binary};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Pass,
Fail,
Skip,
}
impl std::fmt::Display for CheckStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pass => write!(f, "PASS"),
Self::Fail => write!(f, "FAIL"),
Self::Skip => write!(f, "SKIP"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub name: String,
pub status: CheckStatus,
pub message: String,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceReport {
pub plugin_name: String,
pub checks: Vec<CheckResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contract: Option<PluginContract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub autumn_web: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub intentional_root: Vec<String>,
}
impl ConformanceReport {
pub fn passed(&self) -> bool {
self.checks.iter().all(|c| c.status != CheckStatus::Fail)
}
pub fn to_text_report(&self) -> String {
let mut out = String::new();
let overall = if self.passed() { "PASS" } else { "FAIL" };
out.push_str("Plugin conformance: ");
out.push_str(&self.plugin_name);
out.push_str(" \u{2014} ");
out.push_str(overall);
out.push('\n');
out.push_str(&"\u{2500}".repeat(60));
out.push('\n');
for check in &self.checks {
let icon = match check.status {
CheckStatus::Pass => "\u{2713}",
CheckStatus::Fail => "\u{2717}",
CheckStatus::Skip => "\u{2212}",
};
out.push_str(icon);
out.push_str(" [");
out.push_str(&check.status.to_string());
out.push_str("] ");
out.push_str(&check.name);
out.push_str(": ");
out.push_str(&check.message);
out.push('\n');
for diag in &check.diagnostics {
out.push_str(" \u{2192} ");
out.push_str(diag);
out.push('\n');
}
}
out.push_str(&"\u{2500}".repeat(60));
out.push('\n');
if self.passed() {
out.push_str("All conformance checks passed.\n");
} else {
let fails = self
.checks
.iter()
.filter(|c| c.status == CheckStatus::Fail)
.count();
out.push_str(&fails.to_string());
out.push_str(" check(s) failed.\n");
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReportFormat {
Text,
Json,
}
impl std::str::FromStr for ReportFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"text" | "table" => Ok(Self::Text),
"json" => Ok(Self::Json),
other => Err(format!(
"unknown format '{other}'; expected 'text' or 'json'"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct SensitiveRouteDecl {
pub path_pattern: String,
pub auth_mechanism: String,
}
pub struct PluginCheckOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub plugin_name: &'a str,
pub expected_prefix: Option<&'a str>,
pub intentional_root_routes: &'a [String],
pub sensitive_routes: &'a [SensitiveRouteDecl],
pub format: ReportFormat,
pub contracts: &'a ContractDump,
pub deny_experimental: bool,
pub no_routes: bool,
}
pub fn run(opts: &PluginCheckOptions<'_>) {
eprintln!("\u{1F342} autumn plugin-check\n");
compile_binary(opts.package, opts.bin);
let binary = find_binary(opts.package, opts.bin);
let output = Command::new(&binary)
.env("AUTUMN_DUMP_ROUTES", "1")
.env("AUTUMN_DUMP_PLUGIN_CONTRACT", "1")
.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(PLUGIN_CONTRACT_MARKER) {
eprintln!("{line}");
}
}
let contracts = parse_plugin_contracts(&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<RouteInfo> = 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 mut report = build_report(
&PluginCheckOptions {
package: opts.package,
bin: opts.bin,
plugin_name: opts.plugin_name,
expected_prefix: opts.expected_prefix,
intentional_root_routes: opts.intentional_root_routes,
sensitive_routes: opts.sensitive_routes,
format: opts.format.clone(),
contracts: &contracts,
deny_experimental: opts.deny_experimental,
no_routes: opts.no_routes,
},
&routes,
);
fail_on_omitted_routers(
&mut report,
crate::routes_audit::parse_omitted_count(&stderr),
);
let package = opts.package.map(str::to_owned).or_else(|| {
opts.bin
.map(str::to_owned)
.or_else(|| {
binary
.file_stem()
.and_then(|stem| stem.to_str())
.map(str::to_owned)
})
.and_then(|bin| package_owning_bin(&bin))
});
report.autumn_web = locked_autumn_web(std::path::Path::new("."), package.as_deref());
match opts.format {
ReportFormat::Text => print!("{}", report.to_text_report()),
ReportFormat::Json => {
let json = serde_json::to_string_pretty(&report)
.unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
println!("{json}");
}
}
if !report.passed() {
std::process::exit(1);
}
}
pub fn build_report(opts: &PluginCheckOptions<'_>, routes: &[RouteInfo]) -> ConformanceReport {
let mut checks = Vec::new();
checks.push(CheckResult {
name: "installability".to_owned(),
status: CheckStatus::Pass,
message: format!(
"{count} route{plural} collected",
count = routes.len(),
plural = if routes.len() == 1 { "" } else { "s" }
),
diagnostics: vec![],
});
let declared = match opts.contracts {
ContractDump::Present(all) => find_declared(all, opts.plugin_name),
ContractDump::Absent | ContractDump::Malformed(_) => None,
};
let registered_as = declared.and_then(|c| c.registered_as.as_deref());
let route_key = route_key(opts.plugin_name, registered_as, routes);
let registered_routeless = registered_as.filter(|_| opts.no_routes);
checks.push(
check_split_attribution(declared, opts.plugin_name, routes).unwrap_or_else(|| {
check_route_attribution(opts.plugin_name, route_key, routes, registered_routeless)
}),
);
if let Some(prefix) = opts.expected_prefix {
checks.push(check_route_prefix(
route_key,
prefix,
opts.intentional_root_routes,
routes,
));
}
checks.push(check_collisions(routes));
checks.push(check_sensitive_surfaces(
route_key,
routes,
opts.sensitive_routes,
));
checks.push(check_duplicate_registration(route_key, routes));
checks.push(check_plugin_contract(
opts.plugin_name,
opts.contracts,
declared,
));
checks.push(check_experimental_surface(
opts.contracts,
declared,
opts.deny_experimental,
));
ConformanceReport {
plugin_name: opts.plugin_name.to_owned(),
checks,
contract: declared.cloned(),
autumn_web: None,
prefix: opts.expected_prefix.map(str::to_owned),
intentional_root: opts.intentional_root_routes.to_vec(),
}
}
pub fn locked_autumn_web(dir: &std::path::Path, package: Option<&str>) -> Option<String> {
crate::plugin::install::locked_version_for(dir, package, "autumn-web")
}
fn fail_on_omitted_routers(report: &mut ConformanceReport, omitted: usize) {
if omitted == 0 {
return;
}
if let Some(check) = report
.checks
.iter_mut()
.find(|check| check.name == "route-attribution")
{
check.status = CheckStatus::Fail;
check.message = format!(
"{omitted} raw router(s) added via .merge()/.nest() are not enumerable, so their \
routes were not checked. Declare them with AppBuilder::declare_plugin_routes"
);
}
}
fn package_owning_bin(bin: &str) -> Option<String> {
let output = std::process::Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.output()
.ok()?;
let metadata = serde_json::from_slice(&output.stdout).ok()?;
package_of_bin(&metadata, bin)
}
fn package_of_bin(metadata: &serde_json::Value, bin: &str) -> Option<String> {
let mut owners = metadata["packages"]
.as_array()?
.iter()
.filter(|package| {
package["targets"].as_array().is_some_and(|targets| {
targets.iter().any(|target| {
target["name"] == bin
&& target["kind"]
.as_array()
.is_some_and(|kinds| kinds.iter().any(|k| k == "bin"))
})
})
})
.filter_map(|package| package["name"].as_str());
let owner = owners.next()?;
owners.next().is_none().then(|| owner.to_owned())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContractDump {
Absent,
Present(Vec<PluginContract>),
Malformed(String),
}
fn parse_plugin_contracts(stderr: &str) -> ContractDump {
let Some(payload) = stderr
.lines()
.filter_map(|line| line.strip_prefix(PLUGIN_CONTRACT_MARKER))
.next_back()
else {
return ContractDump::Absent;
};
match serde_json::from_str::<Vec<PluginContract>>(payload.trim()) {
Ok(contracts) => ContractDump::Present(contracts),
Err(e) => ContractDump::Malformed(e.to_string()),
}
}
fn find_declared<'a>(
contracts: &'a [PluginContract],
plugin_name: &str,
) -> Option<&'a PluginContract> {
contracts
.iter()
.find(|c| c.plugin == plugin_name || c.registered_as.as_deref() == Some(plugin_name))
}
fn check_plugin_contract(
plugin_name: &str,
dump: &ContractDump,
declared: Option<&PluginContract>,
) -> CheckResult {
let name = "plugin-contract".to_owned();
let all = match dump {
ContractDump::Absent => {
return CheckResult {
name,
status: CheckStatus::Skip,
message: format!(
"the built binary emitted no plugin-contract dump; rebuild against an \
autumn-web newer than {} to have the contract checked",
autumn_web::plugin_contract::AUTUMN_WEB_VERSION
),
diagnostics: vec![],
};
}
ContractDump::Malformed(reason) => {
return CheckResult {
name,
status: CheckStatus::Fail,
message: "the built binary emitted a plugin-contract dump that could not be read"
.to_owned(),
diagnostics: vec![
reason.clone(),
"the contract was NOT checked; this is not the same as a binary that \
predates the dump"
.to_owned(),
],
};
}
ContractDump::Present(all) => all,
};
let Some(contract) = declared else {
let found: Vec<String> = all
.iter()
.map(|c| match &c.registered_as {
Some(registered) if registered != &c.plugin => {
format!("{} (registered as `{registered}`)", c.plugin)
}
_ => c.plugin.clone(),
})
.collect();
let mut diagnostics = vec![format!(
"implement `Plugin::contract` on {plugin_name} and return \
`PluginContract::new(env!(\"CARGO_PKG_NAME\")).autumn_web(\"<range>\")`"
)];
if found.is_empty() {
diagnostics.push("no plugin in this app declares a contract".to_owned());
} else {
diagnostics.push(format!(
"contracts found in this app: {} — `--plugin-name` must match a contract's crate \
name or the name the plugin registered under",
found.join(", ")
));
}
return CheckResult {
name,
status: CheckStatus::Fail,
message: format!("{plugin_name} declares no autumn-web compatibility contract"),
diagnostics,
};
};
let Some(requirement) = contract.autumn_web.as_deref() else {
return CheckResult {
name,
status: CheckStatus::Fail,
message: format!("{plugin_name} returns a contract but declares no autumn-web range"),
diagnostics: vec![
"add `.autumn_web(\"<range>\")` — e.g. `\"0.7\"` for a single minor series"
.to_owned(),
],
};
};
match evaluate(contract, autumn_web::plugin_contract::AUTUMN_WEB_VERSION) {
ContractVerdict::Unparseable { reason, .. } => CheckResult {
name,
status: CheckStatus::Fail,
message: format!("{plugin_name} declares an autumn-web range that cannot be evaluated"),
diagnostics: vec![
reason,
"an unevaluable range disables the framework's startup compatibility check"
.to_owned(),
],
},
_ => CheckResult {
name,
status: CheckStatus::Pass,
message: format!("{} supports autumn-web {requirement}", contract.label()),
diagnostics: vec![],
},
}
}
fn check_experimental_surface(
dump: &ContractDump,
declared: Option<&PluginContract>,
deny: bool,
) -> CheckResult {
let name = "experimental-surface".to_owned();
let unevaluated = |message: String| CheckResult {
name: name.clone(),
status: if deny {
CheckStatus::Fail
} else {
CheckStatus::Skip
},
message: if deny {
format!("{message} — cannot honour --deny-experimental without it")
} else {
message
},
diagnostics: vec![],
};
match dump {
ContractDump::Absent => {
return unevaluated("the built binary emitted no plugin-contract dump".to_owned());
}
ContractDump::Malformed(_) => {
return unevaluated(
"the built binary's plugin-contract dump could not be read; see the \
plugin-contract check"
.to_owned(),
);
}
ContractDump::Present(_) => {}
}
let Some(contract) = declared else {
return unevaluated(
"no contract declared for this plugin; see the plugin-contract check".to_owned(),
);
};
if contract.experimental_surfaces.is_empty() {
return CheckResult {
name,
status: CheckStatus::Pass,
message: "declares no dependency on experimental plugin surface".to_owned(),
diagnostics: vec![],
};
}
let mut diagnostics = Vec::new();
let mut invalid = 0usize;
for entry in &contract.experimental_surfaces {
match surface(entry) {
None => {
invalid += 1;
diagnostics.push(format!(
"{entry}: not a known plugin surface — check the spelling against \
`autumn_web::plugin_contract::PLUGIN_SURFACES`"
));
}
Some(s) if s.tier == SurfaceTier::Stable => {
invalid += 1;
diagnostics.push(format!(
"{entry}: declared experimental, but it is a STABLE surface — drop the \
declaration; it overstates this plugin's exposure"
));
}
Some(s) => diagnostics.push(format!("{}: {}", s.name, s.note)),
}
}
let count = contract.experimental_surfaces.len();
if invalid > 0 {
return CheckResult {
name,
status: CheckStatus::Fail,
message: format!(
"{invalid} of {count} declared experimental surface(s) could not be resolved \
against the registry"
),
diagnostics,
};
}
CheckResult {
name,
status: if deny {
CheckStatus::Fail
} else {
CheckStatus::Pass
},
message: if deny {
format!("{count} experimental surface(s) declared, and --deny-experimental is set")
} else {
format!("depends on {count} experimental surface(s); these may change in any release")
},
diagnostics,
}
}
fn route_key<'a>(
plugin_name: &'a str,
registered_as: Option<&'a str>,
routes: &[RouteInfo],
) -> &'a str {
let carries = |name: &str| {
let source = format!("plugin:{name}");
routes.iter().any(|r| r.source == source)
};
match registered_as {
Some(registered) if !carries(plugin_name) && carries(registered) => registered,
_ => plugin_name,
}
}
fn check_split_attribution(
declared: Option<&PluginContract>,
plugin_name: &str,
routes: &[RouteInfo],
) -> Option<CheckResult> {
let declared = declared?;
let mut names = vec![plugin_name, declared.plugin.as_str()];
names.extend(declared.registered_as.as_deref());
names.sort_unstable();
names.dedup();
let carrying: Vec<&str> = names
.into_iter()
.filter(|name| {
let source = format!("plugin:{name}");
routes.iter().any(|r| r.source == source)
})
.collect();
(carrying.len() > 1).then(|| CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Fail,
message: format!(
"routes are attributed to more than one of this plugin's names: {}",
carrying.join(", ")
),
diagnostics: vec![
"one plugin registers under one `Plugin::name()`; the checks cannot tell which \
routes are its own"
.to_owned(),
],
})
}
fn check_route_attribution(
plugin_name: &str,
route_key: &str,
routes: &[RouteInfo],
registered_as: Option<&str>,
) -> CheckResult {
let expected = format!("plugin:{route_key}");
let plugin_routes: Vec<&RouteInfo> = routes.iter().filter(|r| r.source == expected).collect();
if let Some(registered) = registered_as {
return if plugin_routes.is_empty() {
CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Skip,
message: format!(
"{plugin_name} is registered as `{registered}` and contributes no routes"
),
diagnostics: vec![],
}
} else {
CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Fail,
message: format!(
"--no-routes was given, but {} route(s) are attributed to plugin:{route_key}",
plugin_routes.len()
),
diagnostics: vec![],
}
};
}
if plugin_routes.is_empty() {
return CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Fail,
message: format!(
"No routes attributed to plugin:{plugin_name} — \
check the plugin name or call AppBuilder::declare_plugin_routes"
),
diagnostics: vec![],
};
}
let via = if route_key == plugin_name {
String::new()
} else {
format!(" (the registered name of {plugin_name})")
};
CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Pass,
message: format!(
"{} route(s) correctly attributed to plugin:{route_key}{via}",
plugin_routes.len()
),
diagnostics: vec![],
}
}
fn check_route_prefix(
plugin_name: &str,
prefix: &str,
intentional_root: &[String],
routes: &[RouteInfo],
) -> CheckResult {
let expected = format!("plugin:{plugin_name}");
let plugin_routes: Vec<&RouteInfo> = routes.iter().filter(|r| r.source == expected).collect();
if plugin_routes.is_empty() {
return CheckResult {
name: "route-prefix".to_owned(),
status: CheckStatus::Skip,
message: format!("No routes attributed to plugin:{plugin_name}"),
diagnostics: vec![],
};
}
let off_prefix: Vec<String> = plugin_routes
.iter()
.filter(|r| {
let p = &r.path;
p != prefix
&& !p.starts_with(&format!("{prefix}/"))
&& !intentional_root.contains(p)
&& !is_plugin_asset_route(r)
})
.map(|r| format!("{} {}", r.method, r.path))
.collect();
if off_prefix.is_empty() {
CheckResult {
name: "route-prefix".to_owned(),
status: CheckStatus::Pass,
message: format!("All plugin routes live under {prefix}"),
diagnostics: vec![],
}
} else {
CheckResult {
name: "route-prefix".to_owned(),
status: CheckStatus::Fail,
message: format!(
"{} route(s) not under prefix {prefix} and not declared as intentional root routes",
off_prefix.len()
),
diagnostics: off_prefix,
}
}
}
fn is_plugin_asset_route(route: &RouteInfo) -> bool {
route
.path
.strip_prefix(autumn_web::assets::PLUGIN_ASSETS_PREFIX)
.is_some_and(|rest| rest.starts_with('/'))
&& route
.middleware
.iter()
.any(|label| label == autumn_web::assets::PLUGIN_ASSETS_ROUTE_MARKER)
}
const SENSITIVE_KEYWORDS: &[&str] = &[
"admin",
"debug",
"credential",
"operator",
"secret",
"metrics",
];
fn is_sensitive_path(path: &str) -> bool {
let lower = path.to_lowercase();
SENSITIVE_KEYWORDS.iter().any(|kw| {
lower
.split('/')
.any(|segment| segment == *kw || segment.starts_with(kw))
})
}
fn check_sensitive_surfaces(
plugin_name: &str,
routes: &[RouteInfo],
declared: &[SensitiveRouteDecl],
) -> CheckResult {
let expected = format!("plugin:{plugin_name}");
let sensitive: Vec<&RouteInfo> = routes
.iter()
.filter(|r| r.source == expected && is_sensitive_path(&r.path) && !is_plugin_asset_route(r))
.collect();
if sensitive.is_empty() {
return CheckResult {
name: "sensitive-surfaces".to_owned(),
status: CheckStatus::Pass,
message: "No sensitive-named routes detected".to_owned(),
diagnostics: vec![],
};
}
let mut undeclared: Vec<String> = Vec::new();
for route in &sensitive {
let is_ok = declared.iter().any(|d| {
route.path.starts_with(&d.path_pattern) && !d.auth_mechanism.trim().is_empty()
});
if !is_ok {
undeclared.push(format!(
"{} {} \u{2014} document auth/profile gating with --sensitive-route",
route.method, route.path
));
}
}
if undeclared.is_empty() {
CheckResult {
name: "sensitive-surfaces".to_owned(),
status: CheckStatus::Pass,
message: format!(
"{} sensitive route(s) declared with auth/profile gating",
sensitive.len()
),
diagnostics: vec![],
}
} else {
CheckResult {
name: "sensitive-surfaces".to_owned(),
status: CheckStatus::Fail,
message: format!(
"{} sensitive-named route(s) not declared with auth/profile gating",
undeclared.len()
),
diagnostics: undeclared,
}
}
}
fn check_duplicate_registration(plugin_name: &str, routes: &[RouteInfo]) -> CheckResult {
use std::collections::HashMap;
let expected = format!("plugin:{plugin_name}");
let plugin_routes: Vec<&RouteInfo> = routes.iter().filter(|r| r.source == expected).collect();
if plugin_routes.is_empty() {
return CheckResult {
name: "duplicate-registration".to_owned(),
status: CheckStatus::Skip,
message: format!("No routes attributed to plugin:{plugin_name}"),
diagnostics: vec![],
};
}
let mut counts: HashMap<(&str, &str), usize> = HashMap::new();
for route in &plugin_routes {
*counts.entry((&route.method, &route.path)).or_insert(0) += 1;
}
let mut duplicates: Vec<String> = counts
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|((method, path), count)| format!("{method} {path} \u{2014} appears {count} times"))
.collect();
duplicates.sort();
if duplicates.is_empty() {
CheckResult {
name: "duplicate-registration".to_owned(),
status: CheckStatus::Pass,
message: format!("No duplicate route registrations for plugin:{plugin_name}"),
diagnostics: vec![],
}
} else {
CheckResult {
name: "duplicate-registration".to_owned(),
status: CheckStatus::Fail,
message: format!(
"{} route(s) registered more than once; plugin:{plugin_name} \
may have been installed twice",
duplicates.len()
),
diagnostics: duplicates,
}
}
}
fn normalize_path_for_collision(path: &str) -> String {
path.split('/')
.map(|seg| {
if seg.starts_with('{') && seg.ends_with('}') {
"{}"
} else {
seg
}
})
.collect::<Vec<_>>()
.join("/")
}
fn check_collisions(routes: &[RouteInfo]) -> CheckResult {
use std::collections::HashMap;
let mut by_key: HashMap<(String, String), Vec<&RouteInfo>> = HashMap::new();
for route in routes {
by_key
.entry((
route.method.clone(),
normalize_path_for_collision(&route.path),
))
.or_default()
.push(route);
}
let mut collisions: Vec<String> = by_key
.iter()
.filter(|(_, rs)| rs.len() > 1)
.map(|((method, path), rs)| {
let contributors: Vec<String> = rs
.iter()
.map(|r| format!("{} ({})", r.handler, r.source))
.collect();
format!(
"{method} {path} \u{2014} collides between: {}",
contributors.join(", ")
)
})
.collect();
collisions.sort();
if collisions.is_empty() {
CheckResult {
name: "route-collision".to_owned(),
status: CheckStatus::Pass,
message: "No route collisions detected".to_owned(),
diagnostics: vec![],
}
} else {
CheckResult {
name: "route-collision".to_owned(),
status: CheckStatus::Fail,
message: format!("{} route collision(s) detected", collisions.len()),
diagnostics: collisions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_route(method: &str, path: &str, source: &str) -> RouteInfo {
RouteInfo {
method: method.to_owned(),
path: path.to_owned(),
handler: format!("{}_handler", path.trim_start_matches('/').replace('/', "_")),
source: source.to_owned(),
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
resource_shape: String::new(),
pools: Vec::new(),
}
}
fn no_sensitive() -> Vec<SensitiveRouteDecl> {
vec![]
}
#[test]
fn plugin_asset_routes_are_exempt_from_prefix_and_sensitive_checks() {
let asset = |path: &str| {
let mut route = make_route("GET", path, "plugin:admin");
route.middleware = vec![autumn_web::assets::PLUGIN_ASSETS_ROUTE_MARKER.to_owned()];
route
};
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
asset("/static/_plugins/autumn-admin/admin.js"),
asset("/static/_plugins/autumn-admin/admin.cb7ccaab.js"),
];
let prefix = check_route_prefix("admin", "/admin", &[], &routes);
assert_eq!(prefix.status, CheckStatus::Pass, "{}", prefix.message);
let declared = vec![SensitiveRouteDecl {
path_pattern: "/admin".to_owned(),
auth_mechanism: "Role: admin required".to_owned(),
}];
let sensitive = check_sensitive_surfaces("admin", &routes, &declared);
assert_eq!(sensitive.status, CheckStatus::Pass, "{}", sensitive.message);
let lookalike = vec![asset("/static/_pluginsx/admin.js")];
let prefix = check_route_prefix("admin", "/admin", &[], &lookalike);
assert_eq!(prefix.status, CheckStatus::Fail, "{}", prefix.message);
let forged = vec![make_route(
"GET",
"/static/_plugins/x/admin",
"plugin:admin",
)];
let prefix = check_route_prefix("admin", "/admin", &[], &forged);
assert_eq!(prefix.status, CheckStatus::Fail, "{}", prefix.message);
let sensitive = check_sensitive_surfaces("admin", &forged, &declared);
assert_eq!(sensitive.status, CheckStatus::Fail, "{}", sensitive.message);
}
#[test]
fn attribution_all_attributed_passes() {
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("POST", "/admin/items", "plugin:admin"),
];
let result = check_route_attribution("admin", "admin", &routes, None);
assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
}
#[test]
fn attribution_no_plugin_routes_fails() {
let routes = vec![make_route("GET", "/posts", "user")];
let result = check_route_attribution("admin", "admin", &routes, None);
assert_eq!(result.status, CheckStatus::Fail);
assert!(
result.message.contains("plugin:admin"),
"message should name the plugin: {}",
result.message
);
}
#[test]
fn attribution_message_includes_count() {
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("GET", "/admin/items", "plugin:admin"),
];
let result = check_route_attribution("admin", "admin", &routes, None);
assert!(result.message.contains('2'), "{}", result.message);
}
#[test]
fn prefix_all_under_prefix_passes() {
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("POST", "/admin/items", "plugin:admin"),
];
let result = check_route_prefix("admin", "/admin", &[], &routes);
assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
}
#[test]
fn prefix_route_outside_fails_with_diagnostic() {
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("GET", "/webhook", "plugin:admin"),
];
let result = check_route_prefix("admin", "/admin", &[], &routes);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.diagnostics.iter().any(|d| d.contains("/webhook")));
}
#[test]
fn prefix_no_plugin_routes_skips() {
let routes = vec![make_route("GET", "/posts", "user")];
let result = check_route_prefix("admin", "/admin", &[], &routes);
assert_eq!(result.status, CheckStatus::Skip);
}
#[test]
fn collisions_no_collisions_passes() {
let routes = vec![
make_route("GET", "/posts", "user"),
make_route("GET", "/admin", "plugin:admin"),
];
let result = check_collisions(&routes);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn collisions_host_plugin_collision_fails() {
let routes = vec![
make_route("GET", "/posts", "user"),
make_route("GET", "/posts", "plugin:harvest"),
];
let result = check_collisions(&routes);
assert_eq!(result.status, CheckStatus::Fail);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn collisions_plugin_plugin_collision_fails() {
let routes = vec![
make_route("GET", "/api/feed", "plugin:harvest"),
make_route("GET", "/api/feed", "plugin:feeds"),
];
let result = check_collisions(&routes);
assert_eq!(result.status, CheckStatus::Fail);
}
#[test]
fn collisions_diagnostic_names_method_path_contributors() {
let routes = vec![
make_route("POST", "/items", "user"),
make_route("POST", "/items", "plugin:inventory"),
];
let result = check_collisions(&routes);
assert_eq!(result.status, CheckStatus::Fail);
let diag = &result.diagnostics[0];
assert!(diag.contains("POST"), "missing method: {diag}");
assert!(diag.contains("/items"), "missing path: {diag}");
assert!(diag.contains("user"), "missing user: {diag}");
assert!(diag.contains("plugin:inventory"), "missing plugin: {diag}");
}
#[test]
fn collisions_different_methods_no_collision() {
let routes = vec![
make_route("GET", "/posts", "user"),
make_route("POST", "/posts", "plugin:blog"),
];
let result = check_collisions(&routes);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn collisions_dynamic_segment_different_names_detected() {
let routes = vec![
make_route("GET", "/users/{user_id}", "user"),
make_route("GET", "/users/{id}", "plugin:auth"),
];
let result = check_collisions(&routes);
assert_eq!(
result.status,
CheckStatus::Fail,
"different param names should collide: {}",
result.message
);
}
#[test]
fn collisions_catchall_different_names_detected() {
let routes = vec![
make_route("GET", "/files/{*path}", "user"),
make_route("GET", "/files/{*rest}", "plugin:storage"),
];
let result = check_collisions(&routes);
assert_eq!(
result.status,
CheckStatus::Fail,
"different catch-all names should collide"
);
}
#[test]
fn collisions_catchall_vs_named_param_detected() {
let routes = vec![
make_route("GET", "/files/{id}", "user"),
make_route("GET", "/files/{*rest}", "plugin:storage"),
];
let result = check_collisions(&routes);
assert_eq!(
result.status,
CheckStatus::Fail,
"catch-all and named param at same position conflict in matchit"
);
}
#[test]
fn normalize_path_replaces_param_names() {
assert_eq!(
normalize_path_for_collision("/users/{user_id}/posts/{post_id}"),
"/users/{}/posts/{}"
);
assert_eq!(normalize_path_for_collision("/files/{*rest}"), "/files/{}");
assert_eq!(
normalize_path_for_collision("/static/app.js"),
"/static/app.js"
);
}
#[test]
fn sensitive_no_sensitive_routes_passes() {
let routes = vec![
make_route("GET", "/posts", "plugin:blog"),
make_route("GET", "/api/users", "plugin:blog"),
];
let result = check_sensitive_surfaces("blog", &routes, &no_sensitive());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn sensitive_admin_route_undeclared_fails() {
let routes = vec![make_route("GET", "/admin/dashboard", "plugin:myplugin")];
let result = check_sensitive_surfaces("myplugin", &routes, &no_sensitive());
assert_eq!(result.status, CheckStatus::Fail);
}
#[test]
fn sensitive_admin_route_declared_passes() {
let routes = vec![make_route("GET", "/admin/dashboard", "plugin:myplugin")];
let declared = vec![SensitiveRouteDecl {
path_pattern: "/admin".to_owned(),
auth_mechanism: "Role: admin required".to_owned(),
}];
let result = check_sensitive_surfaces("myplugin", &routes, &declared);
assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
}
#[test]
fn sensitive_empty_auth_mechanism_fails() {
let routes = vec![make_route("GET", "/admin/users", "plugin:myplugin")];
let declared = vec![SensitiveRouteDecl {
path_pattern: "/admin".to_owned(),
auth_mechanism: String::new(),
}];
let result = check_sensitive_surfaces("myplugin", &routes, &declared);
assert_eq!(
result.status,
CheckStatus::Fail,
"empty auth_mechanism must fail"
);
}
#[test]
fn sensitive_only_checks_plugin_routes() {
let routes = vec![
make_route("GET", "/admin/panel", "user"),
make_route("GET", "/posts", "plugin:blog"),
];
let result = check_sensitive_surfaces("blog", &routes, &no_sensitive());
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn sensitive_debug_route_fails() {
let routes = vec![make_route("GET", "/debug/state", "plugin:myplugin")];
let result = check_sensitive_surfaces("myplugin", &routes, &no_sensitive());
assert_eq!(result.status, CheckStatus::Fail);
}
#[test]
fn report_passed_true_all_pass() {
let report = ConformanceReport {
plugin_name: "test".to_owned(),
checks: vec![
CheckResult {
name: "c1".to_owned(),
status: CheckStatus::Pass,
message: "ok".to_owned(),
diagnostics: vec![],
},
CheckResult {
name: "c2".to_owned(),
status: CheckStatus::Skip,
message: "skipped".to_owned(),
diagnostics: vec![],
},
],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
assert!(report.passed());
}
#[test]
fn report_passed_false_any_fail() {
let report = ConformanceReport {
plugin_name: "test".to_owned(),
checks: vec![CheckResult {
name: "c1".to_owned(),
status: CheckStatus::Fail,
message: "fail".to_owned(),
diagnostics: vec![],
}],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
assert!(!report.passed());
}
#[test]
fn report_text_contains_plugin_name() {
let report = ConformanceReport {
plugin_name: "autumn-admin-plugin".to_owned(),
checks: vec![],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
assert!(report.to_text_report().contains("autumn-admin-plugin"));
}
#[test]
fn report_text_shows_overall_pass() {
let report = ConformanceReport {
plugin_name: "test".to_owned(),
checks: vec![],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
assert!(report.to_text_report().contains("PASS"));
}
#[test]
fn report_text_shows_overall_fail() {
let report = ConformanceReport {
plugin_name: "test".to_owned(),
checks: vec![CheckResult {
name: "c1".to_owned(),
status: CheckStatus::Fail,
message: "fail".to_owned(),
diagnostics: vec![],
}],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
assert!(report.to_text_report().contains("FAIL"));
}
#[test]
fn report_serializes_to_json() {
let report = ConformanceReport {
plugin_name: "test-plugin".to_owned(),
checks: vec![CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Pass,
message: "ok".to_owned(),
diagnostics: vec![],
}],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
let json = serde_json::to_string(&report).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["plugin_name"], "test-plugin");
assert_eq!(parsed["checks"][0]["status"], "pass");
}
#[test]
fn parse_format_text() {
let f: ReportFormat = "text".parse().unwrap();
assert_eq!(f, ReportFormat::Text);
}
#[test]
fn parse_format_table_alias() {
let f: ReportFormat = "table".parse().unwrap();
assert_eq!(f, ReportFormat::Text);
}
#[test]
fn parse_format_json() {
let f: ReportFormat = "json".parse().unwrap();
assert_eq!(f, ReportFormat::Json);
}
#[test]
fn parse_format_unknown_is_error() {
let r: Result<ReportFormat, _> = "xml".parse();
assert!(r.is_err());
}
#[test]
fn admin_path_is_sensitive() {
assert!(is_sensitive_path("/admin"));
assert!(is_sensitive_path("/admin/users"));
}
#[test]
fn debug_path_is_sensitive() {
assert!(is_sensitive_path("/debug"));
assert!(is_sensitive_path("/api/debug/state"));
}
#[test]
fn normal_paths_not_sensitive() {
assert!(!is_sensitive_path("/posts"));
assert!(!is_sensitive_path("/api/users"));
}
#[test]
fn duplicate_no_duplicates_passes() {
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("POST", "/admin/items", "plugin:admin"),
];
let result = check_duplicate_registration("admin", &routes);
assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
}
#[test]
fn duplicate_same_route_twice_fails() {
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("GET", "/admin", "plugin:admin"),
];
let result = check_duplicate_registration("admin", &routes);
assert_eq!(result.status, CheckStatus::Fail);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn duplicate_diagnostic_names_route() {
let routes = vec![
make_route("POST", "/admin/items", "plugin:admin"),
make_route("POST", "/admin/items", "plugin:admin"),
];
let result = check_duplicate_registration("admin", &routes);
assert_eq!(result.status, CheckStatus::Fail);
let diag = &result.diagnostics[0];
assert!(diag.contains("POST"), "missing method: {diag}");
assert!(diag.contains("/admin/items"), "missing path: {diag}");
}
#[test]
fn duplicate_no_plugin_routes_skips() {
let routes = vec![make_route("GET", "/posts", "user")];
let result = check_duplicate_registration("admin", &routes);
assert_eq!(result.status, CheckStatus::Skip);
}
#[test]
fn build_report_includes_installability_check() {
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "test",
expected_prefix: None,
intentional_root_routes: &[],
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let routes = vec![make_route("GET", "/posts", "user")];
let report = build_report(&opts, &routes);
assert!(report.checks.iter().any(|c| c.name == "installability"));
}
#[test]
fn build_report_skips_prefix_check_when_none() {
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "test",
expected_prefix: None,
intentional_root_routes: &[],
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let report = build_report(&opts, &[]);
assert!(!report.checks.iter().any(|c| c.name == "route-prefix"));
}
#[test]
fn build_report_includes_prefix_check_when_set() {
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "admin",
expected_prefix: Some("/admin"),
intentional_root_routes: &[],
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let routes = vec![make_route("GET", "/admin", "plugin:admin")];
let report = build_report(&opts, &routes);
assert!(report.checks.iter().any(|c| c.name == "route-prefix"));
assert_eq!(report.prefix.as_deref(), Some("/admin"));
}
#[test]
fn build_report_exempts_declared_intentional_root_routes() {
let declared = vec!["/webhook".to_owned()];
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "admin",
expected_prefix: Some("/admin"),
intentional_root_routes: &declared,
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("POST", "/webhook", "plugin:admin"),
];
let report = build_report(&opts, &routes);
let check = report
.checks
.iter()
.find(|c| c.name == "route-prefix")
.expect("route-prefix check ran");
assert_eq!(check.status, CheckStatus::Pass);
assert!(check.diagnostics.is_empty());
assert_eq!(report.intentional_root, vec!["/webhook".to_owned()]);
let json = serde_json::to_value(&report).expect("serialize");
assert_eq!(json["intentional_root"], serde_json::json!(["/webhook"]));
}
#[test]
fn build_report_intentional_root_exemption_is_exact() {
let declared = vec!["/webhook2".to_owned()];
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "admin",
expected_prefix: Some("/admin"),
intentional_root_routes: &declared,
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let routes = vec![make_route("POST", "/webhook", "plugin:admin")];
let report = build_report(&opts, &routes);
let check = report
.checks
.iter()
.find(|c| c.name == "route-prefix")
.expect("route-prefix check ran");
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.diagnostics.iter().any(|d| d.contains("/webhook")));
assert!(
check
.message
.contains("not declared as intentional root routes"),
"fail message names the exemption: {check:?}"
);
}
#[test]
fn build_report_off_prefix_route_still_fails_without_declaration() {
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "admin",
expected_prefix: Some("/admin"),
intentional_root_routes: &[],
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let routes = vec![
make_route("GET", "/admin", "plugin:admin"),
make_route("POST", "/webhook", "plugin:admin"),
];
let report = build_report(&opts, &routes);
let check = report
.checks
.iter()
.find(|c| c.name == "route-prefix")
.expect("route-prefix check ran");
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.diagnostics.iter().any(|d| d.contains("/webhook")));
}
#[test]
fn build_report_plugin_name_in_report() {
let opts = PluginCheckOptions {
package: None,
bin: None,
plugin_name: "autumn-admin-plugin",
expected_prefix: None,
intentional_root_routes: &[],
sensitive_routes: &[],
format: ReportFormat::Text,
contracts: &ContractDump::Absent,
deny_experimental: false,
no_routes: false,
};
let report = build_report(&opts, &[]);
assert_eq!(report.plugin_name, "autumn-admin-plugin");
}
}
#[cfg(test)]
mod contract_tests {
use super::*;
use autumn_web::plugin_contract::{PLUGIN_CONTRACT_MARKER, PluginContract};
#[test]
fn locked_autumn_web_reads_the_lockfile() {
let tmp = tempfile::tempdir().unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(app.join("Cargo.toml"), "[package]\nname = \"app\"\n").unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n",
)
.unwrap();
assert_eq!(locked_autumn_web(&app, None), None);
let pkg = |v: &str| format!("[[package]]\nname = \"autumn-web\"\nversion = \"{v}\"\n\n");
let lock = format!(
"version = 4\n\n{}[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n",
pkg("0.7.1")
);
std::fs::write(tmp.path().join("Cargo.lock"), &lock).unwrap();
assert_eq!(locked_autumn_web(&app, None).as_deref(), Some("0.7.1"));
std::fs::write(
app.join("Cargo.lock"),
format!("version = 4\n\n{}", pkg("0.1.0")),
)
.unwrap();
assert_eq!(locked_autumn_web(&app, None).as_deref(), Some("0.7.1"));
std::fs::write(
tmp.path().join("Cargo.lock"),
format!("version = 4\n\n{}{}", pkg("0.7.1"), pkg("0.6.0")),
)
.unwrap();
assert_eq!(locked_autumn_web(&app, None), None);
let host = "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\
dependencies = [\"autumn-web 0.7.1\", \"serde\"]\n\n";
let other = "[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n\
dependencies = [\"autumn-web 0.6.0\"]\n\n";
std::fs::write(
tmp.path().join("Cargo.lock"),
format!(
"version = 4\n\n{host}{other}{}{}",
pkg("0.7.1"),
pkg("0.6.0")
),
)
.unwrap();
assert_eq!(locked_autumn_web(&app, None).as_deref(), Some("0.7.1"));
assert_eq!(
locked_autumn_web(tmp.path(), Some("other")).as_deref(),
Some("0.6.0")
);
std::fs::write(
tmp.path().join("Cargo.lock"),
format!(
"version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\
dependencies = [\"autumn-web\"]\n\n{}",
pkg("0.7.1")
),
)
.unwrap();
assert_eq!(locked_autumn_web(&app, None).as_deref(), Some("0.7.1"));
std::fs::write(
tmp.path().join("Cargo.lock"),
format!(
"version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\
dependencies = [\"autumn-web 0.7.1\", \"autumn-web 0.6.0\"]\n\n{}{}",
pkg("0.7.1"),
pkg("0.6.0")
),
)
.unwrap();
assert_eq!(locked_autumn_web(&app, None), None);
}
#[test]
fn omitted_routers_fail_route_attribution() {
let mut report = ConformanceReport {
plugin_name: "autumn-plugin-x".to_owned(),
checks: vec![CheckResult {
name: "route-attribution".to_owned(),
status: CheckStatus::Pass,
message: String::new(),
diagnostics: vec![],
}],
contract: None,
autumn_web: None,
prefix: None,
intentional_root: Vec::new(),
};
fail_on_omitted_routers(&mut report, 0);
assert!(report.passed());
fail_on_omitted_routers(&mut report, 2);
assert!(!report.passed());
assert!(report.checks[0].message.contains("2 raw router(s)"));
}
#[test]
fn package_of_bin_finds_the_one_owner() {
let metadata = serde_json::json!({"packages": [
{"name": "host", "targets": [{"name": "host", "kind": ["bin"]}]},
{"name": "lib", "targets": [{"name": "host", "kind": ["lib"]}]},
{"name": "other", "targets": [{"name": "tool", "kind": ["bin"]}]},
]});
assert_eq!(package_of_bin(&metadata, "host").as_deref(), Some("host"));
assert_eq!(package_of_bin(&metadata, "missing"), None);
let twice = serde_json::json!({"packages": [
{"name": "a", "targets": [{"name": "x", "kind": ["bin"]}]},
{"name": "b", "targets": [{"name": "x", "kind": ["bin"]}]},
]});
assert_eq!(package_of_bin(&twice, "x"), None);
}
fn opts(contracts: &ContractDump) -> PluginCheckOptions<'_> {
PluginCheckOptions {
package: None,
bin: None,
plugin_name: "autumn-plugin-demo",
expected_prefix: None,
intentional_root_routes: &[],
sensitive_routes: &[],
format: ReportFormat::Text,
contracts,
deny_experimental: false,
no_routes: false,
}
}
fn present(contracts: Vec<PluginContract>) -> ContractDump {
ContractDump::Present(contracts)
}
fn route() -> RouteInfo {
RouteInfo {
method: "GET".to_owned(),
path: "/demo".to_owned(),
handler: "demo::index".to_owned(),
source: "plugin:autumn-plugin-demo".to_owned(),
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
resource_shape: String::new(),
pools: Vec::new(),
}
}
fn find<'a>(report: &'a ConformanceReport, name: &str) -> &'a CheckResult {
report
.checks
.iter()
.find(|c| c.name == name)
.unwrap_or_else(|| panic!("no `{name}` check in report"))
}
fn demo_contract() -> PluginContract {
let mut contract = PluginContract::new("autumn-plugin-demo").autumn_web("0.7");
contract.registered_as = Some("autumn-plugin-demo".to_owned());
contract
}
#[test]
fn a_registered_plugin_with_no_routes_skips_route_attribution() {
let dump = present(vec![demo_contract()]);
let mut o = opts(&dump);
o.no_routes = true;
let report = build_report(&o, &[]);
assert_eq!(find(&report, "route-attribution").status, CheckStatus::Skip);
assert!(report.passed(), "{}", report.to_text_report());
}
#[test]
fn no_routes_without_the_assertion_fails_attribution() {
let dump = present(vec![demo_contract()]);
let report = build_report(&opts(&dump), &[]);
assert_eq!(find(&report, "route-attribution").status, CheckStatus::Fail);
let mut o = opts(&dump);
o.no_routes = true;
let report = build_report(&o, &[route()]);
let attribution = find(&report, "route-attribution");
assert_eq!(attribution.status, CheckStatus::Fail);
assert!(
attribution.message.contains("--no-routes"),
"{}",
attribution.message
);
}
#[test]
fn routes_split_across_both_names_fail_attribution() {
let mut contract = demo_contract();
contract.registered_as = Some("demo_crate::DemoPlugin".to_owned());
let dump = present(vec![contract]);
let mut hidden = route();
hidden.source = "plugin:demo_crate::DemoPlugin".to_owned();
hidden.path = "/off-prefix".to_owned();
let report = build_report(&opts(&dump), &[route(), hidden]);
let attribution = find(&report, "route-attribution");
assert_eq!(attribution.status, CheckStatus::Fail);
assert!(
attribution.message.contains("demo_crate::DemoPlugin"),
"{}",
attribution.message
);
assert!(!report.passed());
}
#[test]
fn route_checks_follow_the_registered_name() {
let mut contract = demo_contract();
contract.registered_as = Some("demo_crate::DemoPlugin".to_owned());
let dump = present(vec![contract]);
let mut hidden = route();
hidden.source = "plugin:demo_crate::DemoPlugin".to_owned();
let mut o = opts(&dump);
o.expected_prefix = Some("/elsewhere");
let report = build_report(&o, &[hidden]);
assert_eq!(report.plugin_name, "autumn-plugin-demo");
let attribution = find(&report, "route-attribution");
assert_eq!(
attribution.status,
CheckStatus::Pass,
"{}",
attribution.message
);
assert!(
attribution.message.contains("demo_crate::DemoPlugin"),
"{}",
attribution.message
);
assert_eq!(find(&report, "route-prefix").status, CheckStatus::Fail);
}
#[test]
fn a_registered_plugin_with_a_prefix_and_no_routes_fails() {
let dump = present(vec![demo_contract()]);
let mut o = opts(&dump);
o.expected_prefix = Some("/demo");
let report = build_report(&o, &[]);
assert_eq!(find(&report, "route-attribution").status, CheckStatus::Fail);
assert!(!report.passed());
}
#[test]
fn a_contract_without_a_registered_name_does_not_skip() {
let mut contract = demo_contract();
contract.registered_as = None;
let report = build_report(&opts(&present(vec![contract])), &[]);
assert_eq!(find(&report, "route-attribution").status, CheckStatus::Fail);
}
#[test]
fn an_unregistered_name_with_no_routes_still_fails_route_attribution() {
for dump in [
ContractDump::Absent,
present(vec![]),
present(vec![PluginContract::new("someone-else").autumn_web("0.7")]),
] {
let report = build_report(&opts(&dump), &[]);
assert_eq!(
find(&report, "route-attribution").status,
CheckStatus::Fail,
"{dump:?}"
);
}
}
#[test]
fn the_report_carries_the_declared_contract() {
let dump = present(vec![demo_contract()]);
let report = build_report(&opts(&dump), &[route()]);
assert_eq!(report.contract, Some(demo_contract()));
let json = serde_json::to_value(&report).expect("json");
assert_eq!(json["contract"]["autumn_web"], "0.7");
let report = build_report(&opts(&ContractDump::Absent), &[route()]);
assert_eq!(report.contract, None);
let json = serde_json::to_value(&report).expect("json");
assert!(json.get("contract").is_none(), "{json}");
}
#[test]
fn contracts_are_parsed_from_the_marker_line() {
let stderr = format!(
"\u{1F342} autumn plugin-check\nsome warning\n{PLUGIN_CONTRACT_MARKER}[{{\"plugin\":\"autumn-plugin-demo\",\"autumn_web\":\"0.7\"}}]\ntrailing\n"
);
let ContractDump::Present(parsed) = parse_plugin_contracts(&stderr) else {
panic!("marker present");
};
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].plugin, "autumn-plugin-demo");
assert_eq!(parsed[0].autumn_web.as_deref(), Some("0.7"));
}
#[test]
fn an_absent_marker_parses_as_absent() {
assert_eq!(
parse_plugin_contracts("no marker here\n"),
ContractDump::Absent
);
}
#[test]
fn an_empty_contract_array_is_present_and_empty_not_absent() {
let stderr = format!("{PLUGIN_CONTRACT_MARKER}[]\n");
let ContractDump::Present(parsed) = parse_plugin_contracts(&stderr) else {
panic!("marker present");
};
assert!(parsed.is_empty(), "the app declared no contracts");
}
#[test]
fn a_malformed_marker_payload_is_distinguished_from_an_absent_one() {
let stderr = format!("{PLUGIN_CONTRACT_MARKER}{{not json\n");
assert!(
matches!(parse_plugin_contracts(&stderr), ContractDump::Malformed(_)),
"a marker the CLI cannot read is NOT the same as no marker"
);
}
#[test]
fn the_last_marker_line_wins() {
let stderr = format!(
"{PLUGIN_CONTRACT_MARKER}[]\n{PLUGIN_CONTRACT_MARKER}[{{\"plugin\":\"second\"}}]\n"
);
let ContractDump::Present(parsed) = parse_plugin_contracts(&stderr) else {
panic!("marker present");
};
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].plugin, "second");
}
#[test]
fn a_binary_that_emits_no_marker_skips_the_contract_check() {
let report = build_report(&opts(&ContractDump::Absent), &[route()]);
let result = find(&report, "plugin-contract");
assert_eq!(result.status, CheckStatus::Skip);
}
#[test]
fn a_plugin_with_no_contract_fails_the_contract_check() {
let others = vec![PluginContract::new("some-other-plugin").autumn_web("0.7")];
let report = build_report(&opts(&present(others)), &[route()]);
let result = find(&report, "plugin-contract");
assert_eq!(
result.status,
CheckStatus::Fail,
"the checked plugin declares nothing: {}",
result.message
);
assert!(
result.message.contains("autumn-plugin-demo"),
"{}",
result.message
);
}
#[test]
fn a_declared_range_passes_and_is_reported() {
let contracts = vec![
PluginContract::new("autumn-plugin-demo")
.plugin_version("1.2.3")
.autumn_web("0.7"),
];
let report = build_report(&opts(&present(contracts)), &[route()]);
let result = find(&report, "plugin-contract");
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.message.contains("0.7"), "{}", result.message);
}
#[test]
fn an_unparseable_range_fails_the_contract_check() {
let contracts = vec![PluginContract::new("autumn-plugin-demo").autumn_web("not a req")];
let report = build_report(&opts(&present(contracts)), &[route()]);
let result = find(&report, "plugin-contract");
assert_eq!(
result.status,
CheckStatus::Fail,
"an unevaluable requirement disables the runtime guard silently"
);
assert!(!report.passed());
}
#[test]
fn a_stable_only_plugin_passes_the_experimental_check() {
let contracts = vec![PluginContract::new("autumn-plugin-demo").autumn_web("0.7")];
let report = build_report(&opts(&present(contracts)), &[route()]);
let result = find(&report, "experimental-surface");
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.diagnostics.is_empty());
}
#[test]
fn experimental_surface_is_reported_by_name_without_failing() {
let contracts = vec![
PluginContract::new("autumn-plugin-demo")
.autumn_web("0.7")
.uses_experimental("AppBuilder::with_edge_kv"),
];
let report = build_report(&opts(&present(contracts)), &[route()]);
let result = find(&report, "experimental-surface");
assert_eq!(result.status, CheckStatus::Pass);
assert!(
result
.diagnostics
.iter()
.any(|d| d.contains("AppBuilder::with_edge_kv")),
"{:?}",
result.diagnostics
);
assert!(report.passed());
}
#[test]
fn deny_experimental_turns_the_report_into_a_failure() {
let contracts = vec![
PluginContract::new("autumn-plugin-demo")
.autumn_web("0.7")
.uses_experimental("AppBuilder::with_edge_kv"),
];
let dump = present(contracts);
let mut o = opts(&dump);
o.deny_experimental = true;
let report = build_report(&o, &[route()]);
let result = find(&report, "experimental-surface");
assert_eq!(result.status, CheckStatus::Fail);
assert!(!report.passed());
}
#[test]
fn deny_experimental_does_not_fail_a_stable_only_plugin() {
let contracts = vec![PluginContract::new("autumn-plugin-demo").autumn_web("0.7")];
let dump = present(contracts);
let mut o = opts(&dump);
o.deny_experimental = true;
let report = build_report(&o, &[route()]);
assert_eq!(
find(&report, "experimental-surface").status,
CheckStatus::Pass
);
assert!(report.passed());
}
#[test]
fn an_unknown_experimental_surface_name_fails() {
let contracts = vec![
PluginContract::new("autumn-plugin-demo")
.autumn_web("0.7")
.uses_experimental("AppBuilder::not_a_surface"),
];
let report = build_report(&opts(&present(contracts)), &[route()]);
assert_eq!(
find(&report, "experimental-surface").status,
CheckStatus::Fail
);
}
#[test]
fn the_experimental_check_is_skipped_when_the_binary_emits_no_marker() {
let report = build_report(&opts(&ContractDump::Absent), &[route()]);
assert_eq!(
find(&report, "experimental-surface").status,
CheckStatus::Skip
);
}
#[test]
fn a_malformed_last_dump_does_not_fall_back_to_an_earlier_one() {
let stderr = format!(
"{PLUGIN_CONTRACT_MARKER}[{{\"plugin\":\"stale\"}}]\n{PLUGIN_CONTRACT_MARKER}[{{\"plugin\":\"trunc\"\n"
);
assert!(
matches!(parse_plugin_contracts(&stderr), ContractDump::Malformed(_)),
"the stale first dump must not win"
);
}
#[test]
fn a_malformed_dump_fails_the_contract_check_rather_than_skipping_it() {
let dump = ContractDump::Malformed("expected value at line 1".to_owned());
let report = build_report(&opts(&dump), &[route()]);
let result = find(&report, "plugin-contract");
assert_eq!(
result.status,
CheckStatus::Fail,
"an unreadable answer is not the same as no answer"
);
assert!(!report.passed());
}
#[test]
fn deny_experimental_fails_closed_when_the_contract_cannot_be_read() {
for dump in [
ContractDump::Absent,
ContractDump::Malformed("bad".to_owned()),
ContractDump::Present(vec![]),
] {
let mut o = opts(&dump);
o.deny_experimental = true;
let report = build_report(&o, &[route()]);
assert_eq!(
find(&report, "experimental-surface").status,
CheckStatus::Fail,
"--deny-experimental must not silently no-op for {dump:?}"
);
}
}
#[test]
fn a_contract_is_found_under_the_name_the_plugin_registered_under() {
let mut contract = PluginContract::new("autumn-plugin-demo").autumn_web("0.7");
contract.registered_as = Some("demo_crate::DemoPlugin".to_owned());
let dump = present(vec![contract]);
for name in ["autumn-plugin-demo", "demo_crate::DemoPlugin"] {
let mut o = opts(&dump);
o.plugin_name = name;
let report = build_report(&o, &[route()]);
assert_eq!(
find(&report, "plugin-contract").status,
CheckStatus::Pass,
"--plugin-name {name} should resolve"
);
}
}
#[test]
fn the_not_found_diagnostic_names_both_identities() {
let mut contract = PluginContract::new("autumn-plugin-demo").autumn_web("0.7");
contract.registered_as = Some("demo_crate::DemoPlugin".to_owned());
let dump = present(vec![contract]);
let mut o = opts(&dump);
o.plugin_name = "something-else";
let report = build_report(&o, &[route()]);
let joined = find(&report, "plugin-contract").diagnostics.join("\n");
assert!(joined.contains("autumn-plugin-demo"), "{joined}");
assert!(joined.contains("demo_crate::DemoPlugin"), "{joined}");
}
#[test]
fn the_older_binary_skip_message_names_this_builds_version() {
let report = build_report(&opts(&ContractDump::Absent), &[route()]);
let message = &find(&report, "plugin-contract").message;
assert!(
message.contains(autumn_web::plugin_contract::AUTUMN_WEB_VERSION),
"{message}"
);
}
#[test]
fn the_text_report_renders_the_new_checks() {
let contracts = vec![
PluginContract::new("autumn-plugin-demo")
.autumn_web("0.7")
.uses_experimental("AppBuilder::with_edge_kv"),
];
let report = build_report(&opts(&present(contracts)), &[route()]);
let text = report.to_text_report();
assert!(text.contains("plugin-contract"), "{text}");
assert!(text.contains("experimental-surface"), "{text}");
}
}