use autumn_web::config::{DeployConfig, ProcessRole, Role, RolePreset};
use serde::Serialize;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
const SIGNING_SECRET_MIN_LEN: usize = 32;
const SIGNING_SECRET_DEMO_VALUES: &[&str] = &[
"changeme",
"change_me",
"change-me",
"secret",
"supersecret",
"super-secret",
"super_secret",
"your-secret-here",
"your_secret_here",
"insert-secret-here",
"replace-this",
"replace_me",
"todo",
"fixme",
"example",
"placeholder",
"dev_only",
"dev-only",
"test_secret",
"test-secret",
"test",
"password",
];
#[cfg(test)]
const KNOWN_TOML_SECTIONS: &[&str] = &[
"server",
"database",
"log",
"telemetry",
"health",
"actuator",
"cors",
"session",
"jobs",
"auth",
"security",
"i18n",
"storage",
"backup",
"mail",
"profile",
"compression",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckResult {
pub name: &'static str,
pub status: CheckStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Summary {
pub passed: usize,
pub warned: usize,
pub failed: usize,
}
#[derive(Clone, Copy)]
pub struct DoctorOptions {
pub json: bool,
pub strict: bool,
pub online: bool,
}
#[allow(dead_code)]
pub trait Check {
fn run(&self) -> CheckResult;
}
#[derive(Debug, Clone)]
pub struct DoctorDeprecation {
pub path: String,
pub replacement: Option<String>,
pub since: String,
pub remove_in: String,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub struct PluginWiring {
pub crate_name: String,
pub community: bool,
pub dependency: bool,
pub mount: bool,
pub mount_qualified: bool,
pub orphaned_migrations: Vec<String>,
}
pub fn check_plugin_residue_impl(wirings: &[PluginWiring]) -> CheckResult {
let mut failures: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for wiring in wirings {
let name = &wiring.crate_name;
match (wiring.dependency, wiring.mount) {
(false, true) if wiring.mount_qualified => failures.push(format!(
"{name} is mounted in the builder chain but not declared in [dependencies] — this app does not compile; run `autumn plugin add {name}`, or delete the mount"
)),
(true, false) => warnings.push(format!(
"{name} is declared in [dependencies] but never mounted — {}, or run `autumn plugin remove {name}` to take the dependency back",
if wiring.community {
"`autumn plugin add` writes no mount for a community crate, so add the `.plugin(...)` call from its README".to_owned()
} else {
format!("run `autumn plugin add {name}` to finish the install")
}
)),
(false, true | false) | (true, true) => {}
}
if !wiring.dependency && !wiring.mount && !wiring.orphaned_migrations.is_empty() {
warnings.push(format!(
"{name} is not installed, but migrations it declares are still recorded as applied: {} — the tables they created are still in the database; `autumn plugin remove {name} --drop-data` reverts them",
wiring.orphaned_migrations.join(", ")
));
}
}
let status = if failures.is_empty() {
if warnings.is_empty() {
CheckStatus::Pass
} else {
CheckStatus::Warn
}
} else {
CheckStatus::Fail
};
if status == CheckStatus::Pass {
return CheckResult {
name: "plugin_residue",
status,
detail: Some("no orphaned plugin wiring or migrations".into()),
hint: None,
};
}
let detail = failures
.into_iter()
.chain(warnings)
.collect::<Vec<_>>()
.join("\n");
CheckResult {
name: "plugin_residue",
status,
detail: Some(detail),
hint: Some(
"`autumn plugin list` shows what is installable; `autumn plugin remove <name>` unwires a plugin cleanly",
),
}
}
const DEPENDENCY_DETAIL_LIMIT: usize = 10;
const DEPENDENCY_WAIVER_NAME_LIMIT: usize = 3;
pub fn check_dependencies_impl(eval: &crate::deps::Evaluation) -> CheckResult {
use crate::deps::{Evaluation, STALE_AFTER_DAYS};
let (status, detail, hint) = match eval {
Evaluation::AuditorMissing { checks } => (
CheckStatus::Pass,
format!(
"not evaluated. cargo-deny is not installed (`cargo install --locked cargo-deny`). Doctor cannot check that deny.toml is valid, so CI can still reject it; {}",
dependency_checks_phrase(checks)
),
None,
),
Evaluation::DatabaseMissing { checks } => (
CheckStatus::Pass,
format!(
"not evaluated — no local advisory database (`cargo deny fetch db`); {}",
dependency_checks_phrase(checks)
),
None,
),
Evaluation::NoPolicy => (
CheckStatus::Warn,
format!(
"no {} — the app has no dependency policy, and its CI gate needs one",
crate::deps::POLICY_FILE
),
Some(
"copy the `deny.toml` that `autumn new` generates; docs/guide/supply-chain.md explains it",
),
),
Evaluation::Unavailable { reason, .. } => (
CheckStatus::Warn,
format!("the dependency audit produced no verdict: {reason}"),
Some(
"`deny.toml` is the policy both `autumn doctor` and the CI gate read; `cargo deny check` shows the auditor's own error",
),
),
Evaluation::Audited {
findings,
checks,
db_age_days,
auditor,
} => {
return grade_dependency_findings(
findings,
checks,
*db_age_days,
auditor,
STALE_AFTER_DAYS,
);
}
};
CheckResult {
name: "dependencies",
status,
detail: Some(detail),
hint,
}
}
fn dependency_checks_phrase(checks: &[String]) -> String {
if checks.is_empty() {
return "checks: none".to_owned();
}
format!("checks: {}", checks.join(", "))
}
fn grade_dependency_findings(
findings: &[crate::deps::Finding],
checks: &[String],
db_age_days: Option<u64>,
auditor: &str,
stale_after_days: u64,
) -> CheckResult {
let stale = db_age_days.is_some_and(|age| age > stale_after_days);
let live: Vec<&crate::deps::Finding> =
findings.iter().filter(|finding| !finding.waived).collect();
let blocking = live.iter().filter(|finding| finding.blocking).count();
let status = if blocking > 0 {
CheckStatus::Fail
} else if !live.is_empty() || stale {
CheckStatus::Warn
} else {
CheckStatus::Pass
};
let mut context = format!("{auditor}; {}", dependency_checks_phrase(checks));
if let Some(age) = db_age_days {
let plural = if age == 1 { "" } else { "s" };
let _ = write!(context, "; advisory data {age} day{plural} old");
if stale {
context.push_str(" (stale)");
}
}
let stale_hint = "`cargo deny fetch db` refreshes the advisory database; the verdict above is only as fresh as that data";
let finding_hint = "`deny.toml` holds the policy and the waivers; docs/guide/supply-chain.md explains how to fix or waive a finding";
if live.is_empty() {
let waived: Vec<&str> = findings.iter().map(|finding| finding.id.as_str()).collect();
let summary = if waived.is_empty() {
"no advisories or policy violations".to_owned()
} else {
format!(
"no live findings; {} waived ({})",
waived.len(),
crate::deps::name_some(&waived, DEPENDENCY_WAIVER_NAME_LIMIT)
)
};
return CheckResult {
name: "dependencies",
status,
detail: Some(crate::deps::one_line(&format!("{summary} — {context}"))),
hint: stale.then_some(stale_hint),
};
}
let mut ordered: Vec<&crate::deps::Finding> = live.clone();
ordered.sort_by_key(|finding| !finding.blocking);
ordered.extend(findings.iter().filter(|finding| finding.waived));
let waived = findings.len() - live.len();
let plural = if findings.len() == 1 { "" } else { "s" };
let mut counts = format!("{} finding{plural}", findings.len());
if blocking > 0 {
let _ = write!(counts, ", {blocking} blocking");
}
if waived > 0 {
let _ = write!(counts, ", {waived} waived");
}
let mut lines = vec![format!("{counts} — {context}")];
lines.extend(
ordered
.iter()
.take(DEPENDENCY_DETAIL_LIMIT)
.map(|finding| format!(" {}", format_dependency_finding(finding))),
);
if ordered.len() > DEPENDENCY_DETAIL_LIMIT {
lines.push(format!(
" …and {} more",
ordered.len() - DEPENDENCY_DETAIL_LIMIT
));
}
CheckResult {
name: "dependencies",
status,
detail: Some(lines.join("\n")),
hint: Some(if stale { stale_hint } else { finding_hint }),
}
}
fn format_dependency_finding(finding: &crate::deps::Finding) -> String {
let identity = if finding.id == finding.code {
finding.code.clone()
} else {
format!("{} {}", finding.id, finding.code)
};
let mut line = crate::deps::one_line(&format!(
"{identity} ({}) {} — {}",
finding.severity.label(),
finding.package,
finding.title
));
if finding.waived {
line.push_str(" (waived)");
}
line
}
fn parse_applied_migration_versions(output: &str) -> Vec<String> {
parse_applied_migration_tokens(output)
.map(|token| {
token
.split_once('_')
.map_or(token, |(head, _)| head)
.to_owned()
})
.collect()
}
fn parse_applied_migration_tokens(output: &str) -> impl Iterator<Item = &str> {
output.lines().filter_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("[X]")
.or_else(|| trimmed.strip_prefix("[x]"))?
.split_whitespace()
.next()
})
}
fn applied_migration_versions(database_url: &str) -> Vec<String> {
diesel_migration_list(database_url)
.as_deref()
.map_or_else(Vec::new, parse_applied_migration_versions)
}
fn resolve_plugin_wirings(
root: &std::path::Path,
applied: impl FnOnce() -> Vec<String>,
) -> Vec<PluginWiring> {
let Ok(manifest) = std::fs::read_to_string(root.join("Cargo.toml")) else {
return Vec::new();
};
let main_src = read_app_sources(root);
let masked = crate::rust_source::mask_non_code(&main_src);
let mut wirings: Vec<PluginWiring> = crate::plugin::catalog::FIRST_PARTY
.iter()
.map(|entry| PluginWiring {
crate_name: entry.crate_name.to_owned(),
community: false,
dependency: crate::plugin::install::dependency_present(&manifest, entry.crate_name),
mount: crate::plugin::install::mount_present(&main_src, entry),
mount_qualified: crate::plugin::install::mount_call_span(&masked, entry, |argument| {
argument.contains(entry.mount_arg)
})
.is_some(),
orphaned_migrations: Vec::new(),
})
.collect();
let candidates: Vec<(usize, Vec<String>)> = wirings
.iter()
.enumerate()
.filter(|(_, wiring)| !wiring.dependency && !wiring.mount)
.filter_map(|(index, wiring)| {
let entry = crate::plugin::catalog::lookup(&wiring.crate_name)?;
if entry.migrations.is_empty() {
return None;
}
Some((
index,
entry
.migrations
.iter()
.map(|version| {
version
.split_once('_')
.map_or(*version, |(head, _)| head)
.to_owned()
})
.collect(),
))
})
.collect();
if !candidates.is_empty() {
let applied = applied();
for (index, declared) in candidates {
wirings[index].orphaned_migrations = declared
.into_iter()
.filter(|version| applied.contains(version))
.collect();
}
}
for crate_name in community_dependencies(&manifest) {
let Some(struct_name) = crate::plugin::catalog::community_struct_name(&crate_name) else {
continue;
};
let mount = masked.contains(&struct_name);
wirings.push(PluginWiring {
mount,
mount_qualified: mount,
crate_name,
community: true,
dependency: true,
orphaned_migrations: Vec::new(),
});
}
wirings
}
fn read_app_sources(root: &std::path::Path) -> String {
let mut out = String::new();
let mut seen: Vec<std::path::PathBuf> = Vec::new();
let conventional = crate::plugin::install::rs_files_under(&root.join("src"));
let explicit = crate::plugin::install::explicit_target_sources(root);
for path in conventional.into_iter().chain(explicit) {
if seen.contains(&path) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path) {
out.push_str(&content);
out.push('\n');
}
seen.push(path);
}
out
}
fn community_dependencies(manifest: &str) -> Vec<String> {
let Ok(table) = toml::from_str::<toml::Table>(manifest) else {
return Vec::new();
};
table
.get("dependencies")
.and_then(toml::Value::as_table)
.map(|deps| {
deps.keys()
.filter(|name| crate::plugin::catalog::is_community_name(name))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn check_deprecated_keys_impl(found: &[DoctorDeprecation]) -> CheckResult {
if found.is_empty() {
return CheckResult {
name: "deprecated_keys",
status: CheckStatus::Pass,
detail: Some("no deprecated configuration keys in use".into()),
hint: None,
};
}
let lines: Vec<String> = found
.iter()
.map(|d| {
let repl = d
.replacement
.as_deref()
.unwrap_or("remove it (no replacement)");
format!(
"{} is deprecated since {} (use {}; removed in {})",
d.path, d.since, repl, d.remove_in
)
})
.collect();
CheckResult {
name: "deprecated_keys",
status: CheckStatus::Warn,
detail: Some(lines.join("\n")),
hint: Some(
"Migrate to the replacement keys before the removal version; \
deprecated keys are still honored during the deprecation window",
),
}
}
pub fn check_signing_secret_impl(secret: Option<&str>, is_production: bool) -> CheckResult {
match secret {
None if is_production => CheckResult {
name: "signing_secret",
status: CheckStatus::Fail,
detail: Some("no signing secret configured in production".into()),
hint: Some(
"Set AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)",
),
},
None => CheckResult {
name: "signing_secret",
status: CheckStatus::Warn,
detail: Some(
"no signing secret configured (dev/test only): sessions and \
CSRF tokens ride unsigned; local-storage signed URLs use an \
ephemeral per-process key instead"
.into(),
),
hint: Some("Set AUTUMN_SECURITY__SIGNING_SECRET before deploying to production"),
},
Some(s) if is_production => {
let lower = s.to_ascii_lowercase();
if SIGNING_SECRET_DEMO_VALUES.iter().any(|&d| lower == d) {
return CheckResult {
name: "signing_secret",
status: CheckStatus::Fail,
detail: Some("signing secret matches a known demo/template value".into()),
hint: Some("Generate a new secret: `openssl rand -hex 32`"),
};
}
let byte_len = s.len();
if byte_len < SIGNING_SECRET_MIN_LEN {
return CheckResult {
name: "signing_secret",
status: CheckStatus::Fail,
detail: Some(format!(
"signing secret too short: {byte_len} bytes \
(minimum {SIGNING_SECRET_MIN_LEN})"
)),
hint: Some("Generate a longer secret: `openssl rand -hex 32`"),
};
}
CheckResult {
name: "signing_secret",
status: CheckStatus::Pass,
detail: Some("signing secret is present and meets entropy requirements".into()),
hint: None,
}
}
Some(_) => CheckResult {
name: "signing_secret",
status: CheckStatus::Pass,
detail: Some("signing secret is configured".into()),
hint: None,
},
}
}
pub fn check_rate_limit_key_strategy_impl(
key_strategy: &str,
auth_extractor_mounted: bool,
) -> CheckResult {
if !key_strategy.is_empty()
&& key_strategy != "ip"
&& key_strategy != "api_token"
&& key_strategy != "authenticated_principal"
{
return CheckResult {
name: "rate_limit_key_strategy",
status: CheckStatus::Fail,
detail: Some(format!(
"rate_limit.key_strategy = {key_strategy:?} is not a valid strategy"
)),
hint: Some("Expected \"ip\", \"api_token\", or \"authenticated_principal\""),
};
}
if key_strategy == "authenticated_principal" && !auth_extractor_mounted {
return CheckResult {
name: "rate_limit_key_strategy",
status: CheckStatus::Warn,
detail: Some(
"rate_limit.key_strategy = \"authenticated_principal\" is configured \
but no auth extractor is mounted — unauthenticated requests will \
always fall back to IP keying, so the per-principal budget \
is never applied to authenticated callers"
.into(),
),
hint: Some(
"Mount an auth layer (e.g. `[auth]` in autumn.toml) or change \
key_strategy to \"ip\" or \"api_token\"",
),
};
}
CheckResult {
name: "rate_limit_key_strategy",
status: CheckStatus::Pass,
detail: Some(format!(
"rate_limit.key_strategy = {:?} is compatible with the current auth configuration",
if key_strategy.is_empty() {
"ip"
} else {
key_strategy
}
)),
hint: None,
}
}
#[derive(Default)]
pub struct ProxyConflictData {
pub new_ranges: Vec<String>,
pub new_trust_fwd: bool,
pub new_hops: Option<u32>,
pub old_ranges: Vec<String>,
pub old_trust_fwd: bool,
}
pub fn check_proxy_conflict_impl(data: &ProxyConflictData) -> CheckResult {
let new_set = data.new_trust_fwd || !data.new_ranges.is_empty();
let old_set = data.old_trust_fwd || !data.old_ranges.is_empty();
if new_set && old_set {
let new_set: std::collections::HashSet<&str> =
data.new_ranges.iter().map(String::as_str).collect();
let old_set: std::collections::HashSet<&str> =
data.old_ranges.iter().map(String::as_str).collect();
let conflicting = new_set != old_set
|| data.new_trust_fwd != data.old_trust_fwd
|| data.new_hops.is_some();
if conflicting {
return CheckResult {
name: "proxy_config_conflict",
status: CheckStatus::Warn,
detail: Some(
"[security.trusted_proxies] and [security.rate_limit] trusted proxy \
fields are both set with conflicting values."
.into(),
),
hint: Some(
"Remove the deprecated rate_limit.trusted_proxies / \
rate_limit.trust_forwarded_headers fields and keep only \
[security.trusted_proxies]",
),
};
}
}
CheckResult {
name: "proxy_config_conflict",
status: CheckStatus::Pass,
detail: None,
hint: None,
}
}
pub fn check_trusted_hosts_impl(hosts: &[String], is_production: bool) -> CheckResult {
let normalized: Vec<String> = hosts
.iter()
.map(|h| h.trim().to_owned())
.filter(|h| !h.is_empty())
.collect();
if normalized.is_empty() {
return if is_production {
CheckResult {
name: "trusted_hosts",
status: CheckStatus::Fail,
detail: Some("trusted hosts list is empty in production".into()),
hint: Some(
"Set [security.trusted_hosts] hosts = [\"example.com\"] or \
AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
),
}
} else {
CheckResult {
name: "trusted_hosts",
status: CheckStatus::Warn,
detail: Some("trusted hosts list is empty".into()),
hint: Some(
"Set [security.trusted_hosts] hosts to prevent Host-header rebinding attacks",
),
}
};
}
if normalized.iter().any(|h| h == "*") {
return CheckResult {
name: "trusted_hosts",
status: CheckStatus::Warn,
detail: Some("trusted hosts wildcard '*' disables host-header allow-listing".into()),
hint: Some("Prefer explicit hosts in production to fail closed"),
};
}
CheckResult {
name: "trusted_hosts",
status: CheckStatus::Pass,
detail: Some(format!(
"trusted hosts configured ({} entries)",
normalized.len()
)),
hint: None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TlsDoctorData {
NotConfigured,
#[cfg_attr(not(feature = "tls"), allow(dead_code))]
Healthy {
not_yet_valid: bool,
expired: bool,
days_until_expiry: i64,
},
Invalid {
detail: String,
},
#[cfg_attr(feature = "tls", allow(dead_code))]
FeatureDisabled,
}
#[must_use]
pub fn check_tls_impl(data: &TlsDoctorData) -> CheckResult {
match data {
TlsDoctorData::NotConfigured => CheckResult {
name: "tls",
status: CheckStatus::Pass,
detail: Some("no [server.tls] configured; serving plain HTTP".into()),
hint: None,
},
TlsDoctorData::FeatureDisabled => CheckResult {
name: "tls",
status: CheckStatus::Warn,
detail: Some(
"[server.tls] is configured but this autumn CLI was built without the `tls` \
feature, so the certificate could not be inspected"
.into(),
),
hint: Some("Rebuild the autumn CLI with the `tls` feature to enable TLS diagnostics"),
},
TlsDoctorData::Invalid { detail } => CheckResult {
name: "tls",
status: CheckStatus::Fail,
detail: Some(detail.clone()),
hint: Some(
"Fix [server.tls] cert_path/key_path: ensure both files exist, are valid PEM, \
and the key matches the certificate",
),
},
TlsDoctorData::Healthy {
not_yet_valid: true,
..
} => CheckResult {
name: "tls",
status: CheckStatus::Fail,
detail: Some(
"the [server.tls] leaf certificate is not yet valid (its notBefore is in the \
future)"
.into(),
),
hint: Some(
"Wait for the certificate's validity window to open or install a currently-valid \
certificate; a not-yet-valid certificate fails every TLS handshake",
),
},
TlsDoctorData::Healthy {
expired: true,
days_until_expiry,
..
} => CheckResult {
name: "tls",
status: CheckStatus::Fail,
detail: Some(format!(
"the [server.tls] leaf certificate expired {} day(s) ago",
days_until_expiry.abs()
)),
hint: Some("Renew the certificate; an expired certificate fails every TLS handshake"),
},
TlsDoctorData::Healthy {
expired: false,
days_until_expiry,
..
} if *days_until_expiry <= 30 => CheckResult {
name: "tls",
status: CheckStatus::Warn,
detail: Some(format!(
"the [server.tls] leaf certificate expires in {days_until_expiry} day(s)"
)),
hint: Some("Renew the certificate soon to avoid downtime"),
},
TlsDoctorData::Healthy {
days_until_expiry, ..
} => CheckResult {
name: "tls",
status: CheckStatus::Pass,
detail: Some(format!(
"[server.tls] certificate valid ({days_until_expiry} day(s) until expiry)"
)),
hint: None,
},
}
}
const CLIENT_CA_EXPIRY_WARN_DAYS: i64 = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientAuthDoctorData {
NotConfigured,
ModeOff,
#[cfg_attr(feature = "tls", allow(dead_code))]
FeatureDisabled,
Invalid {
detail: String,
},
#[cfg_attr(not(feature = "tls"), allow(dead_code))]
Healthy {
mode: String,
expired_cas: Vec<String>,
near_expiry_cas: Vec<(String, i64)>,
ca_count: usize,
crl_stale: Option<bool>,
crl_coverage_gaps: Vec<String>,
required_path_count: usize,
},
}
#[must_use]
pub fn check_client_auth_impl(data: &ClientAuthDoctorData) -> CheckResult {
match data {
ClientAuthDoctorData::NotConfigured => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Pass,
detail: Some(
"no [server.tls.client_auth] configured; the listener does not request client \
certificates"
.into(),
),
hint: None,
},
ClientAuthDoctorData::ModeOff => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Pass,
detail: Some(
"[server.tls.client_auth] mode = \"off\"; no client certificate is requested"
.into(),
),
hint: None,
},
ClientAuthDoctorData::FeatureDisabled => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Warn,
detail: Some(
"[server.tls.client_auth] is configured but this autumn CLI was built without \
the `tls` feature, so the CA bundle could not be inspected"
.into(),
),
hint: Some("Rebuild the autumn CLI with the `tls` feature to enable mTLS diagnostics"),
},
ClientAuthDoctorData::Invalid { detail } => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Fail,
detail: Some(detail.clone()),
hint: Some(
"Fix [server.tls.client_auth] ca_bundle_path / crl_path: the files must exist, \
be valid PEM, and contain at least one CA (or CRL). The server exits at boot \
on this",
),
},
healthy @ ClientAuthDoctorData::Healthy { .. } => grade_healthy_client_auth(healthy),
}
}
fn grade_crl_coverage_gaps(crl_coverage_gaps: &[String]) -> CheckResult {
CheckResult {
name: "tls_client_auth",
status: CheckStatus::Warn,
detail: Some(format!(
"the [server.tls.client_auth] revocation list has no CRL issued by {} — once any \
CRL is configured, the server refuses handshakes whose revocation status is \
unknown, so the clients of these CAs would be rejected, and the server refuses \
to boot on this",
crl_coverage_gaps.join(", ")
)),
hint: Some(
"Publish a CRL for each CA in the bundle, or remove the uncovered CA. Under \
`--strict` this warning fails the run, matching the runtime",
),
}
}
fn grade_healthy_client_auth(data: &ClientAuthDoctorData) -> CheckResult {
match data {
ClientAuthDoctorData::Healthy {
expired_cas,
ca_count,
..
} if !expired_cas.is_empty() => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Fail,
detail: Some(format!(
"{} of {ca_count} CA(s) in the [server.tls.client_auth] bundle have expired: {}",
expired_cas.len(),
expired_cas.join(", ")
)),
hint: Some(
"Rotate the client CA: ship the new CA alongside the old in one bundle, \
re-issue client certificates, then drop the expired CA. An expired CA verifies \
nothing",
),
},
ClientAuthDoctorData::Healthy {
crl_coverage_gaps, ..
} if !crl_coverage_gaps.is_empty() => grade_crl_coverage_gaps(crl_coverage_gaps),
ClientAuthDoctorData::Healthy {
crl_stale: Some(true),
..
} => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Warn,
detail: Some(
"the [server.tls.client_auth] revocation list is stale — its nextUpdate has \
passed, so no revocation published since then is being enforced"
.into(),
),
hint: Some(
"Re-publish the CRL from the issuing CA. Autumn keeps honoring a stale list \
rather than failing every handshake, so this is silent at runtime",
),
},
ClientAuthDoctorData::Healthy {
near_expiry_cas, ..
} if !near_expiry_cas.is_empty() => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Warn,
detail: Some(format!(
"client CA(s) expiring within {CLIENT_CA_EXPIRY_WARN_DAYS} days: {}",
near_expiry_cas
.iter()
.map(|(subject, days)| format!("{subject} ({days} day(s))"))
.collect::<Vec<_>>()
.join(", ")
)),
hint: Some(
"Start the CA rotation now: ship old + new in one bundle, re-issue client \
certificates, then drop the old CA",
),
},
ClientAuthDoctorData::Healthy {
mode,
required_path_count: 0,
..
} if mode == "optional" => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Warn,
detail: Some(
"[server.tls.client_auth] mode = \"optional\" but no route requires a client \
certificate, so client auth is configured and enforcing nothing"
.into(),
),
hint: Some(
"Add the mTLS-only routes to required_paths, set mode = \"required\" to lock \
the whole listener, or remove the section",
),
},
ClientAuthDoctorData::Healthy {
mode,
ca_count,
required_path_count,
..
} => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Pass,
detail: Some(format!(
"[server.tls.client_auth] mode = \"{mode}\" with {ca_count} trusted client \
CA(s) and {required_path_count} required route prefix(es)"
)),
hint: None,
},
_ => CheckResult {
name: "tls_client_auth",
status: CheckStatus::Warn,
detail: Some("[server.tls.client_auth] could not be graded".into()),
hint: None,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortReachability {
Open,
Refused,
TimedOut,
Error,
}
#[must_use]
pub fn check_acme_ports_for_challenge(
domain: &str,
port_80: PortReachability,
port_443: PortReachability,
dns01: bool,
) -> CheckResult {
match port_80 {
PortReachability::Refused | PortReachability::TimedOut | PortReachability::Error => {
return if dns01 {
CheckResult {
name: "acme_ports",
status: CheckStatus::Warn,
detail: Some(format!(
"port 80 on {domain} is not reachable ({port_80:?}); DNS-01 issuance does \
not need it, but visitors who type http:// will not be redirected to \
HTTPS"
)),
hint: Some(
"Optional under DNS-01: open inbound TCP/80 only if you want the \
HTTP→HTTPS redirect",
),
}
} else {
CheckResult {
name: "acme_ports",
status: CheckStatus::Fail,
detail: Some(format!(
"port 80 on {domain} is not reachable ({port_80:?}); the ACME CA validates \
HTTP-01 over port 80"
)),
hint: Some(
"Open inbound TCP/80 to this host (or forward it) so Let's Encrypt can \
reach the HTTP-01 challenge",
),
}
};
}
PortReachability::Open => {}
}
match port_443 {
PortReachability::Open => CheckResult {
name: "acme_ports",
status: CheckStatus::Pass,
detail: Some(format!("ports 80 and 443 on {domain} are reachable")),
hint: None,
},
other => CheckResult {
name: "acme_ports",
status: CheckStatus::Warn,
detail: Some(format!(
"port 80 on {domain} is reachable but port 443 is not yet ({other:?})"
)),
hint: Some("Ensure the HTTPS listener is up and inbound TCP/443 is open"),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DnsPointsHere {
Matches,
PartialMatch {
unmatched: Vec<String>,
},
ResolvesElsewhere {
resolved: Vec<String>,
},
Unresolved,
LocalIpsUnknown,
}
#[must_use]
pub fn check_acme_dns_for_challenge(
domain: &str,
outcome: &DnsPointsHere,
dns01: bool,
) -> CheckResult {
if dns01 {
return match outcome {
DnsPointsHere::Unresolved => CheckResult {
name: "acme_dns",
status: CheckStatus::Warn,
detail: Some(format!(
"{domain} did not resolve to any address; DNS-01 issuance does not need it \
to, but visitors will not reach the app until it does"
)),
hint: Some(
"Publish A/AAAA records for the domain (and a wildcard record for tenant \
subdomains) so traffic reaches this deployment",
),
},
DnsPointsHere::Matches => CheckResult {
name: "acme_dns",
status: CheckStatus::Pass,
detail: Some(format!("{domain} resolves to this host")),
hint: None,
},
_ => CheckResult {
name: "acme_dns",
status: CheckStatus::Pass,
detail: Some(format!(
"{domain} resolves somewhere other than this host, which DNS-01 does not \
care about: the CA reads a TXT record rather than connecting here"
)),
hint: None,
},
};
}
match outcome {
DnsPointsHere::Matches => CheckResult {
name: "acme_dns",
status: CheckStatus::Pass,
detail: Some(format!("{domain} resolves to this host")),
hint: None,
},
DnsPointsHere::PartialMatch { unmatched } => CheckResult {
name: "acme_dns",
status: CheckStatus::Warn,
detail: Some(format!(
"{domain} resolves to this host but also to {} which {} not one of this host's \
addresses; HTTP-01's challenge token is served only by this process, so the ACME \
CA validating against an unmatched address would 404",
unmatched.join(", "),
if unmatched.len() == 1 { "is" } else { "are" }
)),
hint: Some(
"Point every A/AAAA record for the domain at this host (or remove the extra \
addresses); a stray record can send the CA's HTTP-01 probe to a different server",
),
},
DnsPointsHere::ResolvesElsewhere { resolved } => CheckResult {
name: "acme_dns",
status: CheckStatus::Fail,
detail: Some(format!(
"{domain} resolves to {} which is not one of this host's addresses; HTTP-01 \
validation will reach a different server",
resolved.join(", ")
)),
hint: Some(
"Point the domain's A/AAAA records at this host, or run ACME on the host the \
domain resolves to",
),
},
DnsPointsHere::Unresolved => CheckResult {
name: "acme_dns",
status: CheckStatus::Warn,
detail: Some(format!("{domain} did not resolve to any address")),
hint: Some("Publish A/AAAA records for the domain before requesting a certificate"),
},
DnsPointsHere::LocalIpsUnknown => CheckResult {
name: "acme_dns",
status: CheckStatus::Warn,
detail: Some(format!(
"could not determine this host's public IPs, so cannot confirm {domain} points \
here"
)),
hint: None,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DnsCredentialOutcome {
NotConfigured,
Malformed(String),
Usable {
provider: String,
key: String,
},
Unusable(String),
}
#[must_use]
pub fn check_acme_dns_credential_impl(outcome: &DnsCredentialOutcome) -> CheckResult {
match outcome {
DnsCredentialOutcome::NotConfigured => CheckResult {
name: "acme_dns_credential",
status: CheckStatus::Pass,
detail: Some(
"no [server.tls.acme.dns] section: issuance uses HTTP-01, which needs no DNS \
provider credential"
.to_owned(),
),
hint: None,
},
DnsCredentialOutcome::Malformed(error) => CheckResult {
name: "acme_dns_credential",
status: CheckStatus::Fail,
detail: Some(format!("[server.tls.acme.dns] does not load: {error}")),
hint: Some(
"Fix the section: `provider` must be one of cloudflare/route53/exec, and API \
tokens belong in the encrypted credentials store (`autumn credentials edit`) or \
an AUTUMN_ACME_DNS_* environment variable — never in autumn.toml",
),
},
DnsCredentialOutcome::Usable { provider, key } => CheckResult {
name: "acme_dns_credential",
status: CheckStatus::Pass,
detail: Some(format!(
"the {provider} DNS-01 credential `{key}` carries the fields it needs"
)),
hint: None,
},
DnsCredentialOutcome::Unusable(message) => CheckResult {
name: "acme_dns_credential",
status: CheckStatus::Fail,
detail: Some(message.clone()),
hint: Some(
"Without it no _acme-challenge record can be written, so every issuance and \
renewal fails. Run `autumn credentials edit` to add the credential",
),
},
}
}
#[cfg(feature = "tls")]
#[must_use]
pub fn resolve_acme_dns_credential(
config: &AcmeDoctorConfig,
profile: &str,
base_dir: &std::path::Path,
) -> DnsCredentialOutcome {
use autumn_web::acme::dns::{DnsCredential, process_env, validate_credential};
if let Some(error) = &config.dns_error {
return DnsCredentialOutcome::Malformed(error.clone());
}
let Some(dns) = config.dns.as_ref() else {
return DnsCredentialOutcome::NotConfigured;
};
let store = autumn_web::credentials::load_credentials(profile, base_dir).unwrap_or_default();
let credential = DnsCredential::resolve(dns, &store, &process_env);
let key = dns.credential.trim().to_owned();
match validate_credential(dns.provider, &key, &credential) {
Ok(()) => DnsCredentialOutcome::Usable {
provider: dns.provider.as_str().to_owned(),
key,
},
Err(message) => DnsCredentialOutcome::Unusable(message),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChallengeDnsVisibility {
Answered {
values: usize,
},
Stale {
values: usize,
},
Unanswerable(String),
}
#[must_use]
pub fn check_acme_dns_propagation_impl(
fqdn: &str,
visibility: &ChallengeDnsVisibility,
) -> CheckResult {
match visibility {
ChallengeDnsVisibility::Answered { .. } => CheckResult {
name: "acme_dns_propagation",
status: CheckStatus::Pass,
detail: Some(format!(
"public DNS answers for {fqdn}, so a published DNS-01 challenge record will be \
visible to the CA"
)),
hint: None,
},
ChallengeDnsVisibility::Stale { values } => CheckResult {
name: "acme_dns_propagation",
status: CheckStatus::Warn,
detail: Some(format!(
"{values} leftover TXT record(s) are still published at {fqdn}; an earlier \
issuance did not clean up"
)),
hint: Some(
"Harmless for issuance, but worth removing: they are the fingerprint of an order \
that failed or was interrupted before cleanup",
),
},
ChallengeDnsVisibility::Unanswerable(reason) => CheckResult {
name: "acme_dns_propagation",
status: CheckStatus::Fail,
detail: Some(format!(
"public DNS could not answer for {fqdn}: {reason}. The ACME CA reads the DNS-01 \
challenge record from public DNS, so no wildcard certificate can be issued while \
this name is unanswerable"
)),
hint: Some(
"Check that the zone's NS delegation is live and that its nameservers answer for \
the _acme-challenge name (a `dig TXT _acme-challenge.<domain>` from off-network \
should return NOERROR or NXDOMAIN, never SERVFAIL)",
),
},
}
}
#[cfg(feature = "tls")]
#[must_use]
pub fn resolve_challenge_dns_visibility(
fqdn: &str,
resolvers: &[std::net::SocketAddr],
) -> ChallengeDnsVisibility {
use autumn_web::acme::dns::resolver::lookup_txt_blocking;
let mut last_error = "no resolvers were configured".to_owned();
for resolver in resolvers {
match lookup_txt_blocking(*resolver, fqdn, std::time::Duration::from_secs(3)) {
Ok(answer) if answer.values.is_empty() => {
return ChallengeDnsVisibility::Answered { values: 0 };
}
Ok(answer) => {
return ChallengeDnsVisibility::Stale {
values: answer.values.len(),
};
}
Err(e) => last_error = e,
}
}
ChallengeDnsVisibility::Unanswerable(last_error)
}
#[must_use]
pub fn check_acme_tenancy_domain_impl(
base_domain: Option<&str>,
domains: &[String],
covers_subdomains: bool,
) -> CheckResult {
let Some(base) = base_domain.map(str::trim).filter(|b| !b.is_empty()) else {
return CheckResult {
name: "acme_tenancy_domain",
status: CheckStatus::Pass,
detail: Some(
"no [tenancy] base_domain configured, so no tenant subdomains need certificate \
coverage"
.to_owned(),
),
hint: None,
};
};
if covers_subdomains {
return CheckResult {
name: "acme_tenancy_domain",
status: CheckStatus::Pass,
detail: Some(format!(
"the configured certificate covers every subdomain of the [tenancy] base_domain \
{base}"
)),
hint: None,
};
}
CheckResult {
name: "acme_tenancy_domain",
status: CheckStatus::Fail,
detail: Some(format!(
"[tenancy] base_domain is {base}, but [server.tls.acme] domains ({}) do not cover its \
subdomains: every tenant host would serve a certificate name mismatch",
domains.join(", ")
)),
hint: Some(
"Add the wildcard `*.<base_domain>` to [server.tls.acme] domains and configure \
[server.tls.acme.dns] — a wildcard can only be issued over DNS-01",
),
}
}
#[must_use]
pub fn acme_covers_tenant_subdomains(base_domain: &str, domains: &[String]) -> bool {
let base = base_domain.trim().trim_end_matches('.');
if base.is_empty() {
return false;
}
let probe = format!("autumn-doctor-probe-tenant.{base}");
domains
.iter()
.any(|san| autumn_web::config::san_covers_host(san, &probe))
}
#[must_use]
pub fn probe_port(domain: &str, port: u16) -> PortReachability {
use std::net::ToSocketAddrs as _;
let addrs = match (domain, port).to_socket_addrs() {
Ok(addrs) => addrs.collect::<Vec<_>>(),
Err(_) => return PortReachability::Error,
};
let Some(addr) = addrs.into_iter().next() else {
return PortReachability::Error;
};
match std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(2)) {
Ok(_) => PortReachability::Open,
Err(e) => match e.kind() {
std::io::ErrorKind::ConnectionRefused => PortReachability::Refused,
std::io::ErrorKind::TimedOut => PortReachability::TimedOut,
_ => PortReachability::Error,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomDomainProbe {
pub hostname: String,
pub tenant: String,
pub status: String,
pub dns: CustomDomainDns,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomDomainDns {
PointsHere,
PointsElsewhere {
seen: Vec<String>,
},
Unresolved,
IngressUnknown,
}
#[must_use]
pub fn check_custom_domains_config_impl(
custom_domains: Option<&autumn_web::config::CustomDomainsConfig>,
error: Option<&str>,
registry: &CustomDomainRegistryRead,
) -> Option<CheckResult> {
let registered = registry.domains.len();
if let Some(error) = error {
return Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Fail,
detail: Some(format!(
"[server.tls.acme.custom_domains] does not load: {error}"
)),
hint: Some(
"Fix the section (an unknown key is rejected outright) — the server will not \
boot with it as written",
),
});
}
let cd = custom_domains?;
if !cd.enabled {
return Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Pass,
detail: Some(
"[server.tls.acme.custom_domains] enabled = false: no tenant hostname is \
registrable"
.to_owned(),
),
hint: None,
});
}
if let Err(message) = cd.validate() {
return Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Fail,
detail: Some(message),
hint: Some("Fix [server.tls.acme.custom_domains]; the server exits at boot on this"),
});
}
if let Some(unreadable) = registry.unreadable.as_ref() {
return Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Fail,
detail: Some(format!(
"the custom-domain registry cannot be read ({unreadable}); at boot this leaves \
every connected domain unrouted, and no new domain can be connected until it is \
fixed"
)),
hint: Some(
"Check the ownership and mode of [server.tls.acme.custom_domains] store_dir — the \
server needs to read and write it",
),
});
}
if !registry.skipped.is_empty() {
return Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Warn,
detail: Some(format!(
"{} of {} custom-domain records will not load ({}); the runtime serves the \
rest, those tenants' domains stop routing, and no new domain can be connected \
until they are fixed",
registry.skipped.len(),
registry.skipped.len() + registered,
registry.skipped.join(", ")
)),
hint: Some(
"Restore the affected records from a backup, or delete the files, then restart",
),
});
}
if registered > cd.max_domains {
return Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Warn,
detail: Some(format!(
"{registered} domains are registered but max_domains is {}; the ones already \
stored still load and serve, but no new domain can be connected",
cd.max_domains
)),
hint: Some("Raise [server.tls.acme.custom_domains] max_domains"),
});
}
if let Some(warn) = check_custom_domain_instructions(cd, registry) {
return Some(warn);
}
Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Pass,
detail: Some(format!(
"custom domains are enabled; {registered} registered, cap {}",
cd.max_domains
)),
hint: None,
})
}
fn check_custom_domain_instructions(
cd: &autumn_web::config::CustomDomainsConfig,
registry: &CustomDomainRegistryRead,
) -> Option<CheckResult> {
use std::fmt::Write as _;
const SHOWN: usize = 5;
let ingress = cd.ingress();
let unusable: Vec<(&str, String)> = registry
.domains
.iter()
.filter_map(|(hostname, ..)| {
autumn_web::custom_domain::RoutingRecord::for_hostname(hostname, &ingress)
.err()
.map(|e| (hostname.as_str(), e))
})
.collect();
let (_, first) = unusable.first()?;
let mut names = unusable
.iter()
.take(SHOWN)
.map(|(hostname, _)| *hostname)
.collect::<Vec<_>>()
.join(", ");
if unusable.len() > SHOWN {
let _ = write!(names, " and {} more", unusable.len() - SHOWN);
}
let count = match unusable.len() {
1 => "1 registered domain gets".to_owned(),
n => format!("{n} registered domains get"),
};
Some(CheckResult {
name: "custom_domains",
status: CheckStatus::Warn,
detail: Some(format!("{count} no DNS instructions ({names}): {first}")),
hint: Some(
"Set [server.tls.acme.custom_domains] ingress_ipv4 / ingress_ipv6 for apex \
domains, and ingress_hostname for subdomains",
),
})
}
#[must_use]
pub fn check_custom_domain_dns_impl(probe: &CustomDomainProbe) -> CheckResult {
let CustomDomainProbe {
hostname,
tenant,
status,
dns,
} = probe;
let settled = status == "active" || status == "verified";
match dns {
CustomDomainDns::PointsHere => CheckResult {
name: "custom_domain_dns",
status: CheckStatus::Pass,
detail: Some(format!(
"{hostname} (tenant {tenant}) resolves to this deployment's ingress"
)),
hint: None,
},
CustomDomainDns::IngressUnknown => CheckResult {
name: "custom_domain_dns",
status: CheckStatus::Warn,
detail: Some(format!(
"cannot tell where {hostname} (tenant {tenant}) points: the configured ingress \
does not resolve to any address from here"
)),
hint: Some(
"Check [server.tls.acme.custom_domains] ingress_hostname / ingress_ipv4 / \
ingress_ipv6",
),
},
CustomDomainDns::Unresolved if settled => CheckResult {
name: "custom_domain_dns",
status: CheckStatus::Fail,
detail: Some(format!(
"{hostname} (tenant {tenant}) is {status} but no longer resolves at all; it is \
serving a certificate nobody can reach, and its renewal will fail"
)),
hint: Some(
"Ask the tenant to restore the record, or offboard the domain so renewals stop",
),
},
CustomDomainDns::Unresolved => CheckResult {
name: "custom_domain_dns",
status: CheckStatus::Warn,
detail: Some(format!(
"{hostname} (tenant {tenant}) is {status} and does not resolve yet"
)),
hint: Some("The tenant has not published the DNS record yet"),
},
CustomDomainDns::PointsElsewhere { seen } => CheckResult {
name: "custom_domain_dns",
status: if settled {
CheckStatus::Fail
} else {
CheckStatus::Warn
},
detail: Some(format!(
"{hostname} (tenant {tenant}) is {status} but resolves to {}, which {} not this \
deployment's ingress",
seen.join(", "),
if seen.len() == 1 { "is" } else { "are" }
)),
hint: Some(
"Point the record back at this deployment's ingress, or offboard the domain",
),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomDomainTxt {
Present,
Missing,
Stale {
values: usize,
},
Unanswerable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomDomainOwnershipProbe {
pub hostname: String,
pub tenant: String,
pub token: String,
pub txt: CustomDomainTxt,
}
#[must_use]
pub fn grade_custom_domain_txt(
token: &str,
answers: &[Result<Vec<String>, String>],
) -> CustomDomainTxt {
let mut last_error = "no resolvers are configured".to_owned();
let mut answered = false;
let mut values = 0;
for answer in answers {
match answer {
Ok(seen) if seen.iter().any(|v| v.trim() == token) => return CustomDomainTxt::Present,
Ok(seen) => {
answered = true;
values = values.max(seen.len());
}
Err(e) => last_error.clone_from(e),
}
}
match (answered, values) {
(false, _) => CustomDomainTxt::Unanswerable(last_error),
(true, 0) => CustomDomainTxt::Missing,
(true, values) => CustomDomainTxt::Stale { values },
}
}
#[must_use]
pub fn check_custom_domain_txt_impl(probe: &CustomDomainOwnershipProbe) -> CheckResult {
let CustomDomainOwnershipProbe {
hostname,
tenant,
token,
txt,
} = probe;
let record = autumn_web::custom_domain::verification_record_name(hostname);
let publish = "Publish the TXT record from the domain's DNS instructions; the address record \
alone does not verify a domain";
match txt {
CustomDomainTxt::Present => CheckResult {
name: "custom_domain_txt",
status: CheckStatus::Pass,
detail: Some(format!(
"{record} carries the ownership token for {hostname} (tenant {tenant})"
)),
hint: None,
},
CustomDomainTxt::Missing => CheckResult {
name: "custom_domain_txt",
status: CheckStatus::Warn,
detail: Some(format!(
"{hostname} (tenant {tenant}) is pending_dns: its ownership TXT record is not \
published. Expected `{record}` TXT `{token}`"
)),
hint: Some(publish),
},
CustomDomainTxt::Stale { values } => CheckResult {
name: "custom_domain_txt",
status: CheckStatus::Warn,
detail: Some(format!(
"{hostname} (tenant {tenant}) is pending_dns: {record} carries {values} value(s), \
none of them this registration's token `{token}`. A token from an earlier \
registration proves nothing for this one"
)),
hint: Some(publish),
},
CustomDomainTxt::Unanswerable(reason) => CheckResult {
name: "custom_domain_txt",
status: CheckStatus::Warn,
detail: Some(format!(
"cannot read {record} for {hostname} (tenant {tenant}): {reason}"
)),
hint: Some(
"Check [server.tls.acme.custom_domains] resolvers and that outbound DNS (UDP/53) \
is allowed",
),
},
}
}
#[cfg(feature = "tls")]
#[must_use]
pub fn resolve_custom_domain_txt(
hostname: &str,
token: &str,
resolvers: &[std::net::SocketAddr],
) -> CustomDomainTxt {
use autumn_web::acme::dns::resolver::{UdpDnsLookup, txt_values};
let record = autumn_web::custom_domain::verification_record_name(hostname);
let lookup = UdpDnsLookup::new(std::time::Duration::from_secs(3));
let answer = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime.block_on(txt_values(
&record,
resolvers,
&lookup,
std::time::Duration::from_secs(6),
)),
Err(e) => Err(format!("could not start a runtime for the TXT lookup: {e}")),
};
grade_custom_domain_txt(token, &[answer])
}
#[must_use]
pub fn check_custom_domain_http01_impl(
target: &str,
probed: &[(String, PortReachability)],
) -> CheckResult {
let unreachable: Vec<String> = probed
.iter()
.filter(|(_, state)| *state != PortReachability::Open)
.map(|(addr, state)| format!("{addr} ({state:?})"))
.collect();
if probed.is_empty() {
return CheckResult {
name: "custom_domain_http01",
status: CheckStatus::Fail,
detail: Some(format!(
"the custom-domain ingress ({target}) does not resolve to any address from here, \
so tenant HTTP-01 validation cannot be checked"
)),
hint: Some(
"Check [server.tls.acme.custom_domains] ingress_hostname / ingress_ipv4 / \
ingress_ipv6",
),
};
}
if unreachable.is_empty() {
return CheckResult {
name: "custom_domain_http01",
status: CheckStatus::Pass,
detail: Some(format!(
"port 80 is reachable on all {} address(es) of the custom-domain ingress \
({target})",
probed.len()
)),
hint: None,
};
}
CheckResult {
name: "custom_domain_http01",
status: CheckStatus::Fail,
detail: Some(format!(
"port 80 is not reachable on {} of the {} address(es) of the custom-domain ingress \
({target}): {}. Every tenant custom domain is validated over HTTP-01, whatever \
challenge this deployment's own certificate uses",
unreachable.len(),
probed.len(),
unreachable.join(", ")
)),
hint: Some(
"Open inbound TCP/80 on every address behind the ingress tenants are told to point \
at, so the CA can fetch /.well-known/acme-challenge for their hostnames",
),
}
}
#[must_use]
pub fn probe_port_every_address(target: &str, port: u16) -> Vec<(String, PortReachability)> {
let addrs = resolve_addresses(target);
addrs
.into_iter()
.map(|addr| (addr.to_string(), probe_addr(addr, port)))
.collect()
}
fn probe_addr(addr: std::net::IpAddr, port: u16) -> PortReachability {
let socket = std::net::SocketAddr::new(addr, port);
match std::net::TcpStream::connect_timeout(&socket, std::time::Duration::from_secs(2)) {
Ok(_) => PortReachability::Open,
Err(e) => match e.kind() {
std::io::ErrorKind::ConnectionRefused => PortReachability::Refused,
std::io::ErrorKind::TimedOut => PortReachability::TimedOut,
_ => PortReachability::Error,
},
}
}
#[must_use]
pub fn resolve_custom_domain_dns(
hostname: &str,
ingress: &autumn_web::custom_domain::ExpectedIngress,
) -> CustomDomainDns {
use autumn_web::custom_domain::{ObservedTarget, VerificationOutcome, grade_dns_verification};
let mut expected = ingress.clone();
if let Some(host) = expected.hostname.clone() {
for addr in resolve_addresses(&host) {
match addr {
std::net::IpAddr::V4(v4) if !expected.ipv4.contains(&v4) => expected.ipv4.push(v4),
std::net::IpAddr::V6(v6) if !expected.ipv6.contains(&v6) => expected.ipv6.push(v6),
_ => {}
}
}
}
if expected.ipv4.is_empty() && expected.ipv6.is_empty() {
return CustomDomainDns::IngressUnknown;
}
let observed = resolve_addresses(hostname);
if observed.is_empty() {
return CustomDomainDns::Unresolved;
}
let verdict = grade_dns_verification(&ObservedTarget::Addresses(observed.clone()), &expected);
match verdict {
VerificationOutcome::PointsHere | VerificationOutcome::OwnershipUnproven { .. } => {
CustomDomainDns::PointsHere
}
VerificationOutcome::Unresolved => CustomDomainDns::Unresolved,
VerificationOutcome::PointsElsewhere { .. } => CustomDomainDns::PointsElsewhere {
seen: observed
.into_iter()
.filter(|addr| !ingress_contains(&expected, *addr))
.map(|addr| addr.to_string())
.collect(),
},
}
}
fn ingress_contains(
expected: &autumn_web::custom_domain::ExpectedIngress,
addr: std::net::IpAddr,
) -> bool {
match addr {
std::net::IpAddr::V4(v4) => expected.ipv4.contains(&v4),
std::net::IpAddr::V6(v6) => expected.ipv6.contains(&v6),
}
}
fn resolve_addresses(host: &str) -> Vec<std::net::IpAddr> {
use std::net::ToSocketAddrs as _;
(host, 0_u16)
.to_socket_addrs()
.map(|addrs| addrs.map(|s| s.ip()).collect())
.unwrap_or_default()
}
#[must_use]
pub fn read_custom_domain_registry(store_dir: &std::path::Path) -> CustomDomainRegistryRead {
let entries = match std::fs::read_dir(store_dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CustomDomainRegistryRead::default();
}
Err(e) => {
return CustomDomainRegistryRead {
unreadable: Some(format!("{}: {e}", store_dir.display())),
..CustomDomainRegistryRead::default()
};
}
};
let mut read = CustomDomainRegistryRead::default();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
match std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|bytes| {
serde_json::from_slice::<autumn_web::custom_domain::CustomDomain>(&bytes)
.map_err(|e| e.to_string())
}) {
Ok(domain) => read.domains.push((
domain.hostname,
domain.tenant,
domain.status.as_str().to_owned(),
domain.verification_token,
)),
Err(e) => read.skipped.push(format!("{}: {e}", path.display())),
}
}
read.domains.sort();
read
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CustomDomainRegistryRead {
pub domains: Vec<(String, String, String, Option<String>)>,
pub unreadable: Option<String>,
pub skipped: Vec<String>,
}
pub const MAX_CUSTOM_DOMAIN_PROBES: usize = 25;
#[must_use]
pub fn resolve_dns_points_here(domain: &str) -> DnsPointsHere {
use std::net::ToSocketAddrs as _;
let resolved: Vec<std::net::IpAddr> = match (domain, 0_u16).to_socket_addrs() {
Ok(addrs) => addrs.map(|s| s.ip()).collect(),
Err(_) => return DnsPointsHere::Unresolved,
};
grade_dns_points_here(&resolved, &local_ips())
}
#[must_use]
fn grade_dns_points_here(
resolved: &[std::net::IpAddr],
local_ips: &[std::net::IpAddr],
) -> DnsPointsHere {
if resolved.is_empty() {
return DnsPointsHere::Unresolved;
}
let local_public: Vec<std::net::IpAddr> = local_ips
.iter()
.copied()
.filter(|ip| is_public_ip(*ip))
.collect();
if local_public.is_empty() {
return DnsPointsHere::LocalIpsUnknown;
}
let unmatched: Vec<std::net::IpAddr> = resolved
.iter()
.copied()
.filter(|ip| !local_public.contains(ip))
.collect();
if unmatched.is_empty() {
DnsPointsHere::Matches
} else if unmatched.len() == resolved.len() {
DnsPointsHere::ResolvesElsewhere {
resolved: resolved.iter().map(ToString::to_string).collect(),
}
} else {
DnsPointsHere::PartialMatch {
unmatched: unmatched.iter().map(ToString::to_string).collect(),
}
}
}
const fn is_public_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| is_cgnat_v4(v4))
}
std::net::IpAddr::V6(v6) => {
!(v6.is_loopback()
|| v6.is_unspecified()
|| is_link_local_v6(v6)
|| is_unique_local_v6(v6))
}
}
}
const fn is_cgnat_v4(v4: std::net::Ipv4Addr) -> bool {
let o = v4.octets();
o[0] == 100 && (o[1] & 0xc0) == 64
}
const fn is_link_local_v6(v6: std::net::Ipv6Addr) -> bool {
(v6.segments()[0] & 0xffc0) == 0xfe80
}
const fn is_unique_local_v6(v6: std::net::Ipv6Addr) -> bool {
(v6.segments()[0] & 0xfe00) == 0xfc00
}
fn local_ips() -> Vec<std::net::IpAddr> {
let mut out = Vec::new();
for target in ["8.8.8.8:80", "[2001:4860:4860::8888]:80"] {
if let Ok(sock) = std::net::UdpSocket::bind(if target.starts_with('[') {
"[::]:0"
} else {
"0.0.0.0:0"
}) && sock.connect(target).is_ok()
&& let Ok(addr) = sock.local_addr()
{
let ip = addr.ip();
if !out.contains(&ip) {
out.push(ip);
}
}
}
out
}
fn alert_email_from_missing_warning() -> CheckResult {
CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(
"an operator alert email is configured but `[mail] from` is not set, so SMTP \
delivery will fail; the runtime's alert mail carries no per-message from and \
the mailer has no default from address to fall back to"
.into(),
),
hint: Some(
"Set [mail] from (or AUTUMN_MAIL__FROM) to a sender address like \
alerts@example.com, or configure a signed webhook ([alerts] webhook_url + \
webhook_secret). See docs/guide/operator-alerts.md",
),
}
}
fn alert_email_from_invalid_warning(from: &str) -> CheckResult {
CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(format!(
"`[mail] from` (\"{from}\") is not a valid email address, so SMTP alert \
delivery will fail; the runtime parses it at boot and refuses to start on \
an unparsable sender, and the alert mail has no other `from` to fall back to"
)),
hint: Some(
"Set [mail] from (or AUTUMN_MAIL__FROM) to a valid sender address like \
alerts@example.com, or configure a signed webhook ([alerts] webhook_url + \
webhook_secret). See docs/guide/operator-alerts.md",
),
}
}
fn alert_email_invalid_warning(email: &str) -> CheckResult {
CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(format!(
"`[alerts] email` (\"{email}\") is not a valid email address, so alert delivery \
will fail; the runtime parses the recipient only when sending and rejects it \
with an invalid-address error, delivering nothing"
)),
hint: Some(
"Set [alerts] email (or AUTUMN_ALERTS__EMAIL) to a valid address like \
alerts@example.com, or configure a signed webhook ([alerts] webhook_url + \
webhook_secret). See docs/guide/operator-alerts.md",
),
}
}
fn alert_webhook_url_invalid_warning(url: &str) -> CheckResult {
CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(format!(
"`[alerts] webhook_url` (\"{url}\") is not an absolute http(s) URL, so alert \
delivery will fail; the runtime's HTTP client only sends a URL starting with \
http:// or https://, and a relative value has no base URL to resolve against, so \
every signed webhook POST fails to send"
)),
hint: Some(
"Set [alerts] webhook_url (or AUTUMN_ALERTS__WEBHOOK_URL) to an absolute URL like \
https://alerts.example.com/hooks/autumn, or configure an email destination \
([alerts] email). See docs/guide/operator-alerts.md",
),
}
}
fn alert_no_destination_in_production(custom_channel: bool) -> CheckResult {
if custom_channel {
return CheckResult {
name: "alert_destination",
status: CheckStatus::Pass,
detail: Some(
"no [alerts] email/webhook destination configured, but [alerts] \
custom_channel = true declares a code-registered alert channel \
(AppBuilder::with_alert_channel), which the runtime installs regardless"
.into(),
),
hint: None,
};
}
CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(
"no operator alert destination found in [alerts] config (email or webhook) in \
production; if your app registers an alert channel in code via \
AppBuilder::with_alert_channel, set [alerts] custom_channel = true (or \
AUTUMN_ALERTS__CUSTOM_CHANNEL=true) to declare that and silence this warning \
(doctor is a config-only checker and cannot see code-registered channels); \
otherwise failure conditions (dead-lettered jobs, Down health indicators, 5xx \
spikes, scheduled-task failures) will not be delivered anywhere"
.into(),
),
hint: Some(
"Set [alerts] email and/or webhook_url (or AUTUMN_ALERTS__EMAIL / \
AUTUMN_ALERTS__WEBHOOK_URL); or if you register a channel in code, set [alerts] \
custom_channel = true. See docs/guide/operator-alerts.md",
),
}
}
fn alert_disabled_in_production_warning(has_destination: bool) -> CheckResult {
let detail = if has_destination {
"alerting is disabled ([alerts] enabled = false) in production, so no operator \
alerts will be delivered — even though a destination is configured"
} else {
"alerting is disabled ([alerts] enabled = false) in production, so no operator \
alerts will be delivered"
};
CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(detail.into()),
hint: Some(
"Set [alerts] enabled = true (or AUTUMN_ALERTS__ENABLED=true) to deliver \
operator alerts, or remove the setting (it defaults to enabled). See \
docs/guide/operator-alerts.md",
),
}
}
#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)]
pub fn check_alert_destination_impl(
alerts_enabled: bool,
email: &str,
webhook_configured: bool,
webhook_url: &str,
mail_transport_usable: bool,
is_production: bool,
mail_from: &str,
transport_requires_from: bool,
custom_channel: bool,
native_transport_configured: bool,
) -> CheckResult {
let email_trimmed = email.trim();
let email_configured = !email_trimmed.is_empty();
let email_valid = email_configured && is_valid_alert_mailbox_doctor(email_trimmed);
let mail_from_trimmed = mail_from.trim();
let mail_from_present = !mail_from_trimmed.is_empty();
let mail_from_valid = mail_from_present && is_valid_alert_mailbox_doctor(mail_from_trimmed);
let email_from_ok = mail_from_valid || !transport_requires_from;
let email_destination = email_valid && mail_transport_usable && email_from_ok;
if !alerts_enabled {
if is_production {
return alert_disabled_in_production_warning(
email_destination || webhook_configured || native_transport_configured,
);
}
return CheckResult {
name: "alert_destination",
status: CheckStatus::Pass,
detail: Some(
"operator alerting is disabled ([alerts] enabled = false) (optional outside \
production)"
.into(),
),
hint: None,
};
}
if email_destination || webhook_configured || native_transport_configured {
return CheckResult {
name: "alert_destination",
status: CheckStatus::Pass,
detail: Some("operator alert destination configured".into()),
hint: None,
};
}
if is_production {
if email_configured && !email_valid {
return alert_email_invalid_warning(email_trimmed);
}
if email_configured
&& mail_transport_usable
&& transport_requires_from
&& !mail_from_present
{
return alert_email_from_missing_warning();
}
if email_configured
&& mail_transport_usable
&& transport_requires_from
&& mail_from_present
&& !mail_from_valid
{
return alert_email_from_invalid_warning(mail_from_trimmed);
}
if email_configured && !mail_transport_usable {
return CheckResult {
name: "alert_destination",
status: CheckStatus::Warn,
detail: Some(
"an operator alert email is configured but [mail] transport is disabled, so \
no email alerts will be delivered; the runtime installs no email alert \
channel when the mailer is disabled"
.into(),
),
hint: Some(
"Set [mail] transport to a real backend (smtp/log/file) or configure a signed \
webhook ([alerts] webhook_url + webhook_secret). See \
docs/guide/operator-alerts.md",
),
};
}
let webhook_url_trimmed = webhook_url.trim();
if !webhook_url_trimmed.is_empty() && !is_absolute_http_url_doctor(webhook_url_trimmed) {
return alert_webhook_url_invalid_warning(webhook_url_trimmed);
}
alert_no_destination_in_production(custom_channel)
} else {
CheckResult {
name: "alert_destination",
status: CheckStatus::Pass,
detail: Some(
"no operator alert destination configured (optional outside production)".into(),
),
hint: None,
}
}
}
fn is_absolute_https_url_doctor(url: &str) -> bool {
url.starts_with("https://")
&& ::url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(|h| !h.is_empty()))
.unwrap_or(false)
}
#[must_use]
pub fn check_alert_transports_impl(
pagerduty_routing_key: &str,
pagerduty_url: &str,
slack_webhook_url: &str,
discord_webhook_url: &str,
is_production: bool,
) -> CheckResult {
const NAME: &str = "alert_transports";
let pd_key = pagerduty_routing_key.trim();
let pd_url = pagerduty_url.trim();
let slack = slack_webhook_url.trim();
let discord = discord_webhook_url.trim();
let pass = |detail: &str| CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(detail.to_owned()),
hint: None,
};
let warn = |detail: String, hint: &'static str| CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(detail),
hint: Some(hint),
};
let configured =
!pd_key.is_empty() || !pd_url.is_empty() || !slack.is_empty() || !discord.is_empty();
if !is_production {
return pass(if configured {
"native alert transports configured (validated in production mode)"
} else {
"no native alert transports configured (optional outside production)"
});
}
if !pd_key.is_empty() && pd_key.contains(char::is_whitespace) {
return warn(
format!(
"`[alerts] pagerduty_routing_key` (\"{pd_key}\") contains whitespace, so it is a \
malformed Events API v2 routing key and PagerDuty will reject the event"
),
"Set [alerts] pagerduty_routing_key (or AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY) to your \
integration's routing key with no surrounding or embedded whitespace. See \
docs/guide/operator-alerts.md",
);
}
if pd_key.is_empty() && !pd_url.is_empty() {
return warn(
"`[alerts] pagerduty_url` is set but no `pagerduty_routing_key` is configured, so no \
PagerDuty events will be delivered"
.to_owned(),
"Set [alerts] pagerduty_routing_key (or AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY), or \
remove pagerduty_url. See docs/guide/operator-alerts.md",
);
}
if !pd_key.is_empty() && !pd_url.is_empty() && !is_absolute_http_url_doctor(pd_url) {
return warn(
format!(
"`[alerts] pagerduty_url` (\"{pd_url}\") is not an absolute http(s) URL, so the \
SSRF-hardened HTTP client cannot dispatch it and no PagerDuty events will be \
delivered"
),
"Set [alerts] pagerduty_url (or AUTUMN_ALERTS__PAGERDUTY_URL) to an absolute URL like \
https://events.pagerduty.com/v2/enqueue, or remove it to use the default. See \
docs/guide/operator-alerts.md",
);
}
if !slack.is_empty() && !is_absolute_https_url_doctor(slack) {
return warn(
format!(
"`[alerts] slack_webhook_url` (\"{slack}\") is not an absolute https URL, so no \
Slack alerts will be delivered; Slack only exposes https incoming-webhook \
endpoints and the HTTP client cannot dispatch a relative value"
),
"Set [alerts] slack_webhook_url (or AUTUMN_ALERTS__SLACK_WEBHOOK_URL) to your Slack \
incoming-webhook URL (https://hooks.slack.com/services/…). See \
docs/guide/operator-alerts.md",
);
}
if !discord.is_empty() && !is_absolute_https_url_doctor(discord) {
return warn(
format!(
"`[alerts] discord_webhook_url` (\"{discord}\") is not an absolute https URL, so no \
Discord alerts will be delivered; use the Slack-compatible endpoint \
(https://discord.com/api/webhooks/…/slack)"
),
"Set [alerts] discord_webhook_url (or AUTUMN_ALERTS__DISCORD_WEBHOOK_URL) to your \
Discord webhook's Slack-compatible URL (append /slack). See \
docs/guide/operator-alerts.md",
);
}
pass(if configured {
"configured native alert transports (PagerDuty/Slack/Discord) look deliverable"
} else {
"no native alert transports configured"
})
}
pub fn check_alert_error_rate_threshold_impl(raw: &str, is_production: bool) -> CheckResult {
const NAME: &str = "alert_error_rate_threshold";
let trimmed = raw.trim();
if trimmed.is_empty() {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some("no [alerts] error_rate_threshold set (defaults to 0.05)".into()),
hint: None,
};
}
let valid = trimmed
.parse::<f64>()
.is_ok_and(|v| v.is_finite() && v > 0.0 && v <= 1.0);
if valid {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(format!(
"[alerts] error_rate_threshold ({trimmed}) is a valid 5xx rate in (0, 1]"
)),
hint: None,
};
}
if is_production {
CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(format!(
"[alerts] error_rate_threshold (\"{trimmed}\") is not a valid rate in (0, 1]; \
the 5xx alert will not work as intended (the runtime falls back to the default \
0.05)"
)),
hint: Some(
"Set [alerts] error_rate_threshold (or AUTUMN_ALERTS__ERROR_RATE_THRESHOLD) to a \
fraction in (0, 1], e.g. 0.05 for 5%. See docs/guide/operator-alerts.md",
),
}
} else {
CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(format!(
"[alerts] error_rate_threshold (\"{trimmed}\") is not a valid rate in (0, 1] \
(optional outside production; the runtime falls back to the default 0.05)"
)),
hint: None,
}
}
}
pub fn check_oauth2_provider_impl(
provider_name: &str,
client_id: &str,
client_secret: &str,
is_production: bool,
) -> CheckResult {
let detail_prefix = format!("[auth.oauth2.{provider_name}]");
if client_id.trim().is_empty() {
return if is_production {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Fail,
detail: Some(format!(
"{detail_prefix}: client_id is empty — OAuth2 login will fail in production"
)),
hint: Some(
"Set client_id via AUTUMN_AUTH__OAUTH2__<PROVIDER>__CLIENT_ID \
or in autumn.toml",
),
}
} else {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Warn,
detail: Some(format!(
"{detail_prefix}: client_id is empty (OK for dev if using env vars)"
)),
hint: Some("Set client_id before deploying to production"),
}
};
}
if client_secret.trim().is_empty() {
return if is_production {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Fail,
detail: Some(format!(
"{detail_prefix}: client_secret is empty — OAuth2 login will fail in production"
)),
hint: Some(
"Set client_secret via AUTUMN_AUTH__OAUTH2__<PROVIDER>__CLIENT_SECRET \
or autumn credentials edit",
),
}
} else {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Warn,
detail: Some(format!(
"{detail_prefix}: client_secret is empty (OK for dev if using env vars)"
)),
hint: Some("Set client_secret before deploying to production"),
}
};
}
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Pass,
detail: Some(format!("{detail_prefix}: provider is correctly configured")),
hint: None,
}
}
pub fn check_mail_unsubscribe_config_impl(
has_list_unsubscribe_usage: bool,
base_url: Option<&str>,
mailto: Option<&str>,
transport_disabled: bool,
is_production: bool,
token_ttl_days: i64,
) -> CheckResult {
let base_url = base_url.map(str::trim).filter(|s| !s.is_empty());
let mailto = mailto.map(str::trim).filter(|s| !s.is_empty());
let any_set = base_url.is_some() || mailto.is_some();
if token_ttl_days <= 0 {
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Fail,
detail: Some(format!(
"mail.unsubscribe_token_ttl_days must be a positive number of days (got {token_ttl_days})"
)),
hint: Some(
"Set mail.unsubscribe_token_ttl_days to a positive value (default 30); a non-positive value would make every unsubscribe token immediately expired",
),
};
}
if is_production {
if let Some(url) = base_url
&& !is_valid_https_base_url_doctor(url)
{
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Fail,
detail: Some(format!(
"mail.unsubscribe_base_url is set to an invalid value ({url:?}): \
the runtime requires an absolute https:// URL with a real host"
)),
hint: Some(
"Use an absolute https:// URL with a host and no query/fragment, e.g. https://app.example.com",
),
};
}
if let Some(addr) = mailto
&& !is_valid_mailto_address_doctor(addr)
{
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Fail,
detail: Some(format!(
"mail.unsubscribe_mailto is set to an invalid value ({addr:?}): \
the runtime requires a bare mailbox address or mailto: URI"
)),
hint: Some(
"Use a mailbox like unsubscribe@example.com (or mailto:unsubscribe@example.com)",
),
};
}
}
if !has_list_unsubscribe_usage {
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Pass,
detail: Some("no #[mailer] declares list_unsubscribe".into()),
hint: None,
};
}
if transport_disabled {
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Pass,
detail: Some("mail.transport is disabled; no list mail is emitted".into()),
hint: None,
};
}
if any_set {
return mail_unsubscribe_configured_result(base_url.is_some(), is_production);
}
let detail = Some(
"a #[mailer] declares list_unsubscribe but neither mail.unsubscribe_base_url \
nor mail.unsubscribe_mailto is configured"
.into(),
);
let hint = Some(
"Set mail.unsubscribe_base_url (e.g. https://app.example.com) or mail.unsubscribe_mailto so RFC 8058 List-Unsubscribe headers can be emitted",
);
if is_production {
CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Fail,
detail,
hint,
}
} else {
CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Warn,
detail,
hint,
}
}
}
fn mail_unsubscribe_configured_result(base_url_set: bool, is_production: bool) -> CheckResult {
if is_production && base_url_set {
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Warn,
detail: Some(
"mail.unsubscribe_base_url is set, so the one-click endpoint must persist \
opt-outs; the runtime fails closed in production unless a suppression backend \
(a database pool, or a SuppressionStore registered via \
AppBuilder::with_suppression_store) is available — which doctor cannot verify \
statically"
.into(),
),
hint: Some(
"Configure a database pool (db feature) or register a SuppressionStore before deploying so one-click unsubscribes are recorded",
),
};
}
CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Pass,
detail: Some("list_unsubscribe is wired (unsubscribe URL/mailto configured)".into()),
hint: None,
}
}
fn is_valid_https_base_url_doctor(url: &str) -> bool {
if url
.chars()
.any(|c| c.is_control() || c.is_whitespace() || matches!(c, '<' | '>'))
{
return false;
}
match url.strip_prefix("https://") {
Some(rest) if !rest.is_empty() && !rest.starts_with('/') => {}
_ => return false,
}
let Ok(parsed) = ::url::Url::parse(url) else {
return false;
};
parsed.scheme() == "https"
&& parsed.host_str().is_some_and(|h| !h.is_empty())
&& parsed.username().is_empty()
&& parsed.password().is_none()
&& parsed.query().is_none()
&& parsed.fragment().is_none()
}
fn is_absolute_http_url_doctor(url: &str) -> bool {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return false;
}
::url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(|h| !h.is_empty()))
.unwrap_or(false)
}
fn is_valid_mailto_address_doctor(value: &str) -> bool {
if value
.chars()
.any(|c| c.is_control() || matches!(c, '<' | '>' | ','))
{
return false;
}
let address = value
.trim()
.strip_prefix("mailto:")
.unwrap_or_else(|| value.trim());
let address = address.split('?').next().unwrap_or("");
match address.split_once('@') {
Some((local, domain)) => {
!local.is_empty()
&& !domain.is_empty()
&& domain.contains('.')
&& !address.contains(char::is_whitespace)
&& !address.contains([':', '/'])
}
None => false,
}
}
#[cfg(feature = "postgres")]
fn is_valid_alert_mailbox_doctor(value: &str) -> bool {
value
.parse::<autumn_web::reexports::lettre::message::Mailbox>()
.is_ok()
}
#[cfg(not(feature = "postgres"))]
fn is_valid_alert_mailbox_doctor(value: &str) -> bool {
let value = value.trim();
let addr = match (value.rfind('<'), value.rfind('>')) {
(Some(lt), Some(gt)) if gt > lt => value[lt + 1..gt].trim(),
_ => value,
};
if addr.is_empty() || addr.contains(':') || addr.contains(char::is_whitespace) {
return false;
}
let mut parts = addr.split('@');
match (parts.next(), parts.next(), parts.next()) {
(Some(local), Some(domain), None) => {
!local.is_empty() && !domain.is_empty() && domain.contains('.')
}
_ => false,
}
}
#[must_use]
pub fn check_platform_support_impl(os: &str) -> CheckResult {
use crate::platform::{SupportTier, WINDOWS_PREREQUISITES, commands_in_tier};
if os != "windows" {
return CheckResult {
name: "platform_support",
status: CheckStatus::Pass,
detail: Some(format!(
"{os}: every autumn journey runs natively on this platform"
)),
hint: None,
};
}
let tier_one = commands_in_tier(SupportTier::Native).join(", ");
let tier_two = commands_in_tier(SupportTier::Wsl2).join(", ");
let prerequisites = WINDOWS_PREREQUISITES
.iter()
.map(|p| format!("{} needs {}", p.subject, p.requirement))
.collect::<Vec<_>>()
.join("; ");
CheckResult {
name: "platform_support",
status: CheckStatus::Pass,
detail: Some(format!(
"windows: Tier 1 (native) — {tier_one}. Tier 2 (WSL2), run these from a \
WSL2 shell — {tier_two}. Prerequisites: {prerequisites}. Policy: \
docs/guide/platform-support.md"
)),
hint: None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DaemonServiceReport {
pub service_capable: bool,
pub daemon: Option<(u32, String)>,
pub service: Option<(String, String)>,
pub missing_prerequisites: Vec<String>,
}
#[must_use]
pub fn check_daemon_service_impl(report: &DaemonServiceReport) -> CheckResult {
let mut parts = Vec::new();
match &report.daemon {
Some((pid, endpoint)) => parts.push(format!("daemon running (pid {pid}) on {endpoint}")),
None => parts.push("no daemon running for this project".to_owned()),
}
if report.service_capable {
match &report.service {
Some((name, state)) => parts.push(format!("service `{name}` is {state}")),
None => parts.push(
"no OS service registered (`autumn serve install-service` registers one)"
.to_owned(),
),
}
}
if !report.missing_prerequisites.is_empty() {
parts.push(format!(
"for the service journey you would also need: {}",
report.missing_prerequisites.join("; ")
));
}
CheckResult {
name: "daemon_service",
status: CheckStatus::Pass,
detail: Some(parts.join(". ")),
hint: None,
}
}
fn resolve_daemon_service_report() -> DaemonServiceReport {
let identity = crate::serve::project_identity_for(None);
let daemon = crate::serve::running_daemon_summary(None);
#[cfg(windows)]
{
DaemonServiceReport {
service_capable: true,
daemon,
service: crate::service::registered_service_state(&identity)
.ok()
.flatten(),
missing_prerequisites: crate::service::missing_prerequisites(),
}
}
#[cfg(not(windows))]
{
let _ = identity;
DaemonServiceReport {
service_capable: false,
daemon,
service: None,
missing_prerequisites: Vec::new(),
}
}
}
#[cfg(test)]
mod daemon_service_tests {
use super::{CheckStatus, DaemonServiceReport, check_daemon_service_impl};
#[test]
fn a_running_daemon_is_reported_with_its_pid_and_endpoint() {
let detail = check_daemon_service_impl(&DaemonServiceReport {
service_capable: true,
daemon: Some((4242, "tcp:127.0.0.1:3000".to_owned())),
service: None,
missing_prerequisites: Vec::new(),
})
.detail
.expect("detail");
assert!(detail.contains("4242"), "{detail}");
assert!(detail.contains("tcp:127.0.0.1:3000"), "{detail}");
}
#[test]
fn a_registered_service_is_reported_with_its_name_and_state() {
let detail = check_daemon_service_impl(&DaemonServiceReport {
service_capable: true,
daemon: None,
service: Some(("autumn-demo-a1b2c3d4".to_owned(), "Running".to_owned())),
missing_prerequisites: Vec::new(),
})
.detail
.expect("detail");
assert!(detail.contains("autumn-demo-a1b2c3d4"), "{detail}");
assert!(detail.contains("Running"), "{detail}");
}
#[test]
fn an_unregistered_service_names_the_command_that_registers_one() {
let detail = check_daemon_service_impl(&DaemonServiceReport {
service_capable: true,
..DaemonServiceReport::default()
})
.detail
.expect("detail");
assert!(detail.contains("install-service"), "{detail}");
}
#[test]
fn a_platform_without_os_services_says_nothing_about_them() {
let detail = check_daemon_service_impl(&DaemonServiceReport {
service_capable: false,
..DaemonServiceReport::default()
})
.detail
.expect("detail");
assert!(!detail.contains("service"), "{detail}");
}
#[test]
fn a_missing_prerequisite_is_reported_without_failing_strict() {
let result = check_daemon_service_impl(&DaemonServiceReport {
service_capable: true,
missing_prerequisites: vec!["administrator rights".to_owned()],
..DaemonServiceReport::default()
});
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.detail.as_deref().unwrap().contains("administrator"));
}
#[test]
fn the_check_never_warns_so_strict_cannot_fail_on_it() {
for service_capable in [true, false] {
for prerequisites in [vec![], vec!["administrator rights".to_owned()]] {
for daemon in [None, Some((42, "tcp:127.0.0.1:3000".to_owned()))] {
let result = check_daemon_service_impl(&DaemonServiceReport {
service_capable,
daemon,
service: None,
missing_prerequisites: prerequisites.clone(),
});
assert_eq!(
result.status,
CheckStatus::Pass,
"capable={service_capable} prereqs={prerequisites:?}"
);
}
}
}
}
#[test]
fn a_stopped_daemon_is_not_a_failure() {
let result = check_daemon_service_impl(&DaemonServiceReport {
service_capable: true,
..DaemonServiceReport::default()
});
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn the_check_keeps_one_stable_name() {
assert_eq!(
check_daemon_service_impl(&DaemonServiceReport::default()).name,
"daemon_service"
);
}
}
#[cfg(test)]
mod platform_support_tests {
use super::{CheckStatus, check_platform_support_impl};
use crate::platform::{SupportTier, WINDOWS_PREREQUISITES};
#[test]
fn windows_reports_tier_status_and_flags_prerequisites() {
let detail = check_platform_support_impl("windows")
.detail
.expect("a detail line");
assert!(detail.contains("Tier 1"), "{detail}");
assert!(detail.contains("Tier 2"), "{detail}");
assert!(!detail.contains("vcpkg"), "{detail}");
assert!(detail.contains("WSL2"), "{detail}");
for prerequisite in WINDOWS_PREREQUISITES {
assert!(
detail.contains(prerequisite.subject),
"prerequisite `{}` is not reported: {detail}",
prerequisite.subject
);
}
}
#[test]
fn windows_names_every_tier_two_command_so_nothing_is_a_surprise() {
let detail = check_platform_support_impl("windows")
.detail
.expect("a detail line");
for command in crate::platform::commands_in_tier(SupportTier::Wsl2) {
assert!(
detail.contains(command),
"Tier 2 command `{command}` missing from doctor output: {detail}"
);
}
}
#[test]
fn unix_platforms_pass_with_every_journey_native() {
for os in ["linux", "macos"] {
let result = check_platform_support_impl(os);
assert_eq!(result.status, CheckStatus::Pass, "{os} should pass");
let detail = result.detail.unwrap_or_default();
assert!(
detail.contains("natively"),
"{os} detail should say every journey is native: {detail}"
);
assert!(
!detail.contains("WSL2"),
"{os} must not mention WSL2: {detail}"
);
}
}
#[test]
fn the_check_is_named_so_json_consumers_can_key_on_it() {
assert_eq!(
check_platform_support_impl("windows").name,
"platform_support"
);
assert_eq!(
check_platform_support_impl("linux").name,
"platform_support"
);
}
#[test]
fn windows_does_not_fail_doctor_strict() {
let result = check_platform_support_impl("windows");
assert_eq!(result.status, CheckStatus::Pass);
let summary = super::compute_summary(std::slice::from_ref(&result));
assert_eq!(
super::exit_code(&summary, true),
0,
"platform_support must not fail `doctor --strict` on Windows"
);
}
#[test]
fn the_windows_detail_carries_the_policy_pointer_itself() {
let detail = check_platform_support_impl("windows")
.detail
.expect("a detail line");
assert!(detail.contains("platform-support.md"), "{detail}");
}
}
pub const fn glyph(status: &CheckStatus) -> &'static str {
match status {
CheckStatus::Pass => "✅",
CheckStatus::Warn => "⚠️ ",
CheckStatus::Fail => "❌",
}
}
pub fn compute_summary(results: &[CheckResult]) -> Summary {
let mut passed = 0;
let mut warned = 0;
let mut failed = 0;
for r in results {
match r.status {
CheckStatus::Pass => passed += 1,
CheckStatus::Warn => warned += 1,
CheckStatus::Fail => failed += 1,
}
}
Summary {
passed,
warned,
failed,
}
}
pub const fn exit_code(summary: &Summary, strict: bool) -> i32 {
if summary.failed > 0 || (strict && summary.warned > 0) {
1
} else {
0
}
}
pub fn format_check_line(result: &CheckResult) -> String {
let g = glyph(&result.status);
let mut line = format!("{g} {}", result.name);
if let Some(ref detail) = result.detail {
let _ = write!(line, " — {detail}");
}
if let Some(hint) = result.hint
&& result.status != CheckStatus::Pass
{
let _ = write!(line, "\n hint: {hint}");
}
line
}
pub fn format_summary_line(summary: &Summary, code: i32) -> String {
let verdict = if code == 0 {
"all clear"
} else {
"problems found"
};
let w_label = if summary.warned == 1 {
"warning"
} else {
"warnings"
};
format!(
"{} passed, {} {}, {} failed — {verdict}",
summary.passed, summary.warned, w_label, summary.failed
)
}
pub fn to_json_output(results: &[CheckResult], summary: &Summary) -> String {
#[derive(Serialize)]
struct Output<'a> {
checks: &'a [CheckResult],
summary: &'a Summary,
}
serde_json::to_string_pretty(&Output {
checks: results,
summary,
})
.unwrap_or_else(|_| "{}".to_string())
}
pub fn validate_toml_content(
content: &str,
schema: &HashMap<String, HashSet<String>>,
) -> Vec<(String, Option<String>)> {
autumn_web::config::AutumnConfig::validate_toml(content, schema)
}
fn find_all_profile_files() -> Vec<(String, std::path::PathBuf)> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(".") {
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
let is_profile = path.is_file()
&& filename.starts_with("autumn-")
&& std::path::Path::new(filename)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
if is_profile {
let profile =
filename["autumn-".len()..filename.len() - ".toml".len()].to_string();
files.push((profile, path));
}
}
}
}
files
}
pub fn check_toml_content(content: &str) -> CheckResult {
if let Err(e) = toml::from_str::<toml::Table>(content) {
CheckResult {
name: "autumn_toml",
status: CheckStatus::Fail,
detail: Some(e.to_string()),
hint: Some("Fix the syntax error in autumn.toml"),
}
} else {
let schema = autumn_web::config::AutumnConfig::get_schema_keys();
let mut errors = Vec::new();
let base_errors = validate_toml_content(content, &schema);
for (path, sug) in base_errors {
errors.push(format!(
"autumn.toml: unknown key \"{path}\"{}",
sug.map(|s| format!(" — did you mean \"{s}\"?"))
.unwrap_or_default()
));
}
let profile_files = find_all_profile_files();
for (_, path) in profile_files {
if let Ok(file_content) = std::fs::read_to_string(&path) {
let profile_errors = validate_toml_content(&file_content, &schema);
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("profile config");
for (path, sug) in profile_errors {
errors.push(format!(
"{filename}: unknown key \"{path}\"{}",
sug.map(|s| format!(" — did you mean \"{s}\"?"))
.unwrap_or_default()
));
}
}
}
if errors.is_empty() {
CheckResult {
name: "autumn_toml",
status: CheckStatus::Pass,
detail: Some("autumn.toml and profile configurations are valid".into()),
hint: None,
}
} else {
CheckResult {
name: "autumn_toml",
status: CheckStatus::Warn,
detail: Some(errors.join(", ")),
hint: Some("Remove or rename unrecognised keys in configuration files"),
}
}
}
}
pub fn check_version_compat(cli_version: &str, web_version: &str) -> CheckResult {
let parse = |v: &str| -> Option<(u64, u64, u64)> {
let v = v.trim().trim_start_matches(['=', '^', '~', ' ']);
let parts: Vec<&str> = v.split('.').collect();
if parts.len() < 2 {
return None;
}
let major: u64 = parts[0].parse().ok()?;
let minor: u64 = parts[1].parse().ok()?;
let patch: u64 = if parts.len() >= 3 {
parts[2]
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0)
} else {
0
};
Some((major, minor, patch))
};
let Some(cli) = parse(cli_version) else {
return CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse CLI version: {cli_version}")),
hint: Some("Reinstall autumn-cli"),
};
};
let Some(web) = parse(web_version) else {
return CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse autumn-web version: {web_version}")),
hint: Some("Check autumn-web version in Cargo.toml"),
};
};
if cli.0 != web.0 || cli.1 != web.1 {
CheckResult {
name: "version_compat",
status: CheckStatus::Fail,
detail: Some(format!(
"autumn-cli {cli_version} is incompatible with autumn-web {web_version}"
)),
hint: Some(
"Run `cargo install --path autumn-cli` to match your project's autumn-web version",
),
}
} else if cli.2 != web.2 {
CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some(format!(
"autumn-cli {cli_version} vs autumn-web {web_version} (patch skew)"
)),
hint: Some("Consider updating either the CLI or your project's autumn-web dependency"),
}
} else {
CheckResult {
name: "version_compat",
status: CheckStatus::Pass,
detail: Some(format!(
"autumn-cli {cli_version} matches autumn-web {web_version}"
)),
hint: None,
}
}
}
pub fn check_port_bindable_impl(port: u16, try_bind: impl Fn(u16) -> bool) -> CheckResult {
if try_bind(port) {
CheckResult {
name: "port_bindable",
status: CheckStatus::Pass,
detail: Some(format!("port {port} is available")),
hint: None,
}
} else {
CheckResult {
name: "port_bindable",
status: CheckStatus::Fail,
detail: Some(format!("port {port} is already in use")),
hint: Some("Kill the process using that port, or change server.port in autumn.toml"),
}
}
}
pub fn check_rust_toolchain_impl(current_output: &str, required: &str) -> CheckResult {
let parse_ver = |s: &str| -> Option<(u64, u64, u64)> {
let s = s.trim();
let s = s
.strip_prefix("rustc ")
.map_or(s, |rest| rest.split_whitespace().next().unwrap_or(rest));
let parts: Vec<&str> = s.split('.').collect();
if parts.len() < 3 {
return None;
}
let major: u64 = parts[0].parse().ok()?;
let minor: u64 = parts[1].parse().ok()?;
let patch: u64 = parts[2]
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0);
Some((major, minor, patch))
};
let Some(cur) = parse_ver(current_output) else {
return CheckResult {
name: "rust_toolchain",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse rustc version: {current_output}")),
hint: Some("Run `rustup update` to ensure a known Rust version"),
};
};
let Some(req) = parse_ver(required) else {
return CheckResult {
name: "rust_toolchain",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse MSRV: {required}")),
hint: None,
};
};
if cur >= req {
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: Some(format!(
"rustc {}.{}.{} ≥ MSRV {}.{}.{}",
cur.0, cur.1, cur.2, req.0, req.1, req.2
)),
hint: None,
}
} else {
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Fail,
detail: Some(format!(
"rustc {}.{}.{} < MSRV {}.{}.{}",
cur.0, cur.1, cur.2, req.0, req.1, req.2
)),
hint: Some("Run `rustup update stable` to upgrade your Rust toolchain"),
}
}
}
fn check_rust_toolchain(msrv: &str) -> CheckResult {
let mut rustc = std::process::Command::new("rustc");
rustc.arg("--version");
crate::deps::no_toolchain_installs(&mut rustc);
match rustc.output() {
Ok(out) if out.status.success() => {
let ver = String::from_utf8_lossy(&out.stdout).into_owned();
check_rust_toolchain_impl(ver.trim(), msrv)
}
Ok(out) => rust_toolchain_failure(&String::from_utf8_lossy(&out.stderr)),
Err(_) => CheckResult {
name: "rust_toolchain",
status: CheckStatus::Fail,
detail: Some("`rustc --version` failed".into()),
hint: Some("Install Rust via https://rustup.rs/"),
},
}
}
fn rust_toolchain_failure(stderr: &str) -> CheckResult {
let reason = stderr.lines().map(str::trim).find(|line| !line.is_empty());
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Fail,
detail: Some(reason.map_or_else(
|| "`rustc --version` failed".to_owned(),
|reason| format!("`rustc --version` failed: {reason}"),
)),
hint: Some(if stderr.contains("is not installed") {
"Run `rustup toolchain install` in this project to install its pinned toolchain"
} else {
"Install Rust via https://rustup.rs/"
}),
}
}
fn check_port_bindable(port: u16) -> CheckResult {
check_port_bindable_impl(port, |p| {
std::net::TcpListener::bind(("127.0.0.1", p)).is_ok()
})
}
pub fn check_tailwind_binary_at(path: &std::path::Path) -> CheckResult {
check_tailwind_binary_at_with_executable_probe(path, tailwind_file_is_executable)
}
fn check_tailwind_binary_at_with_executable_probe(
path: &std::path::Path,
executable_probe: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool,
) -> CheckResult {
let symlink_metadata = match path.symlink_metadata() {
Ok(metadata) => metadata,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} not found", path.display())),
hint: Some("Run `autumn setup` to download the Tailwind CSS binary"),
};
}
Err(e) => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("cannot inspect {}: {e}", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
};
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(_) if symlink_metadata.file_type().is_symlink() => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} is a broken symlink", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
Err(e) => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("cannot inspect {}: {e}", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
};
if !metadata.is_file() {
let kind = if metadata.is_dir() {
"directory"
} else {
"non-file filesystem entry"
};
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} is a {kind}, not a file", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
if !executable_probe(path, &metadata) {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} exists but is not executable", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
CheckResult {
name: "tailwind_binary",
status: CheckStatus::Pass,
detail: Some(format!("{} is present and executable", path.display())),
hint: None,
}
}
#[cfg(unix)]
fn tailwind_file_is_executable(path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
nix::unistd::access(path, nix::unistd::AccessFlags::X_OK).is_ok()
}
#[cfg(windows)]
fn tailwind_file_is_executable(path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
use std::io::{Read as _, Seek as _};
if !path
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
{
return false;
}
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut dos_header = [0_u8; 64];
if file.read_exact(&mut dos_header).is_err() || &dos_header[0..2] != b"MZ" {
return false;
}
let pe_offset = u32::from_le_bytes([
dos_header[0x3c],
dos_header[0x3d],
dos_header[0x3e],
dos_header[0x3f],
]);
if file
.seek(std::io::SeekFrom::Start(u64::from(pe_offset)))
.is_err()
{
return false;
}
let mut pe_signature = [0_u8; 4];
file.read_exact(&mut pe_signature).is_ok() && pe_signature == *b"PE\0\0"
}
#[cfg(not(any(unix, windows)))]
fn tailwind_file_is_executable(_path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
true
}
fn check_tailwind_binary() -> CheckResult {
let binary = |target_dir: std::path::PathBuf| {
target_dir.join("autumn").join(if cfg!(windows) {
"tailwindcss.exe"
} else {
"tailwindcss"
})
};
crate::dev::try_resolve_target_directory().map_or_else(tailwind_not_evaluated, |target_dir| {
check_tailwind_binary_at(&binary(target_dir))
})
}
fn tailwind_not_evaluated() -> CheckResult {
CheckResult {
name: "tailwind_binary",
status: CheckStatus::Pass,
detail: Some(
"not evaluated — `cargo metadata` could not name the target directory".to_owned(),
),
hint: Some(
"Fix what stops `cargo metadata` (see the `rust_toolchain` check), then run `autumn doctor` again",
),
}
}
fn check_stale_artifacts() -> CheckResult {
let cargo_lock = std::path::Path::new("Cargo.lock");
let dist = std::path::Path::new("dist");
let target = std::path::Path::new("target");
let lock_mtime = cargo_lock.metadata().and_then(|m| m.modified()).ok();
let dir_older_than_lock = |dir: &std::path::Path| -> bool {
let Some(lock_t) = lock_mtime else {
return false;
};
dir.metadata()
.and_then(|m| m.modified())
.is_ok_and(|dir_t| dir_t < lock_t)
};
let dist_stale = dist.exists() && dir_older_than_lock(dist);
let target_stale = target.exists() && dir_older_than_lock(target);
if dist_stale || target_stale {
let which: Vec<&str> = [
dist_stale.then_some("dist/"),
target_stale.then_some("target/"),
]
.into_iter()
.flatten()
.collect();
CheckResult {
name: "stale_artifacts",
status: CheckStatus::Warn,
detail: Some(format!(
"{} may be stale relative to Cargo.lock",
which.join(", ")
)),
hint: Some("Run `cargo build` or `autumn build` to refresh artifacts"),
}
} else {
CheckResult {
name: "stale_artifacts",
status: CheckStatus::Pass,
detail: Some("artifacts look fresh".into()),
hint: None,
}
}
}
fn check_pg_client_tools_impl(missing: &[&str]) -> CheckResult {
if missing.is_empty() {
CheckResult {
name: "pg_client_tools",
status: CheckStatus::Pass,
detail: Some("pg_dump and pg_restore found".into()),
hint: None,
}
} else {
CheckResult {
name: "pg_client_tools",
status: CheckStatus::Warn,
detail: Some(format!(
"{} not found (checked AUTUMN_PG_BIN_DIR, the managed-Postgres bundle, and PATH); `autumn db backup` / `autumn db restore` need it",
missing.join(", ")
)),
hint: Some(
"Install the PostgreSQL client tools (e.g. `postgresql-client`) or set AUTUMN_PG_BIN_DIR to their bin directory",
),
}
}
}
fn check_pg_client_tools() -> CheckResult {
check_pg_client_tools_with(&crate::db::backup::PgTools::locate())
}
fn check_pg_client_tools_with(tools: &crate::db::backup::PgTools) -> CheckResult {
let missing: Vec<&str> = ["pg_dump", "pg_restore"]
.into_iter()
.filter(|tool| tools.require(tool).is_err())
.collect();
check_pg_client_tools_impl(&missing)
}
fn check_pg_client_tools_sqlite() -> CheckResult {
CheckResult {
name: "pg_client_tools",
status: CheckStatus::Pass,
detail: Some(
"SQLite app: `autumn db backup` / `restore` work on the data file in-process; \
no PostgreSQL client tools required"
.into(),
),
hint: None,
}
}
fn read_msrv() -> Option<String> {
let content = std::fs::read_to_string("Cargo.toml").ok()?;
let table: toml::Table = toml::from_str(&content).ok()?;
if let Some(ver) = table
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("rust-version"))
.and_then(|v| v.as_str())
{
return Some(ver.to_owned());
}
table
.get("package")
.and_then(|p| p.get("rust-version"))
.and_then(|v| v.as_str())
.map(std::borrow::ToOwned::to_owned)
}
fn read_autumn_web_version() -> Option<String> {
read_autumn_web_version_at(std::path::Path::new("."))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutumnWebDependency {
Absent,
Version(String),
WithoutVersion,
Unreadable,
Inherited(String),
}
pub fn read_autumn_web_version_at(root: &std::path::Path) -> Option<String> {
autumn_web_declarations_at(root)
.into_iter()
.find_map(|declaration| match declaration {
AutumnWebDependency::Version(version) => Some(version),
AutumnWebDependency::Absent
| AutumnWebDependency::Unreadable
| AutumnWebDependency::WithoutVersion
| AutumnWebDependency::Inherited(_) => None,
})
}
pub fn autumn_web_declarations_at(root: &std::path::Path) -> Vec<AutumnWebDependency> {
const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
fn is_autumn_web(key: &str, entry: &toml::Value) -> bool {
key == "autumn-web"
|| entry
.get("package")
.and_then(toml::Value::as_str)
.is_some_and(|package| package == "autumn-web")
}
fn is_inherited(entry: &toml::Value) -> bool {
entry
.get("workspace")
.and_then(toml::Value::as_bool)
.unwrap_or(false)
}
fn classify(key: &str, entry: &toml::Value) -> AutumnWebDependency {
match entry {
toml::Value::String(version) => AutumnWebDependency::Version(version.clone()),
toml::Value::Table(table) => table
.get("version")
.and_then(toml::Value::as_str)
.map_or_else(
|| {
if table.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
AutumnWebDependency::Inherited(key.to_owned())
} else {
AutumnWebDependency::WithoutVersion
}
},
|version| AutumnWebDependency::Version(version.to_owned()),
),
_ => AutumnWebDependency::WithoutVersion,
}
}
let manifest = root.join("Cargo.toml");
let Ok(content) = std::fs::read_to_string(&manifest) else {
return if manifest.exists() {
vec![AutumnWebDependency::Unreadable]
} else {
Vec::new()
};
};
let Ok(table) = toml::from_str::<toml::Table>(&content) else {
return vec![AutumnWebDependency::Unreadable];
};
let mut tables: Vec<&toml::Value> = Vec::new();
for kind in KINDS {
tables.extend(table.get(kind));
tables.extend(
table
.get("workspace")
.and_then(|workspace| workspace.get(kind)),
);
}
if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
for target in targets.values() {
for kind in KINDS {
tables.extend(target.get(kind));
}
}
}
tables
.into_iter()
.filter_map(toml::Value::as_table)
.flat_map(|deps| {
deps.iter()
.filter(|(key, entry)| is_autumn_web(key, entry) || is_inherited(entry))
.map(|(key, entry)| classify(key, entry))
})
.filter(|declaration| *declaration != AutumnWebDependency::Absent)
.collect()
}
pub fn workspace_dependency_for(dir: &std::path::Path, key: &str) -> Option<AutumnWebDependency> {
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
let table = toml::from_str::<toml::Table>(&content).ok()?;
let entry = table
.get("workspace")?
.get("dependencies")?
.as_table()?
.iter()
.find(|(candidate, entry)| {
*candidate == key
&& (key == "autumn-web"
|| entry
.get("package")
.and_then(toml::Value::as_str)
.is_some_and(|package| package == "autumn-web"))
})
.map(|(_, entry)| entry)?;
Some(match entry {
toml::Value::String(version) => AutumnWebDependency::Version(version.clone()),
toml::Value::Table(table) => table
.get("version")
.and_then(toml::Value::as_str)
.map_or(AutumnWebDependency::WithoutVersion, |version| {
AutumnWebDependency::Version(version.to_owned())
}),
_ => AutumnWebDependency::WithoutVersion,
})
}
fn tcp_reachable(host: &str, port: u16) -> bool {
use std::net::ToSocketAddrs;
use std::time::Duration;
let addrs: Vec<_> = match format!("{host}:{port}").to_socket_addrs() {
Ok(a) => a.collect(),
Err(_) => return false,
};
addrs
.iter()
.any(|addr| std::net::TcpStream::connect_timeout(addr, Duration::from_secs(1)).is_ok())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DoctorReplicaFallback {
#[default]
FailReadiness,
Primary,
}
impl DoctorReplicaFallback {
fn from_config_value(value: Option<&str>) -> Self {
match value.unwrap_or("fail_readiness") {
"primary" | "fallback_to_primary" | "fallback-to-primary" => Self::Primary,
_ => Self::FailReadiness,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DoctorDatabaseTopology {
pub primary_url: Option<String>,
pub replica_url: Option<String>,
pub legacy_url: Option<String>,
pub auto_migrate_in_production: bool,
pub replica_fallback: DoctorReplicaFallback,
}
fn check_database_topology_contract(
topology: &DoctorDatabaseTopology,
is_production: bool,
) -> CheckResult {
if topology.replica_url.is_some() && topology.primary_url.is_none() {
return CheckResult {
name: "database_topology",
status: CheckStatus::Fail,
detail: Some("replica role configured without a primary/write role".into()),
hint: Some("Set database.primary_url or remove database.replica_url"),
};
}
if is_production && topology.replica_url.is_some() && topology.auto_migrate_in_production {
return CheckResult {
name: "database_topology",
status: CheckStatus::Fail,
detail: Some(
"unsafe migration ownership: production primary/replica topology cannot run migrations from every web replica"
.into(),
),
hint: Some("Run `autumn migrate` as one primary-role job before starting web replicas"),
};
}
if topology.primary_url.is_some() && topology.replica_url.is_some() {
CheckResult {
name: "database_topology",
status: CheckStatus::Pass,
detail: Some("primary and replica database roles configured".into()),
hint: None,
}
} else if topology.primary_url.is_some() {
CheckResult {
name: "database_topology",
status: CheckStatus::Pass,
detail: Some("single primary database role configured".into()),
hint: None,
}
} else {
CheckResult {
name: "database_topology",
status: CheckStatus::Pass,
detail: Some("database not configured".into()),
hint: None,
}
}
}
fn check_process_role(
role: &Result<Role, String>,
jobs_backend: &str,
database_url: Option<&str>,
) -> CheckResult {
match role {
Ok(role) => check_split_topology_on_local(role, jobs_backend, database_url),
Err(message) => CheckResult {
name: "process_role_backend",
status: CheckStatus::Fail,
detail: Some(format!("{message}; the app does not boot")),
hint: Some(
"Set AUTUMN_ROLE (or role in autumn.toml) to combined, web, worker or a declared [roles.<name>] preset",
),
},
}
}
fn check_split_topology_on_local(
role: &Role,
jobs_backend: &str,
database_url: Option<&str>,
) -> CheckResult {
let backend = jobs_backend.trim();
let label = role_label(role);
let role = role.guard_base();
if autumn_web::config::split_role_requires_file_backed_sqlite(role, backend, database_url) {
return CheckResult {
name: "process_role_backend",
status: CheckStatus::Fail,
detail: Some(format!(
"role={label} with jobs.backend=\"sqlite\" on an in-memory database: an in-memory \
SQLite target is private to each process, so a split web/worker topology would \
enqueue into a queue no worker process can see",
)),
hint: Some(
"Point database.url at a sqlite:// FILE for a split web/worker role, or run the combined role",
),
};
}
if autumn_web::config::split_role_requires_durable_backend(role, jobs_backend) {
return CheckResult {
name: "process_role_backend",
status: CheckStatus::Fail,
detail: Some(format!(
"role={label} with jobs.backend=\"{backend}\": a split web/worker role needs a \
durable (postgres/redis/sqlite) jobs backend; \"{backend}\" is not a recognized \
durable backend and falls through to the in-process `local` runtime, which cannot \
share a job queue across the separate web and worker processes",
)),
hint: Some(
"Set jobs.backend = \"postgres\" (or redis, or sqlite on the single-host SQLite tier) for split web/worker roles, or run the combined role",
),
};
}
let detail = if role == ProcessRole::Combined {
format!("role={label}")
} else {
format!("role={label}, jobs.backend={backend}")
};
CheckResult {
name: "process_role_backend",
status: CheckStatus::Pass,
detail: Some(detail),
hint: None,
}
}
fn role_label(role: &Role) -> String {
if role.is_preset() {
format!("{} (base {})", role.name(), role.base().as_str())
} else {
role.name().to_owned()
}
}
fn check_queue_coverage(role: &Role, configured_queues: &[String], pin: &[String]) -> CheckResult {
if !role.capabilities().job_workers {
let who = if role.base() == ProcessRole::Web {
"web-only role".to_owned()
} else {
format!("role {}", role.name())
};
return CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Pass,
detail: Some(format!(
"{who} runs no job workers; queue pinning not applicable"
)),
hint: None,
};
}
let pin = role.pin().unwrap_or(pin);
if pin.is_empty() {
return CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Pass,
detail: Some("no jobs.pin; drains all configured queues".to_string()),
hint: None,
};
}
let known: std::collections::HashSet<&str> =
configured_queues.iter().map(String::as_str).collect();
let pinned: std::collections::HashSet<&str> = pin.iter().map(String::as_str).collect();
let unclaimed: Vec<&str> = configured_queues
.iter()
.map(String::as_str)
.filter(|q| !pinned.contains(q))
.collect();
let unknown_pinned: Vec<&str> = pin
.iter()
.map(String::as_str)
.filter(|q| !known.contains(q))
.collect();
let mut parts: Vec<String> = Vec::new();
if unclaimed.is_empty() {
parts.push(format!(
"jobs.pin={pin:?} claims every configured queue {configured_queues:?}"
));
} else {
parts.push(format!(
"jobs.pin={pin:?} does not claim configured queue(s): {} — ensure another \
worker tier covers them (doctor sees only this process and cannot verify \
topology-wide coverage)",
unclaimed.join(", ")
));
}
if !unknown_pinned.is_empty() {
parts.push(format!(
"pinned queue(s) not in [jobs.queues]: {} — these may be #[job(queue = \"…\")]-\
declared queues that the runtime drains (doctor is config-only and cannot verify)",
unknown_pinned.join(", ")
));
}
parts.push(
"informational only: the runtime startup warning is the authoritative queue-coverage check"
.to_string(),
);
CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Pass,
detail: Some(parts.join("; ")),
hint: None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct FleetTopology {
tiers: Vec<Vec<String>>,
malformed: bool,
derived: bool,
}
impl FleetTopology {
const fn malformed() -> Self {
Self {
tiers: Vec::new(),
malformed: true,
derived: false,
}
}
const fn runs_any_worker(&self) -> bool {
!self.tiers.is_empty()
}
fn has_unpinned_tier(&self) -> bool {
self.tiers.iter().any(Vec::is_empty)
}
fn pinned_union(&self) -> BTreeSet<String> {
self.tiers.iter().flatten().cloned().collect()
}
}
fn check_queue_coverage_topology(
role: &Role,
configured_queues: &[String],
pin: &[String],
declared_queues: &[String],
fleet: Option<&FleetTopology>,
declared_queues_manifest_error: Option<&DeclaredQueuesManifestError>,
) -> CheckResult {
if let Some(err) = declared_queues_manifest_error {
return CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Fail,
detail: Some(format!(
"[jobs.fleet] manifest at `{}` is corrupt ({}), so doctor cannot \
determine the #[job(queue)]-declared queue set and refuses to \
guess rather than silently narrowing it",
err.manifest_path, err.detail
)),
hint: Some(
"Regenerate the manifest with `autumn jobs manifest`, or unset \
`[jobs.fleet] manifest` to fall back to `declared_queues`",
),
};
}
if let Some(fleet) = fleet.filter(|f| f.malformed) {
let _ = fleet;
return CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Fail,
detail: Some(
"[jobs.fleet] tiers is present but is not a list of lists, so no fleet \
topology could be read and topology-wide queue coverage cannot be checked"
.to_string(),
),
hint: Some(
"Write one list per worker tier, e.g. tiers = [[\"critical\"], [\"bulk\", \"default\"]] — a flat list like tiers = [\"critical\"] is a single tier's pin, not a topology",
),
};
}
let Some(fleet) = fleet.filter(|f| f.runs_any_worker()) else {
return check_queue_coverage(role, configured_queues, pin);
};
let needed: BTreeSet<String> = configured_queues
.iter()
.chain(declared_queues.iter())
.cloned()
.collect();
let gap: Vec<String> = if fleet.has_unpinned_tier() {
Vec::new()
} else {
let covered = fleet.pinned_union();
needed
.iter()
.filter(|q| !covered.contains(*q))
.cloned()
.collect()
};
let tier_count = fleet.tiers.len();
let source = if fleet.derived {
", derived from [roles.*]"
} else {
""
};
if gap.is_empty() {
CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Pass,
detail: Some(format!(
"declared fleet topology ({tier_count} tier(s){source}) drains every needed queue {:?} \
(configured + #[job(queue)]-declared); coverage is provable topology-wide",
needed.iter().collect::<Vec<_>>()
)),
hint: None,
}
} else if fleet.derived {
CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Warn,
detail: Some(format!(
"declared fleet topology ({tier_count} tier(s){source}) leaves queue(s) {} drained by no \
[roles.<name>] preset; a tier that runs as a built-in role is not in this list",
gap.join(", ")
)),
hint: Some(
"Add the uncovered queue(s) to the pin of a [roles.<name>] preset, or set \
[jobs.fleet] tiers to list every tier, built-in roles included",
),
}
} else {
CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Fail,
detail: Some(format!(
"declared fleet topology ({tier_count} tier(s){source}) leaves queue(s) {} drained by no \
worker tier; jobs enqueued to them will accumulate forever",
gap.join(", ")
)),
hint: Some(
"Add the uncovered queue(s) to a tier's jobs.pin in [jobs.fleet].tiers, or run an \
unpinned tier that drains everything",
),
}
}
}
fn check_replica_migration_versions(
primary_latest: Option<&str>,
replica_latest: Option<&str>,
fallback: DoctorReplicaFallback,
) -> CheckResult {
match (primary_latest, replica_latest) {
(Some(primary), Some(replica)) if primary == replica => CheckResult {
name: "replica_migrations",
status: CheckStatus::Pass,
detail: Some(format!("replica replayed latest migration {primary}")),
hint: None,
},
(Some(primary), Some(replica)) => {
let detail = format!(
"replica migration version {replica} is behind primary migration version {primary}"
);
match fallback {
DoctorReplicaFallback::FailReadiness => CheckResult {
name: "replica_migrations",
status: CheckStatus::Fail,
detail: Some(detail),
hint: Some(
"Wait for the replica to replay the latest migration before admitting traffic",
),
},
DoctorReplicaFallback::Primary => CheckResult {
name: "replica_migrations",
status: CheckStatus::Warn,
detail: Some(format!("{detail}; reads may fall back to primary")),
hint: Some(
"Restore replica replay or set replica_fallback = \"fail_readiness\" for stricter rollout gates",
),
},
}
}
_ => CheckResult {
name: "replica_migrations",
status: CheckStatus::Warn,
detail: Some("could not determine primary and replica migration versions".into()),
hint: Some("Ensure both roles expose __diesel_schema_migrations"),
},
}
}
fn sqlite_primary_target_is_bootable(
legacy_url: Option<&str>,
primary_url: Option<&str>,
replica_url: Option<&str>,
has_shards: bool,
) -> bool {
use autumn_web::config::{DatabaseBackend, database_backend_consistency};
if primary_url.or(legacy_url).and_then(DatabaseBackend::detect) != Some(DatabaseBackend::Sqlite)
{
return false;
}
database_backend_consistency(legacy_url, primary_url, replica_url, has_shards).is_ok()
}
fn check_database_backend_consistency(
legacy_url: Option<&str>,
primary_url: Option<&str>,
replica_url: Option<&str>,
has_shards: bool,
) -> CheckResult {
match autumn_web::config::database_backend_consistency(
legacy_url,
primary_url,
replica_url,
has_shards,
) {
Ok(()) => CheckResult {
name: "db_backend_consistency",
status: CheckStatus::Pass,
detail: Some("all configured database roles use the same backend".to_owned()),
hint: None,
},
Err(msg) => CheckResult {
name: "db_backend_consistency",
status: CheckStatus::Fail,
detail: Some(msg),
hint: Some(
"Align every database role (database.url, primary_url, replica_url, shards) on a \
single backend — this is exactly what the app validates at boot",
),
},
}
}
fn check_shard_config_resolvable(resolver_error: &str) -> CheckResult {
CheckResult {
name: "db_shard_config",
status: CheckStatus::Fail,
detail: Some(format!(
"[[database.shards]] is malformed — doctor cannot validate the database topology: \
{resolver_error}"
)),
hint: Some(
"Fix the [[database.shards]] entries so each has a string `name` and `primary_url` \
with unique names — the app rejects the same config at boot",
),
}
}
fn check_db_role_connectivity(
role: &'static str,
database_url: &str,
reachable: impl Fn(&str, u16) -> bool,
sqlite_target_bootable: bool,
) -> CheckResult {
if autumn_web::config::DatabaseBackend::detect(database_url)
== Some(autumn_web::config::DatabaseBackend::Sqlite)
{
if sqlite_target_bootable {
return CheckResult {
name: "db_connectivity",
status: CheckStatus::Pass,
detail: Some(format!(
"{role} database is a local SQLite target (no host:port to reach)"
)),
hint: None,
};
}
return CheckResult {
name: "db_connectivity",
status: CheckStatus::Fail,
detail: Some(format!(
"{role} database is a SQLite target, but SQLite is single-writer/single-host: \
read replicas and shards require the postgres backend, and every database role \
must use the same backend"
)),
hint: Some(
"Use a single SQLite database (no replica_url), or switch every database role to \
postgres://",
),
};
}
match parse_db_host_port(database_url) {
None => CheckResult {
name: "db_connectivity",
status: CheckStatus::Warn,
detail: Some(format!(
"cannot parse {role} database URL to extract host:port"
)),
hint: Some("Ensure database URLs in autumn.toml use postgres:// or postgresql://"),
},
Some((host, port)) if reachable(&host, port) => CheckResult {
name: "db_connectivity",
status: CheckStatus::Pass,
detail: Some(format!("{role} database reachable at {host}:{port}")),
hint: None,
},
Some((host, port)) => CheckResult {
name: "db_connectivity",
status: CheckStatus::Fail,
detail: Some(format!(
"cannot connect to {role} database at {host}:{port}"
)),
hint: Some("Start Postgres and verify the configured database role URL"),
},
}
}
fn check_db_connectivity(database_url: &str, sqlite_target_bootable: bool) -> CheckResult {
check_db_role_connectivity(
"primary",
database_url,
tcp_reachable,
sqlite_target_bootable,
)
}
fn check_pending_migrations(database_url: &str) -> CheckResult {
match std::process::Command::new("diesel")
.args(["migration", "pending"])
.env("DATABASE_URL", database_url)
.output()
{
Err(_) => CheckResult {
name: "pending_migrations",
status: CheckStatus::Warn,
detail: Some("diesel CLI not found; cannot check pending migrations".into()),
hint: Some(
"Install diesel_cli: `cargo install diesel_cli --no-default-features --features postgres`",
),
},
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let pending = stdout.lines().filter(|l| !l.trim().is_empty()).count();
if pending == 0 {
CheckResult {
name: "pending_migrations",
status: CheckStatus::Pass,
detail: Some("no pending migrations".into()),
hint: None,
}
} else {
CheckResult {
name: "pending_migrations",
status: CheckStatus::Warn,
detail: Some(format!("{pending} pending migration(s)")),
hint: Some("Run `autumn migrate` to apply pending migrations"),
}
}
}
Ok(_) => CheckResult {
name: "pending_migrations",
status: CheckStatus::Warn,
detail: Some("diesel migration pending returned non-zero".into()),
hint: Some("Run `autumn migrate` to apply pending migrations"),
},
}
}
fn check_pending_migrations_sqlite() -> CheckResult {
CheckResult {
name: "pending_migrations",
status: CheckStatus::Pass,
detail: Some(
"SQLite app: migration checks are not yet wired; skipping the Postgres diesel probe"
.into(),
),
hint: Some(
"SQLite migration support is tracked in https://github.com/autumn-foundation/autumn/issues/1906",
),
}
}
fn check_replica_migrations(
primary_url: &str,
replica_url: &str,
fallback: DoctorReplicaFallback,
) -> CheckResult {
let primary_latest = latest_applied_migration_version(primary_url);
let replica_latest = latest_applied_migration_version(replica_url);
check_replica_migration_versions(
primary_latest.as_deref(),
replica_latest.as_deref(),
fallback,
)
}
fn latest_applied_migration_version(database_url: &str) -> Option<String> {
parse_latest_applied_migration_version(&diesel_migration_list(database_url)?)
}
fn diesel_migration_list(database_url: &str) -> Option<String> {
let output = std::process::Command::new("diesel")
.args(["migration", "list"])
.env("DATABASE_URL", database_url)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse_latest_applied_migration_version(output: &str) -> Option<String> {
parse_applied_migration_tokens(output)
.max()
.map(std::borrow::ToOwned::to_owned)
}
pub fn parse_db_host_port(url: &str) -> Option<(String, u16)> {
let without_scheme = url
.strip_prefix("postgres://")
.or_else(|| url.strip_prefix("postgresql://"))?;
let authority = without_scheme.split('/').next()?;
let host_port = authority
.rfind('@')
.map_or(authority, |at| &authority[at + 1..]);
if let Some(rest) = host_port.strip_prefix('[') {
let close = rest.find(']')?;
let host = &rest[..close];
let after_bracket = &rest[close + 1..];
let port = if let Some(port) = after_bracket.strip_prefix(':') {
port.parse().ok()?
} else if after_bracket.is_empty() {
5432
} else {
return None;
};
return Some((host.to_owned(), port));
}
match host_port.matches(':').count() {
1 => {
let (host, port) = host_port.split_once(':')?;
Some((host.to_owned(), port.parse().ok()?))
}
_ => Some((host_port.to_owned(), 5432)),
}
}
fn resolve_server_port() -> u16 {
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| {
t.get("server")
.and_then(|s| s.get("port"))
.and_then(toml::Value::as_integer)
.and_then(|p| u16::try_from(p).ok())
})
.unwrap_or(3000)
}
fn read_autumn_toml_table() -> Option<toml::Table> {
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|contents| toml::from_str::<toml::Table>(&contents).ok())
}
fn deep_merge(target: &mut toml::Table, source: &toml::Table) {
for (k, v) in source {
if let (Some(source_tbl), Some(target_tbl)) = (
v.as_table(),
target.get_mut(k).and_then(|t| t.as_table_mut()),
) {
deep_merge(target_tbl, source_tbl);
continue;
}
target.insert(k.clone(), v.clone());
}
}
fn get_merged_toml_table(profile: &str) -> toml::Table {
get_merged_toml_table_profiles(&[profile])
}
fn get_merged_toml_table_profiles(profiles: &[&str]) -> toml::Table {
let mut merged = toml::Table::new();
let base_toml = std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok());
if let Some(ref table) = base_toml {
deep_merge(&mut merged, table);
}
for &profile in profiles {
if let Some(prof) = base_toml
.as_ref()
.and_then(|t| t.get("profile"))
.and_then(toml::Value::as_table)
.and_then(|p| p.get(profile))
.and_then(toml::Value::as_table)
{
deep_merge(&mut merged, prof);
}
if let Some(table) = std::fs::read_to_string(format!("autumn-{profile}.toml"))
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
{
deep_merge(&mut merged, &table);
}
}
merged
}
fn inline_profile_lookup_names(profile: &str) -> Vec<&str> {
match profile {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn override_file_lookup_names(profile: &str, selected_input: &str) -> Vec<String> {
match profile {
"prod" if selected_input.eq_ignore_ascii_case("production") => {
vec!["production".to_owned(), "prod".to_owned()]
}
"prod" => vec!["prod".to_owned(), "production".to_owned()],
"dev" if selected_input.eq_ignore_ascii_case("development") => {
vec!["development".to_owned(), "dev".to_owned()]
}
"dev" => vec!["dev".to_owned(), "development".to_owned()],
other => vec![other.to_owned()],
}
}
fn get_merged_toml_table_runtime(normalized_profile: &str, selected_input: &str) -> toml::Table {
get_merged_toml_table_runtime_with(normalized_profile, selected_input, |f| {
std::path::PathBuf::from(f)
})
}
fn get_merged_toml_table_runtime_with(
normalized_profile: &str,
selected_input: &str,
resolve_path: impl Fn(&str) -> std::path::PathBuf,
) -> toml::Table {
let mut merged = toml::Table::new();
let base_toml = std::fs::read_to_string(resolve_path("autumn.toml"))
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok());
if let Some(ref table) = base_toml {
deep_merge(&mut merged, table);
}
for profile_name in inline_profile_lookup_names(normalized_profile) {
if let Some(prof) = base_toml
.as_ref()
.and_then(|t| t.get("profile"))
.and_then(toml::Value::as_table)
.and_then(|p| p.get(profile_name))
.and_then(toml::Value::as_table)
{
deep_merge(&mut merged, prof);
}
}
for profile_name in override_file_lookup_names(normalized_profile, selected_input) {
if let Some(table) =
std::fs::read_to_string(resolve_path(&format!("autumn-{profile_name}.toml")))
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
{
deep_merge(&mut merged, &table);
break;
}
}
merged
}
fn resolve_config_path_manifest_aware(filename: &str) -> std::path::PathBuf {
use autumn_web::config::Env as _;
if let Ok(manifest_dir) = autumn_web::config::OsEnv.var("AUTUMN_MANIFEST_DIR") {
let candidate = std::path::PathBuf::from(manifest_dir).join(filename);
if candidate.exists() {
return candidate;
}
}
std::path::PathBuf::from(filename)
}
pub struct DoctorOAuth2Provider {
pub name: String,
pub client_id: String,
pub client_secret: String,
}
fn resolve_oauth2_providers() -> Vec<DoctorOAuth2Provider> {
let raw_profile = std::env::var("AUTUMN_ENV")
.or_else(|_| std::env::var("AUTUMN_PROFILE"))
.unwrap_or_else(|_| "dev".to_owned());
let profile = match raw_profile.trim().to_lowercase().as_str() {
"production" => "prod".to_owned(),
"development" => "dev".to_owned(),
other => other.to_owned(),
};
let merged_toml = get_merged_toml_table(&profile);
resolve_oauth2_providers_from_sources(
|key| std::env::var(key).ok().filter(|v| !v.is_empty()),
Some(&merged_toml),
&profile,
)
}
fn resolve_oauth2_providers_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
profile: &str,
) -> Vec<DoctorOAuth2Provider>
where
F: Fn(&str) -> Option<String>,
{
let credentials =
autumn_web::credentials::load_credentials(profile, std::path::Path::new(".")).ok();
table
.and_then(|t| t.get("auth"))
.and_then(toml::Value::as_table)
.and_then(|auth| auth.get("oauth2"))
.and_then(toml::Value::as_table)
.map(|providers| {
providers
.iter()
.map(|(name, val)| {
let mut client_id = val
.as_table()
.and_then(|t| t.get("client_id"))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned();
let mut client_secret = val
.as_table()
.and_then(|t| t.get("client_secret"))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned();
let normalized_name = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_lowercase();
let upper = normalized_name.to_uppercase();
let env_id_key = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
if let Some(id) = env_var(&env_id_key) {
client_id = id;
}
let env_secret_key = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
if let Some(sec) = env_var(&env_secret_key) {
client_secret = sec;
}
if let Some(ref creds) = credentials {
let id_key = format!("oauth2_{normalized_name}_client_id");
if client_id.is_empty() {
if let Some(id) = creds.get::<String>(&id_key) {
client_id = id;
} else if let Some(id) =
creds.get::<String>(&format!("oauth2_{name}_client_id"))
{
client_id = id;
}
}
let secret_key = format!("oauth2_{normalized_name}_client_secret");
if client_secret.is_empty() {
if let Some(sec) = creds.get::<String>(&secret_key) {
client_secret = sec;
} else if let Some(sec) =
creds.get::<String>(&format!("oauth2_{name}_client_secret"))
{
client_secret = sec;
}
}
}
DoctorOAuth2Provider {
name: name.clone(),
client_id,
client_secret,
}
})
.collect()
})
.unwrap_or_default()
}
pub fn file_declares_list_unsubscribe(content: &str) -> bool {
let without_comments = strip_rust_comments(content);
let collapsed: String = without_comments
.chars()
.filter(|c| !c.is_whitespace())
.collect();
collapsed.contains("mailer(list_unsubscribe")
}
fn strip_rust_comments(content: &str) -> String {
let mut out = String::with_capacity(content.len());
let mut chars = content.chars().peekable();
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'/') {
for n in chars.by_ref() {
if n == '\n' {
out.push('\n');
break;
}
}
} else if c == '/' && chars.peek() == Some(&'*') {
chars.next();
let mut prev = '\0';
for n in chars.by_ref() {
if prev == '*' && n == '/' {
break;
}
prev = n;
}
} else {
out.push(c);
}
}
out
}
fn detect_list_unsubscribe_usage() -> bool {
scan_dir_for_list_unsubscribe(std::path::Path::new("."))
}
fn scan_dir_for_list_unsubscribe(root: &std::path::Path) -> bool {
const SKIP_DIRS: &[&str] = &[
"target",
".git",
"node_modules",
"dist",
".github",
"vendor",
"tests",
"examples",
];
let Ok(entries) = std::fs::read_dir(root) else {
return false;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
let skip = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| SKIP_DIRS.contains(&n));
if !skip && scan_dir_for_list_unsubscribe(&path) {
return true;
}
} else if path.extension().and_then(|e| e.to_str()) == Some("rs")
&& std::fs::read_to_string(&path)
.is_ok_and(|content| file_declares_list_unsubscribe(&content))
{
return true;
}
}
false
}
fn resolve_mail_unsubscribe(table: Option<&toml::Table>) -> (Option<String>, Option<String>) {
let mail = table
.and_then(|t| t.get("mail"))
.and_then(toml::Value::as_table);
let read = |env_key: &str, toml_key: &str| {
std::env::var(env_key).map_or_else(
|_| {
mail.and_then(|m| m.get(toml_key))
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(std::borrow::ToOwned::to_owned)
},
|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
},
)
};
(
read("AUTUMN_MAIL__UNSUBSCRIBE_BASE_URL", "unsubscribe_base_url"),
read("AUTUMN_MAIL__UNSUBSCRIBE_MAILTO", "unsubscribe_mailto"),
)
}
fn resolve_mail_from(table: Option<&toml::Table>) -> Option<String> {
let mail = table
.and_then(|t| t.get("mail"))
.and_then(toml::Value::as_table);
std::env::var("AUTUMN_MAIL__FROM").map_or_else(
|_| {
mail.and_then(|m| m.get("from"))
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(std::borrow::ToOwned::to_owned)
},
|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
},
)
}
fn resolve_unsubscribe_token_ttl_days(table: Option<&toml::Table>) -> i64 {
resolve_unsubscribe_token_ttl_days_from_sources(
std::env::var("AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS").ok(),
table,
)
}
fn unsubscribe_ttl_toml_type_invalid(table: Option<&toml::Table>) -> bool {
table
.and_then(|t| t.get("mail"))
.and_then(toml::Value::as_table)
.and_then(|m| m.get("unsubscribe_token_ttl_days"))
.is_some_and(|v| v.as_integer().is_none())
}
fn unsubscribe_dest_toml_type_invalid(table: Option<&toml::Table>) -> bool {
let mail = table
.and_then(|t| t.get("mail"))
.and_then(toml::Value::as_table);
["unsubscribe_base_url", "unsubscribe_mailto"]
.iter()
.any(|key| {
mail.and_then(|m| m.get(*key))
.is_some_and(|v| v.as_str().is_none())
})
}
fn resolve_unsubscribe_token_ttl_days_from_sources(
env_value: Option<String>,
table: Option<&toml::Table>,
) -> i64 {
const DEFAULT_TTL_DAYS: i64 = 30;
let toml_or_default = table
.and_then(|t| t.get("mail"))
.and_then(toml::Value::as_table)
.and_then(|m| m.get("unsubscribe_token_ttl_days"))
.and_then(toml::Value::as_integer)
.unwrap_or(DEFAULT_TTL_DAYS);
if let Some(raw) = env_value
&& let Ok(days) = raw.parse::<i64>()
{
return days;
}
toml_or_default
}
fn parse_mail_transport(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"log" => Some("log"),
"file" => Some("file"),
"smtp" => Some("smtp"),
"disabled" => Some("disabled"),
_ => None,
}
}
fn resolve_effective_mail_transport(
env_value: Option<String>,
toml_transport: Option<&str>,
normalized_profile: &str,
) -> String {
if let Some(raw) = env_value
&& let Some(parsed) = parse_mail_transport(&raw)
{
return parsed.to_owned();
}
if let Some(parsed) = toml_transport.and_then(parse_mail_transport) {
return parsed.to_owned();
}
if normalized_profile == "dev" {
"log".to_owned()
} else {
"disabled".to_owned()
}
}
fn mail_transport_requires_from(transport: &str) -> bool {
transport == "smtp"
}
fn resolve_database_topology_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> DoctorDatabaseTopology
where
F: Fn(&str) -> Option<String>,
{
let database = table
.and_then(|t| t.get("database"))
.and_then(toml::Value::as_table);
let primary_url = first_env(
&env_var,
&[
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_DATABASE__URL",
"DATABASE_URL",
],
)
.or_else(|| first_toml_string(database, &["primary_url", "url"]));
let legacy_url = first_env(&env_var, &["AUTUMN_DATABASE__URL"])
.or_else(|| first_toml_string(database, &["url"]));
let replica_url = first_env(&env_var, &["AUTUMN_DATABASE__REPLICA_URL"])
.or_else(|| first_toml_string(database, &["replica_url"]));
let auto_migrate_in_production =
first_env(&env_var, &["AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION"])
.as_deref()
.and_then(parse_config_bool)
.or_else(|| database?.get("auto_migrate_in_production")?.as_bool())
.unwrap_or(false);
let replica_fallback = DoctorReplicaFallback::from_config_value(
first_env(&env_var, &["AUTUMN_DATABASE__REPLICA_FALLBACK"])
.or_else(|| first_toml_string(database, &["replica_fallback"]))
.as_deref(),
);
DoctorDatabaseTopology {
primary_url,
replica_url,
legacy_url,
auto_migrate_in_production,
replica_fallback,
}
}
fn resolve_process_role_and_backend(
table: Option<&toml::Table>,
env: &dyn autumn_web::config::Env,
) -> (Result<Role, String>, String) {
resolve_process_role_and_backend_from_sources(
|key| env.var(key).ok().filter(|value| !value.is_empty()),
table,
)
}
fn resolve_process_role_and_backend_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> (Result<Role, String>, String)
where
F: Fn(&str) -> Option<String>,
{
let role = resolve_process_role_from_sources(&env_var, table);
let jobs = table
.and_then(|t| t.get("jobs"))
.and_then(toml::Value::as_table);
let backend = first_env(&env_var, &["AUTUMN_JOBS__BACKEND"])
.or_else(|| first_toml_string(jobs, &["backend"]))
.unwrap_or_else(|| "local".to_owned());
(role, backend)
}
fn resolve_process_role_from_sources<F>(
env_var: &F,
table: Option<&toml::Table>,
) -> Result<Role, String>
where
F: Fn(&str) -> Option<String>,
{
use autumn_web::config::RoleName;
let configured = match table.and_then(|t| t.get("role")) {
None => RoleName::default(),
Some(toml::Value::String(value)) => value
.parse::<RoleName>()
.map_err(|err| format!("role: {err}"))?,
Some(toml::Value::Table(table))
if table.len() == 1
&& table
.values()
.all(|v| v.as_table().is_some_and(toml::Table::is_empty)) =>
{
table
.keys()
.next()
.map_or_else(|| Ok(RoleName::default()), |name| name.parse())
.map_err(|err| format!("role: {err}"))?
}
Some(other) => {
return Err(format!(
"role: expected a string, found {}",
other.type_str()
));
}
};
let from_env = env_var("AUTUMN_ROLE")
.map(|value| RoleName::parse_env_override(&value))
.transpose()
.map_err(|err| format!("AUTUMN_ROLE: {err}"))?
.flatten();
let roles = resolve_role_presets(table)?;
autumn_web::config::resolve_role(&configured, &roles).map_err(|err| format!("role: {err}"))?;
let source = if from_env.is_some() {
"AUTUMN_ROLE"
} else {
"role"
};
let name = from_env.unwrap_or(configured);
autumn_web::config::resolve_role(&name, &roles).map_err(|err| format!("{source}: {err}"))
}
fn resolve_role_presets(
table: Option<&toml::Table>,
) -> Result<std::collections::BTreeMap<String, RolePreset>, String> {
let Some(value) = table.and_then(|t| t.get("roles")) else {
return Ok(std::collections::BTreeMap::new());
};
let roles: std::collections::BTreeMap<String, RolePreset> = value
.clone()
.try_into()
.map_err(|err| format!("roles: {err}"))?;
autumn_web::config::validate_role_presets(&roles).map_err(|err| err.to_string())?;
Ok(roles)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RouteGroupsManifestError {
Missing(String),
NoGroups(String),
Corrupt(String),
}
fn resolve_route_groups<F>(
table: Option<&toml::Table>,
read_file: F,
) -> Result<Option<Vec<String>>, RouteGroupsManifestError>
where
F: Fn(&str) -> Option<String>,
{
let Some(section) = table
.and_then(|t| t.get("route_groups"))
.and_then(toml::Value::as_table)
else {
return Ok(None);
};
if let Some(path) = section.get("manifest").and_then(toml::Value::as_str) {
let Some(contents) = read_file(path) else {
return Err(RouteGroupsManifestError::Missing(format!(
"route_groups.manifest {path:?} is not there"
)));
};
let routes: Vec<serde_json::Value> = serde_json::from_str(&contents).map_err(|err| {
RouteGroupsManifestError::Corrupt(format!(
"route_groups.manifest {path:?} is not `autumn routes --format json` output: {err}"
))
})?;
let groups: std::collections::BTreeSet<String> = routes
.iter()
.filter_map(|route| route.get("group").and_then(serde_json::Value::as_str))
.map(str::to_owned)
.collect();
if groups.is_empty() {
return Err(RouteGroupsManifestError::NoGroups(format!(
"route_groups.manifest {path:?} names no route group"
)));
}
return Ok(Some(groups.into_iter().collect()));
}
let declared: Vec<String> = section
.get("declared")
.and_then(toml::Value::as_array)
.map(|groups| {
groups
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
Ok((!declared.is_empty()).then_some(declared))
}
fn check_route_group_mounts<F>(table: Option<&toml::Table>, read_file: F) -> CheckResult
where
F: Fn(&str) -> Option<String>,
{
const NAME: &str = "route_group_mounts";
let pass = |detail: String| CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(detail),
hint: None,
};
let Ok(roles) = resolve_role_presets(table) else {
return pass("skipped: [roles] is invalid; see process_role_backend".to_owned());
};
if !roles.values().any(|preset| preset.mount.is_some()) {
return pass("no [roles.<name>] sets mount; every HTTP role serves every group".to_owned());
}
let groups = match resolve_route_groups(table, read_file) {
Ok(Some(groups)) => groups,
Ok(None) => {
return pass(
"not checked: set [route_groups] manifest or declared to check that each \
group has a role"
.to_owned(),
);
}
Err(error) => {
let (status, detail) = match error {
RouteGroupsManifestError::Corrupt(detail) => (CheckStatus::Fail, detail),
RouteGroupsManifestError::Missing(detail)
| RouteGroupsManifestError::NoGroups(detail) => (CheckStatus::Warn, detail),
};
return CheckResult {
name: NAME,
status,
detail: Some(detail),
hint: Some("Regenerate it: autumn routes --format json > <manifest path>"),
};
}
};
let missing = autumn_web::config::groups_mounted_by_no_preset(&roles, &groups);
if missing.is_empty() {
return pass(format!(
"{} route group(s), each mounted by a role",
groups.len()
));
}
let names: Vec<String> = missing.iter().map(|g| format!("{g:?}")).collect();
CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(format!(
"route group(s) {} mounted by no [roles.<name>] preset; no process serves \
their routes",
names.join(", ")
)),
hint: Some(
"Add each group to the mount list of a role, or add a role without mount. \
A built-in role (web, combined) is not counted.",
),
}
}
fn resolve_queues_and_pin(table: Option<&toml::Table>) -> (Vec<String>, Vec<String>) {
resolve_queues_and_pin_from_sources(|key| std::env::var(key).ok(), table)
}
fn resolve_queues_and_pin_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> (Vec<String>, Vec<String>)
where
F: Fn(&str) -> Option<String>,
{
let jobs = table
.and_then(|t| t.get("jobs"))
.and_then(toml::Value::as_table);
let mut queues =
jobs.and_then(|j| j.get("queues"))
.map_or_else(Vec::new, |value| match value {
toml::Value::Array(items) => items
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect(),
toml::Value::Table(map) => map.keys().cloned().collect(),
_ => Vec::new(),
});
if queues.is_empty() {
queues.push("default".to_owned());
}
let toml_pin = || {
jobs.and_then(|j| j.get("pin"))
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
};
let pin = env_var("AUTUMN_JOBS__PIN").map_or_else(toml_pin, |csv| {
csv.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
});
(queues, pin)
}
fn resolve_fleet_topology_with_pin(
table: Option<&toml::Table>,
jobs_pin: Option<&[String]>,
) -> Option<FleetTopology> {
let jobs = table
.and_then(|t| t.get("jobs"))
.and_then(toml::Value::as_table);
let Some(fleet) = jobs.and_then(|jobs| jobs.get("fleet")) else {
return derive_fleet_topology(table, jobs_pin);
};
let Some(fleet) = fleet.as_table() else {
return Some(FleetTopology::malformed());
};
let Some(declared) = fleet.get("tiers") else {
return derive_fleet_topology(table, jobs_pin);
};
let Some(declared) = declared.as_array() else {
return Some(FleetTopology::malformed());
};
let mut tiers: Vec<Vec<String>> = Vec::with_capacity(declared.len());
for entry in declared {
let Some(tier) = entry.as_array() else {
return Some(FleetTopology::malformed());
};
let mut names = Vec::with_capacity(tier.len());
for name in tier {
let Some(name) = name.as_str() else {
return Some(FleetTopology::malformed());
};
names.push(name.to_owned());
}
tiers.push(names);
}
if tiers.is_empty() {
return derive_fleet_topology(table, jobs_pin);
}
Some(FleetTopology {
tiers,
malformed: false,
derived: false,
})
}
#[cfg(test)]
fn resolve_fleet_topology(table: Option<&toml::Table>) -> Option<FleetTopology> {
resolve_fleet_topology_with_pin(table, None)
}
fn derive_fleet_topology(
table: Option<&toml::Table>,
jobs_pin: Option<&[String]>,
) -> Option<FleetTopology> {
let roles = resolve_role_presets(table).ok()?;
if let Some(jobs_pin) = jobs_pin {
let tiers = autumn_web::config::derive_fleet_tiers(&roles, jobs_pin).ok()?;
return (!tiers.is_empty()).then_some(FleetTopology {
tiers,
malformed: false,
derived: true,
});
}
let jobs_pin: Vec<String> = table
.and_then(|t| t.get("jobs"))
.and_then(|jobs| jobs.get("pin"))
.and_then(toml::Value::as_array)
.map(|pin| {
pin.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
let tiers = autumn_web::config::derive_fleet_tiers(&roles, &jobs_pin).ok()?;
(!tiers.is_empty()).then_some(FleetTopology {
tiers,
malformed: false,
derived: true,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DeclaredQueuesManifestError {
manifest_path: String,
detail: String,
}
fn resolve_declared_queues(
table: Option<&toml::Table>,
) -> Result<Vec<String>, DeclaredQueuesManifestError> {
resolve_declared_queues_from_sources(|p| std::fs::read_to_string(p).ok(), table)
}
fn resolve_declared_queues_from_sources<F>(
read_file: F,
table: Option<&toml::Table>,
) -> Result<Vec<String>, DeclaredQueuesManifestError>
where
F: Fn(&str) -> Option<String>,
{
let Some(fleet) = table
.and_then(|t| t.get("jobs"))
.and_then(toml::Value::as_table)
.and_then(|j| j.get("fleet"))
.and_then(toml::Value::as_table)
else {
return Ok(Vec::new());
};
if let Some(path) = fleet.get("manifest").and_then(toml::Value::as_str)
&& let Some(contents) = read_file(path)
{
match crate::jobs::parse_manifest_queues(&contents) {
Ok(crate::jobs::ManifestQueues::Present(queues)) => return Ok(queues),
Ok(crate::jobs::ManifestQueues::Absent)
| Err(crate::jobs::ManifestQueuesError::NotToml(_)) => {
}
Err(crate::jobs::ManifestQueuesError::BadQueuesArray(detail)) => {
return Err(DeclaredQueuesManifestError {
manifest_path: path.to_owned(),
detail,
});
}
}
}
Ok(fleet
.get("declared_queues")
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default())
}
fn first_env<F>(env_var: &F, keys: &[&str]) -> Option<String>
where
F: Fn(&str) -> Option<String>,
{
keys.iter().find_map(|key| {
env_var(key)
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
})
}
fn first_toml_string(database: Option<&toml::Table>, keys: &[&str]) -> Option<String> {
let database = database?;
keys.iter().find_map(|key| {
database
.get(*key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
})
}
fn parse_config_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn resolve_active_profiles() -> (String, String, Vec<String>) {
let raw_profile = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| std::env::var("AUTUMN_PROFILE").ok())
.filter(|v| !v.trim().is_empty())
.unwrap_or_default();
let raw_trimmed = raw_profile.trim().to_owned();
let canonical = if raw_trimmed.eq_ignore_ascii_case("production")
|| raw_trimmed.eq_ignore_ascii_case("prod")
{
"prod".to_owned()
} else if raw_trimmed.eq_ignore_ascii_case("development")
|| raw_trimmed.eq_ignore_ascii_case("dev")
|| raw_trimmed.is_empty()
{
"dev".to_owned()
} else {
raw_trimmed.clone()
};
let alias = match canonical.as_str() {
"prod" => Some("production".to_owned()),
"dev" => Some("development".to_owned()),
_ => None,
};
let profiles: Vec<String> = alias
.into_iter()
.chain(std::iter::once(canonical.clone()))
.collect();
(canonical, raw_trimmed, profiles)
}
fn resolve_proxy_conflict_data() -> ProxyConflictData {
let (canonical, selected, _profiles) = resolve_active_profiles();
let table = get_merged_toml_table_runtime(&canonical, &selected);
let parse_csv_env = |var: &str| -> Option<Vec<String>> {
std::env::var(var).ok().map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(std::borrow::ToOwned::to_owned)
.collect()
})
};
let parse_bool_env = |var: &str| -> Option<bool> {
std::env::var(var)
.ok()
.map(|v| matches!(v.trim(), "true" | "1"))
};
let security = table.get("security");
let tp_section = security.and_then(|s| s.get("trusted_proxies"));
let rl_section = security.and_then(|s| s.get("rate_limit"));
let toml_csv = |section: Option<&toml::Value>, key: &str| -> Vec<String> {
section
.and_then(|s| s.get(key))
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(toml::Value::as_str)
.map(std::borrow::ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
};
let toml_bool = |section: Option<&toml::Value>, key: &str| -> bool {
section
.and_then(|s| s.get(key))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
};
ProxyConflictData {
new_ranges: parse_csv_env("AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES")
.unwrap_or_else(|| toml_csv(tp_section, "ranges")),
new_trust_fwd: parse_bool_env("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS")
.unwrap_or_else(|| toml_bool(tp_section, "trust_forwarded_headers")),
new_hops: std::env::var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.or_else(|| {
tp_section
.and_then(|s| s.get("trusted_hops"))
.and_then(toml::Value::as_integer)
.and_then(|v| u32::try_from(v).ok())
}),
old_ranges: parse_csv_env("AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES")
.unwrap_or_else(|| toml_csv(rl_section, "trusted_proxies")),
old_trust_fwd: parse_bool_env("AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS")
.unwrap_or_else(|| toml_bool(rl_section, "trust_forwarded_headers")),
}
}
fn resolve_deprecations() -> Vec<DoctorDeprecation> {
use autumn_web::config::{OsEnv, deprecated_config_keys, detect_deprecated_keys_for};
let (canonical, selected, _profiles) = resolve_active_profiles();
let file_table = get_merged_toml_table_runtime(&canonical, &selected);
detect_deprecated_keys_for(&canonical, &file_table, &OsEnv, deprecated_config_keys())
.into_iter()
.map(|f| DoctorDeprecation {
path: f.path,
replacement: f.replacement,
since: f.since,
remove_in: f.remove_in,
})
.collect()
}
fn resolve_rate_limit_key_strategy() -> String {
if let Ok(val) = std::env::var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY")
&& !val.is_empty()
{
return val;
}
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| {
t.get("security")
.and_then(|s| s.get("rate_limit"))
.and_then(|rl| rl.get("key_strategy"))
.and_then(toml::Value::as_str)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
})
.unwrap_or_default()
}
fn resolve_auth_extractor_mounted() -> bool {
if let Ok(val) = std::env::var("AUTUMN_AUTH__ENABLED") {
return !val.trim().eq_ignore_ascii_case("false");
}
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.is_some_and(|t| {
t.get("auth").is_some_and(|auth| {
auth.get("enabled")
.and_then(toml::Value::as_bool)
.unwrap_or(true) })
})
}
fn resolve_optional_signing_secret() -> Option<String> {
if let Ok(val) = std::env::var("AUTUMN_SECURITY__SIGNING_SECRET")
&& !val.is_empty()
{
return Some(val);
}
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| {
t.get("security")
.and_then(|s| s.get("signing_secret"))
.and_then(|ss| ss.get("secret"))
.and_then(toml::Value::as_str)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
})
}
fn resolve_deploy_signing_secret(
merged: &toml::Table,
env: &dyn autumn_web::config::Env,
) -> Option<String> {
if let Ok(val) = env.var("AUTUMN_SECURITY__SIGNING_SECRET")
&& !val.is_empty()
{
return Some(val);
}
merged
.get("security")
.and_then(|s| s.get("signing_secret"))
.and_then(|ss| ss.get("secret"))
.and_then(toml::Value::as_str)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
}
fn resolve_deploy_previous_signing_secrets(merged: &toml::Table) -> Result<Vec<String>, String> {
let Some(value) = merged
.get("security")
.and_then(|s| s.get("signing_secret"))
.and_then(|ss| ss.get("previous_secrets"))
else {
return Ok(vec![]);
};
let Some(arr) = value.as_array() else {
return Err("previous_secrets must be an array of strings".into());
};
let mut out = Vec::with_capacity(arr.len());
for (i, elem) in arr.iter().enumerate() {
let Some(s) = elem.as_str() else {
return Err(format!("previous_secrets[{i}] must be a string"));
};
out.push(s.to_owned());
}
Ok(out)
}
fn resolve_deploy_db_backed_runtime(
merged: &toml::Table,
env: &dyn autumn_web::config::Env,
) -> bool {
let env_var = |key: &str| env.var(key).ok().filter(|value| !value.is_empty());
let jobs = merged.get("jobs").and_then(toml::Value::as_table);
let jobs_db_backed = matches!(
first_env(&env_var, &["AUTUMN_JOBS__BACKEND"])
.or_else(|| first_toml_string(jobs, &["backend"]))
.as_deref()
.map(str::trim),
Some("postgres" | "sqlite")
);
let scheduler = merged.get("scheduler").and_then(toml::Value::as_table);
let scheduler_db_backed = first_env(&env_var, &["AUTUMN_SCHEDULER__BACKEND"])
.or_else(|| first_toml_string(scheduler, &["backend"]))
.as_deref()
.and_then(autumn_web::config::SchedulerBackend::from_env_value)
.is_some_and(|backend| {
matches!(
backend,
autumn_web::config::SchedulerBackend::Postgres
| autumn_web::config::SchedulerBackend::Sqlite
)
});
jobs_db_backed || scheduler_db_backed
}
fn resolve_trusted_hosts() -> Vec<String> {
if let Ok(val) = std::env::var("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS") {
return val
.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
.collect();
}
let table = read_autumn_toml_table().unwrap_or_default();
let profile = std::env::var("AUTUMN_ENV")
.ok()
.or_else(|| std::env::var("AUTUMN_PROFILE").ok())
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let parse_hosts = |root: &toml::Table| {
root.get("security")
.and_then(|s| s.get("trusted_hosts"))
.and_then(|th| th.get("hosts"))
.and_then(toml::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(toml::Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
if !profile.is_empty()
&& let Some(profile_table) = table
.get("profile")
.and_then(|v| v.get(&profile))
.and_then(toml::Value::as_table)
{
let profile_hosts = parse_hosts(profile_table);
if !profile_hosts.is_empty() {
return profile_hosts;
}
}
parse_hosts(&table)
}
const TLS_ENV_VAR_NAMES: [&str; 4] = [
"AUTUMN_SERVER__TLS__CERT_PATH",
"AUTUMN_SERVER__TLS__KEY_PATH",
"AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS",
"AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS",
];
fn any_tls_env_var_set_in(env: &dyn autumn_web::config::Env) -> bool {
TLS_ENV_VAR_NAMES.iter().any(|name| env.var(name).is_ok())
}
fn resolve_tls_paths_from_sources(
tls_section_present: bool,
toml_cert: Option<String>,
toml_key: Option<String>,
env_cert: Option<String>,
env_key: Option<String>,
any_tls_env: bool,
) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let cert = env_cert.or(toml_cert);
let key = env_key.or(toml_key);
if !(tls_section_present || any_tls_env) {
return None;
}
Some((
std::path::PathBuf::from(cert.unwrap_or_default()),
std::path::PathBuf::from(key.unwrap_or_default()),
))
}
fn resolve_tls_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let (canonical, selected, _) = resolve_active_profiles();
let merged = get_merged_toml_table_runtime_with(
&canonical,
&selected,
resolve_config_path_manifest_aware,
);
let tls = merged
.get("server")
.and_then(toml::Value::as_table)
.and_then(|s| s.get("tls"))
.and_then(toml::Value::as_table);
let denv: Box<dyn autumn_web::config::Env> =
match autumn_web::dotenv::os_env_with_dotenv_for_profile(&canonical) {
Ok(e) => Box::new(e),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
let env_cert = denv.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
let env_key = denv.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
let toml_cert = tls
.and_then(|t| t.get("cert_path"))
.and_then(toml::Value::as_str)
.map(str::to_owned);
let toml_key = tls
.and_then(|t| t.get("key_path"))
.and_then(toml::Value::as_str)
.map(str::to_owned);
let any_tls_env = any_tls_env_var_set_in(denv.as_ref());
resolve_tls_paths_from_sources(
tls.is_some(),
toml_cert,
toml_key,
env_cert,
env_key,
any_tls_env,
)
}
fn resolve_static_tls_presence() -> (bool, bool) {
let (canonical, selected, _) = resolve_active_profiles();
let merged = get_merged_toml_table_runtime(&canonical, &selected);
let tls = merged
.get("server")
.and_then(toml::Value::as_table)
.and_then(|s| s.get("tls"))
.and_then(toml::Value::as_table);
let denv: Box<dyn autumn_web::config::Env> =
match autumn_web::dotenv::os_env_with_dotenv_for_profile(&canonical) {
Ok(e) => Box::new(e),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
let (cert_env, key_env) = static_tls_env_presence_in(denv.as_ref());
static_tls_presence_from(tls, cert_env, key_env)
}
fn static_tls_env_presence_in(env: &dyn autumn_web::config::Env) -> (bool, bool) {
(
env.var("AUTUMN_SERVER__TLS__CERT_PATH").is_ok(),
env.var("AUTUMN_SERVER__TLS__KEY_PATH").is_ok(),
)
}
fn static_tls_presence_from(
tls: Option<&toml::Table>,
cert_env_present: bool,
key_env_present: bool,
) -> (bool, bool) {
let cert_present = tls.is_some_and(|t| t.contains_key("cert_path")) || cert_env_present;
let key_present = tls.is_some_and(|t| t.contains_key("key_path")) || key_env_present;
(cert_present, key_present)
}
fn resolve_tls_doctor_data() -> TlsDoctorData {
let Some((cert, key)) = resolve_tls_paths() else {
return TlsDoctorData::NotConfigured;
};
if cert.as_os_str().is_empty() || key.as_os_str().is_empty() {
return TlsDoctorData::Invalid {
detail: "[server.tls] must set both cert_path and key_path".to_owned(),
};
}
#[cfg(feature = "tls")]
{
let now = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
)
.unwrap_or(i64::MAX);
match autumn_web::tls::inspect_leaf(&cert, &key, now) {
Ok(inspection) => TlsDoctorData::Healthy {
not_yet_valid: inspection.is_not_yet_valid(now),
expired: inspection.is_expired(now),
days_until_expiry: inspection.days_until_expiry(now),
},
Err(e) => TlsDoctorData::Invalid {
detail: e.to_string(),
},
}
}
#[cfg(not(feature = "tls"))]
{
let _ = (cert, key);
TlsDoctorData::FeatureDisabled
}
}
fn resolve_client_auth_doctor_data(tls: Option<&toml::Table>) -> ClientAuthDoctorData {
let section = match tls.and_then(|t| t.get("client_auth")) {
None => return ClientAuthDoctorData::NotConfigured,
Some(toml::Value::Table(section)) => section,
Some(other) => {
return ClientAuthDoctorData::Invalid {
detail: format!(
"[server.tls] client_auth must be a table (a `[server.tls.client_auth]` \
section); found {other}"
),
};
}
};
let mode = match section.get("mode") {
None => "off".to_owned(),
Some(value) => match value.as_str() {
Some(m @ ("off" | "optional" | "required")) => m.to_owned(),
_ => {
return ClientAuthDoctorData::Invalid {
detail: format!(
"[server.tls.client_auth] mode must be one of \"off\", \"optional\" or \
\"required\"; found {value}"
),
};
}
},
};
if let Some(detail) = client_auth_key_shape_error(section) {
return ClientAuthDoctorData::Invalid { detail };
}
let required_path_count = section
.get("required_paths")
.and_then(toml::Value::as_array)
.map_or(0, Vec::len);
if mode == "off" {
if required_path_count > 0 {
return ClientAuthDoctorData::Invalid {
detail: "[server.tls.client_auth] lists required_paths but mode = \"off\", so no \
certificate is ever requested and those routes would reject every \
request"
.to_owned(),
};
}
return ClientAuthDoctorData::ModeOff;
}
let bundle = section
.get("ca_bundle_path")
.and_then(toml::Value::as_str)
.unwrap_or_default();
if bundle.is_empty() {
return ClientAuthDoctorData::Invalid {
detail: format!(
"[server.tls.client_auth] mode = \"{mode}\" needs ca_bundle_path — the PEM \
bundle of client CAs to verify against"
),
};
}
let crl = section
.get("crl_path")
.and_then(toml::Value::as_str)
.filter(|p| !p.is_empty());
let validated = toml::Value::Table(section.clone())
.try_into::<autumn_web::config::ClientAuthConfig>()
.map_err(|e| e.to_string())
.and_then(|c| c.validate());
if let Err(detail) = validated {
return ClientAuthDoctorData::Invalid { detail };
}
grade_client_auth_trust_store(mode, bundle, crl, required_path_count)
}
fn client_auth_key_shape_error(section: &toml::Table) -> Option<String> {
let bad = |key: &str, want: &str, value: &toml::Value| {
Some(format!(
"[server.tls.client_auth] {key} must be {want}; found {value}"
))
};
if let Some(value) = section.get("required_paths") {
let all_strings = value
.as_array()
.is_some_and(|a| a.iter().all(toml::Value::is_str));
if !all_strings {
return bad("required_paths", "an array of path-prefix strings", value);
}
}
if let Some(value) = section.get("crl_path")
&& value.as_str().is_none_or(|p| p.trim().is_empty())
{
return bad("crl_path", "a non-empty string path", value);
}
if let Some(value) = section.get("ca_bundle_path")
&& !value.is_str()
{
return bad("ca_bundle_path", "a string path", value);
}
if let Some(value) = section.get("reload_interval_secs")
&& value.as_integer().is_none_or(|n| n < 0)
{
return bad("reload_interval_secs", "a non-negative integer", value);
}
None
}
fn grade_client_auth_trust_store(
mode: String,
bundle: &str,
crl: Option<&str>,
required_path_count: usize,
) -> ClientAuthDoctorData {
#[cfg(feature = "tls")]
{
let now = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
)
.unwrap_or(i64::MAX);
let cas = match autumn_web::tls::client_auth::inspect_client_ca_bundle(
std::path::Path::new(bundle),
) {
Ok(cas) => cas,
Err(e) => {
return ClientAuthDoctorData::Invalid {
detail: e.to_string(),
};
}
};
let (crl_stale, crl_coverage_gaps) = match crl {
Some(path) => {
let path = std::path::Path::new(path);
let inspection = match autumn_web::tls::client_auth::inspect_crl(path) {
Ok(inspection) => inspection,
Err(e) => {
return ClientAuthDoctorData::Invalid {
detail: e.to_string(),
};
}
};
let gaps = match autumn_web::tls::client_auth::crl_coverage_gaps(
std::path::Path::new(bundle),
path,
) {
Ok(gaps) => gaps,
Err(e) => {
return ClientAuthDoctorData::Invalid {
detail: e.to_string(),
};
}
};
(Some(inspection.is_stale(now)), gaps)
}
None => (None, Vec::new()),
};
let expired_cas: Vec<String> = cas
.iter()
.filter(|ca| ca.is_expired(now))
.map(|ca| ca.subject.clone())
.collect();
let near_expiry_cas: Vec<(String, i64)> = cas
.iter()
.filter(|ca| !ca.is_expired(now))
.map(|ca| (ca.subject.clone(), ca.days_until_expiry(now)))
.filter(|(_, days)| *days <= CLIENT_CA_EXPIRY_WARN_DAYS)
.collect();
ClientAuthDoctorData::Healthy {
mode,
expired_cas,
near_expiry_cas,
ca_count: cas.len(),
crl_stale,
crl_coverage_gaps,
required_path_count,
}
}
#[cfg(not(feature = "tls"))]
{
let _ = (mode, bundle, crl, required_path_count);
ClientAuthDoctorData::FeatureDisabled
}
}
const fn should_run_static_tls_check(acme_configured: bool, static_cert_key_present: bool) -> bool {
static_cert_key_present || !acme_configured
}
#[must_use]
pub fn check_tls_mode_impl(
has_cert: bool,
has_key: bool,
acme_configured: bool,
) -> Option<CheckResult> {
let static_configured = has_cert || has_key;
match (static_configured, acme_configured) {
(true, true) => Some(CheckResult {
name: "tls_mode",
status: CheckStatus::Fail,
detail: Some(
"[server.tls] sets a static cert_path/key_path AND [server.tls.acme]; choose \
exactly one — remove the static cert to use ACME, or remove [server.tls.acme] \
to serve the static certificate"
.into(),
),
hint: Some(
"Static TLS cert/key and [server.tls.acme] are mutually exclusive; configure \
exactly one",
),
}),
(true, false) if !(has_cert && has_key) => Some(CheckResult {
name: "tls_mode",
status: CheckStatus::Fail,
detail: Some(
"[server.tls] cert_path and key_path must be set together; set both, or \
configure [server.tls.acme] instead"
.into(),
),
hint: Some("Set both cert_path and key_path"),
}),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct AcmeDoctorConfig {
pub domains: Vec<String>,
pub contact_email: String,
pub http_challenge_port: u16,
pub renew_before_days: u32,
pub cache_dir: std::path::PathBuf,
pub directory_label: String,
pub directory_error: Option<String>,
pub port_error: Option<String>,
pub ca_root_path: Option<std::path::PathBuf>,
pub ca_root_error: Option<String>,
pub domains_error: Option<String>,
pub renew_before_days_error: Option<String>,
pub cache_dir_error: Option<String>,
pub contact_email_error: Option<String>,
pub section_error: Option<String>,
pub dns: Option<autumn_web::config::AcmeDnsConfig>,
pub dns_error: Option<String>,
pub custom_domains: Option<autumn_web::config::CustomDomainsConfig>,
pub custom_domains_error: Option<String>,
}
fn parse_acme_directory(acme: &toml::Table) -> Result<autumn_web::config::AcmeDirectory, String> {
acme.get("directory").cloned().map_or_else(
|| Ok(autumn_web::config::AcmeDirectory::default()),
|value| {
let rendered = value.to_string();
value
.try_into::<autumn_web::config::AcmeDirectory>()
.map_err(|_| rendered.trim().to_owned())
},
)
}
fn parse_acme_scalar<T: serde::de::DeserializeOwned>(
acme: &toml::Table,
key: &str,
default: T,
) -> Result<T, String> {
acme.get(key).cloned().map_or_else(
|| Ok(default),
|value| {
let rendered = value.to_string();
value
.try_into::<T>()
.map_err(|_| rendered.trim().to_owned())
},
)
}
fn parse_acme_http_challenge_port(acme: &toml::Table) -> Result<u16, String> {
parse_acme_scalar(acme, "http_challenge_port", 80)
}
fn parse_acme_renew_before_days(acme: &toml::Table) -> Result<u32, String> {
parse_acme_scalar(acme, "renew_before_days", 30)
}
fn acme_directory_label(directory: &autumn_web::config::AcmeDirectory) -> String {
match directory {
autumn_web::config::AcmeDirectory::Staging => "staging".to_owned(),
autumn_web::config::AcmeDirectory::Production => "production".to_owned(),
autumn_web::config::AcmeDirectory::Custom { url } => {
format!("custom-{}", acme_short_hash(url))
}
}
}
fn acme_short_hash(input: &str) -> String {
use sha2::{Digest as _, Sha256};
let digest = Sha256::digest(input.as_bytes());
let mut out = String::with_capacity(16);
for byte in &digest[..8] {
let _ = write!(out, "{byte:02x}");
}
out
}
fn resolve_acme_doctor_config(toml_table: Option<&toml::Table>) -> Option<AcmeDoctorConfig> {
let acme_value = toml_table?
.get("server")
.and_then(toml::Value::as_table)?
.get("tls")
.and_then(toml::Value::as_table)?
.get("acme")?;
let Some(acme) = acme_value.as_table() else {
let empty = toml::Table::new();
let mut config = resolve_acme_fields(&empty);
config.section_error = Some(acme_value.to_string().trim().to_owned());
return Some(config);
};
Some(resolve_acme_fields(acme))
}
fn resolve_acme_fields(acme: &toml::Table) -> AcmeDoctorConfig {
let mut domains = Vec::new();
let mut domains_error = None;
match acme.get("domains") {
None => {}
Some(toml::Value::Array(array)) => {
for (index, entry) in array.iter().enumerate() {
let Some(domain) = entry.as_str() else {
domains_error = Some(format!(
"entry at index {index} ({}) is not a string",
entry.to_string().trim()
));
break;
};
domains.push(domain.to_owned());
}
}
Some(other) => {
domains_error = Some(format!("value {} is not a list", other.to_string().trim()));
}
}
let (contact_email, contact_email_error) =
match parse_acme_scalar(acme, "contact_email", String::new()) {
Ok(email) => (email, None),
Err(bad_value) => (String::new(), Some(bad_value)),
};
let (http_challenge_port, port_error) = match parse_acme_http_challenge_port(acme) {
Ok(port) => (port, None),
Err(bad_value) => (80, Some(bad_value)),
};
let (renew_before_days, renew_before_days_error) = match parse_acme_renew_before_days(acme) {
Ok(days) => (days, None),
Err(bad_value) => (30, Some(bad_value)),
};
let (cache_dir, cache_dir_error) =
match parse_acme_scalar(acme, "cache_dir", std::path::PathBuf::from("config/acme")) {
Ok(dir) => (dir, None),
Err(bad_value) => (std::path::PathBuf::from("config/acme"), Some(bad_value)),
};
let (directory_label, directory_error) = match parse_acme_directory(acme) {
Ok(directory) => (acme_directory_label(&directory), None),
Err(bad_value) => ("staging".to_owned(), Some(bad_value)),
};
let (ca_root_path, ca_root_error) = match acme.get("ca_root_path") {
None => (None, None),
Some(toml::Value::String(path)) => (Some(std::path::PathBuf::from(path)), None),
Some(other) => (None, Some(other.to_string())),
};
let (dns, dns_error) =
acme.get("dns").map_or((None, None), |value| match value
.clone()
.try_into::<autumn_web::config::AcmeDnsConfig>(
) {
Ok(dns) => (Some(dns), None),
Err(e) => (None, Some(e.to_string())),
});
let (custom_domains, custom_domains_error) =
acme.get("custom_domains").map_or((None, None), |value| {
match value
.clone()
.try_into::<autumn_web::config::CustomDomainsConfig>()
{
Ok(cd) => (Some(cd), None),
Err(e) => (None, Some(e.to_string())),
}
});
AcmeDoctorConfig {
domains,
contact_email,
http_challenge_port,
renew_before_days,
cache_dir,
directory_label,
directory_error,
port_error,
ca_root_path,
ca_root_error,
domains_error,
renew_before_days_error,
cache_dir_error,
contact_email_error,
section_error: None,
dns,
dns_error,
custom_domains,
custom_domains_error,
}
}
const fn acme_config_fail(detail: String, hint: &'static str) -> CheckResult {
CheckResult {
name: "acme_config",
status: CheckStatus::Fail,
detail: Some(detail),
hint: Some(hint),
}
}
fn check_acme_deserialize_errors(config: &AcmeDoctorConfig) -> Option<CheckResult> {
let AcmeDoctorConfig {
directory_error,
port_error,
domains_error,
renew_before_days_error,
cache_dir_error,
contact_email_error,
section_error,
..
} = config;
if let Some(bad_value) = section_error {
return Some(acme_config_fail(
format!(
"[server.tls] acme value {bad_value} is not valid: [server.tls.acme] must be a \
table. The runtime fails to boot on any other type"
),
"Write [server.tls.acme] as a table with its own keys",
));
}
if let Some(bad_value) = domains_error {
return Some(acme_config_fail(
format!(
"[server.tls.acme] domains {bad_value}: it must be a list of string hostnames. \
The runtime fails to boot on any other type"
),
"List only string hostnames in [server.tls.acme] domains",
));
}
if let Some(bad_value) = directory_error {
return Some(acme_config_fail(
format!(
"[server.tls.acme] directory value {bad_value} is not a valid ACME directory: use \
\"staging\", \"production\", or a custom directory URL. The runtime fails to boot \
on this value"
),
"Set [server.tls.acme] directory to \"staging\", \"production\", or a custom directory \
URL",
));
}
if let Some(bad_value) = port_error {
return Some(acme_config_fail(
format!(
"[server.tls.acme] http_challenge_port value {bad_value} is not a valid port: it \
must be an integer in the range 0-65535 (the runtime fails to boot on an \
out-of-range or non-integer value)"
),
"Set [server.tls.acme] http_challenge_port to a valid port number (80, or the port a \
front-end forwards `:80` to)",
));
}
if let Some(bad_value) = renew_before_days_error {
return Some(acme_config_fail(
format!(
"[server.tls.acme] renew_before_days value {bad_value} is not a valid renewal \
window: it must be a whole number of days in the range 0-4294967295 (the runtime \
fails to boot on a negative, out-of-range, or non-integer value such as a quoted \
string)"
),
"Set [server.tls.acme] renew_before_days to a whole number of days below 90 (default \
30), unquoted",
));
}
if let Some(bad_value) = cache_dir_error {
return Some(acme_config_fail(
format!(
"[server.tls.acme] cache_dir value {bad_value} is not a valid path: it must be \
a string. The runtime fails to boot on any other type"
),
"Set [server.tls.acme] cache_dir to a quoted directory path",
));
}
if let Some(bad_value) = contact_email_error {
return Some(acme_config_fail(
format!(
"[server.tls.acme] contact_email value {bad_value} is not valid: it must be a \
string. The runtime fails to boot on any other type"
),
"Set [server.tls.acme] contact_email to a quoted email address",
));
}
None
}
#[must_use]
pub fn check_acme_config_impl(config: &AcmeDoctorConfig) -> Option<CheckResult> {
let AcmeDoctorConfig {
domains,
contact_email,
http_challenge_port,
renew_before_days,
..
} = config;
if let Some(deserialize_fail) = check_acme_deserialize_errors(config) {
return Some(deserialize_fail);
}
if domains.is_empty() {
return Some(acme_config_fail(
"[server.tls.acme] domains must list at least one domain to request a certificate for"
.to_owned(),
"Add at least one domain to [server.tls.acme] domains",
));
}
if contact_email.trim().is_empty() {
return Some(acme_config_fail(
"[server.tls.acme] contact_email must be set (the ACME CA requires an account contact \
for expiry notifications)"
.to_owned(),
"Set [server.tls.acme] contact_email",
));
}
if *http_challenge_port == 0 {
return Some(acme_config_fail(
"[server.tls.acme] http_challenge_port must not be 0: port 0 binds an ephemeral \
OS-assigned port that the ACME HTTP-01 validator (which always connects on port 80) \
can never reach, so every issuance fails. Use 80, or the port a front-end forwards \
`:80` to"
.to_owned(),
"Set [server.tls.acme] http_challenge_port to 80 (or the port `:80` forwards to)",
));
}
if *renew_before_days >= 90 {
return Some(acme_config_fail(
format!(
"[server.tls.acme] renew_before_days ({renew_before_days}) must be less than 90: \
it is compared against the issued certificate's remaining validity, and \
publicly-trusted CAs (e.g. Let's Encrypt) issue certificates that live at most \
~90 days. A value >= the certificate lifetime keeps the cert perpetually inside \
its renew-before window, so the renewal loop would order a fresh certificate \
every hour and burn the CA's rate limits"
),
"Set [server.tls.acme] renew_before_days below 90 (default 30)",
));
}
if let Some(fail) = check_acme_domain_entries(domains, config.dns.is_some()) {
return Some(fail);
}
config.dns.as_ref().and_then(|dns| {
dns.validate().err().map(|message| {
acme_config_fail(
message,
"Fix the [server.tls.acme.dns] section; the server refuses to boot on it",
)
})
})
}
fn check_acme_domain_entries(domains: &[String], dns_configured: bool) -> Option<CheckResult> {
for (index, domain) in domains.iter().enumerate() {
let trimmed = domain.trim();
if trimmed.is_empty() {
return Some(acme_config_fail(
format!(
"[server.tls.acme] domains must not contain blank entries (entry at index \
{index} is empty or whitespace-only)"
),
"Remove blank/whitespace-only entries from [server.tls.acme] domains",
));
}
if trimmed.contains('*') {
if !dns_configured {
return Some(acme_config_fail(
format!(
"[server.tls.acme] wildcard domain `{trimmed}` needs the DNS-01 \
challenge: an ACME CA will not validate a wildcard identifier over \
HTTP-01. Add a [server.tls.acme.dns] section naming your DNS provider \
(and the credentials-store key holding its API token), or list explicit \
hostnames instead"
),
"Add a [server.tls.acme.dns] section, or list explicit hostnames",
));
}
let Some(base) = trimmed.strip_prefix("*.") else {
return Some(acme_config_fail(
format!(
"[server.tls.acme] domain `{trimmed}` is not a usable wildcard: a \
wildcard SAN must be written as `*.` followed by the base domain (e.g. \
`*.myapp.com`) — a `*` anywhere else is not matched by any client"
),
"Write the wildcard as `*.<base domain>`",
));
};
if base.is_empty() || base.contains('*') {
return Some(acme_config_fail(
format!(
"[server.tls.acme] domain `{trimmed}` is not a usable wildcard: exactly \
one leading `*.` is allowed and the base domain after it must be \
non-empty (e.g. `*.myapp.com`)"
),
"Write the wildcard as `*.<base domain>`, with exactly one leading `*.`",
));
}
}
if domain != trimmed {
return Some(acme_config_fail(
format!(
"[server.tls.acme] domain `{domain}` (entry at index {index}) has leading or \
trailing whitespace: the entry is used verbatim as the certificate's SAN and \
as the ACME order's DNS identifier, so the padded value would be requested \
as-is. Write it as `{trimmed}`"
),
"Remove the leading/trailing whitespace from the [server.tls.acme] domains entry",
));
}
}
None
}
fn acme_online_probe_domains(config: &AcmeDoctorConfig) -> Vec<String> {
let mut probed: Vec<String> = Vec::new();
for domain in &config.domains {
let target = domain.strip_prefix("*.").unwrap_or(domain).to_owned();
if !target.is_empty() && !probed.contains(&target) {
probed.push(target);
}
}
probed
}
const WILDCARD_PROBE_LABEL: &str = "doctor-check";
fn acme_online_reachability_hosts(config: &AcmeDoctorConfig) -> Vec<String> {
let mut hosts: Vec<String> = Vec::new();
for domain in &config.domains {
let host = match domain.strip_prefix("*.") {
Some("") => continue,
Some(base) => format!("{WILDCARD_PROBE_LABEL}.{base}"),
None => domain.clone(),
};
if !host.is_empty() && !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
fn resolve_acme_stored_cert_data(
cache_dir: &std::path::Path,
directory_label: &str,
domains: &[String],
) -> TlsDoctorData {
let Some((chain, key)) = configured_acme_cert_pair(cache_dir, directory_label, domains) else {
return TlsDoctorData::NotConfigured;
};
#[cfg(feature = "tls")]
{
let now = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
)
.unwrap_or(i64::MAX);
match autumn_web::tls::inspect_leaf(&chain, &key, now) {
Ok(inspection) => TlsDoctorData::Healthy {
not_yet_valid: inspection.is_not_yet_valid(now),
expired: inspection.is_expired(now),
days_until_expiry: inspection.days_until_expiry(now),
},
Err(e) => TlsDoctorData::Invalid {
detail: e.to_string(),
},
}
}
#[cfg(not(feature = "tls"))]
{
let _ = (chain, key);
TlsDoctorData::FeatureDisabled
}
}
#[cfg(feature = "tls")]
fn configured_acme_cert_pair(
cache_dir: &std::path::Path,
directory_label: &str,
domains: &[String],
) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
autumn_web::acme::store::FsAcmeStore::new(cache_dir, directory_label)
.find_cert_for_domains(domains)
}
#[cfg(not(feature = "tls"))]
fn configured_acme_cert_pair(
cache_dir: &std::path::Path,
directory_label: &str,
domains: &[String],
) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let cert_dir = cache_dir.join(directory_label);
let id = acme_cert_id(domains);
let chain = cert_dir.join(format!("{id}.chain.pem"));
let key = cert_dir.join(format!("{id}.key.pem"));
if chain.exists() && key.exists() {
Some((chain, key))
} else {
None
}
}
#[cfg(any(not(feature = "tls"), test))]
fn acme_cert_id(domains: &[String]) -> String {
use sha2::{Digest as _, Sha256};
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
sorted.sort_unstable();
sorted.dedup();
let mut hasher = Sha256::new();
for domain in sorted {
hasher.update(domain.as_bytes());
hasher.update(b"\0");
}
let digest = hasher.finalize();
let mut out = String::with_capacity(32);
for byte in &digest[..16] {
let _ = write!(out, "{byte:02x}");
}
out
}
const MAX_CA_ROOT_BYTES: u64 = 1 << 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcmeCaRootData {
NotConfigured,
MissingForPrivateDirectory,
Malformed {
value: String,
},
Blank,
Usable,
ExtraCertificatesIgnored {
path: String,
certificates: usize,
},
UnneededForPublicDirectory {
path: String,
directory: String,
},
Unreadable {
path: String,
reason: String,
},
NotACertificate {
path: String,
reason: String,
},
}
fn check_acme_ca_root_failure(data: &AcmeCaRootData) -> CheckResult {
match data {
AcmeCaRootData::Malformed { value } => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Fail,
detail: Some(format!(
"[server.tls.acme] ca_root_path is {value}, not a string; the server will not \
start with this value"
)),
hint: Some("Set ca_root_path to a quoted path, e.g. \"config/ca-root.pem\""),
},
AcmeCaRootData::Blank => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Fail,
detail: Some(
"[server.tls.acme] ca_root_path is set but blank; the server refuses to start \
with it"
.into(),
),
hint: Some(
"Remove ca_root_path to use the platform trust store, or point it at the PEM \
root that signs your ACME directory's HTTPS certificate",
),
},
AcmeCaRootData::Unreadable { path, reason } => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Fail,
detail: Some(format!(
"[server.tls.acme] ca_root_path {path} cannot be read ({reason}); the ACME \
client would trust no roots at all and every order would fail its TLS handshake"
)),
hint: Some(
"Point ca_root_path at the PEM root that signs your ACME directory's HTTPS \
certificate, or remove it to use the platform trust store",
),
},
AcmeCaRootData::NotACertificate { path, reason } => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Fail,
detail: Some(format!(
"[server.tls.acme] ca_root_path {path} yields no usable trust anchor ({reason})"
)),
hint: Some(
"ca_root_path must be a PEM file whose first section is the ACME directory's \
root certificate (a `-----BEGIN CERTIFICATE-----` block)",
),
},
AcmeCaRootData::NotConfigured
| AcmeCaRootData::MissingForPrivateDirectory
| AcmeCaRootData::Usable
| AcmeCaRootData::ExtraCertificatesIgnored { .. }
| AcmeCaRootData::UnneededForPublicDirectory { .. } => {
unreachable!("advisory ca_root_path outcomes are graded by check_acme_ca_root_impl")
}
}
}
#[must_use]
pub fn check_acme_ca_root_impl(data: &AcmeCaRootData) -> CheckResult {
match data {
AcmeCaRootData::NotConfigured => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Pass,
detail: Some(
"no ca_root_path configured; the ACME client uses the platform trust store \
(correct for Let's Encrypt staging and production)"
.into(),
),
hint: None,
},
AcmeCaRootData::MissingForPrivateDirectory => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Warn,
detail: Some(
"[server.tls.acme] sets a custom directory but no ca_root_path, so the ACME \
client falls back to the platform trust store; unless that directory's root is \
installed host-wide, the TLS handshake to it fails and every order dies before \
an authorization is created"
.into(),
),
hint: Some(
"Point ca_root_path at the PEM root that signs your ACME directory's HTTPS \
certificate, or install it in the host trust store",
),
},
AcmeCaRootData::Usable => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Pass,
detail: Some("ca_root_path holds a usable trust anchor for the ACME directory".into()),
hint: None,
},
AcmeCaRootData::ExtraCertificatesIgnored { path, certificates } => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Warn,
detail: Some(format!(
"[server.tls.acme] ca_root_path {path} holds {certificates} certificates, but \
only the FIRST is installed as a trust anchor; if the root is not first, every \
ACME order will fail its TLS handshake"
)),
hint: Some(
"Point ca_root_path at a file containing just the ACME directory's root \
certificate, not a leaf/intermediate bundle",
),
},
AcmeCaRootData::UnneededForPublicDirectory { path, directory } => CheckResult {
name: "acme_ca_root",
status: CheckStatus::Warn,
detail: Some(format!(
"[server.tls.acme] ca_root_path {path} is set with directory = \"{directory}\"; \
Let's Encrypt's API endpoint is publicly trusted, and this replaces the \
platform trust store with just that root, so orders will fail unless it \
happens to sign Let's Encrypt's endpoint"
)),
hint: Some(
"Remove ca_root_path for a Let's Encrypt directory; it is only for a private CA \
or a Pebble test server reached via a custom directory",
),
},
failure => check_acme_ca_root_failure(failure),
}
}
fn is_custom_directory(directory_label: &str) -> bool {
!matches!(directory_label, "staging" | "production")
}
fn resolve_acme_ca_root_data(
path: Option<&std::path::Path>,
ca_root_error: Option<&str>,
directory_label: &str,
) -> AcmeCaRootData {
use rustls::pki_types::CertificateDer;
use rustls::pki_types::pem::PemObject as _;
if let Some(value) = ca_root_error {
return AcmeCaRootData::Malformed {
value: value.to_owned(),
};
}
let Some(path) = path else {
return if is_custom_directory(directory_label) {
AcmeCaRootData::MissingForPrivateDirectory
} else {
AcmeCaRootData::NotConfigured
};
};
if path.to_str().is_none_or(|p| p.trim().is_empty()) {
return AcmeCaRootData::Blank;
}
let rendered = path.display().to_string();
match std::fs::metadata(path) {
Err(e) => {
return AcmeCaRootData::Unreadable {
path: rendered,
reason: e.to_string(),
};
}
Ok(meta) if !meta.is_file() => {
return AcmeCaRootData::Unreadable {
path: rendered,
reason: "not a regular file".to_owned(),
};
}
Ok(meta) if meta.len() > MAX_CA_ROOT_BYTES => {
return AcmeCaRootData::Unreadable {
path: rendered,
reason: format!("{} bytes is far larger than a root PEM", meta.len()),
};
}
Ok(_) => {}
}
let first = match CertificateDer::from_pem_file(path) {
Ok(der) => der,
Err(e) => {
return AcmeCaRootData::NotACertificate {
path: rendered,
reason: e.to_string(),
};
}
};
let mut roots = rustls::RootCertStore::empty();
if let Err(e) = roots.add(first) {
return AcmeCaRootData::NotACertificate {
path: rendered,
reason: e.to_string(),
};
}
if !is_custom_directory(directory_label) {
return AcmeCaRootData::UnneededForPublicDirectory {
path: rendered,
directory: directory_label.to_owned(),
};
}
let certificates = CertificateDer::pem_file_iter(path).map_or(1, |iter| iter.flatten().count());
if certificates > 1 {
return AcmeCaRootData::ExtraCertificatesIgnored {
path: rendered,
certificates,
};
}
AcmeCaRootData::Usable
}
#[must_use]
pub fn check_acme_stored_cert_impl(data: &TlsDoctorData) -> CheckResult {
match data {
TlsDoctorData::NotConfigured => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Pass,
detail: Some("no ACME certificate stored yet; it will be issued on first boot".into()),
hint: None,
},
TlsDoctorData::FeatureDisabled => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Warn,
detail: Some(
"[server.tls.acme] is configured but this autumn CLI was built without the `tls` \
feature, so the stored certificate could not be inspected"
.into(),
),
hint: Some("Rebuild the autumn CLI with the `tls` feature to enable TLS diagnostics"),
},
TlsDoctorData::Invalid { detail } => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Fail,
detail: Some(detail.clone()),
hint: Some("Delete the corrupt cert from the ACME cache_dir so it is re-issued"),
},
TlsDoctorData::Healthy {
not_yet_valid: true,
..
} => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Fail,
detail: Some(
"the stored ACME certificate is not yet valid (its notBefore is in the future)"
.into(),
),
hint: Some("Check the renewal task logs; renewal should have replaced it"),
},
TlsDoctorData::Healthy {
expired: true,
days_until_expiry,
..
} => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Fail,
detail: Some(format!(
"the stored ACME certificate expired {} day(s) ago",
days_until_expiry.abs()
)),
hint: Some("Check the renewal task logs; renewal should have replaced it"),
},
TlsDoctorData::Healthy {
expired: false,
days_until_expiry,
..
} if *days_until_expiry <= 30 => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Warn,
detail: Some(format!(
"the stored ACME certificate expires in {days_until_expiry} day(s)"
)),
hint: Some("The renewal task should renew it automatically; watch for renewal errors"),
},
TlsDoctorData::Healthy {
days_until_expiry, ..
} => CheckResult {
name: "acme_stored_cert",
status: CheckStatus::Pass,
detail: Some(format!(
"stored ACME certificate valid ({days_until_expiry} day(s) until expiry)"
)),
hint: None,
},
}
}
fn resolve_alert_destination() -> (String, bool, String) {
let selected_input = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_default()
.trim()
.to_owned();
let normalized_profile = normalize_alert_profile(&selected_input);
let merged = get_merged_toml_table_runtime(&normalized_profile, &selected_input);
let (_email_present, webhook) =
resolve_alert_destination_from_sources(|key| std::env::var(key).ok(), Some(&merged), "");
let email = std::env::var("AUTUMN_ALERTS__EMAIL").unwrap_or_else(|_| {
merged
.get("alerts")
.and_then(|a| a.get("email"))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned()
});
let webhook_url = std::env::var("AUTUMN_ALERTS__WEBHOOK_URL").unwrap_or_else(|_| {
merged
.get("alerts")
.and_then(|a| a.get("webhook_url"))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned()
});
(email, webhook, webhook_url)
}
fn normalize_alert_profile(selected_input: &str) -> String {
match selected_input.trim().to_ascii_lowercase().as_str() {
"prod" | "production" => "prod".to_owned(),
"dev" | "development" => "dev".to_owned(),
"" => String::new(),
_ => selected_input.trim().to_owned(),
}
}
fn resolve_alert_destination_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
profile: &str,
) -> (bool, bool)
where
F: Fn(&str) -> Option<String>,
{
let profile_alerts = if profile.is_empty() {
None
} else {
table
.and_then(|t| t.get("profile"))
.and_then(|v| v.get(profile))
.and_then(toml::Value::as_table)
.and_then(|p| p.get("alerts"))
.and_then(toml::Value::as_table)
};
let base_alerts = table
.and_then(|t| t.get("alerts"))
.and_then(toml::Value::as_table);
let resolve_value = |env_key: &str, key: &str| -> String {
if let Some(val) = env_var(env_key) {
return val;
}
if let Some(v) = profile_alerts
.and_then(|t| t.get(key))
.and_then(toml::Value::as_str)
{
return v.to_owned();
}
if let Some(v) = base_alerts
.and_then(|t| t.get(key))
.and_then(toml::Value::as_str)
{
return v.to_owned();
}
String::new()
};
let resolve =
|env_key: &str, key: &str| -> bool { !resolve_value(env_key, key).trim().is_empty() };
let email = resolve("AUTUMN_ALERTS__EMAIL", "email");
let webhook_url = resolve_value("AUTUMN_ALERTS__WEBHOOK_URL", "webhook_url");
let webhook_secret = resolve("AUTUMN_ALERTS__WEBHOOK_SECRET", "webhook_secret");
let webhook = is_absolute_http_url_doctor(webhook_url.trim()) && webhook_secret;
(email, webhook)
}
fn resolve_alert_enabled() -> bool {
let selected_input = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_default()
.trim()
.to_owned();
let normalized_profile = normalize_alert_profile(&selected_input);
let merged = get_merged_toml_table_runtime(&normalized_profile, &selected_input);
resolve_alert_enabled_from_sources(|key| std::env::var(key).ok(), Some(&merged), "")
}
fn resolve_alert_enabled_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
profile: &str,
) -> bool
where
F: Fn(&str) -> Option<String>,
{
if let Some(val) = env_var("AUTUMN_ALERTS__ENABLED") {
match val.as_str() {
"true" | "1" => return true,
"false" | "0" => return false,
_ => {}
}
}
if !profile.is_empty()
&& let Some(v) = table
.and_then(|t| t.get("profile"))
.and_then(|v| v.get(profile))
.and_then(toml::Value::as_table)
.and_then(|p| p.get("alerts"))
.and_then(toml::Value::as_table)
.and_then(|a| a.get("enabled"))
.and_then(toml::Value::as_bool)
{
return v;
}
if let Some(v) = table
.and_then(|t| t.get("alerts"))
.and_then(toml::Value::as_table)
.and_then(|a| a.get("enabled"))
.and_then(toml::Value::as_bool)
{
return v;
}
true
}
fn resolve_alert_transports() -> (String, String, String, String) {
let selected_input = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_default()
.trim()
.to_owned();
let normalized_profile = normalize_alert_profile(&selected_input);
let merged = get_merged_toml_table_runtime(&normalized_profile, &selected_input);
let read = |env_key: &str, key: &str| -> String {
std::env::var(env_key).unwrap_or_else(|_| {
merged
.get("alerts")
.and_then(|a| a.get(key))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned()
})
};
(
read(
"AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY",
"pagerduty_routing_key",
),
read("AUTUMN_ALERTS__PAGERDUTY_URL", "pagerduty_url"),
read("AUTUMN_ALERTS__SLACK_WEBHOOK_URL", "slack_webhook_url"),
read("AUTUMN_ALERTS__DISCORD_WEBHOOK_URL", "discord_webhook_url"),
)
}
fn native_alert_transport_configured(
pagerduty_routing_key: &str,
slack_webhook_url: &str,
discord_webhook_url: &str,
) -> bool {
!pagerduty_routing_key.trim().is_empty()
|| !slack_webhook_url.trim().is_empty()
|| !discord_webhook_url.trim().is_empty()
}
fn resolve_alert_custom_channel() -> bool {
let selected_input = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_default()
.trim()
.to_owned();
let normalized_profile = normalize_alert_profile(&selected_input);
let merged = get_merged_toml_table_runtime(&normalized_profile, &selected_input);
resolve_alert_custom_channel_from_sources(|key| std::env::var(key).ok(), Some(&merged), "")
}
fn resolve_alert_custom_channel_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
profile: &str,
) -> bool
where
F: Fn(&str) -> Option<String>,
{
if let Some(val) = env_var("AUTUMN_ALERTS__CUSTOM_CHANNEL") {
match val.as_str() {
"true" | "1" => return true,
"false" | "0" => return false,
_ => {}
}
}
if !profile.is_empty()
&& let Some(v) = table
.and_then(|t| t.get("profile"))
.and_then(|v| v.get(profile))
.and_then(toml::Value::as_table)
.and_then(|p| p.get("alerts"))
.and_then(toml::Value::as_table)
.and_then(|a| a.get("custom_channel"))
.and_then(toml::Value::as_bool)
{
return v;
}
if let Some(v) = table
.and_then(|t| t.get("alerts"))
.and_then(toml::Value::as_table)
.and_then(|a| a.get("custom_channel"))
.and_then(toml::Value::as_bool)
{
return v;
}
false
}
fn resolve_alert_error_rate_threshold() -> String {
let selected_input = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_default()
.trim()
.to_owned();
let normalized_profile = normalize_alert_profile(&selected_input);
let merged = get_merged_toml_table_runtime(&normalized_profile, &selected_input);
resolve_alert_error_rate_threshold_from_sources(
|key| std::env::var(key).ok(),
Some(&merged),
"",
)
}
fn resolve_alert_error_rate_threshold_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
profile: &str,
) -> String
where
F: Fn(&str) -> Option<String>,
{
let stringify = |v: &toml::Value| -> Option<String> {
match v {
toml::Value::Float(f) => Some(f.to_string()),
toml::Value::Integer(i) => Some(i.to_string()),
toml::Value::String(s) => Some(s.clone()),
_ => None,
}
};
if let Some(val) = env_var("AUTUMN_ALERTS__ERROR_RATE_THRESHOLD") {
return val;
}
if !profile.is_empty()
&& let Some(v) = table
.and_then(|t| t.get("profile"))
.and_then(|v| v.get(profile))
.and_then(toml::Value::as_table)
.and_then(|p| p.get("alerts"))
.and_then(toml::Value::as_table)
.and_then(|a| a.get("error_rate_threshold"))
.and_then(stringify)
{
return v;
}
if let Some(v) = table
.and_then(|t| t.get("alerts"))
.and_then(toml::Value::as_table)
.and_then(|a| a.get("error_rate_threshold"))
.and_then(stringify)
{
return v;
}
String::new()
}
fn resolve_is_production() -> bool {
for var in ["AUTUMN_ENV", "AUTUMN_PROFILE"] {
if let Ok(val) = std::env::var(var) {
let v = val.trim().to_ascii_lowercase();
if v == "prod" || v == "production" {
return true;
}
if !v.is_empty() {
return false;
}
}
}
false
}
fn tailwind_enabled() -> bool {
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| t.get("tailwind").and_then(toml::Value::as_bool))
.unwrap_or_else(|| std::path::Path::new("build.rs").exists())
}
fn resolve_compression_enabled() -> bool {
if let Ok(val) = std::env::var("AUTUMN_COMPRESSION__ENABLED") {
match val.as_str() {
"true" | "1" => return true,
"false" | "0" => return false,
_ => {}
}
}
let table = read_autumn_toml_table().unwrap_or_default();
let profile = std::env::var("AUTUMN_ENV")
.ok()
.or_else(|| std::env::var("AUTUMN_PROFILE").ok())
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let parse_enabled = |root: &toml::Table| {
root.get("compression")
.and_then(|v| v.get("enabled"))
.and_then(toml::Value::as_bool)
};
if !profile.is_empty()
&& let Some(enabled) = table
.get("profile")
.and_then(|v| v.get(&profile))
.and_then(toml::Value::as_table)
.and_then(parse_enabled)
{
return enabled;
}
parse_enabled(&table).unwrap_or(false)
}
fn deploy_preflight_result(
name: &'static str,
check: crate::deploy::PreflightCheck,
) -> CheckResult {
CheckResult {
name,
status: if check.passed {
CheckStatus::Pass
} else {
CheckStatus::Fail
},
detail: Some(check.detail),
hint: check.hint,
}
}
type DoctorTask = Box<dyn FnOnce() -> CheckResult + Send>;
fn media_check_result(check: crate::deploy::PreflightCheck) -> CheckResult {
CheckResult {
name: check.name,
status: if check.passed {
CheckStatus::Pass
} else if check.deferred {
CheckStatus::Warn
} else {
CheckStatus::Fail
},
detail: Some(check.detail),
hint: check.hint,
}
}
fn media_ssh_target(
hosts: &Result<Vec<String>, String>,
deploy_cfg: &DeployConfig,
) -> Result<crate::deploy::exec::SshTarget, String> {
if deploy_cfg.user.starts_with('-') {
return Err(format!(
"`[deploy] user` {:?} starts with `-`; ssh would read it as an option",
deploy_cfg.user
));
}
match hosts.as_ref()?.as_slice() {
[host] if host.starts_with('-') => Err(format!(
"`[deploy]` host {host:?} starts with `-`; ssh would read it as an option"
)),
[host] => Ok(crate::deploy::exec::SshTarget {
host: host.clone(),
user: deploy_cfg.user.clone(),
port: deploy_cfg.ssh_port,
}),
others => Err(format!(
"MediaMTX provisioning needs exactly one `[deploy]` host; found {}",
others.len()
)),
}
}
fn media_doctor_tasks<E, F>(
cfg: &crate::deploy::media::MediaMtxHostConfig,
ffmpeg_bin: &str,
host: Option<Result<F, String>>,
) -> Vec<DoctorTask>
where
E: crate::deploy::exec::DeployExecutor + 'static,
F: Fn() -> E + Clone + Send + 'static,
{
use crate::deploy::media;
let mut tasks: Vec<DoctorTask> = Vec::new();
for pure in [
media::mediamtx_ports_distinct,
media::mediamtx_ports_match_bases,
] {
let cfg = cfg.clone();
tasks.push(Box::new(move || media_check_result(pure(&cfg))));
}
let Some(host) = host else {
return tasks;
};
let names = [
media::CHECK_FFMPEG_PREFLIGHT,
media::CHECK_MEDIAMTX_BINARY,
media::CHECK_RECORDINGS_DIR_WRITABLE,
media::CHECK_MEDIAMTX_PORTS_AVAILABLE,
];
match host {
Ok(executor) => {
let ffmpeg_bin = ffmpeg_bin.to_owned();
let exec = executor.clone();
tasks.push(Box::new(move || {
media_check_result(media::ffmpeg_preflight(&exec(), &ffmpeg_bin))
}));
let host_checks: [fn(&E, &media::MediaMtxHostConfig) -> crate::deploy::PreflightCheck;
3] = [
media::mediamtx_binary_preflight,
media::recordings_dir_writable,
media::mediamtx_ports_available,
];
for check in host_checks {
let (cfg, exec) = (cfg.clone(), executor.clone());
tasks.push(Box::new(move || media_check_result(check(&exec(), &cfg))));
}
}
Err(message) => {
for name in names {
let message = message.clone();
tasks.push(Box::new(move || CheckResult {
name,
status: CheckStatus::Fail,
detail: Some(message),
hint: Some(MEDIA_HOST_HINT),
}));
}
}
}
tasks
}
const MEDIA_HOST_HINT: &str =
"Set one `[deploy] host` for MediaMTX provisioning (a media fleet is not supported)";
fn resolve_deploy_doctor_config(
merged: &toml::Table,
env: &dyn autumn_web::config::Env,
) -> (Option<DeployConfig>, Option<CheckResult>) {
let mut deploy_cfg = match merged.get("deploy").cloned() {
Some(value) => match value.try_into::<DeployConfig>() {
Ok(cfg) => Some(cfg),
Err(err) => {
return (
None,
Some(CheckResult {
name: "deploy_config",
status: CheckStatus::Fail,
detail: Some(format!("[deploy] is present but invalid: {err}")),
hint: Some(
"Fix the [deploy] section in autumn.toml so its fields match the \
expected types (see `autumn deploy check`)",
),
}),
);
}
},
None => None,
};
autumn_web::config::apply_deploy_env_overrides(&mut deploy_cfg, env);
(deploy_cfg, None)
}
fn deploy_profile_config_load_check(
deploy_profile_raw: &str,
env: &dyn autumn_web::config::Env,
) -> Option<CheckResult> {
match autumn_web::config::AutumnConfig::load_with_env_lenient_unknown_roots(env) {
Ok(_) => None,
Err(err) => Some(CheckResult {
name: "deploy_config",
status: CheckStatus::Fail,
detail: Some(format!(
"deploy profile config failed to load under `[deploy] profile = \
{deploy_profile_raw}`: {err} — `autumn deploy check` would reject this"
)),
hint: Some(
"Fix the deploy-profile config (e.g. a malformed or unreadable \
autumn-<profile>.toml override) so it loads under the deploy profile \
(see `autumn deploy check`)",
),
}),
}
}
const DEPLOY_HOST_SPELLING_HINT: &str = "Fix the [deploy] host spelling in autumn.toml — \
set either `host` (one server) or `hosts` (a fleet), never both (see `autumn deploy \
check`)";
const DEPLOY_HOST_SPELLING_REACHABILITY_HINT: &str = "Fix the [deploy] host spelling in autumn.toml before probing reachability (see \
`autumn deploy check`)";
#[allow(clippy::too_many_lines)]
pub fn run(opts: DoctorOptions) {
type Task = Box<dyn FnOnce() -> CheckResult + Send>;
let cli_version = env!("CARGO_PKG_VERSION");
let _ = ctrlc::set_handler(|| {
crate::deps::stop_all();
std::process::exit(130);
});
if !opts.json {
println!("\u{1F342} autumn doctor\n");
}
let msrv = read_msrv().unwrap_or_else(|| crate::MSRV.to_owned());
let web_ver = read_autumn_web_version();
let toml_result = std::fs::read_to_string("autumn.toml");
let (db_canonical, db_selected, _) = resolve_active_profiles();
let merged_db_toml = get_merged_toml_table_runtime(&db_canonical, &db_selected);
let db_denv: Box<dyn autumn_web::config::Env> =
match autumn_web::dotenv::os_env_with_dotenv_for_profile(&db_canonical) {
Ok(e) => Box::new(e),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
let db_topology = resolve_database_topology_from_sources(
|key| db_denv.var(key).ok().filter(|value| !value.is_empty()),
Some(&merged_db_toml),
);
let (process_role, jobs_backend) =
resolve_process_role_and_backend(Some(&merged_db_toml), db_denv.as_ref());
let port = resolve_server_port();
let tailwind = tailwind_enabled();
let signing_secret = resolve_optional_signing_secret();
let trusted_hosts = resolve_trusted_hosts();
let is_production = resolve_is_production();
let rate_limit_key_strategy = resolve_rate_limit_key_strategy();
let auth_extractor_mounted = resolve_auth_extractor_mounted();
let compression_enabled = resolve_compression_enabled();
let (alert_email, alert_webhook_configured, alert_webhook_url) = resolve_alert_destination();
let alerts_enabled = resolve_alert_enabled();
let alert_custom_channel = resolve_alert_custom_channel();
let alert_error_rate_threshold = resolve_alert_error_rate_threshold();
let (alert_pd_routing_key, alert_pd_url, alert_slack_url, alert_discord_url) =
resolve_alert_transports();
let proxy_conflict_data = resolve_proxy_conflict_data();
let (dotenv_has_example, dotenv_has_env, dotenv_uncovered) = resolve_dotenv_state();
let mut tasks: Vec<Task> = Vec::new();
tasks.push(Box::new(|| {
check_platform_support_impl(std::env::consts::OS)
}));
let daemon_service = resolve_daemon_service_report();
tasks.push(Box::new(move || check_daemon_service_impl(&daemon_service)));
tasks.push(Box::new(move || check_rust_toolchain(&msrv)));
if let Some(web) = web_ver {
tasks.push(Box::new(move || check_version_compat(cli_version, &web)));
}
match toml_result {
Ok(content) => tasks.push(Box::new(move || check_toml_content(&content))),
Err(_) => tasks.push(Box::new(|| CheckResult {
name: "autumn_toml",
status: CheckStatus::Warn,
detail: Some("autumn.toml not found in current directory".into()),
hint: Some("Run `autumn doctor` from your project root (where autumn.toml lives)"),
})),
}
let topology_for_check = db_topology.clone();
tasks.push(Box::new(move || {
check_database_topology_contract(&topology_for_check, is_production)
}));
let split_topology_db_url = db_topology.primary_url.clone();
tasks.push(Box::new(move || {
check_process_role(
&process_role,
&jobs_backend,
split_topology_db_url.as_deref(),
)
}));
let (queue_canonical, queue_selected, _) = resolve_active_profiles();
let merged_jobs_toml = get_merged_toml_table_runtime(&queue_canonical, &queue_selected);
let (configured_queues, jobs_pin) = resolve_queues_and_pin(Some(&merged_jobs_toml));
let (queue_coverage_role, _) =
resolve_process_role_and_backend(Some(&merged_jobs_toml), db_denv.as_ref());
let queue_coverage_role =
queue_coverage_role.unwrap_or_else(|_| Role::from(ProcessRole::Combined));
let fleet_topology = resolve_fleet_topology_with_pin(Some(&merged_jobs_toml), Some(&jobs_pin));
let (declared_queues, declared_queues_manifest_error) =
match resolve_declared_queues(Some(&merged_jobs_toml)) {
Ok(queues) => (queues, None),
Err(err) => (Vec::new(), Some(err)),
};
tasks.push(Box::new(move || {
check_queue_coverage_topology(
&queue_coverage_role,
&configured_queues,
&jobs_pin,
&declared_queues,
fleet_topology.as_ref(),
declared_queues_manifest_error.as_ref(),
)
}));
let route_groups_toml = merged_jobs_toml;
tasks.push(Box::new(move || {
check_route_group_mounts(Some(&route_groups_toml), |path| {
std::fs::read_to_string(path).ok()
})
}));
let shard_resolution = crate::migrate::try_resolve_shard_database_urls_from_sources(
|key| db_denv.var(key),
Some(&merged_db_toml),
);
let db_has_shards = shard_resolution
.as_ref()
.is_ok_and(|shards| !shards.is_empty());
let malformed_shard_error = shard_resolution.err();
let shards_malformed = malformed_shard_error.is_some();
let backend_consistent = autumn_web::config::database_backend_consistency(
db_topology.legacy_url.as_deref(),
db_topology.primary_url.as_deref(),
db_topology.replica_url.as_deref(),
db_has_shards,
)
.is_ok();
let db_configured = db_topology.primary_url.is_some()
|| db_topology.legacy_url.is_some()
|| db_topology.replica_url.is_some()
|| db_has_shards;
if db_configured {
let legacy = db_topology.legacy_url.clone();
let primary = db_topology.primary_url.clone();
let replica = db_topology.replica_url.clone();
tasks.push(Box::new(move || {
check_database_backend_consistency(
legacy.as_deref(),
primary.as_deref(),
replica.as_deref(),
db_has_shards,
)
}));
}
if let Some(err) = malformed_shard_error {
tasks.push(Box::new(move || check_shard_config_resolvable(&err)));
}
let sqlite_primary_bootable = sqlite_primary_target_is_bootable(
db_topology.legacy_url.as_deref(),
db_topology.primary_url.as_deref(),
db_topology.replica_url.as_deref(),
db_has_shards,
);
if backend_consistent && !shards_malformed {
if let Some(url) = db_topology.primary_url.clone() {
let primary_for_pending = url.clone();
let primary_is_sqlite = autumn_web::config::DatabaseBackend::detect(&url)
== Some(autumn_web::config::DatabaseBackend::Sqlite);
tasks.push(Box::new(move || {
check_db_connectivity(&url, sqlite_primary_bootable)
}));
if primary_is_sqlite {
tasks.push(Box::new(check_pending_migrations_sqlite));
} else {
tasks.push(Box::new(move || {
check_pending_migrations(&primary_for_pending)
}));
}
}
if let Some(url) = db_topology.replica_url.clone() {
tasks.push(Box::new(move || {
check_db_role_connectivity("replica", &url, tcp_reachable, false)
}));
}
if let (Some(primary_url), Some(replica_url)) = (
db_topology.primary_url.clone(),
db_topology.replica_url.clone(),
) {
let fallback = db_topology.replica_fallback;
tasks.push(Box::new(move || {
check_replica_migrations(&primary_url, &replica_url, fallback)
}));
}
}
tasks.push(Box::new(move || check_port_bindable(port)));
if tailwind {
tasks.push(Box::new(check_tailwind_binary));
}
if db_topology
.primary_url
.as_deref()
.and_then(autumn_web::config::DatabaseBackend::detect)
== Some(autumn_web::config::DatabaseBackend::Sqlite)
{
tasks.push(Box::new(check_pg_client_tools_sqlite));
} else {
tasks.push(Box::new(check_pg_client_tools));
}
tasks.push(Box::new(move || {
check_signing_secret_impl(signing_secret.as_deref(), is_production)
}));
tasks.push(Box::new(move || {
check_trusted_hosts_impl(&trusted_hosts, is_production)
}));
let (deploy_canonical, deploy_selected, _) = resolve_active_profiles();
let merged_deploy_toml = get_merged_toml_table_runtime(&deploy_canonical, &deploy_selected);
let deploy_denv: Box<dyn autumn_web::config::Env> =
match autumn_web::dotenv::os_env_with_dotenv_for_profile(&deploy_canonical) {
Ok(e) => Box::new(e),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
let (deploy_cfg, deploy_config_check) =
resolve_deploy_doctor_config(&merged_deploy_toml, deploy_denv.as_ref());
if let Some(check) = deploy_config_check {
tasks.push(Box::new(move || check));
}
if let Some(deploy_cfg) = deploy_cfg {
let deploy_profile_raw = crate::deploy::trimmed_deploy_profile(&deploy_cfg.profile);
let deploy_profile_canonical =
crate::deploy::canonicalize_deploy_profile(&deploy_profile_raw);
let merged_deploy_toml =
get_merged_toml_table_runtime(&deploy_profile_canonical, &deploy_profile_raw);
let deploy_denv: Box<dyn autumn_web::config::Env> =
match crate::deploy::deploy_profile_env_overlay(&deploy_profile_raw) {
Ok(e) => Box::new(e),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
if let Some(check) =
deploy_profile_config_load_check(&deploy_profile_raw, deploy_denv.as_ref())
{
tasks.push(Box::new(move || check));
}
let deploy_signing =
resolve_deploy_signing_secret(&merged_deploy_toml, deploy_denv.as_ref());
let deploy_previous_signing_result =
resolve_deploy_previous_signing_secrets(&merged_deploy_toml);
let deploy_db_topology = resolve_database_topology_from_sources(
|key| deploy_denv.var(key).ok().filter(|value| !value.is_empty()),
Some(&merged_deploy_toml),
);
let deploy_shards_result = crate::migrate::try_resolve_shard_database_urls_from_sources(
|key| deploy_denv.var(key),
Some(&merged_deploy_toml),
);
let deploy_db_configured = deploy_db_topology.primary_url.is_some()
|| deploy_db_topology.replica_url.is_some()
|| deploy_shards_result
.as_ref()
.is_ok_and(|shards| !shards.is_empty());
let deploy_db_url_result = deploy_shards_result.map(|deploy_shards| {
deploy_db_topology
.primary_url
.or_else(|| deploy_shards.into_iter().next().map(|(_, url)| url))
});
let deploy_db_backed_runtime =
resolve_deploy_db_backed_runtime(&merged_deploy_toml, deploy_denv.as_ref());
let deploy_is_production = crate::deploy::is_production_profile(Some(
&crate::deploy::canonicalize_deploy_profile(&deploy_cfg.profile),
));
let deploy_hosts = crate::deploy::deploy_host_list(&deploy_cfg);
let media_hosts = deploy_hosts.clone();
tasks.push(Box::new({
let deploy_hosts = deploy_hosts.clone();
move || match &deploy_hosts {
Ok(hosts) => deploy_preflight_result(
"deploy_host",
crate::deploy::grade_deploy_hosts_present(hosts),
),
Err(message) => CheckResult {
name: "deploy_host",
status: CheckStatus::Fail,
detail: Some(message.clone()),
hint: Some(DEPLOY_HOST_SPELLING_HINT),
},
}
}));
if opts.online {
let ssh_port = deploy_cfg.ssh_port;
tasks.push(Box::new(move || match &deploy_hosts {
Ok(hosts) => deploy_preflight_result(
crate::deploy::DOCTOR_PREFLIGHT_GRADERS[0],
crate::deploy::grade_fleet_ssh_reachability(
hosts,
ssh_port,
std::time::Duration::from_secs(5),
),
),
Err(message) => CheckResult {
name: crate::deploy::DOCTOR_PREFLIGHT_GRADERS[0],
status: CheckStatus::Fail,
detail: Some(message.clone()),
hint: Some(DEPLOY_HOST_SPELLING_REACHABILITY_HINT),
},
}));
}
match deploy_previous_signing_result {
Ok(deploy_previous_signing) => {
tasks.push(Box::new(move || {
deploy_preflight_result(
crate::deploy::DOCTOR_PREFLIGHT_GRADERS[1],
crate::deploy::grade_signing_secret(
deploy_signing.as_deref(),
&deploy_previous_signing,
deploy_is_production,
),
)
}));
}
Err(msg) => {
tasks.push(Box::new(move || CheckResult {
name: crate::deploy::DOCTOR_PREFLIGHT_GRADERS[1],
status: CheckStatus::Fail,
detail: Some(format!(
"security.signing_secret.previous_secrets is present but invalid: {msg}"
)),
hint: Some(
"Set security.signing_secret.previous_secrets to an array of strings \
(see `autumn deploy check`).",
),
}));
}
}
match deploy_db_url_result {
Ok(deploy_db_url) => {
tasks.push(Box::new(move || {
deploy_preflight_result(
crate::deploy::DOCTOR_PREFLIGHT_GRADERS[2],
crate::deploy::grade_database_url(
deploy_db_url.as_deref(),
std::path::Path::new("migrations"),
deploy_db_configured,
deploy_db_backed_runtime,
),
)
}));
}
Err(msg) => {
tasks.push(Box::new(move || CheckResult {
name: crate::deploy::DOCTOR_PREFLIGHT_GRADERS[2],
status: CheckStatus::Fail,
detail: Some(format!("[[database.shards]] is present but invalid: {msg}")),
hint: Some(
"Fix the [[database.shards]] entries in autumn.toml so each has a string \
`name` and `primary_url` with unique names (see `autumn deploy check`)",
),
}));
}
}
tasks.push(Box::new(|| {
deploy_preflight_result(
crate::deploy::DOCTOR_PREFLIGHT_GRADERS[3],
crate::deploy::grade_migrate_check(std::path::Path::new("migrations")),
)
}));
match crate::deploy::media::media_host_config_from_value(toml::Value::Table(
merged_deploy_toml,
)) {
Ok((media_cfg, ffmpeg_bin)) if media_cfg.enabled => {
let host = opts.online.then(|| {
media_ssh_target(&media_hosts, &deploy_cfg)
.map(|target| move || crate::deploy::exec::SshExecutor::new(target.clone()))
});
tasks.extend(media_doctor_tasks(&media_cfg, &ffmpeg_bin, host));
}
Ok(_) => {}
Err(err) => tasks.push(Box::new(move || CheckResult {
name: "media_config",
status: CheckStatus::Fail,
detail: Some(format!("[media] is present but invalid: {err}")),
hint: Some("Fix the `[media.mediamtx]` / `[media.ffmpeg]` keys in autumn.toml"),
})),
}
}
let (acme_canonical, acme_selected, _) = resolve_active_profiles();
let merged_acme_toml = get_merged_toml_table_runtime(&acme_canonical, &acme_selected);
let acme_config = resolve_acme_doctor_config(Some(&merged_acme_toml));
let acme_credentials_profile = if acme_canonical.trim().is_empty() {
"dev".to_owned()
} else {
acme_canonical.trim().to_ascii_lowercase()
};
let tenancy_base_domain = merged_acme_toml
.get("tenancy")
.and_then(toml::Value::as_table)
.and_then(|t| t.get("base_domain"))
.and_then(toml::Value::as_str)
.map(str::to_owned);
let (has_static_cert, has_static_key) = resolve_static_tls_presence();
let (has_cert_path, has_key_path) = resolve_tls_paths()
.map_or((false, false), |(cert, key)| {
(!cert.as_os_str().is_empty(), !key.as_os_str().is_empty())
});
let static_cert_key_present = has_cert_path && has_key_path;
if let Some(mode_fail) =
check_tls_mode_impl(has_static_cert, has_static_key, acme_config.is_some())
{
tasks.push(Box::new(move || mode_fail));
}
if should_run_static_tls_check(acme_config.is_some(), static_cert_key_present) {
let tls_data = resolve_tls_doctor_data();
tasks.push(Box::new(move || check_tls_impl(&tls_data)));
}
let client_auth_data = resolve_client_auth_doctor_data(
merged_acme_toml
.get("server")
.and_then(toml::Value::as_table)
.and_then(|s| s.get("tls"))
.and_then(toml::Value::as_table),
);
tasks.push(Box::new(move || check_client_auth_impl(&client_auth_data)));
if let Some(acme) = acme_config {
if let Some(config_fail) = check_acme_config_impl(&acme) {
tasks.push(Box::new(move || config_fail));
} else {
let stored = resolve_acme_stored_cert_data(
&acme.cache_dir,
&acme.directory_label,
&acme.domains,
);
tasks.push(Box::new(move || check_acme_stored_cert_impl(&stored)));
let ca_root = resolve_acme_ca_root_data(
acme.ca_root_path.as_deref(),
acme.ca_root_error.as_deref(),
&acme.directory_label,
);
tasks.push(Box::new(move || check_acme_ca_root_impl(&ca_root)));
#[cfg(feature = "tls")]
{
let credential = resolve_acme_dns_credential(
&acme,
&acme_credentials_profile,
std::path::Path::new("."),
);
tasks.push(Box::new(move || {
check_acme_dns_credential_impl(&credential)
}));
}
#[cfg(not(feature = "tls"))]
if acme.dns.is_some() || acme.dns_error.is_some() {
tasks.push(Box::new(|| CheckResult {
name: "acme_dns_credential",
status: CheckStatus::Warn,
detail: Some(
"this autumn binary was built without the `tls` feature, so the DNS-01 \
provider credential cannot be read or validated"
.to_owned(),
),
hint: Some("Rebuild the CLI with the `tls` feature to run this check"),
}));
}
let tenancy_base = tenancy_base_domain;
let tenancy_domains = acme.domains.clone();
let covers = tenancy_base
.as_deref()
.is_some_and(|base| acme_covers_tenant_subdomains(base, &tenancy_domains));
tasks.push(Box::new(move || {
check_acme_tenancy_domain_impl(tenancy_base.as_deref(), &tenancy_domains, covers)
}));
let cd_cfg = acme.custom_domains.clone();
let cd_error = acme.custom_domains_error.clone();
let registered = cd_cfg
.as_ref()
.filter(|cd| cd.enabled)
.map(|cd| read_custom_domain_registry(&cd.store_dir))
.unwrap_or_default();
let cd_ingress = cd_cfg
.as_ref()
.filter(|cd| cd.enabled)
.map(autumn_web::config::CustomDomainsConfig::ingress);
let http01_targets: Vec<String> = cd_ingress.as_ref().map_or_else(Vec::new, |ing| {
ing.hostname
.iter()
.cloned()
.chain(ing.ipv4.iter().map(ToString::to_string))
.chain(ing.ipv6.iter().map(ToString::to_string))
.collect()
});
#[cfg(feature = "tls")]
let txt_resolvers = cd_cfg
.as_ref()
.and_then(|cd| cd.resolver_addrs().ok())
.unwrap_or_default();
let registry_read = registered.clone();
tasks.push(Box::new(move || {
check_custom_domains_config_impl(
cd_cfg.as_ref(),
cd_error.as_deref(),
®istry_read,
)
.unwrap_or_else(|| {
CheckResult {
name: "custom_domains",
status: CheckStatus::Pass,
detail: Some(
"no [server.tls.acme.custom_domains] section: tenants cannot connect their \
own domains"
.to_owned(),
),
hint: None,
}
})
}));
if opts.online {
let registered = registered.domains;
let total = registered.len();
let probe_ingress = cd_ingress.unwrap_or_default();
for (index, (hostname, tenant, status, token)) in registered
.into_iter()
.take(MAX_CUSTOM_DOMAIN_PROBES)
.enumerate()
{
#[cfg(feature = "tls")]
if let (Some(token), "pending_dns") = (token, status.as_str()) {
let (hostname, tenant) = (hostname.clone(), tenant.clone());
let resolvers = txt_resolvers.clone();
tasks.push(Box::new(move || {
let txt = resolve_custom_domain_txt(&hostname, &token, &resolvers);
check_custom_domain_txt_impl(&CustomDomainOwnershipProbe {
hostname,
tenant,
token,
txt,
})
}));
}
#[cfg(not(feature = "tls"))]
let _ = token;
let probe_ingress = probe_ingress.clone();
tasks.push(Box::new(move || {
let dns = resolve_custom_domain_dns(&hostname, &probe_ingress);
let mut result = check_custom_domain_dns_impl(&CustomDomainProbe {
hostname,
tenant,
status,
dns,
});
if index + 1 == MAX_CUSTOM_DOMAIN_PROBES && total > MAX_CUSTOM_DOMAIN_PROBES
{
let detail = result.detail.take().unwrap_or_default();
result.detail = Some(format!(
"{detail} (probed the first {MAX_CUSTOM_DOMAIN_PROBES} of {total} \
registered domains)"
));
}
result
}));
}
}
if opts.online {
for target in http01_targets {
tasks.push(Box::new(move || {
let probed = probe_port_every_address(&target, 80);
check_custom_domain_http01_impl(&target, &probed)
}));
}
}
if opts.online {
let dns01 = acme.dns.is_some();
for host in acme_online_reachability_hosts(&acme) {
let d80 = host.clone();
tasks.push(Box::new(move || {
let p80 = probe_port(&d80, 80);
let p443 = probe_port(&d80, 443);
check_acme_ports_for_challenge(&d80, p80, p443, dns01)
}));
let d_dns = host;
tasks.push(Box::new(move || {
let outcome = resolve_dns_points_here(&d_dns);
check_acme_dns_for_challenge(&d_dns, &outcome, dns01)
}));
}
#[cfg(feature = "tls")]
if let Some(dns_cfg) = acme.dns.clone() {
let resolvers = dns_cfg.resolver_addrs().unwrap_or_default();
for domain in acme_online_probe_domains(&acme) {
let fqdn = autumn_web::acme::dns::challenge_fqdn(&domain);
let resolvers = resolvers.clone();
tasks.push(Box::new(move || {
let visibility = resolve_challenge_dns_visibility(&fqdn, &resolvers);
check_acme_dns_propagation_impl(&fqdn, &visibility)
}));
}
}
}
}
}
tasks.push(Box::new(move || {
check_rate_limit_key_strategy_impl(&rate_limit_key_strategy, auth_extractor_mounted)
}));
tasks.push(Box::new(move || {
check_proxy_conflict_impl(&proxy_conflict_data)
}));
let deprecated_keys = resolve_deprecations();
tasks.push(Box::new(move || {
check_deprecated_keys_impl(&deprecated_keys)
}));
let oauth2_providers = resolve_oauth2_providers();
for p in oauth2_providers {
tasks.push(Box::new(move || {
check_oauth2_provider_impl(&p.name, &p.client_id, &p.client_secret, is_production)
}));
}
let raw_mail_profile = std::env::var("AUTUMN_ENV")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_else(|| "dev".to_owned());
let selected_input = raw_mail_profile.trim().to_owned();
let normalized_profile = match selected_input.to_lowercase().as_str() {
"prod" | "production" => "prod".to_owned(),
"dev" | "development" | "" => "dev".to_owned(),
other => other.to_owned(),
};
let merged_mail_toml = get_merged_toml_table_runtime(&normalized_profile, &selected_input);
let (unsub_base_url, unsub_mailto) = resolve_mail_unsubscribe(Some(&merged_mail_toml));
let effective_transport = resolve_effective_mail_transport(
std::env::var("AUTUMN_MAIL__TRANSPORT").ok(),
merged_mail_toml
.get("mail")
.and_then(toml::Value::as_table)
.and_then(|m| m.get("transport"))
.and_then(toml::Value::as_str),
&normalized_profile,
);
let mail_transport_disabled = effective_transport == "disabled";
let unsub_token_ttl_days = resolve_unsubscribe_token_ttl_days(Some(&merged_mail_toml));
let unsub_ttl_toml_type_invalid = unsubscribe_ttl_toml_type_invalid(Some(&merged_mail_toml));
let unsub_dest_toml_type_invalid = unsubscribe_dest_toml_type_invalid(Some(&merged_mail_toml));
let has_list_unsubscribe_usage = detect_list_unsubscribe_usage();
tasks.push(Box::new(move || {
if unsub_dest_toml_type_invalid {
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Fail,
detail: Some(
"mail.unsubscribe_base_url and mail.unsubscribe_mailto must be strings; a \
non-string TOML value (e.g. an integer or array) fails configuration loading \
before the app can boot"
.into(),
),
hint: Some(
"Set mail.unsubscribe_base_url / mail.unsubscribe_mailto to quoted string values",
),
};
}
if unsub_ttl_toml_type_invalid {
return CheckResult {
name: "mail_unsubscribe",
status: CheckStatus::Fail,
detail: Some(
"mail.unsubscribe_token_ttl_days must be an integer number of days; a \
non-integer TOML value (e.g. a quoted string or a float) fails configuration \
loading before the app can boot"
.into(),
),
hint: Some(
"Set mail.unsubscribe_token_ttl_days to a bare integer, e.g. unsubscribe_token_ttl_days = 30",
),
};
}
check_mail_unsubscribe_config_impl(
has_list_unsubscribe_usage,
unsub_base_url.as_deref(),
unsub_mailto.as_deref(),
mail_transport_disabled,
is_production,
unsub_token_ttl_days,
)
}));
tasks.push(Box::new(check_stale_artifacts));
tasks.push(Box::new(check_maintenance_mode));
tasks.push(Box::new(move || {
check_compression_impl(compression_enabled, is_production)
}));
let mail_transport_usable = !mail_transport_disabled;
let mail_from = resolve_mail_from(Some(&merged_mail_toml)).unwrap_or_default();
let transport_requires_from = mail_transport_requires_from(&effective_transport);
let alert_native_configured = native_alert_transport_configured(
&alert_pd_routing_key,
&alert_slack_url,
&alert_discord_url,
);
tasks.push(Box::new(move || {
check_alert_destination_impl(
alerts_enabled,
&alert_email,
alert_webhook_configured,
&alert_webhook_url,
mail_transport_usable,
is_production,
&mail_from,
transport_requires_from,
alert_custom_channel,
alert_native_configured,
)
}));
tasks.push(Box::new(move || {
check_alert_error_rate_threshold_impl(&alert_error_rate_threshold, is_production)
}));
tasks.push(Box::new(move || {
check_alert_transports_impl(
&alert_pd_routing_key,
&alert_pd_url,
&alert_slack_url,
&alert_discord_url,
is_production,
)
}));
tasks.push(Box::new(move || {
check_dotenv_impl(dotenv_has_example, dotenv_has_env, &dotenv_uncovered)
}));
tasks.push(Box::new(|| {
check_offsite_backup_impl(&resolve_offsite_backup_data())
}));
tasks.push(Box::new(check_system_test_browser));
tasks.push(Box::new(|| {
let has_auth_starter = std::path::Path::new("src/routes/auth.rs").exists();
let unregistered = resolve_gdpr_unregistered_tables();
check_gdpr_export_registration_impl(
has_auth_starter,
&unregistered.iter().map(String::as_str).collect::<Vec<_>>(),
)
}));
tasks.push(Box::new(|| {
let found = resolve_unprivate_sensitive_columns();
check_model_private_columns_impl(&found)
}));
let edge_virtual_workspace_root =
edge_manifest_is_virtual_workspace_root(std::path::Path::new("."));
tasks.push(Box::new(move || {
if edge_virtual_workspace_root {
return edge_virtual_workspace_warn("edge_target");
}
let capsule_bin = resolve_edge_capsule_bin(std::path::Path::new("."));
let scan = crate::edge_scan::resolve_edge_scan_with_extra_file(
std::path::Path::new("."),
&[],
capsule_bin.as_deref(),
);
let installed = !scan.is_empty() && crate::build::edge_target_installed();
check_edge_target_impl(&scan, installed)
}));
tasks.push(Box::new(move || {
if edge_virtual_workspace_root {
return edge_virtual_workspace_warn("edge_routes");
}
let capsule_bin = resolve_edge_capsule_bin(std::path::Path::new("."));
let scan = crate::edge_scan::resolve_edge_scan_with_extra_file(
std::path::Path::new("."),
&[],
capsule_bin.as_deref(),
);
let capsule_bin_exists = capsule_bin.is_some_and(|p| p.exists());
check_edge_routes_impl(&scan, capsule_bin_exists)
}));
tasks.push(Box::new(move || {
if edge_virtual_workspace_root {
return edge_virtual_workspace_warn("edge_capabilities");
}
let capsule_bin = resolve_edge_capsule_bin(std::path::Path::new("."));
let scan = crate::edge_scan::resolve_edge_scan_with_extra_file(
std::path::Path::new("."),
&[],
capsule_bin.as_deref(),
);
check_edge_capabilities_impl(&scan)
}));
let residue_database_url = db_topology.primary_url.clone().or(db_topology.legacy_url);
tasks.push(Box::new(move || {
let wirings = resolve_plugin_wirings(std::path::Path::new("."), || {
residue_database_url
.as_deref()
.map_or_else(Vec::new, applied_migration_versions)
});
check_plugin_residue_impl(&wirings)
}));
tasks.push(Box::new(|| {
check_dependencies_impl(&crate::deps::evaluate_within(
std::path::Path::new("."),
crate::deps::DOCTOR_BUDGET,
))
}));
let boundaries_path = std::path::Path::new(autumn_web::graph::boundaries::CONFIG_FILE);
if boundaries_path.exists() {
tasks.push(Box::new(move || check_table_boundaries(boundaries_path)));
}
run_doctor_tasks(tasks, opts);
}
fn check_table_boundaries(config_path: &std::path::Path) -> CheckResult {
use crate::boundaries::{
DOCTOR_BUDGET, cargo_facts, load_baseline, load_config, load_graph, relative_to_config,
};
let config = match load_config(config_path) {
Ok(config) => config,
Err(e) => return table_boundaries_result(CheckStatus::Fail, e),
};
let graph = config
.graph
.as_deref()
.map(|p| load_graph(&relative_to_config(config_path, p)));
let dir = config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let facts = cargo_facts(dir, &[], true, Some(DOCTOR_BUDGET));
let baseline = config
.baseline
.as_deref()
.map(|p| load_baseline(&relative_to_config(config_path, p)))
.transpose()
.map(Option::flatten);
check_table_boundaries_impl(config, graph, facts, baseline)
}
fn check_table_boundaries_impl(
config: autumn_web::graph::boundaries::BoundaryConfig,
graph: Option<Result<autumn_web::graph::manifest::ArchitectureGraph, String>>,
facts: Result<crate::boundaries::CargoFacts, String>,
baseline: Result<Option<autumn_web::graph::boundaries::Baseline>, String>,
) -> CheckResult {
let mut notes = Vec::new();
let graph = match graph {
None => {
notes.push(
"graph rule not run: set `graph` in boundaries.toml to a committed \
architecture graph"
.to_owned(),
);
None
}
Some(Err(e)) => {
notes.push(format!("graph rule not run: {e}"));
None
}
Some(Ok(graph)) => Some(graph),
};
let facts = match facts {
Ok(facts) => Some(facts),
Err(e) => {
notes.push(format!("crate rule not run: {e}"));
None
}
};
let baseline = match baseline {
Ok(baseline) => baseline,
Err(e) => return table_boundaries_result(CheckStatus::Warn, e),
};
let (result, _) = match crate::boundaries::evaluate(
config,
graph.as_ref(),
facts.as_ref(),
baseline.as_ref(),
) {
Ok(evaluated) => evaluated,
Err(e) => return table_boundaries_result(CheckStatus::Fail, e),
};
if result.passes() && notes.is_empty() {
return CheckResult {
name: "table_boundaries",
status: CheckStatus::Pass,
detail: Some(format!(
"no new boundary violations ({} known in the baseline, {} pattern warning(s))",
result.known.len(),
result.warnings.len()
)),
hint: None,
};
}
let mut detail = if result.passes() {
String::new()
} else {
result.render().trim_end().to_owned()
};
for note in notes {
if !detail.is_empty() {
detail.push('\n');
}
detail.push_str(¬e);
}
table_boundaries_result(CheckStatus::Warn, detail)
}
const fn table_boundaries_result(status: CheckStatus, detail: String) -> CheckResult {
CheckResult {
name: "table_boundaries",
status,
detail: Some(detail),
hint: Some(
"Run `autumn graph check-boundaries` for the full report. \
See docs/guide/table-ownership.md",
),
}
}
fn run_doctor_tasks(tasks: Vec<Box<dyn FnOnce() -> CheckResult + Send>>, opts: DoctorOptions) {
#[allow(clippy::needless_collect)]
let handles: Vec<std::thread::JoinHandle<CheckResult>> =
tasks.into_iter().map(std::thread::spawn).collect();
let results: Vec<CheckResult> = handles
.into_iter()
.map(|h| {
h.join().unwrap_or_else(|_| CheckResult {
name: "internal_error",
status: CheckStatus::Fail,
detail: Some("a check panicked unexpectedly".into()),
hint: None,
})
})
.collect();
let summary = compute_summary(&results);
let code = exit_code(&summary, opts.strict);
if opts.json {
println!("{}", to_json_output(&results, &summary));
} else {
for r in &results {
println!("{}", format_check_line(r));
}
println!();
println!("{}", format_summary_line(&summary, code));
}
std::process::exit(code);
}
pub fn check_compression_impl(compression_enabled: bool, is_production: bool) -> CheckResult {
if is_production && !compression_enabled {
return CheckResult {
name: "compression",
status: CheckStatus::Warn,
detail: Some(
"response compression is disabled in production; \
text payloads (HTML/JSON/CSS) are served uncompressed"
.into(),
),
hint: Some(
"Set [compression] enabled = true in autumn.toml (or AUTUMN_COMPRESSION__ENABLED=true) \
if you are not using a CDN or reverse-proxy that compresses for you. \
Read the BREACH/CRIME tradeoff in docs/guide/compression.md before enabling.",
),
};
}
CheckResult {
name: "compression",
status: CheckStatus::Pass,
detail: Some(if compression_enabled {
"response compression is enabled".into()
} else {
"response compression is disabled (off by default; use CDN or enable explicitly)".into()
}),
hint: None,
}
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)] pub struct OffsiteBackupData {
pub configured: bool,
pub bucket: Option<String>,
pub access_key_env: Option<String>,
pub secret_key_env: Option<String>,
pub access_key_env_set: bool,
pub secret_key_env_set: bool,
pub shared_bucket_without_optin: bool,
}
#[must_use]
pub fn check_offsite_backup_impl(data: &OffsiteBackupData) -> CheckResult {
const NAME: &str = "offsite_backup";
if !data.configured {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(
"no offsite backup destination configured (a backup on the same disk as the \
database is not disaster recovery)"
.into(),
),
hint: Some(
"Add a [backup.offsite] section (see docs) and run `autumn db backup --upload` \
to keep a verified copy off the server.",
),
};
}
let Some(bucket) = data.bucket.as_deref().filter(|b| !b.trim().is_empty()) else {
return CheckResult {
name: NAME,
status: CheckStatus::Fail,
detail: Some(
"[backup.offsite] is configured but backup.offsite.s3.bucket is unset".into(),
),
hint: Some("Set backup.offsite.s3.bucket to the offsite bucket name."),
};
};
if data.shared_bucket_without_optin {
return CheckResult {
name: NAME,
status: CheckStatus::Fail,
detail: Some(format!(
"offsite bucket {bucket:?} is the same as the app [storage.s3] bucket at the \
same endpoint, without opt-in"
)),
hint: Some(
"Point the offsite backup at a distinct bucket, or set \
backup.offsite.allow_shared_bucket = true to acknowledge the shared bucket.",
),
};
}
let mut missing: Vec<&str> = Vec::new();
if data
.access_key_env
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
missing.push("backup.offsite.s3.access_key_id_env (name unset)");
} else if !data.access_key_env_set {
missing.push("access key env var is not set in the environment");
}
if data
.secret_key_env
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
missing.push("backup.offsite.s3.secret_access_key_env (name unset)");
} else if !data.secret_key_env_set {
missing.push("secret key env var is not set in the environment");
}
if !missing.is_empty() {
return CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(format!(
"offsite bucket {bucket:?} configured, but credentials are not ready: {}",
missing.join("; ")
)),
hint: Some(
"Set backup.offsite.s3.access_key_id_env / secret_access_key_env to env var \
names, and export those variables with the S3 access key / secret.",
),
};
}
CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(format!(
"offsite backups configured (bucket {bucket:?}, credentials via env-var indirection)"
)),
hint: None,
}
}
fn resolve_offsite_backup_data() -> OffsiteBackupData {
let Ok(cfg) = autumn_web::config::AutumnConfig::load() else {
return OffsiteBackupData::default();
};
let Some(offsite) = cfg.backup.offsite else {
return OffsiteBackupData::default();
};
let profile = cfg.profile.clone().unwrap_or_else(|| "dev".to_owned());
let denv: Box<dyn autumn_web::config::Env> =
match autumn_web::dotenv::os_env_with_dotenv_for_profile(&profile) {
Ok(e) => Box::new(e),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
let bucket = offsite.s3.bucket.clone();
let storage_is_s3 = cfg.storage.backend == autumn_web::storage::StorageBackend::S3;
let shares_app_bucket = offsite_shares_active_app_bucket(
storage_is_s3,
bucket.as_deref(),
offsite.s3.endpoint.as_deref(),
cfg.storage.s3.bucket.as_deref(),
cfg.storage.s3.endpoint.as_deref(),
);
OffsiteBackupData {
configured: true,
bucket,
access_key_env: offsite.s3.access_key_id_env.clone(),
secret_key_env: offsite.s3.secret_access_key_env.clone(),
access_key_env_set: cred_env_present(
denv.as_ref(),
offsite.s3.access_key_id_env.as_deref(),
),
secret_key_env_set: cred_env_present(
denv.as_ref(),
offsite.s3.secret_access_key_env.as_deref(),
),
shared_bucket_without_optin: shares_app_bucket && !offsite.allow_shared_bucket,
}
}
fn offsite_shares_active_app_bucket(
storage_is_s3: bool,
offsite_bucket: Option<&str>,
offsite_endpoint: Option<&str>,
app_bucket: Option<&str>,
app_endpoint: Option<&str>,
) -> bool {
fn trimmed(b: Option<&str>) -> Option<&str> {
b.map(str::trim).filter(|s| !s.is_empty())
}
if !storage_is_s3 {
return false;
}
let Some(offsite_bucket) = trimmed(offsite_bucket) else {
return false;
};
crate::db::backup::destinations_conflict(
offsite_bucket,
offsite_endpoint,
trimmed(app_bucket),
app_endpoint,
)
}
fn cred_env_present(env: &dyn autumn_web::config::Env, name: Option<&str>) -> bool {
name.map(str::trim)
.filter(|v| !v.is_empty())
.is_some_and(|v| env.var(v).is_ok_and(|val| !val.is_empty()))
}
fn resolve_dotenv_state() -> (bool, bool, Vec<String>) {
let has_env_example = std::path::Path::new(".env.example").exists();
let has_env = std::path::Path::new(".env").exists();
let gitignore = std::fs::read_to_string(".gitignore").unwrap_or_default();
let uncovered = present_secret_dotenv_files(std::path::Path::new("."))
.into_iter()
.filter(|name| !gitignore_covers(&gitignore, name))
.collect();
(has_env_example, has_env, uncovered)
}
fn present_secret_dotenv_files(dir: &std::path::Path) -> Vec<String> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.filter_map(Result::ok) {
if !entry.path().is_file() {
continue;
}
if let Some(name) = entry.file_name().to_str()
&& is_secret_dotenv_filename(name)
{
files.push(name.to_owned());
}
}
}
files.sort();
files
}
fn is_secret_dotenv_filename(name: &str) -> bool {
let Some(rest) = name.strip_prefix(".env") else {
return false;
};
match rest {
"" | ".local" => true,
_ => {
rest.starts_with('.')
&& rest.rsplit('.').next() == Some("local")
&& rest.matches('.').count() >= 2
}
}
}
fn gitignore_covers(contents: &str, filename: &str) -> bool {
let mut ignored = false;
for line in contents.lines() {
let entry = line.trim();
if entry.is_empty() || entry.starts_with('#') {
continue;
}
let (negation, body) = entry
.strip_prefix('!')
.map_or((false, entry), |rest| (true, rest));
let pattern = body.strip_prefix("**/").unwrap_or(body);
let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
if pattern.ends_with('/') || pattern.contains('/') {
continue;
}
if glob_matches_basename(pattern, filename) {
ignored = !negation;
}
}
ignored
}
fn glob_matches_basename(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0_usize, 0_usize);
let mut star: Option<usize> = None;
let mut star_ti = 0_usize;
while ti < t.len() {
if pi < p.len() && p[pi] == t[ti] {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
star_ti = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub fn check_dotenv_impl(
has_env_example: bool,
has_env: bool,
uncovered_secret_files: &[String],
) -> CheckResult {
if !uncovered_secret_files.is_empty() {
let mut detail = format!(
"{} present but not gitignored — you risk committing secrets",
uncovered_secret_files.join(", ")
);
if has_env_example && !has_env {
detail.push_str("; also copy `.env.example` to `.env` to get started");
}
return CheckResult {
name: "dotenv",
status: CheckStatus::Warn,
detail: Some(detail),
hint: Some(
"Add your local .env files to .gitignore (e.g. `.env`, `.env.local`, `.env.*.local`)",
),
};
}
if has_env_example && !has_env {
return CheckResult {
name: "dotenv",
status: CheckStatus::Warn,
detail: Some("`.env.example` is present but no `.env` exists".into()),
hint: Some("Copy `.env.example` to `.env` and fill in local values"),
};
}
CheckResult {
name: "dotenv",
status: CheckStatus::Pass,
detail: Some(if has_env {
"`.env` handling looks correct".into()
} else {
".env not configured".into()
}),
hint: None,
}
}
pub fn check_maintenance_mode() -> CheckResult {
use autumn_web::maintenance::{MAINTENANCE_FLAG_FILE, MaintenanceState};
let path = std::path::Path::new(MAINTENANCE_FLAG_FILE);
match MaintenanceState::load_from_file(path) {
Ok(Some(config)) => {
let detail = config.message.as_ref().map_or_else(
|| "maintenance mode is ON".to_owned(),
|msg| format!("maintenance mode is ON — \"{msg}\""),
);
CheckResult {
name: "maintenance_mode",
status: CheckStatus::Warn,
detail: Some(detail),
hint: Some("Run `autumn maintenance off` to re-enable normal traffic"),
}
}
Ok(None) | Err(_) => CheckResult {
name: "maintenance_mode",
status: CheckStatus::Pass,
detail: Some("maintenance mode is off".into()),
hint: None,
},
}
}
pub fn browser_candidate_paths() -> Vec<std::path::PathBuf> {
autumn_web::browser_detect::browser_candidates()
}
fn probe_browser_version(path: &std::path::Path) -> Option<String> {
autumn_web::browser_detect::probe_version(path)
}
fn cargo_toml_features_has_key(cargo_toml: &str, key: &str) -> bool {
let quoted_key = format!("\"{key}\"");
let mut in_features = false;
for line in cargo_toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if trimmed == "[features]"
|| (trimmed.starts_with("[features]")
&& trimmed["[features]".len()..].trim_start().starts_with('#'))
{
in_features = true;
continue;
}
if in_features {
if trimmed.starts_with('[') {
break;
}
let bare = trimmed
.strip_prefix(key)
.is_some_and(|r| r.trim_start().starts_with('='));
let quoted = trimmed
.strip_prefix(quoted_key.as_str())
.is_some_and(|r| r.trim_start().starts_with('='));
if bare || quoted {
return true;
}
}
}
false
}
pub fn check_system_test_browser() -> CheckResult {
let candidates = browser_candidate_paths();
for path in &candidates {
if let Some(version) = probe_browser_version(path) {
return CheckResult {
name: "system_test_browser",
status: CheckStatus::Pass,
detail: Some(format!(
"Chromium for system tests: {version} ({})",
path.display()
)),
hint: None,
};
}
}
let project_uses_system_tests = std::env::current_dir()
.ok()
.and_then(|d| std::fs::read_to_string(d.join("Cargo.toml")).ok())
.is_some_and(|s| cargo_toml_features_has_key(&s, "system-tests"));
if project_uses_system_tests {
CheckResult {
name: "system_test_browser",
status: CheckStatus::Warn,
detail: Some("no Chromium binary found — system tests will be skipped".into()),
hint: Some(
"Install: apt-get install chromium-browser \
or set AUTUMN_CHROMIUM=/path/to/chrome",
),
}
} else {
CheckResult {
name: "system_test_browser",
status: CheckStatus::Pass,
detail: Some("no Chromium binary found (project does not use system-tests)".into()),
hint: None,
}
}
}
fn resolve_gdpr_unregistered_tables() -> Vec<String> {
let schema_path = std::path::Path::new("src/schema.rs");
let Ok(schema) = std::fs::read_to_string(schema_path) else {
return Vec::new();
};
let mut declared_tables: Vec<String> = Vec::new();
for line in schema.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed
.strip_prefix("diesel::table!")
.map(str::trim)
.filter(|r| r.starts_with('{'))
{
let _ = rest;
}
if let Some(open_paren) = trimmed.find('(')
&& (trimmed.ends_with('{') || trimmed.ends_with("{ "))
{
let name = trimmed[..open_paren].trim();
if !name.is_empty() && !name.starts_with("//") && !name.starts_with("diesel") {
declared_tables.push(name.to_owned());
}
}
}
if declared_tables.is_empty() {
return Vec::new();
}
let registered_tables = collect_gdpr_registered_tables_from_source();
declared_tables
.into_iter()
.filter(|t| !registered_tables.contains(t))
.collect()
}
fn collect_gdpr_registered_tables_from_source() -> std::collections::HashSet<String> {
let mut found = std::collections::HashSet::new();
for path in glob_rs_files(std::path::Path::new("src")) {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
scan_source_for_gdpr_registrations(&content, &mut found);
}
found
}
fn scan_source_for_gdpr_registrations(
content: &str,
found: &mut std::collections::HashSet<String>,
) {
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
if let Some(idx) = line.find("ModelRegistration::") {
let rest = &line[idx..];
if extract_quoted_table_name(rest, found) {
continue;
}
for j in 1..=3 {
if let Some(next) = lines.get(i + j)
&& extract_quoted_table_name(next.trim(), found)
{
break;
}
}
}
}
}
fn extract_quoted_table_name(s: &str, found: &mut std::collections::HashSet<String>) -> bool {
if let Some(start) = s.find('"')
&& let Some(end) = s[start + 1..].find('"')
{
let name = &s[start + 1..start + 1 + end];
if !name.is_empty() && !name.contains(' ') {
found.insert(name.to_owned());
return true;
}
}
false
}
fn glob_rs_files(dir: impl AsRef<std::path::Path>) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.append(&mut glob_rs_files(&path));
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
out
}
pub fn check_gdpr_export_registration_impl(
has_auth_starter: bool,
unregistered_tables: &[&str],
) -> CheckResult {
if !has_auth_starter {
return CheckResult {
name: "gdpr_export_registration",
status: CheckStatus::Pass,
detail: Some("auth starter not present; GDPR registration not required".into()),
hint: None,
};
}
if unregistered_tables.is_empty() {
return CheckResult {
name: "gdpr_export_registration",
status: CheckStatus::Pass,
detail: Some("all repositories are registered for GDPR export/erasure".into()),
hint: None,
};
}
let tables = unregistered_tables.join(", ");
CheckResult {
name: "gdpr_export_registration",
status: CheckStatus::Warn,
detail: Some(format!(
"{} repository model(s) not registered for GDPR export/erasure: {tables}",
unregistered_tables.len()
)),
hint: Some(
"Register each model via GdprRegistry in your application state. \
See docs/guide/gdpr-compliance.md for the export/erasure API.",
),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnprivateSensitiveColumn {
pub model: String,
pub column: String,
}
pub fn column_name_is_sensitive(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.contains("password")
|| lower.contains("token")
|| lower.contains("secret")
|| lower.ends_with("_hash")
|| lower == "hash"
}
pub fn check_model_private_columns_impl(found: &[UnprivateSensitiveColumn]) -> CheckResult {
if found.is_empty() {
return CheckResult {
name: "model_private_columns",
status: CheckStatus::Pass,
detail: Some("no sensitively-named model columns are missing `#[private]`".into()),
hint: None,
};
}
let lines: Vec<String> = found
.iter()
.map(|c| {
format!(
"{}.{} looks sensitive but is not marked `#[private]` (it will serialize into JSON responses)",
c.model, c.column
)
})
.collect();
CheckResult {
name: "model_private_columns",
status: CheckStatus::Warn,
detail: Some(lines.join("\n")),
hint: Some(
"Mark sensitive columns `#[private]` so they never leak into `Json`/`--api` \
responses; the field stays writable and queryable.",
),
}
}
fn resolve_unprivate_sensitive_columns() -> Vec<UnprivateSensitiveColumn> {
let mut found = Vec::new();
for path in glob_rs_files(std::path::Path::new("src")) {
if let Ok(content) = std::fs::read_to_string(&path) {
scan_source_for_unprivate_sensitive_columns(&content, &mut found);
}
}
found
}
fn attr_line_is_model(line: &str) -> bool {
let Some(rest) = line.trim_start().strip_prefix("#[") else {
return false;
};
let path = rest
.split(|c: char| c == '(' || c == ']' || c == ',' || c.is_whitespace())
.next()
.unwrap_or("");
!path.is_empty() && path.rsplit("::").next() == Some("model")
}
fn scan_source_for_unprivate_sensitive_columns(
content: &str,
found: &mut Vec<UnprivateSensitiveColumn>,
) {
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
if !attr_line_is_model(lines[i]) {
i += 1;
continue;
}
let mut j = i + 1;
let mut model_name: Option<String> = None;
while j < lines.len() {
let t = lines[j].trim_start();
if let Some(rest) = t
.strip_prefix("pub struct ")
.or_else(|| t.strip_prefix("struct "))
{
model_name = rest
.split(|c: char| c == '{' || c == '(' || c.is_whitespace())
.next()
.filter(|name| !name.is_empty())
.map(str::to_owned);
break;
}
j += 1;
}
let Some(model_name) = model_name else {
i += 1;
continue;
};
let mut k = j + 1;
let mut field_is_private = false;
while k < lines.len() {
let t = lines[k].trim();
if t.starts_with('}') {
break;
}
if t.starts_with("#[private") || t.starts_with("#[encrypted") {
field_is_private = true;
} else if let Some(field) = parse_pub_field_name(t) {
if column_name_is_sensitive(&field) && !field_is_private {
found.push(UnprivateSensitiveColumn {
model: model_name.clone(),
column: field,
});
}
field_is_private = false;
}
k += 1;
}
i = k + 1;
}
}
fn parse_pub_field_name(line: &str) -> Option<String> {
let rest = line.strip_prefix("pub ")?;
let colon = rest.find(':')?;
let name = rest[..colon].trim();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some(name.to_owned())
}
const EDGE_CAPSULE_BIN: &str = "src/bin/edge-capsule.rs";
const EDGE_CAPSULE_BIN_DIR: &str = "src/bin/edge-capsule/main.rs";
fn edge_manifest_is_virtual_workspace_root(root: &std::path::Path) -> bool {
let Ok(content) = std::fs::read_to_string(root.join("Cargo.toml")) else {
return false;
};
let Ok(table) = toml::from_str::<toml::Table>(&content) else {
return false;
};
table.contains_key("workspace") && !table.contains_key("package")
}
fn edge_virtual_workspace_warn(name: &'static str) -> CheckResult {
CheckResult {
name,
status: CheckStatus::Warn,
detail: Some("Cargo.toml is a workspace root with no [package]".into()),
hint: Some("Run `autumn doctor` from the member crate directory that owns your app"),
}
}
pub fn resolve_edge_capsule_bin(root: &std::path::Path) -> Option<std::path::PathBuf> {
let conventional = || conventional_edge_capsule_bin(root);
let content = std::fs::read_to_string(root.join("Cargo.toml")).ok()?;
let table = toml::from_str::<toml::Table>(&content).ok()?;
let package_name_is_edge_capsule = table
.get("package")
.and_then(|package| package.get("name"))
.and_then(toml::Value::as_str)
== Some("edge-capsule");
let pathless_target = || {
if package_name_is_edge_capsule {
let main_rs = root.join("src/main.rs");
if main_rs.is_file() {
return main_rs;
}
}
conventional()
};
if let Some(bins) = table.get("bin").and_then(toml::Value::as_array) {
for bin in bins {
if bin.get("name").and_then(toml::Value::as_str) == Some("edge-capsule") {
return Some(
bin.get("path")
.and_then(toml::Value::as_str)
.map_or_else(pathless_target, |path| root.join(path)),
);
}
}
}
let autobins_disabled = table
.get("package")
.and_then(|package| package.get("autobins"))
.and_then(toml::Value::as_bool)
== Some(false);
if autobins_disabled {
return None;
}
Some(pathless_target())
}
fn conventional_edge_capsule_bin(root: &std::path::Path) -> std::path::PathBuf {
let flat = root.join(EDGE_CAPSULE_BIN);
if flat.exists() {
return flat;
}
let dir_style = root.join(EDGE_CAPSULE_BIN_DIR);
if dir_style.exists() { dir_style } else { flat }
}
pub fn check_edge_target_impl(
scan: &crate::edge_scan::EdgeScan,
target_installed: bool,
) -> CheckResult {
const NAME: &str = "edge_target";
if scan.is_empty() {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some("no #[edge] routes".into()),
hint: None,
};
}
if target_installed {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(format!(
"{} #[edge] route(s); the {} target is installed",
scan.functions.len(),
crate::build::EDGE_TARGET,
)),
hint: None,
};
}
let mut files: Vec<&str> = scan.functions.iter().map(|f| f.file.as_str()).collect();
files.sort_unstable();
files.dedup();
CheckResult {
name: NAME,
status: CheckStatus::Fail,
detail: Some(format!(
"{} #[edge] route(s) in {} need the {} target, which is not installed",
scan.functions.len(),
files.join(", "),
crate::build::EDGE_TARGET,
)),
hint: Some(crate::build::EDGE_TARGET_HINT),
}
}
pub fn check_edge_routes_impl(
scan: &crate::edge_scan::EdgeScan,
capsule_bin_exists: bool,
) -> CheckResult {
const NAME: &str = "edge_routes";
if scan.is_empty() {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some("no #[edge] routes".into()),
hint: None,
};
}
let guarded = scan.guarded();
if !guarded.is_empty() {
let lines: Vec<String> = guarded
.iter()
.map(|f| format!("{} also carries #[{}]", f.location(), f.guards.join("]/#[")))
.collect();
return CheckResult {
name: NAME,
status: CheckStatus::Fail,
detail: Some(lines.join("\n")),
hint: Some(
"Remove #[edge] or the conflicting attribute: edge routes are unauthenticated \
read-path routes served without origin middleware — the capsule has no session, \
auth, or rate-limit state, and #[intercept] layers do not run there.",
),
};
}
let unregistered = scan.unregistered();
if !unregistered.is_empty() {
let lines: Vec<String> = unregistered
.iter()
.map(|f| format!("{} is not registered", f.location()))
.collect();
return CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(lines.join("\n")),
hint: Some(
"Add the handler to edge_routes![] and pass it to the edge-capsule bin; \
an unregistered #[edge] route is only ever served by the origin.",
),
};
}
if !capsule_bin_exists {
return CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(format!(
"{} #[edge] route(s) registered but {EDGE_CAPSULE_BIN} is missing",
scan.functions.len()
)),
hint: Some(
"Create src/bin/edge-capsule.rs calling autumn_edge::serve(...) with your \
edge_routes![] list so `autumn build` can compile the capsule.",
),
};
}
CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(format!(
"{} #[edge] route(s) registered with edge_routes![]",
scan.registered_fns().len()
)),
hint: None,
}
}
pub fn check_edge_capabilities_impl(scan: &crate::edge_scan::EdgeScan) -> CheckResult {
const NAME: &str = "edge_capabilities";
if scan.is_empty() {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some("no #[edge] routes".into()),
hint: None,
};
}
let unsupported = scan.unsupported();
if unsupported.is_empty() {
return CheckResult {
name: NAME,
status: CheckStatus::Pass,
detail: Some(format!(
"{} #[edge] route(s) use only edge capabilities",
scan.functions.len()
)),
hint: None,
};
}
CheckResult {
name: NAME,
status: CheckStatus::Warn,
detail: Some(crate::edge_scan::format_unsupported(&unsupported)),
hint: Some(crate::edge_scan::EDGE_UNSUPPORTED_HINT),
}
}
#[cfg(test)]
mod media_doctor_tests {
use super::{
CheckResult, CheckStatus, media_check_result, media_doctor_tasks, media_ssh_target,
};
use crate::deploy::exec::test_support::RecordingExecutor;
use crate::deploy::media::{
CHECK_FFMPEG_PREFLIGHT, CHECK_MEDIAMTX_BINARY, CHECK_MEDIAMTX_PORTS_AVAILABLE,
CHECK_MEDIAMTX_PORTS_DISTINCT, CHECK_MEDIAMTX_PORTS_MATCH_BASES,
CHECK_RECORDINGS_DIR_WRITABLE, MediaMtxHostConfig,
};
use autumn_web::config::DeployConfig;
fn enabled() -> MediaMtxHostConfig {
MediaMtxHostConfig {
enabled: true,
..MediaMtxHostConfig::default()
}
}
fn run(tasks: Vec<super::DoctorTask>) -> Vec<CheckResult> {
tasks.into_iter().map(|task| task()).collect()
}
fn status_of(results: &[CheckResult], name: &str) -> CheckStatus {
results
.iter()
.find(|result| result.name == name)
.unwrap_or_else(|| panic!("no `{name}` result in {results:?}"))
.status
.clone()
}
fn healthy_host() -> RecordingExecutor {
RecordingExecutor::new()
.with_stdout("media-ffmpeg-preflight", "ffmpeg version 6.1")
.with_stdout("media-mediamtx-binary", "absent")
.with_stdout("media-recordings-dir", "present")
.with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
)
}
#[test]
fn media_check_result_maps_pass_deferred_and_fail() {
let check = |passed, deferred| crate::deploy::PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
scope: None,
passed,
deferred,
detail: "d".to_owned(),
hint: None,
};
assert_eq!(
media_check_result(check(true, false)).status,
CheckStatus::Pass
);
assert_eq!(
media_check_result(check(false, true)).status,
CheckStatus::Warn
);
assert_eq!(
media_check_result(check(false, false)).status,
CheckStatus::Fail
);
assert_eq!(
media_check_result(check(true, false)).name,
CHECK_FFMPEG_PREFLIGHT
);
}
#[test]
fn offline_runs_only_the_pure_checks() {
let results = run(media_doctor_tasks::<
RecordingExecutor,
fn() -> RecordingExecutor,
>(&enabled(), "/usr/bin/ffmpeg", None));
let names: Vec<_> = results.iter().map(|result| result.name).collect();
assert_eq!(
names,
[
CHECK_MEDIAMTX_PORTS_DISTINCT,
CHECK_MEDIAMTX_PORTS_MATCH_BASES
]
);
assert!(
results
.iter()
.all(|result| result.status == CheckStatus::Pass)
);
}
#[test]
fn offline_catches_a_port_clash() {
let cfg = MediaMtxHostConfig {
hls_port: 1935,
..enabled()
};
let results = run(media_doctor_tasks::<
RecordingExecutor,
fn() -> RecordingExecutor,
>(&cfg, "/usr/bin/ffmpeg", None));
assert_eq!(
status_of(&results, CHECK_MEDIAMTX_PORTS_DISTINCT),
CheckStatus::Fail
);
}
#[test]
fn online_runs_all_six_over_the_executor() {
let results = run(media_doctor_tasks(
&enabled(),
"/usr/bin/ffmpeg",
Some(Ok(healthy_host)),
));
for name in [
CHECK_MEDIAMTX_PORTS_DISTINCT,
CHECK_MEDIAMTX_PORTS_MATCH_BASES,
CHECK_FFMPEG_PREFLIGHT,
CHECK_MEDIAMTX_BINARY,
CHECK_RECORDINGS_DIR_WRITABLE,
CHECK_MEDIAMTX_PORTS_AVAILABLE,
] {
assert_eq!(
status_of(&results, name),
CheckStatus::Pass,
"{name}: {results:?}"
);
}
assert_eq!(results.len(), 6);
}
#[test]
fn online_shows_a_deferred_ffmpeg_path_as_a_warning() {
let results = run(media_doctor_tasks(
&enabled(),
"${AUTUMN_MEDIA__FFMPEG__BIN}",
Some(Ok(healthy_host)),
));
assert_eq!(
status_of(&results, CHECK_FFMPEG_PREFLIGHT),
CheckStatus::Warn
);
}
#[test]
fn online_fails_each_host_check_when_there_is_no_single_host() {
let results = run(media_doctor_tasks::<
RecordingExecutor,
fn() -> RecordingExecutor,
>(
&enabled(),
"/usr/bin/ffmpeg",
Some(Err("needs one host".to_owned())),
));
for name in [
CHECK_FFMPEG_PREFLIGHT,
CHECK_MEDIAMTX_BINARY,
CHECK_RECORDINGS_DIR_WRITABLE,
CHECK_MEDIAMTX_PORTS_AVAILABLE,
] {
assert_eq!(status_of(&results, name), CheckStatus::Fail, "{name}");
}
assert_eq!(results.len(), 6, "the pure checks still run");
}
#[test]
fn media_ssh_target_needs_exactly_one_host() {
let deploy = DeployConfig {
user: "deployer".to_owned(),
ssh_port: 2222,
..DeployConfig::default()
};
let one = media_ssh_target(&Ok(vec!["app.example.com".to_owned()]), &deploy)
.expect("one host is a target");
assert_eq!(one.host, "app.example.com");
assert_eq!(one.user, "deployer");
assert_eq!(one.port, 2222);
let two = vec!["a.example.com".to_owned(), "b.example.com".to_owned()];
assert!(media_ssh_target(&Ok(two), &deploy).is_err());
assert!(media_ssh_target(&Ok(Vec::new()), &deploy).is_err());
assert_eq!(
media_ssh_target(&Err("bad hosts".to_owned()), &deploy).unwrap_err(),
"bad hosts"
);
}
#[test]
fn media_ssh_target_refuses_an_option_shaped_user_or_host() {
let bad_user = DeployConfig {
user: "-oProxyCommand=touch /tmp/x".to_owned(),
..DeployConfig::default()
};
assert!(media_ssh_target(&Ok(vec!["app.example.com".to_owned()]), &bad_user).is_err());
let deploy = DeployConfig::default();
assert!(media_ssh_target(&Ok(vec!["-oProxyCommand=x".to_owned()]), &deploy).is_err());
}
#[test]
fn doctor_runs_the_media_tasks_in_the_deploy_branch() {
let src = include_str!("doctor.rs");
let run_body = src
.split("pub fn run(opts: DoctorOptions)")
.nth(1)
.expect("run present");
assert!(
run_body.contains("tasks.extend(media_doctor_tasks("),
"doctor must add the media tasks"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn edge_scan_of(src: &str) -> crate::edge_scan::EdgeScan {
crate::edge_scan::scan_sources(&[("src/routes.rs", src)])
}
fn healthy_edge_scan() -> crate::edge_scan::EdgeScan {
edge_scan_of("#[edge]\nfn greet() {}\nfn wire() { edge_routes![greet]; }")
}
#[test]
fn deploy_host_spelling_hints_carry_no_line_continuation_gutter() {
assert_eq!(
DEPLOY_HOST_SPELLING_HINT,
"Fix the [deploy] host spelling in autumn.toml \u{2014} set either `host` (one \
server) or `hosts` (a fleet), never both (see `autumn deploy check`)",
);
assert_eq!(
DEPLOY_HOST_SPELLING_REACHABILITY_HINT,
"Fix the [deploy] host spelling in autumn.toml before probing reachability \
(see `autumn deploy check`)",
);
for hint in [
DEPLOY_HOST_SPELLING_HINT,
DEPLOY_HOST_SPELLING_REACHABILITY_HINT,
] {
assert!(
!hint.contains(" "),
"a run of spaces mid-sentence means a `\\` continuation was dropped: {hint}",
);
}
}
#[test]
fn edge_target_passes_without_edge_routes() {
let scan = edge_scan_of("#[get(\"/\")]\nfn home() {}");
for installed in [true, false] {
let r = check_edge_target_impl(&scan, installed);
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.detail.as_deref(), Some("no #[edge] routes"));
assert!(r.hint.is_none());
}
}
#[test]
fn edge_target_passes_when_installed() {
let r = check_edge_target_impl(&healthy_edge_scan(), true);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.unwrap().contains("wasm32-wasip1"));
}
#[test]
fn edge_target_fails_with_rustup_hint_when_missing() {
let r = check_edge_target_impl(&healthy_edge_scan(), false);
assert_eq!(r.status, CheckStatus::Fail);
let detail = r.detail.unwrap();
assert!(detail.contains("1 #[edge] route(s)"), "{detail}");
assert!(detail.contains("src/routes.rs"), "{detail}");
assert_eq!(r.hint, Some("Run `rustup target add wasm32-wasip1`"));
}
#[test]
fn edge_routes_passes_without_edge_routes() {
let r = check_edge_routes_impl(&edge_scan_of("fn home() {}"), false);
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.detail.as_deref(), Some("no #[edge] routes"));
}
#[test]
fn edge_routes_passes_when_registered_with_a_capsule_bin() {
let r = check_edge_routes_impl(&healthy_edge_scan(), true);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.unwrap().contains("1 #[edge] route(s) registered"));
}
#[test]
fn edge_routes_warns_on_an_unregistered_handler() {
let scan = edge_scan_of("#[edge]\nfn greet() {}");
let r = check_edge_routes_impl(&scan, true);
assert_eq!(r.status, CheckStatus::Warn);
assert!(
r.detail.unwrap().contains("greet @ src/routes.rs:2"),
"the warning must name the handler and its location"
);
assert!(r.hint.unwrap().contains("edge_routes![]"));
}
#[test]
fn edge_capabilities_passes_without_edge_routes() {
let r = check_edge_capabilities_impl(&edge_scan_of("fn plain() {}"));
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.detail.as_deref(), Some("no #[edge] routes"));
}
#[test]
fn edge_capabilities_passes_for_supported_capabilities() {
let scan = edge_scan_of("#[edge(needs(kv))]\nfn note(cache: EdgeCache) {}");
let r = check_edge_capabilities_impl(&scan);
assert_eq!(r.status, CheckStatus::Pass, "{:?}", r.detail);
}
#[test]
fn edge_capabilities_warns_on_a_capability_the_edge_cannot_provide() {
let scan = edge_scan_of(
"#[get(\"/d\")]\n#[edge(needs(db))]\nfn dash(db: Db) {}\nfn wire() { edge_routes![dash]; }",
);
let r = check_edge_capabilities_impl(&scan);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.unwrap();
assert!(detail.contains("dash @ src/routes.rs:3"), "{detail}");
assert!(detail.contains("needs(db)"), "{detail}");
assert!(detail.contains("`Db`"), "{detail}");
let hint = r.hint.unwrap();
assert!(hint.contains("needs(kv)"), "{hint}");
assert!(hint.contains("Remove #[edge]"), "{hint}");
}
#[test]
fn edge_routes_fails_on_an_auth_guarded_handler() {
let scan =
edge_scan_of("#[edge]\n#[secured]\nfn dash() {}\nfn wire() { edge_routes![dash]; }");
let r = check_edge_routes_impl(&scan, true);
assert_eq!(r.status, CheckStatus::Fail);
let detail = r.detail.unwrap();
assert!(detail.contains("dash @ src/routes.rs:3"), "{detail}");
assert!(detail.contains("#[secured]"), "{detail}");
assert!(r.hint.unwrap().contains("unauthenticated read-path"));
}
#[test]
fn edge_routes_guard_failure_outranks_registration_warning() {
let scan = edge_scan_of("#[edge]\n#[authorize(\"admin\")]\nfn dash() {}");
assert_eq!(
check_edge_routes_impl(&scan, false).status,
CheckStatus::Fail
);
}
#[test]
fn edge_routes_warns_when_the_capsule_bin_is_missing() {
let r = check_edge_routes_impl(&healthy_edge_scan(), false);
assert_eq!(r.status, CheckStatus::Warn);
assert!(
r.detail.unwrap().contains("src/bin/edge-capsule.rs"),
"the warning must name the file to create"
);
assert!(r.hint.unwrap().contains("autumn_edge::serve"));
}
#[test]
fn virtual_workspace_root_true_for_workspace_without_package() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n",
)
.unwrap();
assert!(edge_manifest_is_virtual_workspace_root(dir.path()));
}
#[test]
fn virtual_workspace_root_false_for_a_normal_package() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
)
.unwrap();
assert!(!edge_manifest_is_virtual_workspace_root(dir.path()));
}
#[test]
fn virtual_workspace_root_false_for_workspace_with_own_package() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\"]\n\n[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
)
.unwrap();
assert!(!edge_manifest_is_virtual_workspace_root(dir.path()));
}
#[test]
fn virtual_workspace_root_false_when_manifest_is_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!edge_manifest_is_virtual_workspace_root(dir.path()));
}
#[test]
fn virtual_workspace_root_true_despite_a_comment_mentioning_package() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"# each member has a [package] table\n[workspace]\nmembers = [\"app\"]\n",
)
.unwrap();
assert!(edge_manifest_is_virtual_workspace_root(dir.path()));
}
#[test]
fn resolve_edge_capsule_bin_none_without_a_manifest() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(resolve_edge_capsule_bin(dir.path()), None);
}
#[test]
fn resolve_edge_capsule_bin_conventional_path_by_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
)
.unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join(EDGE_CAPSULE_BIN))
);
}
#[test]
fn resolve_edge_capsule_bin_recognizes_the_directory_style_layout() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src/bin/edge-capsule")).unwrap();
std::fs::write(dir.path().join(EDGE_CAPSULE_BIN_DIR), "fn main() {}\n").unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join(EDGE_CAPSULE_BIN_DIR))
);
}
#[test]
fn resolve_edge_capsule_bin_recognizes_the_package_named_edge_capsules_main_rs() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"edge-capsule\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join("src/main.rs"))
);
}
#[test]
fn resolve_edge_capsule_bin_falls_back_to_convention_when_package_named_edge_capsule_has_no_main_rs()
{
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"edge-capsule\"\nversion = \"0.1.0\"\n",
)
.unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join(EDGE_CAPSULE_BIN))
);
}
#[test]
fn resolve_edge_capsule_bin_none_when_autobins_disabled_and_undeclared() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nautobins = false\n",
)
.unwrap();
assert_eq!(resolve_edge_capsule_bin(dir.path()), None);
}
#[test]
fn resolve_edge_capsule_bin_honors_a_custom_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nautobins = false\n\n\
[[bin]]\nname = \"edge-capsule\"\npath = \"cmd/edge.rs\"\n",
)
.unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join("cmd/edge.rs"))
);
}
#[test]
fn resolve_edge_capsule_bin_explicit_entry_without_path_uses_convention() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
[[bin]]\nname = \"edge-capsule\"\n",
)
.unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join(EDGE_CAPSULE_BIN))
);
}
#[test]
fn resolve_edge_capsule_bin_explicit_entry_without_path_on_the_edge_capsule_package_uses_main_rs()
{
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"edge-capsule\"\nversion = \"0.1.0\"\n\n\
[[bin]]\nname = \"edge-capsule\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join("src/main.rs"))
);
}
#[test]
fn resolve_edge_capsule_bin_ignores_an_unrelated_bin_entry() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\
[[bin]]\nname = \"cli\"\npath = \"src/bin/cli.rs\"\n",
)
.unwrap();
assert_eq!(
resolve_edge_capsule_bin(dir.path()),
Some(dir.path().join(EDGE_CAPSULE_BIN))
);
}
#[test]
fn offsite_backup_pass_when_unconfigured() {
let r = check_offsite_backup_impl(&OffsiteBackupData::default());
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.unwrap().contains("not disaster recovery"));
}
#[test]
fn offsite_backup_fail_when_bucket_unset() {
let data = OffsiteBackupData {
configured: true,
..Default::default()
};
let r = check_offsite_backup_impl(&data);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.unwrap().contains("bucket is unset"));
}
#[test]
fn offsite_backup_fail_on_shared_bucket_without_optin() {
let data = OffsiteBackupData {
configured: true,
bucket: Some("shared".to_owned()),
shared_bucket_without_optin: true,
..Default::default()
};
let r = check_offsite_backup_impl(&data);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.unwrap().contains("same as the app"));
}
#[test]
fn doctor_shared_bucket_matches_runtime_guard() {
assert!(!offsite_shares_active_app_bucket(
false,
Some("shared"),
None,
Some("shared"),
None
));
let local_data = OffsiteBackupData {
configured: true,
bucket: Some("shared".to_owned()),
access_key_env: Some("K".to_owned()),
secret_key_env: Some("S".to_owned()),
access_key_env_set: true,
secret_key_env_set: true,
shared_bucket_without_optin: false, };
assert_ne!(
check_offsite_backup_impl(&local_data).status,
CheckStatus::Fail
);
assert!(offsite_shares_active_app_bucket(
true,
Some("shared"),
Some("https://s3.us-east-1.amazonaws.com"),
Some("shared"),
None,
));
assert!(offsite_shares_active_app_bucket(
true,
Some("shared"),
None,
Some("shared"),
None
));
assert!(!offsite_shares_active_app_bucket(
true,
Some("offsite"),
None,
Some("appbucket"),
None
));
assert!(!offsite_shares_active_app_bucket(
true,
Some("shared"),
Some("https://minio.example:9000"),
Some("shared"),
None,
));
}
#[test]
fn offsite_backup_warn_when_credentials_not_ready() {
let data = OffsiteBackupData {
configured: true,
bucket: Some("offsite".to_owned()),
access_key_env: Some("OFF_KEY".to_owned()),
secret_key_env: Some("OFF_SECRET".to_owned()),
access_key_env_set: false,
secret_key_env_set: false,
shared_bucket_without_optin: false,
};
let r = check_offsite_backup_impl(&data);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.unwrap();
assert!(detail.contains("credentials are not ready"));
assert!(!detail.contains("supersecret"));
}
#[test]
fn offsite_backup_pass_when_ready() {
let data = OffsiteBackupData {
configured: true,
bucket: Some("offsite".to_owned()),
access_key_env: Some("OFF_KEY".to_owned()),
secret_key_env: Some("OFF_SECRET".to_owned()),
access_key_env_set: true,
secret_key_env_set: true,
shared_bucket_without_optin: false,
};
let r = check_offsite_backup_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.unwrap().contains("offsite backups configured"));
}
#[test]
fn cred_env_present_honors_dotenv_supplied_creds() {
let overlay = autumn_web::config::MockEnv::new().with("OFF_SECRET", "from-dot-env-prod");
assert!(cred_env_present(&overlay, Some("OFF_SECRET")));
assert!(!cred_env_present(&overlay, Some("MISSING")));
assert!(!cred_env_present(&overlay, None));
assert!(!cred_env_present(&overlay, Some(" ")));
let empty = autumn_web::config::MockEnv::new().with("OFF_SECRET", "");
assert!(!cred_env_present(&empty, Some("OFF_SECRET")));
}
#[test]
fn known_toml_sections_includes_backup() {
assert!(KNOWN_TOML_SECTIONS.contains(&"backup"));
}
#[test]
fn model_private_columns_pass_when_none_flagged() {
let r = check_model_private_columns_impl(&[]);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn pg_client_tools_pass_when_all_present() {
let r = check_pg_client_tools_impl(&[]);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.hint.is_none());
}
#[test]
fn pg_client_tools_warn_lists_missing() {
let r = check_pg_client_tools_impl(&["pg_dump", "pg_restore"]);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.unwrap();
assert!(detail.contains("pg_dump"));
assert!(detail.contains("pg_restore"));
assert!(r.hint.unwrap().contains("AUTUMN_PG_BIN_DIR"));
}
#[test]
fn pg_client_tools_pass_when_found_via_bin_dir() {
let temp = tempfile::tempdir().expect("temp dir");
let bin = temp.path();
for tool in ["pg_dump", "pg_restore"] {
let name = if cfg!(windows) {
format!("{tool}.exe")
} else {
tool.to_owned()
};
std::fs::write(bin.join(name), b"").expect("write fake tool");
}
let tools = crate::db::backup::PgTools::with_dirs(vec![bin.to_path_buf()]);
let r = check_pg_client_tools_with(&tools);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.hint.is_none());
}
#[test]
fn model_private_columns_warn_lists_offenders() {
let found = vec![
UnprivateSensitiveColumn {
model: "User".into(),
column: "password_hash".into(),
},
UnprivateSensitiveColumn {
model: "ApiKey".into(),
column: "secret".into(),
},
];
let r = check_model_private_columns_impl(&found);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.unwrap();
assert!(detail.contains("User.password_hash"));
assert!(detail.contains("ApiKey.secret"));
}
#[test]
fn sensitive_name_heuristic_matches_expected() {
assert!(column_name_is_sensitive("password_hash"));
assert!(column_name_is_sensitive("reset_token"));
assert!(column_name_is_sensitive("api_secret"));
assert!(column_name_is_sensitive("password"));
assert!(!column_name_is_sensitive("email"));
assert!(!column_name_is_sensitive("display_name"));
}
#[test]
fn scan_flags_unprivate_sensitive_columns_only() {
let src = r"
#[model]
pub struct User {
#[id]
pub id: i64,
pub email: String,
pub password_hash: String,
#[private]
pub reset_token: String,
}
";
let mut found = Vec::new();
scan_source_for_unprivate_sensitive_columns(src, &mut found);
assert_eq!(
found.len(),
1,
"only password_hash should be flagged: {found:?}"
);
assert_eq!(found[0].model, "User");
assert_eq!(found[0].column, "password_hash");
}
#[test]
fn scan_terminates_struct_on_brace_with_trailing_tokens() {
for closing in ["} // User", "};", "} // trailing"] {
let src = format!(
"#[model]\npub struct User {{\n #[id]\n pub id: i64,\n pub password_hash: String,\n{closing}\npub struct Other {{\n pub api_secret: String,\n}}\n"
);
let mut found = Vec::new();
scan_source_for_unprivate_sensitive_columns(&src, &mut found);
assert_eq!(
found.len(),
1,
"closing line `{closing}` must terminate the struct: {found:?}"
);
assert_eq!(found[0].model, "User");
assert_eq!(found[0].column, "password_hash");
}
}
#[test]
fn scan_treats_encrypted_as_private_in_json() {
let src = r"
#[model]
pub struct Vault {
#[id]
pub id: i64,
#[encrypted]
pub api_secret: String,
}
";
let mut found = Vec::new();
scan_source_for_unprivate_sensitive_columns(src, &mut found);
assert!(
found.is_empty(),
"encrypted columns are private-in-JSON: {found:?}"
);
}
#[test]
fn scan_matches_path_qualified_model_attributes() {
for attr in [
"#[autumn_web::model]",
"#[macros::model]",
"#[model(table = \"users\")]",
] {
let src = format!(
"{attr}\npub struct User {{\n #[id]\n pub id: i64,\n pub password_hash: String,\n}}\n"
);
let mut found = Vec::new();
scan_source_for_unprivate_sensitive_columns(&src, &mut found);
assert_eq!(
found.len(),
1,
"path-qualified `{attr}` must be scanned and flag password_hash: {found:?}"
);
assert_eq!(found[0].column, "password_hash");
}
assert!(attr_line_is_model("#[autumn_web::model(table = \"x\")]"));
assert!(attr_line_is_model(" #[macros::model]"));
assert!(attr_line_is_model("#[model]"));
assert!(!attr_line_is_model("#[models]"));
assert!(!attr_line_is_model("#[model_config]"));
assert!(!attr_line_is_model("pub struct User {"));
}
#[test]
fn test_recursive_toml_validation() {
let valid_toml = r#"
[server]
port = 4000
host = "0.0.0.0"
[database]
primary_url = "postgres://localhost/db"
"#;
let r = check_toml_content(valid_toml);
assert_eq!(r.status, CheckStatus::Pass);
let typo_toml = r#"
[database]
primry_url = "postgres://localhost/db"
"#;
let r = check_toml_content(typo_toml);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.as_ref().unwrap();
assert!(detail.contains("database.primry_url"));
assert!(detail.contains("did you mean \"database.primary_url\"?"));
let unknown_toml = r"
[server]
xyz_completely_unknown_key = 123
";
let r = check_toml_content(unknown_toml);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.as_ref().unwrap();
assert!(detail.contains("server.xyz_completely_unknown_key"));
assert!(!detail.contains("did you mean"));
}
#[test]
fn mail_unsubscribe_no_usage_passes() {
let r = check_mail_unsubscribe_config_impl(false, None, None, false, true, 30);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn mail_unsubscribe_usage_without_config_fails_in_prod() {
let r = check_mail_unsubscribe_config_impl(true, None, None, false, true, 30);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.unwrap().contains("list_unsubscribe"));
}
#[test]
fn mail_unsubscribe_usage_without_config_warns_outside_prod() {
let r = check_mail_unsubscribe_config_impl(true, None, Some(" "), false, false, 30);
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn mail_unsubscribe_disabled_transport_passes() {
let r = check_mail_unsubscribe_config_impl(true, None, None, true, true, 30);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn mail_unsubscribe_base_url_warns_in_prod_but_passes_outside() {
let r = check_mail_unsubscribe_config_impl(
true,
Some("https://app.example.com"),
None,
false,
true,
30,
);
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.detail.unwrap().contains("suppression backend"));
let r = check_mail_unsubscribe_config_impl(
true,
Some("https://app.example.com"),
None,
false,
false,
30,
);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn mail_unsubscribe_usage_with_mailto_passes() {
let r = check_mail_unsubscribe_config_impl(
true,
None,
Some("unsub@example.com"),
false,
true,
30,
);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn detects_mailer_attribute_but_not_builder_or_comment() {
assert!(file_declares_list_unsubscribe(
"#[mailer(list_unsubscribe = \"weekly_digest\")]"
));
assert!(file_declares_list_unsubscribe(
"#[mailer(\n list_unsubscribe = \"x\"\n)]"
));
assert!(!file_declares_list_unsubscribe(
"Mail::builder().list_unsubscribe(\"x\").build()"
));
assert!(!file_declares_list_unsubscribe(
"// remember to set list_unsubscribe later"
));
assert!(!file_declares_list_unsubscribe(
"// #[mailer(list_unsubscribe = \"x\")]"
));
assert!(!file_declares_list_unsubscribe(
"let x = 1; // #[mailer(list_unsubscribe = \"x\")]"
));
assert!(!file_declares_list_unsubscribe(
"/* #[mailer(list_unsubscribe = \"weekly\")] */"
));
assert!(!file_declares_list_unsubscribe(
"before /*\n#[mailer(list_unsubscribe = \"x\")]\n*/ after"
));
}
#[test]
fn scan_finds_workspace_mailer_but_skips_vendored_copy() {
let root = tempfile::tempdir().expect("temp dir");
let vendored = root.path().join("vendor/autumn-web/tests");
std::fs::create_dir_all(&vendored).expect("create vendor dir");
std::fs::write(
vendored.join("mail_unsubscribe.rs"),
"#[mailer(list_unsubscribe = \"weekly_digest\")]\nfn x() {}",
)
.expect("write vendored source");
assert!(
!scan_dir_for_list_unsubscribe(root.path()),
"vendored dependency sources must be skipped"
);
for tree in ["tests", "examples"] {
let dir = root.path().join(tree);
std::fs::create_dir_all(&dir).expect("create test/example dir");
std::fs::write(
dir.join("fixture.rs"),
"#[mailer(list_unsubscribe = \"weekly_digest\")]\nfn f() {}",
)
.expect("write fixture source");
}
assert!(
!scan_dir_for_list_unsubscribe(root.path()),
"tests/ and examples/ fixtures must be skipped"
);
let app = root.path().join("crates/marketing/src");
std::fs::create_dir_all(&app).expect("create app dir");
std::fs::write(
app.join("mailers.rs"),
"#[mailer(list_unsubscribe = \"weekly_digest\")]\nfn y() {}",
)
.expect("write app source");
assert!(
scan_dir_for_list_unsubscribe(root.path()),
"workspace member mailer must still be detected"
);
}
#[test]
fn inline_profile_lookup_order_matches_runtime() {
assert_eq!(
inline_profile_lookup_names("prod"),
vec!["production", "prod"]
);
assert_eq!(
inline_profile_lookup_names("dev"),
vec!["development", "dev"]
);
assert_eq!(inline_profile_lookup_names("staging"), vec!["staging"]);
}
#[test]
fn override_file_lookup_order_prefers_selected_spelling() {
assert_eq!(
override_file_lookup_names("prod", "prod"),
vec!["prod".to_owned(), "production".to_owned()]
);
assert_eq!(
override_file_lookup_names("prod", "production"),
vec!["production".to_owned(), "prod".to_owned()]
);
assert_eq!(
override_file_lookup_names("dev", "dev"),
vec!["dev".to_owned(), "development".to_owned()]
);
assert_eq!(
override_file_lookup_names("dev", "development"),
vec!["development".to_owned(), "dev".to_owned()]
);
assert_eq!(
override_file_lookup_names("staging", "staging"),
vec!["staging".to_owned()]
);
}
#[test]
fn mail_unsubscribe_invalid_base_url_fails_in_prod() {
let r = check_mail_unsubscribe_config_impl(
true,
Some("http://app.example.com"),
None,
false,
true,
30,
);
assert_eq!(r.status, CheckStatus::Fail);
let r = check_mail_unsubscribe_config_impl(
true,
Some("https://app.example.com:abc"),
None,
false,
true,
30,
);
assert_eq!(r.status, CheckStatus::Fail);
}
#[test]
fn mail_unsubscribe_invalid_mailto_fails_in_prod() {
let r = check_mail_unsubscribe_config_impl(
true,
None,
Some("unsubscribe example.com"),
false,
true,
30,
);
assert_eq!(r.status, CheckStatus::Fail);
}
#[test]
fn mail_unsubscribe_invalid_value_is_lenient_outside_prod() {
let r = check_mail_unsubscribe_config_impl(
true,
Some("http://app.example.com"),
None,
false,
false,
30,
);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn doctor_url_validation_matches_runtime_cases() {
assert!(is_valid_https_base_url_doctor("https://app.example.com"));
assert!(is_valid_https_base_url_doctor(
"https://app.example.com:8443"
));
assert!(is_valid_https_base_url_doctor(
"https://app.example.com/base"
));
assert!(!is_valid_https_base_url_doctor("http://app.example.com"));
assert!(!is_valid_https_base_url_doctor(
"https://app.example.com:abc"
));
assert!(!is_valid_https_base_url_doctor("https://@/base"));
assert!(!is_valid_https_base_url_doctor("https:///path"));
assert!(!is_valid_https_base_url_doctor("https:/app.example.com"));
assert!(!is_valid_https_base_url_doctor("https:app.example.com"));
assert!(!is_valid_https_base_url_doctor(
"https://user@app.example.com"
));
assert!(!is_valid_https_base_url_doctor(
"https://app.example.com?q=1"
));
assert!(is_valid_mailto_address_doctor("unsub@example.com"));
assert!(is_valid_mailto_address_doctor("mailto:unsub@example.com"));
assert!(!is_valid_mailto_address_doctor("not-an-email"));
assert!(!is_valid_mailto_address_doctor("unsubscribe example.com"));
assert!(!is_valid_mailto_address_doctor("https://unsub@example.com"));
assert!(!is_valid_mailto_address_doctor("unsub@https://example.com"));
}
#[test]
fn is_absolute_http_url_doctor_matches_runtime_absoluteness() {
assert!(is_absolute_http_url_doctor(
"https://alerts.example.com/hooks"
));
assert!(is_absolute_http_url_doctor(
"http://alerts.example.com/hooks"
));
assert!(is_absolute_http_url_doctor(
"https://user@h.example.com/x?a=1"
));
assert!(is_absolute_http_url_doctor("http://h.example.com:8080/x"));
assert!(!is_absolute_http_url_doctor("hooks.example/x"));
assert!(!is_absolute_http_url_doctor("/hooks/autumn"));
assert!(!is_absolute_http_url_doctor("alerts.example.com"));
assert!(!is_absolute_http_url_doctor(""));
assert!(!is_absolute_http_url_doctor("ftp://h.example.com/x"));
assert!(!is_absolute_http_url_doctor("ws://h.example.com/x"));
assert!(!is_absolute_http_url_doctor("https://"));
assert!(!is_absolute_http_url_doctor("http://"));
}
#[cfg(feature = "postgres")]
#[test]
fn is_valid_alert_mailbox_matches_lettre_and_accepts_display_name() {
assert!(is_valid_alert_mailbox_doctor("oncall@example.com"));
assert!(is_valid_alert_mailbox_doctor("a+b@sub.example.co"));
assert!(is_valid_alert_mailbox_doctor("Ops <ops@example.com>"));
assert!(!is_valid_alert_mailbox_doctor("mailto:oncall@example.com"));
assert!(!is_valid_alert_mailbox_doctor("https://oncall@example.com"));
assert!(!is_valid_alert_mailbox_doctor("not-an-address"));
assert!(!is_valid_alert_mailbox_doctor("oncall example.com"));
assert!(!is_valid_alert_mailbox_doctor(""));
for case in [
"oncall@example.com",
"a+b@sub.example.co",
"Ops <ops@example.com>",
"mailto:oncall@example.com",
"https://oncall@example.com",
"not-an-address",
"oncall example.com",
"",
] {
let runtime_ok = case
.parse::<autumn_web::reexports::lettre::message::Mailbox>()
.is_ok();
assert_eq!(
is_valid_alert_mailbox_doctor(case),
runtime_ok,
"doctor and the runtime lettre parser must agree on {case:?}"
);
}
}
#[test]
fn mail_unsubscribe_usage_with_both_url_and_mailto_warns_in_prod() {
let r = check_mail_unsubscribe_config_impl(
true,
Some("https://app.example.com"),
Some("unsub@example.com"),
false,
true,
30,
);
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn transport_resolver_trims_and_ignores_invalid_env() {
assert_eq!(
resolve_effective_mail_transport(Some(" SMTP ".to_owned()), Some("disabled"), "prod"),
"smtp"
);
assert_eq!(
resolve_effective_mail_transport(Some(" disabled ".to_owned()), None, "prod"),
"disabled"
);
assert_eq!(
resolve_effective_mail_transport(Some("bogus".to_owned()), Some("disabled"), "prod"),
"disabled"
);
assert_eq!(resolve_effective_mail_transport(None, None, "dev"), "log");
assert_eq!(
resolve_effective_mail_transport(None, None, "prod"),
"disabled"
);
assert_eq!(
resolve_effective_mail_transport(None, Some("LOG"), "prod"),
"log"
);
}
#[test]
fn ttl_resolver_ignores_invalid_env_like_runtime() {
let table: toml::Table =
toml::from_str("[mail]\nunsubscribe_token_ttl_days = 14\n").unwrap();
assert_eq!(
resolve_unsubscribe_token_ttl_days_from_sources(None, Some(&table)),
14
);
assert_eq!(
resolve_unsubscribe_token_ttl_days_from_sources(None, None),
30
);
assert_eq!(
resolve_unsubscribe_token_ttl_days_from_sources(Some("7".to_owned()), Some(&table)),
7
);
assert_eq!(
resolve_unsubscribe_token_ttl_days_from_sources(Some(String::new()), Some(&table)),
14
);
assert_eq!(
resolve_unsubscribe_token_ttl_days_from_sources(Some("abc".to_owned()), Some(&table)),
14
);
assert_eq!(
resolve_unsubscribe_token_ttl_days_from_sources(Some(" ".to_owned()), None),
30
);
}
#[test]
fn ttl_toml_type_invalid_flags_non_integer_values() {
assert!(!unsubscribe_ttl_toml_type_invalid(None));
let empty: toml::Table = toml::from_str("[mail]\n").unwrap();
assert!(!unsubscribe_ttl_toml_type_invalid(Some(&empty)));
let int: toml::Table = toml::from_str("[mail]\nunsubscribe_token_ttl_days = 30\n").unwrap();
assert!(!unsubscribe_ttl_toml_type_invalid(Some(&int)));
let string: toml::Table =
toml::from_str("[mail]\nunsubscribe_token_ttl_days = \"30\"\n").unwrap();
assert!(unsubscribe_ttl_toml_type_invalid(Some(&string)));
let float: toml::Table =
toml::from_str("[mail]\nunsubscribe_token_ttl_days = 30.0\n").unwrap();
assert!(unsubscribe_ttl_toml_type_invalid(Some(&float)));
}
#[test]
fn dest_toml_type_invalid_flags_non_string_values() {
assert!(!unsubscribe_dest_toml_type_invalid(None));
let empty: toml::Table = toml::from_str("[mail]\n").unwrap();
assert!(!unsubscribe_dest_toml_type_invalid(Some(&empty)));
let strings: toml::Table = toml::from_str(
"[mail]\nunsubscribe_base_url = \"https://x\"\nunsubscribe_mailto = \"u@x.com\"\n",
)
.unwrap();
assert!(!unsubscribe_dest_toml_type_invalid(Some(&strings)));
let int_url: toml::Table = toml::from_str("[mail]\nunsubscribe_base_url = 42\n").unwrap();
assert!(unsubscribe_dest_toml_type_invalid(Some(&int_url)));
let arr_mailto: toml::Table =
toml::from_str("[mail]\nunsubscribe_mailto = [\"u@x.com\"]\n").unwrap();
assert!(unsubscribe_dest_toml_type_invalid(Some(&arr_mailto)));
}
#[test]
fn mail_unsubscribe_non_positive_ttl_fails_in_any_profile() {
for is_prod in [true, false] {
let r = check_mail_unsubscribe_config_impl(false, None, None, true, is_prod, 0);
assert_eq!(r.status, CheckStatus::Fail, "ttl=0 prod={is_prod}");
let r = check_mail_unsubscribe_config_impl(false, None, None, true, is_prod, -5);
assert_eq!(r.status, CheckStatus::Fail, "ttl=-5 prod={is_prod}");
}
}
#[test]
fn mail_unsubscribe_invalid_destination_fails_even_without_usage() {
let r = check_mail_unsubscribe_config_impl(
false,
Some("http://app.example.com"),
None,
true,
true,
30,
);
assert_eq!(r.status, CheckStatus::Fail);
let r = check_mail_unsubscribe_config_impl(
false,
None,
Some("unsubscribe example.com"),
true,
true,
30,
);
assert_eq!(r.status, CheckStatus::Fail);
}
#[test]
fn strip_rust_comments_handles_unterminated_block_comment() {
let result = strip_rust_comments("code /* unclosed comment");
assert!(result.contains("code"));
assert!(!result.contains("unclosed"));
}
#[test]
fn strip_rust_comments_removes_line_comments_and_preserves_code() {
let result = strip_rust_comments("let x = 1; // this comment should disappear");
assert!(
result.contains("let x = 1;"),
"code before comment preserved: {result}"
);
assert!(
!result.contains("disappear"),
"line comment stripped: {result}"
);
}
#[test]
fn glyph_pass() {
assert_eq!(glyph(&CheckStatus::Pass), "✅");
}
#[test]
fn glyph_warn() {
assert_eq!(glyph(&CheckStatus::Warn), "⚠️ ");
}
#[test]
fn glyph_fail() {
assert_eq!(glyph(&CheckStatus::Fail), "❌");
}
#[test]
fn trusted_hosts_fail_in_production_when_empty() {
let result = check_trusted_hosts_impl(&[], true);
assert_eq!(result.name, "trusted_hosts");
assert!(matches!(result.status, CheckStatus::Fail));
}
#[test]
fn trusted_hosts_warn_on_wildcard() {
let result = check_trusted_hosts_impl(&["*".to_owned()], true);
assert_eq!(result.name, "trusted_hosts");
assert!(matches!(result.status, CheckStatus::Warn));
}
#[test]
fn trusted_hosts_warn_on_wildcard_when_mixed_with_other_entries() {
let result = check_trusted_hosts_impl(&["example.com".to_owned(), "*".to_owned()], true);
assert_eq!(result.name, "trusted_hosts");
assert!(matches!(result.status, CheckStatus::Warn));
}
fn healthy_client_auth(mode: &str, required_path_count: usize) -> ClientAuthDoctorData {
ClientAuthDoctorData::Healthy {
mode: mode.to_owned(),
expired_cas: Vec::new(),
near_expiry_cas: Vec::new(),
ca_count: 1,
crl_stale: None,
crl_coverage_gaps: Vec::new(),
required_path_count,
}
}
#[test]
fn client_auth_passes_when_not_configured() {
let r = check_client_auth_impl(&ClientAuthDoctorData::NotConfigured);
assert_eq!(r.name, "tls_client_auth");
assert!(matches!(r.status, CheckStatus::Pass));
assert!(r.hint.is_none());
}
#[test]
fn client_auth_passes_when_mode_is_off() {
let r = check_client_auth_impl(&ClientAuthDoctorData::ModeOff);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn client_auth_warns_when_the_cli_lacks_the_tls_feature() {
let r = check_client_auth_impl(&ClientAuthDoctorData::FeatureDisabled);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(r.hint.is_some());
}
#[test]
fn client_auth_fails_on_an_unloadable_bundle() {
let r = check_client_auth_impl(&ClientAuthDoctorData::Invalid {
detail: "no CAs found in the mTLS client CA bundle `ca.pem`".to_owned(),
});
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.unwrap().contains("ca.pem"));
}
#[test]
fn client_auth_fails_on_an_expired_ca() {
let mut data = healthy_client_auth("required", 0);
if let ClientAuthDoctorData::Healthy { expired_cas, .. } = &mut data {
expired_cas.push("CN=Retired CA".to_owned());
}
let r = check_client_auth_impl(&data);
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.unwrap().contains("CN=Retired CA"));
}
#[test]
fn client_auth_warns_on_a_near_expiry_ca() {
let mut data = healthy_client_auth("required", 0);
if let ClientAuthDoctorData::Healthy {
near_expiry_cas, ..
} = &mut data
{
near_expiry_cas.push(("CN=Aging CA".to_owned(), 12));
}
let r = check_client_auth_impl(&data);
assert!(matches!(r.status, CheckStatus::Warn));
let detail = r.detail.unwrap();
assert!(detail.contains("CN=Aging CA"), "{detail}");
assert!(detail.contains("12 day(s)"), "{detail}");
}
#[test]
fn client_auth_warns_on_a_stale_crl() {
let mut data = healthy_client_auth("required", 1);
if let ClientAuthDoctorData::Healthy { crl_stale, .. } = &mut data {
*crl_stale = Some(true);
}
let r = check_client_auth_impl(&data);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(r.detail.unwrap().contains("stale"));
}
#[test]
fn client_auth_warns_when_optional_enforces_nothing() {
let r = check_client_auth_impl(&healthy_client_auth("optional", 0));
assert!(matches!(r.status, CheckStatus::Warn));
assert!(r.detail.unwrap().contains("enforcing nothing"));
}
#[test]
fn client_auth_does_not_warn_when_optional_guards_a_route() {
let r = check_client_auth_impl(&healthy_client_auth("optional", 1));
assert!(matches!(r.status, CheckStatus::Pass), "{:?}", r.detail);
}
#[test]
fn client_auth_does_not_warn_when_required_locks_the_whole_listener() {
let r = check_client_auth_impl(&healthy_client_auth("required", 0));
assert!(matches!(r.status, CheckStatus::Pass), "{:?}", r.detail);
let detail = r.detail.unwrap();
assert!(detail.contains("required"), "{detail}");
assert!(detail.contains("1 trusted client CA(s)"), "{detail}");
}
#[test]
fn client_auth_grades_the_worst_problem_first() {
let data = ClientAuthDoctorData::Healthy {
mode: "required".to_owned(),
expired_cas: vec!["CN=Retired CA".to_owned()],
near_expiry_cas: vec![("CN=Aging CA".to_owned(), 3)],
ca_count: 2,
crl_stale: Some(true),
crl_coverage_gaps: vec!["CN=Uncovered CA".to_owned()],
required_path_count: 0,
};
let r = check_client_auth_impl(&data);
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.unwrap().contains("expired"));
}
#[test]
fn client_auth_warns_on_a_crl_coverage_gap() {
let mut data = healthy_client_auth("required", 1);
if let ClientAuthDoctorData::Healthy {
crl_coverage_gaps, ..
} = &mut data
{
crl_coverage_gaps.push("CN=New CA".to_owned());
}
let r = check_client_auth_impl(&data);
assert!(matches!(r.status, CheckStatus::Warn));
let detail = r.detail.unwrap();
assert!(detail.contains("CN=New CA"), "{detail}");
assert!(detail.contains("refuses to boot"), "{detail}");
}
#[test]
fn client_auth_grades_an_expired_ca_above_a_crl_coverage_gap() {
let mut data = healthy_client_auth("required", 1);
if let ClientAuthDoctorData::Healthy {
expired_cas,
crl_coverage_gaps,
..
} = &mut data
{
expired_cas.push("CN=Retired CA".to_owned());
crl_coverage_gaps.push("CN=New CA".to_owned());
}
let r = check_client_auth_impl(&data);
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.unwrap().contains("expired"));
}
#[test]
fn client_auth_fails_on_an_unsupported_mode() {
for bad in [
toml::Value::Integer(1),
toml::Value::String("requred".to_owned()),
toml::Value::Boolean(true),
] {
let mut section = toml::Table::new();
section.insert("mode".to_owned(), bad.clone());
section.insert(
"ca_bundle_path".to_owned(),
toml::Value::String("ca.pem".to_owned()),
);
let mut tls = toml::Table::new();
tls.insert("client_auth".to_owned(), toml::Value::Table(section));
let data = resolve_client_auth_doctor_data(Some(&tls));
assert!(
matches!(data, ClientAuthDoctorData::Invalid { .. }),
"mode = {bad} should be graded invalid, got {data:?}"
);
assert!(matches!(
check_client_auth_impl(&data).status,
CheckStatus::Fail
));
}
}
#[test]
fn client_auth_fails_on_required_paths_under_mode_off() {
let mut section = toml::Table::new();
section.insert("mode".to_owned(), toml::Value::String("off".to_owned()));
section.insert(
"required_paths".to_owned(),
toml::Value::Array(vec![toml::Value::String("/internal/".to_owned())]),
);
let mut tls = toml::Table::new();
tls.insert("client_auth".to_owned(), toml::Value::Table(section));
let data = resolve_client_auth_doctor_data(Some(&tls));
assert!(
matches!(data, ClientAuthDoctorData::Invalid { .. }),
"got {data:?}"
);
let result = check_client_auth_impl(&data);
assert!(matches!(result.status, CheckStatus::Fail));
assert!(result.detail.unwrap().contains("required_paths"));
}
fn client_auth_tls_with(key: &str, value: toml::Value) -> toml::Table {
let mut section = toml::Table::new();
section.insert(
"mode".to_owned(),
toml::Value::String("required".to_owned()),
);
section.insert(
"ca_bundle_path".to_owned(),
toml::Value::String("ca.pem".to_owned()),
);
section.insert(key.to_owned(), value);
let mut tls = toml::Table::new();
tls.insert("client_auth".to_owned(), toml::Value::Table(section));
tls
}
#[test]
fn client_auth_fails_on_malformed_key_shapes() {
let cases: Vec<(&str, toml::Value, &str)> = vec![
("required_paths", "/internal".into(), "\"/internal\""),
("required_paths", toml::Value::Array(vec![1.into()]), "1"),
(
"required_paths",
toml::Value::Array(vec!["/users/{id}".into()]),
"/users/{id}",
),
("crl_path", "".into(), "crl_path"),
("crl_path", " ".into(), "crl_path"),
("ca_bundle_path", 1.into(), "ca_bundle_path"),
("reload_interval_secs", 1.5.into(), "1.5"),
("crl_path", 1.into(), "1"),
("reload_interval_secs", "60".into(), "\"60\""),
("reload_interval_secs", (-1).into(), "-1"),
];
for (key, value, needle) in cases {
let tls = client_auth_tls_with(key, value.clone());
let data = resolve_client_auth_doctor_data(Some(&tls));
let ClientAuthDoctorData::Invalid { detail } = &data else {
panic!("{key} = {value} should be invalid, got {data:?}");
};
assert!(detail.contains(key) && detail.contains(needle), "{detail}");
}
}
#[test]
fn client_auth_fails_on_malformed_key_under_mode_off() {
let mut tls = client_auth_tls_with("crl_path", 1.into());
if let Some(toml::Value::Table(t)) = tls.get_mut("client_auth") {
t.insert("mode".to_owned(), "off".into());
}
let data = resolve_client_auth_doctor_data(Some(&tls));
assert!(
matches!(data, ClientAuthDoctorData::Invalid { .. }),
"{data:?}"
);
}
#[test]
fn client_auth_fails_when_the_section_is_not_a_table() {
let mut tls = toml::Table::new();
tls.insert(
"client_auth".to_owned(),
toml::Value::String("required".to_owned()),
);
let data = resolve_client_auth_doctor_data(Some(&tls));
assert!(
matches!(data, ClientAuthDoctorData::Invalid { .. }),
"got {data:?}"
);
assert!(matches!(
check_client_auth_impl(&data).status,
CheckStatus::Fail
));
}
#[test]
fn client_auth_reads_an_absent_mode_as_off() {
let mut tls = toml::Table::new();
tls.insert(
"client_auth".to_owned(),
toml::Value::Table(toml::Table::new()),
);
assert!(matches!(
resolve_client_auth_doctor_data(Some(&tls)),
ClientAuthDoctorData::ModeOff
));
}
#[test]
fn tls_pass_when_not_configured() {
let r = check_tls_impl(&TlsDoctorData::NotConfigured);
assert_eq!(r.name, "tls");
assert!(matches!(r.status, CheckStatus::Pass));
assert!(r.hint.is_none());
}
#[test]
fn tls_fail_when_expired() {
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: true,
days_until_expiry: -3,
});
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.as_deref().unwrap().contains("expired"));
assert!(r.hint.is_some());
}
#[test]
fn tls_fail_when_not_yet_valid() {
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: true,
expired: false,
days_until_expiry: 365,
});
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.as_deref().unwrap().contains("not yet valid"));
assert!(r.hint.is_some());
}
#[test]
fn tls_warn_when_near_expiry() {
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: false,
days_until_expiry: 10,
});
assert!(matches!(r.status, CheckStatus::Warn));
assert!(r.detail.as_deref().unwrap().contains("10 day"));
}
#[test]
fn tls_warn_at_exactly_thirty_days() {
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: false,
days_until_expiry: 30,
});
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn tls_pass_when_healthy() {
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: false,
days_until_expiry: 365,
});
assert!(matches!(r.status, CheckStatus::Pass));
assert!(r.hint.is_none());
}
#[cfg(feature = "tls")]
#[test]
fn tls_grades_from_raw_timestamp_not_day_bucket() {
use autumn_web::tls::LeafInspection;
let now: i64 = 1_700_000_000;
let not_before = now - 86_400;
let just_expired = LeafInspection {
not_before_unix: not_before,
not_after_unix: now - 3_600,
};
assert!(just_expired.is_expired(now));
assert!(!just_expired.is_not_yet_valid(now));
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: just_expired.is_not_yet_valid(now),
expired: just_expired.is_expired(now),
days_until_expiry: just_expired.days_until_expiry(now),
});
assert!(
matches!(r.status, CheckStatus::Fail),
"cert expired <24h ago must Fail, got {:?}",
r.status
);
let expiring_soon = LeafInspection {
not_before_unix: not_before,
not_after_unix: now + 3_600,
};
assert!(!expiring_soon.is_expired(now));
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: expiring_soon.is_not_yet_valid(now),
expired: expiring_soon.is_expired(now),
days_until_expiry: expiring_soon.days_until_expiry(now),
});
assert!(
matches!(r.status, CheckStatus::Warn),
"cert expiring in <24h (not expired) must Warn, got {:?}",
r.status
);
let healthy = LeafInspection {
not_before_unix: not_before,
not_after_unix: now + 40 * 86_400,
};
assert!(!healthy.is_expired(now));
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: healthy.is_not_yet_valid(now),
expired: healthy.is_expired(now),
days_until_expiry: healthy.days_until_expiry(now),
});
assert!(
matches!(r.status, CheckStatus::Pass),
"cert with 40 days left must Pass, got {:?}",
r.status
);
let not_yet_valid = LeafInspection {
not_before_unix: now + 3_600,
not_after_unix: now + 40 * 86_400,
};
assert!(not_yet_valid.is_not_yet_valid(now));
assert!(!not_yet_valid.is_expired(now));
let r = check_tls_impl(&TlsDoctorData::Healthy {
not_yet_valid: not_yet_valid.is_not_yet_valid(now),
expired: not_yet_valid.is_expired(now),
days_until_expiry: not_yet_valid.days_until_expiry(now),
});
assert!(
matches!(r.status, CheckStatus::Fail),
"not-yet-valid cert must Fail, got {:?}",
r.status
);
assert!(r.detail.as_deref().unwrap().contains("not yet valid"));
}
#[test]
fn tls_fail_when_invalid() {
let r = check_tls_impl(&TlsDoctorData::Invalid {
detail: "cert file not found".to_owned(),
});
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.detail.as_deref(), Some("cert file not found"));
assert!(r.hint.is_some());
}
#[test]
fn tls_tuning_only_env_resolves_as_incomplete_config() {
let resolved = resolve_tls_paths_from_sources(
false, None, None, None, None, true, );
let (cert, key) = resolved.expect("tuning-only TLS env must resolve as configured");
assert!(
cert.as_os_str().is_empty() && key.as_os_str().is_empty(),
"cert/key must be empty so the caller reports 'must set both'"
);
assert!(
cert.as_os_str().is_empty() || key.as_os_str().is_empty(),
"mirrors resolve_tls_doctor_data's incomplete-config guard"
);
let data = TlsDoctorData::Invalid {
detail: "[server.tls] must set both cert_path and key_path".to_owned(),
};
let r = check_tls_impl(&data);
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.as_deref().unwrap().contains("must set both"));
}
#[test]
fn tls_section_in_manifest_dir_config_is_detected() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[server.tls]\ncert_path = \"/etc/tls/cert.pem\"\nkey_path = \"/etc/tls/key.pem\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("dev", "dev", move |f| base.join(f));
let tls = merged
.get("server")
.and_then(toml::Value::as_table)
.and_then(|s| s.get("tls"))
.and_then(toml::Value::as_table);
assert!(
tls.is_some(),
"a [server.tls] section in the manifest-dir autumn.toml must be visible"
);
let toml_cert = tls
.and_then(|t| t.get("cert_path"))
.and_then(toml::Value::as_str)
.map(str::to_owned);
let toml_key = tls
.and_then(|t| t.get("key_path"))
.and_then(toml::Value::as_str)
.map(str::to_owned);
let (cert, key) =
resolve_tls_paths_from_sources(tls.is_some(), toml_cert, toml_key, None, None, false)
.expect("manifest-dir [server.tls] must resolve as configured, not NotConfigured");
assert_eq!(cert, std::path::PathBuf::from("/etc/tls/cert.pem"));
assert_eq!(key, std::path::PathBuf::from("/etc/tls/key.pem"));
}
#[test]
fn tls_paths_none_when_unconfigured() {
assert!(
resolve_tls_paths_from_sources(false, None, None, None, None, false).is_none(),
"no section and no TLS env means not configured"
);
}
#[test]
fn unrecognized_tls_env_var_is_not_detected() {
use autumn_web::config::MockEnv;
let unrecognized = MockEnv::new().with("AUTUMN_SERVER__TLS__ENABLED", "false");
assert!(
!any_tls_env_var_set_in(&unrecognized),
"an unrecognized AUTUMN_SERVER__TLS__* var must not mark TLS configured"
);
assert!(
resolve_tls_paths_from_sources(
false, None,
None,
None,
None,
any_tls_env_var_set_in(&unrecognized),
)
.is_none(),
"an unrecognized TLS env var must resolve as not-configured (no spurious Fail)"
);
let recognized = MockEnv::new().with("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS", "5");
assert!(
any_tls_env_var_set_in(&recognized),
"a recognized TLS tuning var must still mark TLS configured"
);
}
#[test]
fn tls_paths_env_overrides_toml() {
let resolved = resolve_tls_paths_from_sources(
true,
Some("/toml/c.pem".to_owned()),
Some("/toml/k.pem".to_owned()),
Some("/env/c.pem".to_owned()),
Some("/env/k.pem".to_owned()),
true,
)
.expect("configured");
assert_eq!(resolved.0, std::path::PathBuf::from("/env/c.pem"));
assert_eq!(resolved.1, std::path::PathBuf::from("/env/k.pem"));
}
#[test]
fn tls_empty_env_override_clears_toml_path() {
use autumn_web::config::{Env, MockEnv};
let env = MockEnv::new().with("AUTUMN_SERVER__TLS__CERT_PATH", "");
let env_cert = env.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
let env_key = env.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
assert_eq!(
env_cert.as_deref(),
Some(""),
"a present-but-empty CERT_PATH must survive as Some(\"\"), not be dropped"
);
assert!(env_key.is_none(), "an unset KEY_PATH stays None");
let (cert, key) = resolve_tls_paths_from_sources(
true, Some("/toml/cert.pem".to_owned()), Some("/toml/key.pem".to_owned()), env_cert, env_key, any_tls_env_var_set_in(&env),
)
.expect("a present TLS env var means TLS is configured");
assert!(
cert.as_os_str().is_empty(),
"empty CERT_PATH override must clear the TOML cert path"
);
assert_eq!(
key,
std::path::PathBuf::from("/toml/key.pem"),
"the unset KEY_PATH keeps the TOML value"
);
let r = check_tls_impl(&TlsDoctorData::Invalid {
detail: "[server.tls] must set both cert_path and key_path".to_owned(),
});
assert!(matches!(r.status, CheckStatus::Fail));
}
#[test]
fn dotenv_supplied_tls_var_is_detected() {
use autumn_web::config::{Env, MockEnv};
let env = MockEnv::new()
.with("AUTUMN_SERVER__TLS__CERT_PATH", "/dotenv/cert.pem")
.with("AUTUMN_SERVER__TLS__KEY_PATH", "/dotenv/key.pem");
assert!(
any_tls_env_var_set_in(&env),
"a dotenv-supplied TLS var must count as configured"
);
let env_cert = env
.var("AUTUMN_SERVER__TLS__CERT_PATH")
.ok()
.filter(|v| !v.trim().is_empty());
let env_key = env
.var("AUTUMN_SERVER__TLS__KEY_PATH")
.ok()
.filter(|v| !v.trim().is_empty());
let resolved = resolve_tls_paths_from_sources(
false, None, None, env_cert,
env_key,
any_tls_env_var_set_in(&env),
)
.expect("dotenv-supplied TLS cert/key must resolve as configured");
assert_eq!(resolved.0, std::path::PathBuf::from("/dotenv/cert.pem"));
assert_eq!(resolved.1, std::path::PathBuf::from("/dotenv/key.pem"));
let tuning_only = MockEnv::new().with("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS", "30");
assert!(
any_tls_env_var_set_in(&tuning_only),
"a dotenv-supplied tuning var must count as configured"
);
let (cert, key) = resolve_tls_paths_from_sources(
false,
None,
None,
None,
None,
any_tls_env_var_set_in(&tuning_only),
)
.expect("tuning-only dotenv TLS var must resolve as configured");
assert!(
cert.as_os_str().is_empty() && key.as_os_str().is_empty(),
"cert/key must be empty so the caller reports 'must set both'"
);
}
#[test]
fn tls_warn_when_feature_disabled() {
let r = check_tls_impl(&TlsDoctorData::FeatureDisabled);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(r.detail.as_deref().unwrap().contains("tls"));
}
#[test]
fn acme_ports_pass_when_both_open() {
let r = check_acme_ports_for_challenge(
"app.example.com",
PortReachability::Open,
PortReachability::Open,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn acme_ports_fail_when_80_unreachable() {
for p80 in [
PortReachability::Refused,
PortReachability::TimedOut,
PortReachability::Error,
] {
let r = check_acme_ports_for_challenge(
"app.example.com",
p80,
PortReachability::Open,
false,
);
assert!(matches!(r.status, CheckStatus::Fail), "port80={p80:?}");
assert!(r.detail.as_deref().unwrap().contains("port 80"));
}
}
#[test]
fn acme_ports_warn_when_only_443_down() {
let r = check_acme_ports_for_challenge(
"app.example.com",
PortReachability::Open,
PortReachability::Refused,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn acme_dns_pass_when_points_here() {
let r = check_acme_dns_for_challenge("app.example.com", &DnsPointsHere::Matches, false);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn acme_dns_fail_when_resolves_elsewhere() {
let r = check_acme_dns_for_challenge(
"app.example.com",
&DnsPointsHere::ResolvesElsewhere {
resolved: vec!["203.0.113.7".to_owned()],
},
false,
);
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.as_deref().unwrap().contains("203.0.113.7"));
}
#[test]
fn acme_dns_warn_when_indeterminate() {
for outcome in [DnsPointsHere::Unresolved, DnsPointsHere::LocalIpsUnknown] {
let r = check_acme_dns_for_challenge("app.example.com", &outcome, false);
assert!(matches!(r.status, CheckStatus::Warn), "outcome={outcome:?}");
}
}
#[test]
fn dns_private_local_ips_are_inconclusive_not_elsewhere() {
let resolved: Vec<std::net::IpAddr> = vec!["203.0.113.10".parse().unwrap()];
let local: Vec<std::net::IpAddr> = vec![
"10.0.0.5".parse().unwrap(), "172.16.4.9".parse().unwrap(), "192.168.1.20".parse().unwrap(), "100.72.0.1".parse().unwrap(), "169.254.3.4".parse().unwrap(), "127.0.0.1".parse().unwrap(), "fe80::1".parse().unwrap(), "fd00::1".parse().unwrap(), ];
assert_eq!(
grade_dns_points_here(&resolved, &local),
DnsPointsHere::LocalIpsUnknown,
"only private/link-local local IPs must be inconclusive, never ResolvesElsewhere"
);
}
#[test]
fn dns_public_local_ip_matching_result_points_here() {
let ip: std::net::IpAddr = "203.0.113.10".parse().unwrap();
let local: Vec<std::net::IpAddr> = vec!["192.168.1.20".parse().unwrap(), ip];
assert_eq!(grade_dns_points_here(&[ip], &local), DnsPointsHere::Matches);
}
#[test]
fn dns_public_local_ip_not_in_result_resolves_elsewhere() {
let resolved: Vec<std::net::IpAddr> = vec!["203.0.113.10".parse().unwrap()];
let local: Vec<std::net::IpAddr> = vec!["198.51.100.7".parse().unwrap()];
assert_eq!(
grade_dns_points_here(&resolved, &local),
DnsPointsHere::ResolvesElsewhere {
resolved: vec!["203.0.113.10".to_owned()],
}
);
}
#[test]
fn dns_unresolved_when_no_addresses() {
let local: Vec<std::net::IpAddr> = vec!["203.0.113.9".parse().unwrap()];
assert_eq!(
grade_dns_points_here(&[], &local),
DnsPointsHere::Unresolved
);
}
#[test]
fn dns_partial_match_flags_unmatched_address() {
let here: std::net::IpAddr = "203.0.113.10".parse().unwrap();
let elsewhere: std::net::IpAddr = "198.51.100.7".parse().unwrap();
let resolved: Vec<std::net::IpAddr> = vec![here, elsewhere];
let local: Vec<std::net::IpAddr> = vec![here];
assert_eq!(
grade_dns_points_here(&resolved, &local),
DnsPointsHere::PartialMatch {
unmatched: vec!["198.51.100.7".to_owned()],
},
"an unmatched resolved address must not be swallowed by a first-match Pass"
);
let outcome = grade_dns_points_here(&resolved, &local);
let r = check_acme_dns_for_challenge("app.example.com", &outcome, false);
assert!(
matches!(r.status, CheckStatus::Warn),
"a partial DNS match must be a Warn, not a clean Pass"
);
assert!(
r.detail
.as_deref()
.unwrap_or_default()
.contains("198.51.100.7"),
"the unmatched address must be reported: {:?}",
r.detail
);
}
#[test]
fn is_public_ip_classifies_ranges() {
for public in ["203.0.113.10", "198.51.100.7", "2001:4860:4860::8888"] {
assert!(
is_public_ip(public.parse().unwrap()),
"{public} should be public"
);
}
for private in [
"10.0.0.1",
"172.16.0.1",
"192.168.0.1",
"100.64.0.1",
"100.127.255.255",
"169.254.1.1",
"127.0.0.1",
"0.0.0.0",
"fe80::1",
"fc00::1",
"fdff::1",
"::1",
] {
assert!(
!is_public_ip(private.parse().unwrap()),
"{private} should NOT be public"
);
}
assert!(is_public_ip("100.128.0.1".parse().unwrap()));
assert!(is_public_ip("100.63.255.255".parse().unwrap()));
}
#[test]
fn acme_ca_root_unset_against_a_custom_directory_warns() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::MissingForPrivateDirectory);
assert_eq!(r.status, CheckStatus::Warn);
assert_eq!(r.name, "acme_ca_root");
assert!(r.hint.unwrap().contains("ca_root_path"));
}
#[test]
fn acme_ca_root_unset_passes() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::NotConfigured);
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.name, "acme_ca_root");
}
#[test]
fn acme_ca_root_usable_passes() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::Usable);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn acme_ca_root_unreadable_fails() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::Unreadable {
path: "/etc/autumn/pebble-root.pem".to_owned(),
reason: "No such file or directory".to_owned(),
});
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.unwrap().contains("/etc/autumn/pebble-root.pem"));
assert!(r.hint.is_some());
}
#[test]
fn acme_ca_root_non_certificate_fails() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::NotACertificate {
path: "/etc/autumn/notes.txt".to_owned(),
reason: "no items found".to_owned(),
});
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.unwrap().contains("BEGIN CERTIFICATE"));
}
#[test]
fn acme_ca_root_malformed_toml_value_fails() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::Malformed {
value: "123".to_owned(),
});
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.unwrap().contains("not a string"));
}
#[test]
fn acme_ca_root_bundle_warns_that_only_the_first_cert_is_used() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::ExtraCertificatesIgnored {
path: "/etc/autumn/fullchain.pem".to_owned(),
certificates: 3,
});
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.unwrap();
assert!(detail.contains('3'));
assert!(detail.contains("FIRST"));
}
#[test]
fn acme_ca_root_with_a_public_directory_warns() {
let r = check_acme_ca_root_impl(&AcmeCaRootData::UnneededForPublicDirectory {
path: "/etc/autumn/private-root.pem".to_owned(),
directory: "production".to_owned(),
});
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.detail.unwrap().contains("production"));
}
#[test]
fn acme_ca_root_resolver_matches_what_the_runtime_accepts() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
resolve_acme_ca_root_data(None, None, "production"),
AcmeCaRootData::NotConfigured
);
assert_eq!(
resolve_acme_ca_root_data(None, None, "custom-abc"),
AcmeCaRootData::MissingForPrivateDirectory
);
assert!(matches!(
resolve_acme_ca_root_data(None, Some("123"), "custom-abc"),
AcmeCaRootData::Malformed { .. }
));
let missing = dir.path().join("absent.pem");
assert!(matches!(
resolve_acme_ca_root_data(Some(&missing), None, "custom-abc"),
AcmeCaRootData::Unreadable { .. }
));
assert!(matches!(
resolve_acme_ca_root_data(Some(dir.path()), None, "custom-abc"),
AcmeCaRootData::Unreadable { .. }
));
let junk = dir.path().join("junk.pem");
std::fs::write(&junk, "not a certificate").expect("write");
assert!(matches!(
resolve_acme_ca_root_data(Some(&junk), None, "custom-abc"),
AcmeCaRootData::NotACertificate { .. }
));
let bogus = dir.path().join("bogus.pem");
std::fs::write(
&bogus,
"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
)
.expect("write");
assert!(
matches!(
resolve_acme_ca_root_data(Some(&bogus), None, "custom-abc"),
AcmeCaRootData::NotACertificate { .. }
),
"a PEM block holding junk must not pass; the runtime rejects it"
);
let (root_pem, _) = self_signed_root_pem();
let root = dir.path().join("root.pem");
std::fs::write(&root, &root_pem).expect("write");
assert_eq!(
resolve_acme_ca_root_data(Some(&root), None, "custom-abc"),
AcmeCaRootData::Usable
);
assert!(matches!(
resolve_acme_ca_root_data(Some(&root), None, "production"),
AcmeCaRootData::UnneededForPublicDirectory { .. }
));
let bundle = dir.path().join("fullchain.pem");
std::fs::write(&bundle, format!("{root_pem}{root_pem}")).expect("write");
assert!(matches!(
resolve_acme_ca_root_data(Some(&bundle), None, "custom-abc"),
AcmeCaRootData::ExtraCertificatesIgnored {
certificates: 2,
..
}
));
assert!(
matches!(
resolve_acme_ca_root_data(Some(&bundle), None, "production"),
AcmeCaRootData::UnneededForPublicDirectory { .. }
),
"a bundle must not mask the public-directory warning"
);
}
fn self_signed_root_pem() -> (String, String) {
let key = rcgen::KeyPair::generate().expect("key");
let mut params = rcgen::CertificateParams::new(Vec::new()).expect("params");
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
];
let cert = params.self_signed(&key).expect("self-sign");
(cert.pem(), key.serialize_pem())
}
#[test]
fn acme_stored_cert_pass_when_none_yet() {
let r = check_acme_stored_cert_impl(&TlsDoctorData::NotConfigured);
assert!(matches!(r.status, CheckStatus::Pass));
assert!(r.detail.as_deref().unwrap().contains("first boot"));
}
#[test]
fn acme_stored_cert_fail_when_expired() {
let r = check_acme_stored_cert_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: true,
days_until_expiry: -3,
});
assert!(matches!(r.status, CheckStatus::Fail));
}
#[test]
fn acme_stored_cert_fail_when_not_yet_valid() {
let r = check_acme_stored_cert_impl(&TlsDoctorData::Healthy {
not_yet_valid: true,
expired: false,
days_until_expiry: 365,
});
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.as_deref().unwrap().contains("not yet valid"));
}
#[test]
fn acme_stored_cert_warn_when_near_expiry() {
let r = check_acme_stored_cert_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: false,
days_until_expiry: 10,
});
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn acme_stored_cert_pass_when_healthy() {
let r = check_acme_stored_cert_impl(&TlsDoctorData::Healthy {
not_yet_valid: false,
expired: false,
days_until_expiry: 75,
});
assert!(matches!(r.status, CheckStatus::Pass));
}
fn acme_doctor_cfg(domains: &[&str], contact_email: &str) -> AcmeDoctorConfig {
AcmeDoctorConfig {
domains: domains.iter().map(|d| (*d).to_owned()).collect(),
contact_email: contact_email.to_owned(),
http_challenge_port: 80,
renew_before_days: 30,
cache_dir: std::path::PathBuf::from("config/acme"),
directory_label: "staging".to_owned(),
directory_error: None,
port_error: None,
ca_root_path: None,
ca_root_error: None,
domains_error: None,
renew_before_days_error: None,
cache_dir_error: None,
contact_email_error: None,
section_error: None,
dns: None,
dns_error: None,
custom_domains: None,
custom_domains_error: None,
}
}
fn acme_dns_cfg(
provider: autumn_web::config::AcmeDnsProvider,
) -> autumn_web::config::AcmeDnsConfig {
toml::from_str(&format!("provider = \"{}\"\n", provider.as_str()))
.expect("the minimal DNS section parses")
}
fn read_of(count: usize) -> CustomDomainRegistryRead {
CustomDomainRegistryRead {
domains: (0..count)
.map(|i| {
(
format!("d{i}.clientco.com"),
"tenant-a".to_owned(),
"active".to_owned(),
None,
)
})
.collect(),
..CustomDomainRegistryRead::default()
}
}
fn custom_domains_config(enabled: bool) -> autumn_web::config::CustomDomainsConfig {
autumn_web::config::CustomDomainsConfig {
enabled,
ingress_hostname: Some("ingress.myapp.com".to_owned()),
..autumn_web::config::CustomDomainsConfig::default()
}
}
#[test]
fn custom_domains_config_check_grades_the_section() {
let empty = CustomDomainRegistryRead::default();
assert!(check_custom_domains_config_impl(None, None, &empty).is_none());
let off =
check_custom_domains_config_impl(Some(&custom_domains_config(false)), None, &empty)
.expect("a present section is always graded");
assert_eq!(off.status, CheckStatus::Pass);
assert!(off.detail.unwrap().contains("enabled = false"));
let on =
check_custom_domains_config_impl(Some(&custom_domains_config(true)), None, &read_of(3))
.unwrap();
assert_eq!(on.status, CheckStatus::Pass);
assert!(on.detail.unwrap().contains('3'));
let no_ingress = autumn_web::config::CustomDomainsConfig {
enabled: true,
ingress_hostname: None,
..autumn_web::config::CustomDomainsConfig::default()
};
assert_eq!(
check_custom_domains_config_impl(Some(&no_ingress), None, &empty)
.unwrap()
.status,
CheckStatus::Fail
);
assert_eq!(
check_custom_domains_config_impl(None, Some("unknown field `ingres_hostname`"), &empty)
.unwrap()
.status,
CheckStatus::Fail
);
let mut capped = custom_domains_config(true);
capped.max_domains = 2;
assert_eq!(
check_custom_domains_config_impl(Some(&capped), None, &read_of(5))
.unwrap()
.status,
CheckStatus::Warn
);
}
fn read_of_hosts(hosts: &[&str]) -> CustomDomainRegistryRead {
CustomDomainRegistryRead {
domains: hosts
.iter()
.map(|h| {
(
(*h).to_owned(),
"tenant-a".to_owned(),
"pending_dns".to_owned(),
None,
)
})
.collect(),
..CustomDomainRegistryRead::default()
}
}
#[test]
fn custom_domains_config_check_warns_when_a_domain_gets_no_dns_instructions() {
let hostname_only = custom_domains_config(true);
let warn = check_custom_domains_config_impl(
Some(&hostname_only),
None,
&read_of_hosts(&["app.clientco.com", "clientco.co.uk"]),
)
.unwrap();
assert_eq!(warn.status, CheckStatus::Warn);
let detail = warn.detail.unwrap();
assert!(detail.starts_with("1 registered domain gets"), "{detail}");
assert!(detail.contains("clientco.co.uk"), "{detail}");
assert!(!detail.contains("app.clientco.com"), "{detail}");
assert!(warn.hint.unwrap().contains("ingress_ipv4"));
let subs = check_custom_domains_config_impl(
Some(&hostname_only),
None,
&read_of_hosts(&["app.clientco.com", "app.clientco.co.uk"]),
)
.unwrap();
assert_eq!(subs.status, CheckStatus::Pass);
let mut with_addresses = custom_domains_config(true);
with_addresses.ingress_ipv4 = vec!["203.0.113.10".to_owned()];
let pass = check_custom_domains_config_impl(
Some(&with_addresses),
None,
&read_of_hosts(&["clientco.co.uk"]),
)
.unwrap();
assert_eq!(pass.status, CheckStatus::Pass);
with_addresses.ingress_hostname = None;
let no_cname = check_custom_domains_config_impl(
Some(&with_addresses),
None,
&read_of_hosts(&["app.clientco.co.uk"]),
)
.unwrap();
assert_eq!(no_cname.status, CheckStatus::Warn);
assert!(no_cname.hint.unwrap().contains("ingress_hostname"));
let hosts: Vec<String> = (1..=7).map(|i| format!("c{i}.co.uk")).collect();
let hosts: Vec<&str> = hosts.iter().map(String::as_str).collect();
let many =
check_custom_domains_config_impl(Some(&hostname_only), None, &read_of_hosts(&hosts))
.unwrap();
let detail = many.detail.unwrap();
assert!(detail.starts_with("7 registered domains get"), "{detail}");
assert!(detail.contains("c5.co.uk and 2 more"), "{detail}");
assert!(!detail.contains("c6.co.uk"), "{detail}");
}
fn probe(status: &str, dns: CustomDomainDns) -> CustomDomainProbe {
CustomDomainProbe {
hostname: "app.clientco.com".to_owned(),
tenant: "tenant-a".to_owned(),
status: status.to_owned(),
dns,
}
}
#[test]
fn the_probe_names_only_the_addresses_that_are_not_the_ingress() {
let ingress = autumn_web::custom_domain::ExpectedIngress {
hostname: None,
ipv4: vec!["203.0.113.10".parse().unwrap()],
ipv6: vec![],
};
assert!(ingress_contains(&ingress, "203.0.113.10".parse().unwrap()));
assert!(!ingress_contains(&ingress, "198.51.100.7".parse().unwrap()));
let empty = autumn_web::custom_domain::ExpectedIngress::default();
assert_eq!(
resolve_custom_domain_dns("no-such-host.invalid", &empty),
CustomDomainDns::IngressUnknown
);
}
#[test]
fn a_live_custom_domain_whose_dns_moved_away_fails() {
let moved = probe(
"active",
CustomDomainDns::PointsElsewhere {
seen: vec!["198.51.100.7".to_owned()],
},
);
let result = check_custom_domain_dns_impl(&moved);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap();
assert!(detail.contains("app.clientco.com"), "{detail}");
assert!(detail.contains("tenant-a"), "{detail}");
assert!(detail.contains("198.51.100.7"), "{detail}");
assert_eq!(
check_custom_domain_dns_impl(&probe("active", CustomDomainDns::Unresolved)).status,
CheckStatus::Fail
);
}
#[test]
fn a_pending_custom_domain_that_does_not_point_here_yet_is_only_a_warning() {
assert_eq!(
check_custom_domain_dns_impl(&probe("pending_dns", CustomDomainDns::Unresolved)).status,
CheckStatus::Warn
);
assert_eq!(
check_custom_domain_dns_impl(&probe(
"pending_dns",
CustomDomainDns::PointsElsewhere {
seen: vec!["198.51.100.7".to_owned()],
}
))
.status,
CheckStatus::Warn
);
assert_eq!(
check_custom_domain_dns_impl(&probe("active", CustomDomainDns::PointsHere)).status,
CheckStatus::Pass
);
assert_eq!(
check_custom_domain_dns_impl(&probe("active", CustomDomainDns::IngressUnknown)).status,
CheckStatus::Warn
);
}
#[test]
fn the_ingress_hostname_is_resolved_alongside_the_configured_addresses() {
let ingress = autumn_web::custom_domain::ExpectedIngress {
hostname: Some("localhost".to_owned()),
ipv4: vec!["203.0.113.10".parse().unwrap()],
ipv6: vec![],
};
assert_eq!(
resolve_custom_domain_dns("localhost", &ingress),
CustomDomainDns::PointsHere,
"a domain pointing at the ingress HOSTNAME must grade as pointing here even when \
apex addresses are configured too"
);
let apex_only = autumn_web::custom_domain::ExpectedIngress {
hostname: Some("no-such-ingress.invalid".to_owned()),
ipv4: vec!["203.0.113.10".parse().unwrap()],
ipv6: vec![],
};
assert!(matches!(
resolve_custom_domain_dns("localhost", &apex_only),
CustomDomainDns::PointsElsewhere { .. }
));
}
#[test]
fn a_registry_doctor_cannot_read_is_a_failure_not_an_empty_one() {
let unreadable = CustomDomainRegistryRead {
unreadable: Some("/var/lib/autumn/custom-domains: permission denied".to_owned()),
..CustomDomainRegistryRead::default()
};
let result =
check_custom_domains_config_impl(Some(&custom_domains_config(true)), None, &unreadable)
.unwrap();
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap();
assert!(detail.contains("permission denied"), "{detail}");
assert!(detail.contains("cannot be read"), "{detail}");
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("good.json"),
serde_json::json!({
"hostname": "app.clientco.com",
"tenant": "tenant-a",
"status": "active",
"failure_reason": null,
"registered_at_unix": 1,
"verified_at_unix": 1,
"activated_at_unix": 1,
"cert_not_after_unix": 2,
"consecutive_failures": 0,
"next_attempt_unix": null
})
.to_string(),
)
.unwrap();
std::fs::write(dir.path().join("torn.json"), "{not json").unwrap();
let read = read_custom_domain_registry(dir.path());
assert_eq!(read.domains.len(), 1, "the readable record still counts");
assert_eq!(read.skipped.len(), 1, "and the torn one is reported");
assert!(read.unreadable.is_none());
let partial =
check_custom_domains_config_impl(Some(&custom_domains_config(true)), None, &read)
.unwrap();
assert_eq!(partial.status, CheckStatus::Warn);
let detail = partial.detail.unwrap();
assert!(detail.contains("will not load"), "{detail}");
assert!(
detail.contains("no new domain can be connected"),
"{detail}"
);
}
#[test]
fn port_80_is_required_for_custom_domains_whatever_the_deployment_uses() {
let optional_for_the_deployment = check_acme_ports_for_challenge(
"myapp.com",
PortReachability::Refused,
PortReachability::Open,
true,
);
assert_eq!(optional_for_the_deployment.status, CheckStatus::Warn);
let result = check_custom_domain_http01_impl(
"ingress.myapp.com",
&[("203.0.113.10".to_owned(), PortReachability::Refused)],
);
assert_eq!(
result.status,
CheckStatus::Fail,
"a closed port 80 blocks every tenant custom domain"
);
let detail = result.detail.unwrap();
assert!(detail.contains("ingress.myapp.com"), "{detail}");
assert!(detail.contains("HTTP-01"), "{detail}");
for unreachable in [PortReachability::TimedOut, PortReachability::Error] {
assert_eq!(
check_custom_domain_http01_impl(
"203.0.113.10",
&[("203.0.113.10".to_owned(), unreachable)]
)
.status,
CheckStatus::Fail
);
}
assert_eq!(
check_custom_domain_http01_impl(
"ingress.myapp.com",
&[("203.0.113.10".to_owned(), PortReachability::Open)]
)
.status,
CheckStatus::Pass
);
}
#[test]
fn one_unreachable_address_behind_the_ingress_fails_the_http01_check() {
let mixed = check_custom_domain_http01_impl(
"ingress.myapp.com",
&[
("203.0.113.10".to_owned(), PortReachability::Open),
("203.0.113.11".to_owned(), PortReachability::TimedOut),
("2001:db8::1".to_owned(), PortReachability::Open),
],
);
assert_eq!(mixed.status, CheckStatus::Fail);
let detail = mixed.detail.unwrap();
assert!(detail.contains("203.0.113.11"), "{detail}");
assert!(
!detail.contains("203.0.113.10"),
"only the unreachable addresses are named: {detail}"
);
let all_open = check_custom_domain_http01_impl(
"ingress.myapp.com",
&[
("203.0.113.10".to_owned(), PortReachability::Open),
("2001:db8::1".to_owned(), PortReachability::Open),
],
);
assert_eq!(all_open.status, CheckStatus::Pass);
assert!(all_open.detail.unwrap().contains('2'));
let unresolvable = check_custom_domain_http01_impl("ingress.myapp.com", &[]);
assert_eq!(unresolvable.status, CheckStatus::Fail);
assert!(unresolvable.detail.unwrap().contains("does not resolve"));
let probed = probe_port_every_address("localhost", 1);
assert!(!probed.is_empty());
assert!(
probed
.iter()
.all(|(_, state)| *state != PortReachability::Open)
);
}
#[test]
fn the_txt_grader_tells_missing_from_stale_from_present() {
let ok = |values: &[&str]| Ok(values.iter().map(|v| (*v).to_owned()).collect());
assert_eq!(
grade_custom_domain_txt("tok", &[Err("timeout".to_owned()), ok(&["tok"])]),
CustomDomainTxt::Present
);
assert_eq!(
grade_custom_domain_txt("tok", &[ok(&[])]),
CustomDomainTxt::Missing
);
assert_eq!(
grade_custom_domain_txt("tok", &[ok(&["old-token"]), ok(&[])]),
CustomDomainTxt::Stale { values: 1 }
);
assert_eq!(
grade_custom_domain_txt("tok", &[Err("REFUSED".to_owned())]),
CustomDomainTxt::Unanswerable("REFUSED".to_owned())
);
}
#[test]
fn the_txt_check_names_the_record_and_the_token_to_publish() {
let probe = |txt| CustomDomainOwnershipProbe {
hostname: "app.clientco.com".to_owned(),
tenant: "tenant-b".to_owned(),
token: "tok".to_owned(),
txt,
};
let present = check_custom_domain_txt_impl(&probe(CustomDomainTxt::Present));
assert_eq!(present.name, "custom_domain_txt");
assert_eq!(present.status, CheckStatus::Pass);
let missing = check_custom_domain_txt_impl(&probe(CustomDomainTxt::Missing));
assert_eq!(missing.status, CheckStatus::Warn);
let detail = missing.detail.unwrap();
assert!(detail.contains("not published"), "{detail}");
assert!(
detail.contains("`_autumn-challenge.app.clientco.com` TXT `tok`"),
"{detail}"
);
let stale = check_custom_domain_txt_impl(&probe(CustomDomainTxt::Stale { values: 1 }));
assert_eq!(stale.status, CheckStatus::Warn);
assert!(stale.detail.unwrap().contains("earlier registration"));
}
#[test]
fn the_registry_reader_skips_unreadable_records_and_sorts() {
let dir = tempfile::tempdir().unwrap();
let absent = read_custom_domain_registry(&dir.path().join("absent"));
assert!(absent.domains.is_empty());
assert!(
absent.unreadable.is_none(),
"a directory that does not exist yet is not a fault"
);
for (file, host) in [("b.json", "b.clientco.com"), ("a.json", "a.clientco.com")] {
std::fs::write(
dir.path().join(file),
serde_json::json!({
"hostname": host,
"tenant": "tenant-a",
"status": "active",
"failure_reason": null,
"registered_at_unix": 1,
"verified_at_unix": 1,
"activated_at_unix": 1,
"cert_not_after_unix": 2,
"consecutive_failures": 0,
"next_attempt_unix": null
})
.to_string(),
)
.unwrap();
}
std::fs::write(dir.path().join("corrupt.json"), "{not json").unwrap();
std::fs::write(dir.path().join("ignored.txt"), "irrelevant").unwrap();
let read = read_custom_domain_registry(dir.path());
let records = read.domains;
assert_eq!(
records.len(),
2,
"the corrupt record must not blind the rest"
);
assert_eq!(records[0].0, "a.clientco.com");
assert_eq!(records[1].0, "b.clientco.com");
assert_eq!(records[0].3, None);
}
#[test]
fn dns_credential_check_passes_when_no_dns_section_is_configured() {
let result = check_acme_dns_credential_impl(&DnsCredentialOutcome::NotConfigured);
assert_eq!(result.status, CheckStatus::Pass);
assert!(
result.detail.unwrap().contains("HTTP-01"),
"the pass must explain why no credential is needed"
);
}
#[test]
fn a_missing_dns_credential_fails_and_says_where_to_put_it() {
let outcome = DnsCredentialOutcome::Unusable(
"no Cloudflare API token found for [server.tls.acme.dns] credential `acme_dns`: add \
it under `[acme_dns]` in the encrypted credentials store (`autumn credentials edit`) \
as `api_token = \"...\"`, or set the AUTUMN_ACME_DNS_API_TOKEN environment variable"
.to_owned(),
);
let result = check_acme_dns_credential_impl(&outcome);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap();
assert!(detail.contains("api_token"), "got: {detail}");
assert!(detail.contains("autumn credentials edit"), "got: {detail}");
}
#[test]
fn a_malformed_dns_section_fails_and_names_the_problem() {
let result = check_acme_dns_credential_impl(&DnsCredentialOutcome::Malformed(
"unknown field `api_token`".to_owned(),
));
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap().contains("api_token"));
assert!(result.hint.unwrap().contains("never in autumn.toml"));
}
#[test]
fn a_usable_dns_credential_passes_and_names_the_provider_and_key() {
let result = check_acme_dns_credential_impl(&DnsCredentialOutcome::Usable {
provider: "cloudflare".to_owned(),
key: "acme_dns".to_owned(),
});
assert_eq!(result.status, CheckStatus::Pass);
let detail = result.detail.unwrap();
assert!(detail.contains("cloudflare"), "got: {detail}");
assert!(detail.contains("acme_dns"), "got: {detail}");
}
#[test]
fn challenge_dns_visibility_grades_answerability_then_leftovers() {
let fqdn = "_acme-challenge.myapp.com";
let pass =
check_acme_dns_propagation_impl(fqdn, &ChallengeDnsVisibility::Answered { values: 0 });
assert_eq!(pass.status, CheckStatus::Pass);
let warn =
check_acme_dns_propagation_impl(fqdn, &ChallengeDnsVisibility::Stale { values: 2 });
assert_eq!(warn.status, CheckStatus::Warn);
assert!(warn.detail.unwrap().contains('2'));
let fail = check_acme_dns_propagation_impl(
fqdn,
&ChallengeDnsVisibility::Unanswerable(
"SERVFAIL — the zone's nameservers did not answer".to_owned(),
),
);
assert_eq!(fail.status, CheckStatus::Fail);
let detail = fail.detail.unwrap();
assert!(
detail.contains(fqdn),
"the message must name the record: {detail}"
);
assert!(detail.contains("SERVFAIL"), "got: {detail}");
}
#[test]
fn tenancy_base_domain_must_be_covered_by_the_certificate() {
let single = vec!["myapp.com".to_owned()];
let covers = acme_covers_tenant_subdomains("myapp.com", &single);
assert!(!covers, "an apex-only certificate covers no subdomain");
let result = check_acme_tenancy_domain_impl(Some("myapp.com"), &single, covers);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap();
assert!(detail.contains("myapp.com"), "got: {detail}");
assert!(result.hint.unwrap().contains("*.<base_domain>"));
let wildcard = vec!["myapp.com".to_owned(), "*.myapp.com".to_owned()];
let covers = acme_covers_tenant_subdomains("myapp.com", &wildcard);
assert!(covers);
assert_eq!(
check_acme_tenancy_domain_impl(Some("myapp.com"), &wildcard, covers).status,
CheckStatus::Pass
);
assert_eq!(
check_acme_tenancy_domain_impl(None, &single, false).status,
CheckStatus::Pass
);
assert_eq!(
check_acme_tenancy_domain_impl(Some(" "), &single, false).status,
CheckStatus::Pass
);
}
#[test]
fn one_explicit_tenant_san_is_not_subdomain_coverage() {
let explicit = vec!["myapp.com".to_owned(), "tenant1.myapp.com".to_owned()];
assert!(!acme_covers_tenant_subdomains("myapp.com", &explicit));
assert!(!acme_covers_tenant_subdomains(
"myapp.com",
&["*.other.com".to_owned()]
));
}
#[test]
fn resolving_elsewhere_is_not_a_failure_under_dns01() {
let elsewhere = DnsPointsHere::ResolvesElsewhere {
resolved: vec!["203.0.113.10".to_owned()],
};
assert_eq!(
check_acme_dns_for_challenge("myapp.com", &elsewhere, false).status,
CheckStatus::Fail
);
let dns01 = check_acme_dns_for_challenge("myapp.com", &elsewhere, true);
assert_eq!(dns01.status, CheckStatus::Pass);
assert!(
dns01.detail.unwrap().contains("TXT record"),
"the pass must say why the mismatch does not matter"
);
for inconclusive in [
DnsPointsHere::PartialMatch {
unmatched: vec!["203.0.113.10".to_owned()],
},
DnsPointsHere::LocalIpsUnknown,
] {
assert_eq!(
check_acme_dns_for_challenge("myapp.com", &inconclusive, true).status,
CheckStatus::Pass
);
}
let unresolved =
check_acme_dns_for_challenge("myapp.com", &DnsPointsHere::Unresolved, true);
assert_eq!(unresolved.status, CheckStatus::Warn);
assert!(unresolved.detail.unwrap().contains("visitors"));
for dns01 in [false, true] {
assert_eq!(
check_acme_dns_for_challenge("myapp.com", &DnsPointsHere::Matches, dns01).status,
CheckStatus::Pass
);
}
}
#[test]
fn port_80_is_only_a_warning_under_dns01() {
let http01 = check_acme_ports_for_challenge(
"myapp.com",
PortReachability::Refused,
PortReachability::Open,
false,
);
assert_eq!(http01.status, CheckStatus::Fail);
let dns01 = check_acme_ports_for_challenge(
"myapp.com",
PortReachability::Refused,
PortReachability::Open,
true,
);
assert_eq!(dns01.status, CheckStatus::Warn);
assert!(
dns01.detail.unwrap().contains("redirect"),
"the warning must say what is actually lost"
);
for dns in [false, true] {
assert_eq!(
check_acme_ports_for_challenge(
"myapp.com",
PortReachability::Open,
PortReachability::Open,
dns
)
.status,
CheckStatus::Pass
);
}
}
#[test]
fn wildcard_domains_are_probed_as_their_base_domain() {
let mut cfg = acme_doctor_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
cfg.dns = Some(acme_dns_cfg(
autumn_web::config::AcmeDnsProvider::Cloudflare,
));
assert_eq!(
acme_online_probe_domains(&cfg),
vec!["myapp.com".to_owned()],
"the apex is probed once, and the wildcard resolves to it"
);
let cfg = acme_doctor_cfg(&["*.myapp.com"], "ops@myapp.com");
assert_eq!(
acme_online_probe_domains(&cfg),
vec!["myapp.com".to_owned()]
);
let cfg = acme_doctor_cfg(&["a.example.com", "b.example.com"], "ops@example.com");
assert_eq!(
acme_online_probe_domains(&cfg),
vec!["a.example.com".to_owned(), "b.example.com".to_owned()]
);
}
#[test]
fn wildcard_reachability_probes_a_covered_host() {
let cfg = acme_doctor_cfg(&["*.myapp.com"], "ops@myapp.com");
assert_eq!(
acme_online_reachability_hosts(&cfg),
vec!["doctor-check.myapp.com".to_owned()]
);
assert_eq!(
acme_online_probe_domains(&cfg),
vec!["myapp.com".to_owned()],
"the challenge record stays at the base domain"
);
let cfg = acme_doctor_cfg(&["*.a.com", "b.com", "*.", "*.sub.a.com"], "o@a.com");
assert_eq!(
acme_online_reachability_hosts(&cfg),
vec![
"doctor-check.a.com".to_owned(),
"b.com".to_owned(),
"doctor-check.sub.a.com".to_owned(),
]
);
let cfg = acme_doctor_cfg(&["myapp.com", "*.myapp.com", "*.myapp.com"], "o@myapp.com");
assert_eq!(
acme_online_reachability_hosts(&cfg),
vec!["myapp.com".to_owned(), "doctor-check.myapp.com".to_owned()]
);
}
#[test]
fn acme_config_fail_when_domains_empty() {
let r = check_acme_config_impl(&acme_doctor_cfg(&[], "ops@example.com"))
.expect("empty domains must be a FAIL, not Pass");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
}
#[test]
fn acme_config_fail_when_contact_email_blank() {
let r = check_acme_config_impl(&acme_doctor_cfg(&["app.example.com"], " "))
.expect("blank contact_email must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
}
#[test]
fn acme_config_fail_when_wildcard_domain_has_no_dns_provider() {
let r = check_acme_config_impl(&acme_doctor_cfg(&["*.example.com"], "ops@example.com"))
.expect("a wildcard with no DNS-01 provider must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
let detail = r.detail.as_deref().unwrap_or_default();
assert!(detail.contains("[server.tls.acme.dns]"), "got: {detail}");
assert!(detail.contains("DNS-01"), "got: {detail}");
let mut cfg = acme_doctor_cfg(&["*.example.com"], "ops@example.com");
cfg.dns = Some(acme_dns_cfg(
autumn_web::config::AcmeDnsProvider::Cloudflare,
));
assert!(
check_acme_config_impl(&cfg).is_none(),
"a wildcard WITH a DNS-01 provider is a valid config"
);
}
#[test]
fn acme_config_ok_when_valid() {
assert!(
check_acme_config_impl(&acme_doctor_cfg(&["app.example.com"], "ops@example.com"))
.is_none(),
"a valid ACME config must not raise an acme_config failure"
);
}
#[test]
fn acme_config_fail_when_domain_entry_blank() {
let r = check_acme_config_impl(&acme_doctor_cfg(&[""], "ops@example.com"))
.expect("a blank domain entry must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
assert!(
r.detail.as_deref().unwrap_or_default().contains("blank"),
"detail must mention blank entries: {:?}",
r.detail
);
let r = check_acme_config_impl(&acme_doctor_cfg(&[" "], "ops@example.com"))
.expect("a whitespace-only domain entry must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
}
#[test]
fn acme_config_fail_when_http_challenge_port_zero() {
let mut cfg = acme_doctor_cfg(&["app.example.com"], "ops@example.com");
cfg.http_challenge_port = 0;
let r = check_acme_config_impl(&cfg).expect("http_challenge_port = 0 must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
assert!(
r.detail
.as_deref()
.unwrap_or_default()
.contains("http_challenge_port"),
"detail must mention http_challenge_port: {:?}",
r.detail
);
}
fn grade_acme_toml(toml_text: &str) -> CheckResult {
let raw: toml::Table = toml::from_str(toml_text).unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
check_acme_config_impl(&acme).expect("config must FAIL")
}
#[test]
fn acme_config_fail_when_cache_dir_not_a_string() {
for bad in ["42", "true", "[\"config/acme\"]", "{ x = 1 }"] {
let r = grade_acme_toml(&format!(
"[server.tls.acme]\ndomains = [\"a.example.com\"]\n\
contact_email = \"ops@example.com\"\ncache_dir = {bad}\n"
));
assert!(matches!(r.status, CheckStatus::Fail), "{bad}");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("cache_dir") && detail.contains(bad),
"detail must name cache_dir and {bad}: {detail}"
);
}
}
#[test]
fn acme_cache_dir_string_and_absent_still_resolve() {
let raw: toml::Table =
toml::from_str("[server.tls.acme]\ncache_dir = \"/var/acme\"\n").unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).unwrap();
assert!(acme.cache_dir_error.is_none());
assert_eq!(acme.cache_dir, std::path::PathBuf::from("/var/acme"));
let raw: toml::Table = toml::from_str("[server.tls.acme]\n").unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).unwrap();
assert!(acme.cache_dir_error.is_none());
assert_eq!(acme.cache_dir, std::path::PathBuf::from("config/acme"));
}
#[test]
fn acme_config_fail_when_acme_section_not_a_table() {
for bad in ["\"yes\"", "true", "7", "[1]"] {
let r = grade_acme_toml(&format!("[server.tls]\nacme = {bad}\n"));
assert!(matches!(r.status, CheckStatus::Fail), "{bad}");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("[server.tls.acme]")
&& detail.contains("table")
&& detail.contains(bad),
"detail must say acme must be a table and show {bad}: {detail}"
);
}
}
#[test]
fn acme_config_names_type_error_for_contact_email_and_domains() {
let r = grade_acme_toml(
"[server.tls.acme]\ndomains = [\"a.example.com\"]\ncontact_email = 42\n",
);
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("contact_email") && detail.contains("42") && detail.contains("string"),
"{detail}"
);
let r = grade_acme_toml(
"[server.tls.acme]\ndomains = \"a.example.com\"\ncontact_email = \"o@example.com\"\n",
);
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("a.example.com") && detail.contains("list"),
"{detail}"
);
assert!(!detail.contains("at least one domain"), "{detail}");
}
#[test]
fn acme_doctor_agrees_with_runtime_deserialize() {
let bad_values = ["42", "true", "[\"x\"]", "{ x = 1 }"];
let mut cases = vec![
"[server.tls]\nacme = \"yes\"".to_owned(),
"[server.tls]\nacme = 7".to_owned(),
"[server.tls.acme]\ndomains = [\"a.example.com\"]\ncontact_email = \"o@example.com\""
.to_owned(),
];
for bad in bad_values {
for key in ["cache_dir", "contact_email", "domains"] {
let email = if key == "contact_email" {
bad
} else {
"\"o@example.com\""
};
let domains = if key == "domains" {
bad
} else {
"[\"a.example.com\"]"
};
let dir = if key == "cache_dir" {
bad
} else {
"\"/var/acme\""
};
cases.push(format!(
"[server.tls.acme]\ndomains = {domains}\ncontact_email = {email}\ncache_dir = {dir}"
));
}
}
for text in cases {
let raw: toml::Table = toml::from_str(&text).unwrap();
let doctor_fails = resolve_acme_doctor_config(Some(&raw))
.and_then(|acme| check_acme_config_impl(&acme))
.is_some();
let runtime_ok = raw
.get("server")
.and_then(|server| server.get("tls"))
.cloned()
.is_some_and(|tls| {
tls.try_into::<autumn_web::config::TlsConfig>()
.is_ok_and(|tls| tls.acme.as_ref().is_none_or(|a| a.validate().is_ok()))
});
assert_eq!(doctor_fails, !runtime_ok, "disagreement on:\n{text}");
}
}
#[test]
fn acme_config_fail_when_http_challenge_port_malformed() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
http_challenge_port = 70000
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(
acme.port_error.is_some(),
"an out-of-range http_challenge_port must be recorded, not defaulted to 80"
);
let r = check_acme_config_impl(&acme)
.expect("an out-of-range http_challenge_port must be a FAIL, not silently 80");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("http_challenge_port") && detail.contains("70000"),
"detail must name the bad port value: {detail}"
);
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
http_challenge_port = \"80\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(
acme.port_error.is_some(),
"a quoted-string http_challenge_port must be recorded, not defaulted to 80"
);
let r = check_acme_config_impl(&acme)
.expect("a non-integer http_challenge_port must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
}
#[test]
fn acme_config_ok_when_http_challenge_port_valid() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
http_challenge_port = 8080
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(acme.port_error.is_none());
assert_eq!(acme.http_challenge_port, 8080);
assert!(
check_acme_config_impl(&acme).is_none(),
"a valid http_challenge_port must not raise an acme_config FAIL"
);
}
#[test]
fn acme_config_fail_when_renew_before_days_at_or_above_cert_lifetime() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
renew_before_days = 100
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert_eq!(acme.renew_before_days, 100);
let r = check_acme_config_impl(&acme).expect("renew_before_days >= 90 must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("renew_before_days") && detail.contains("100"),
"detail must name the bad renew_before_days value: {detail}"
);
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
renew_before_days = 30
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert_eq!(acme.renew_before_days, 30);
assert!(
check_acme_config_impl(&acme).is_none(),
"a valid renew_before_days must not raise an acme_config FAIL"
);
}
#[test]
fn acme_config_fail_when_renew_before_days_malformed() {
for (bad_line, needle) in [
("renew_before_days = \"30\"", "\"30\""),
("renew_before_days = 30.5", "30.5"),
("renew_before_days = true", "true"),
("renew_before_days = -1", "-1"),
("renew_before_days = 4294967296", "4294967296"),
] {
let raw: toml::Table = toml::from_str(&format!(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
{bad_line}
"
))
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(
acme.renew_before_days_error.is_some(),
"`{bad_line}` must be recorded, not silently defaulted to 30"
);
let r = check_acme_config_impl(&acme)
.unwrap_or_else(|| panic!("`{bad_line}` must be an acme_config FAIL"));
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("renew_before_days") && detail.contains(needle),
"detail must name the bad renew_before_days value: {detail}"
);
assert!(
detail.contains("is not a valid renewal window"),
"`{bad_line}` must FAIL as a malformed value, not as a >= 90 \
renewal window: {detail}"
);
}
}
#[test]
fn acme_config_ok_when_renew_before_days_valid_or_absent() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
renew_before_days = 45
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(acme.renew_before_days_error.is_none());
assert_eq!(acme.renew_before_days, 45);
assert!(check_acme_config_impl(&acme).is_none());
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(acme.renew_before_days_error.is_none());
assert_eq!(
acme.renew_before_days, 30,
"absent key uses the runtime default"
);
assert!(check_acme_config_impl(&acme).is_none());
}
#[test]
fn acme_config_fail_when_domain_entry_whitespace_padded() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\" app.example.com \"]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
let r = check_acme_config_impl(&acme)
.expect("a whitespace-padded domain must be an acme_config FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("whitespace") && detail.contains("` app.example.com `"),
"detail must name the problem and echo the RAW padded entry (not just \
the trimmed spelling it suggests): {detail}"
);
for line in [
"domains = [\" app.example.com\"]",
"domains = [\"app.example.com \"]",
"domains = [\"\\tapp.example.com\\n\"]",
] {
let raw: toml::Table = toml::from_str(&format!(
"\
[server.tls.acme]
{line}
contact_email = \"ops@example.com\"
"
))
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
let r = check_acme_config_impl(&acme)
.unwrap_or_else(|| panic!("`{line}` must be an acme_config FAIL"));
assert!(
r.detail
.as_deref()
.unwrap_or_default()
.contains("whitespace"),
"`{line}` must FAIL on whitespace: {:?}",
r.detail
);
}
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\", \" www.example.com\"]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
let r = check_acme_config_impl(&acme).expect("a padded entry at index 1 must FAIL");
assert!(
r.detail.as_deref().unwrap_or_default().contains("index 1"),
"detail must name the offending index: {:?}",
r.detail
);
for (line, needle) in [
("domains = [\" \"]", "blank entries"),
("domains = [\" *.example.com \"]", "wildcard"),
] {
let raw: toml::Table = toml::from_str(&format!(
"\
[server.tls.acme]
{line}
contact_email = \"ops@example.com\"
"
))
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
let r = check_acme_config_impl(&acme).expect("must FAIL");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains(needle),
"`{line}` should report `{needle}`, got: {detail}"
);
}
}
#[test]
fn acme_config_grader_agrees_with_runtime_validate() {
let cases: &[(&[&str], &str, u16, u32)] = &[
(&["app.example.com"], "ops@example.com", 80, 30),
(
&["app.example.com", "www.example.com"],
"ops@example.com",
8080,
89,
),
(&[" app.example.com "], "ops@example.com", 80, 30),
(&["app.example.com\t"], "ops@example.com", 80, 30),
(&["\u{a0}app.example.com"], "ops@example.com", 80, 30),
(&[" *.example.com "], "ops@example.com", 80, 30),
(&[" "], "ops@example.com", 80, 30),
(
&["app.example.com", " www.example.com"],
"ops@example.com",
80,
30,
),
(&[], "ops@example.com", 80, 30),
(&["app.example.com"], " ", 80, 30),
(&["app.example.com"], "ops@example.com", 0, 30),
(&["app.example.com"], "ops@example.com", 80, 0),
(&["app.example.com"], "ops@example.com", 80, 90),
(&["app.example.com"], "ops@example.com", 80, 100),
(&["app .example.com"], "ops@example.com", 80, 30),
];
for (domains, contact_email, http_challenge_port, renew_before_days) in cases {
for dns in [
None,
Some(acme_dns_cfg(
autumn_web::config::AcmeDnsProvider::Cloudflare,
)),
Some(
toml::from_str::<autumn_web::config::AcmeDnsConfig>("provider = \"exec\"\n")
.expect("parses; validate() is what rejects it"),
),
] {
let mut doctor_cfg = acme_doctor_cfg(domains, contact_email);
doctor_cfg.http_challenge_port = *http_challenge_port;
doctor_cfg.renew_before_days = *renew_before_days;
doctor_cfg.dns = dns.clone();
let runtime_cfg = autumn_web::config::AcmeConfig {
domains: domains.iter().map(|d| (*d).to_owned()).collect(),
contact_email: (*contact_email).to_owned(),
directory: autumn_web::config::AcmeDirectory::Staging,
cache_dir: std::path::PathBuf::from("config/acme"),
http_challenge_port: *http_challenge_port,
renew_before_days: *renew_before_days,
ca_root_path: None,
dns: dns.clone(),
custom_domains: None,
};
assert_eq!(
check_acme_config_impl(&doctor_cfg).is_some(),
runtime_cfg.validate().is_err(),
"doctor and AcmeConfig::validate disagree on domains={domains:?} \
contact_email={contact_email:?} port={http_challenge_port} \
renew_before_days={renew_before_days} dns={:?} (runtime said {:?})",
dns.as_ref().map(|d| d.provider),
runtime_cfg.validate()
);
}
}
}
#[test]
fn wildcard_domain_parity_depends_on_the_dns_section() {
let wildcard_cases: &[&[&str]] = &[
&["*.myapp.com"],
&["myapp.com", "*.myapp.com"],
&["*"],
&["*."],
&["*myapp.com"],
&["app.*.myapp.com"],
&["*.*.myapp.com"],
];
for domains in wildcard_cases {
for dns in [
None,
Some(acme_dns_cfg(
autumn_web::config::AcmeDnsProvider::Cloudflare,
)),
] {
let mut doctor_cfg = acme_doctor_cfg(domains, "ops@myapp.com");
doctor_cfg.dns = dns.clone();
let runtime_cfg = autumn_web::config::AcmeConfig {
domains: domains.iter().map(|d| (*d).to_owned()).collect(),
contact_email: "ops@myapp.com".to_owned(),
directory: autumn_web::config::AcmeDirectory::Staging,
cache_dir: std::path::PathBuf::from("config/acme"),
http_challenge_port: 80,
renew_before_days: 30,
ca_root_path: None,
dns: dns.clone(),
custom_domains: None,
};
assert_eq!(
check_acme_config_impl(&doctor_cfg).is_some(),
runtime_cfg.validate().is_err(),
"doctor and AcmeConfig::validate disagree on domains={domains:?} with \
dns={} (runtime said {:?})",
dns.is_some(),
runtime_cfg.validate()
);
}
}
}
#[test]
fn acme_config_fail_when_domain_entry_not_a_string() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\", 123]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(
acme.domains_error.is_some(),
"a non-string domain entry must be recorded, not silently dropped"
);
let r = check_acme_config_impl(&acme)
.expect("a non-string domain entry must be a FAIL, not silently dropped");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("domains") && detail.contains("123"),
"detail must name the non-string domains entry: {detail}"
);
}
#[test]
fn acme_config_fail_when_directory_invalid() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
directory = \"prod\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(
acme.directory_error.is_some(),
"a malformed `directory` must be recorded as an error, not defaulted"
);
let r = check_acme_config_impl(&acme)
.expect("an invalid `directory` must be a FAIL, not silently staging");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "acme_config");
let detail = r.detail.as_deref().unwrap_or_default();
assert!(
detail.contains("prod"),
"detail must name the bad value: {detail}"
);
assert!(
detail.contains("directory"),
"detail must mention `directory`: {detail}"
);
}
#[test]
fn acme_config_ok_for_valid_directories() {
let cases = [
("directory = \"staging\"", "staging"),
("directory = \"production\"", "production"),
(
"directory = { custom = { url = \"https://acme.example.com/directory\" } }",
"custom-",
),
];
for (line, label_prefix) in cases {
let raw: toml::Table = toml::from_str(&format!(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
{line}
"
))
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(
acme.directory_error.is_none(),
"valid directory `{line}` must not be an error"
);
assert!(
acme.directory_label.starts_with(label_prefix),
"directory label for `{line}` should start with `{label_prefix}`, got `{}`",
acme.directory_label
);
assert!(
check_acme_config_impl(&acme).is_none(),
"valid directory `{line}` must not raise an acme_config FAIL"
);
}
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
assert!(acme.directory_error.is_none());
assert_eq!(acme.directory_label, "staging");
}
#[test]
fn acme_only_config_skips_static_tls_check() {
assert!(
!should_run_static_tls_check(true, false),
"ACME-only deployment must skip the static [server.tls] cert/key check"
);
assert!(should_run_static_tls_check(true, true));
assert!(should_run_static_tls_check(false, true));
assert!(should_run_static_tls_check(false, false));
}
#[test]
fn tls_mode_fails_static_and_acme_together() {
let r = check_tls_mode_impl(true, true, true).expect("both modes must Fail");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "tls_mode");
assert!(r.detail.as_deref().unwrap().contains("[server.tls.acme]"));
assert!(check_tls_mode_impl(true, false, true).is_some());
assert!(check_tls_mode_impl(false, true, true).is_some());
}
#[test]
fn tls_mode_fails_half_static_pair_without_acme() {
for (has_cert, has_key) in [(true, false), (false, true)] {
let r = check_tls_mode_impl(has_cert, has_key, false)
.unwrap_or_else(|| panic!("half pair ({has_cert},{has_key}) must Fail"));
assert!(matches!(r.status, CheckStatus::Fail));
assert!(
r.detail
.as_deref()
.unwrap()
.contains("must be set together")
);
}
}
#[test]
fn tls_mode_passes_valid_single_mode_configs() {
assert!(
check_tls_mode_impl(false, false, true).is_none(),
"ACME-only is a valid single mode"
);
assert!(
check_tls_mode_impl(true, true, false).is_none(),
"static-only with both paths is a valid single mode"
);
assert!(check_tls_mode_impl(false, false, false).is_none());
}
#[test]
fn empty_static_path_with_acme_is_mixed_mode_not_acme_only() {
let raw: toml::Table = toml::from_str(
"\
[server.tls]
cert_path = \"\"
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let tls = raw
.get("server")
.and_then(toml::Value::as_table)
.and_then(|s| s.get("tls"))
.and_then(toml::Value::as_table);
let (has_static_cert, has_static_key) = static_tls_presence_from(tls, false, false);
assert!(
has_static_cert,
"a present-but-empty `cert_path` must count as static-mode present"
);
assert!(!has_static_key, "no `key_path` key is set");
let acme = resolve_acme_doctor_config(Some(&raw)).expect("[server.tls.acme] present");
let r = check_tls_mode_impl(has_static_cert, has_static_key, true)
.expect("mixed static+ACME (present-but-empty cert_path) must FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "tls_mode");
assert!(
r.detail
.as_deref()
.unwrap_or_default()
.contains("[server.tls.acme]"),
"the FAIL must name the mutually-exclusive [server.tls.acme]"
);
assert_eq!(acme.domains, ["app.example.com"]);
}
#[test]
fn acme_only_without_static_keys_is_valid() {
let raw: toml::Table = toml::from_str(
"\
[server.tls.acme]
domains = [\"app.example.com\"]
contact_email = \"ops@example.com\"
",
)
.unwrap();
let tls = raw
.get("server")
.and_then(toml::Value::as_table)
.and_then(|s| s.get("tls"))
.and_then(toml::Value::as_table);
let (has_static_cert, has_static_key) = static_tls_presence_from(tls, false, false);
assert!(
!has_static_cert && !has_static_key,
"ACME-only config must report NO static cert/key keys present"
);
assert!(
check_tls_mode_impl(has_static_cert, has_static_key, true).is_none(),
"ACME-only (no static keys) is a valid single mode — no mixed-mode FAIL"
);
}
#[test]
fn tls_mode_fails_when_dotenv_overlay_supplies_cert_key_with_acme() {
use autumn_web::config::MockEnv;
let env = MockEnv::new()
.with("AUTUMN_SERVER__TLS__CERT_PATH", "/dotenv/cert.pem")
.with("AUTUMN_SERVER__TLS__KEY_PATH", "");
let (cert_env, key_env) = static_tls_env_presence_in(&env);
assert!(
cert_env,
"a dotenv-supplied CERT_PATH must be seen as present"
);
assert!(
key_env,
"a present-but-empty dotenv KEY_PATH override is still present"
);
let (has_static_cert, has_static_key) = static_tls_presence_from(None, cert_env, key_env);
assert!(has_static_cert && has_static_key);
let r = check_tls_mode_impl(has_static_cert, has_static_key, true)
.expect("dotenv-supplied static cert/key + [server.tls.acme] must FAIL as mixed mode");
assert!(matches!(r.status, CheckStatus::Fail));
assert_eq!(r.name, "tls_mode");
assert!(
r.detail
.as_deref()
.unwrap_or_default()
.contains("[server.tls.acme]"),
"the FAIL must name the mutually-exclusive [server.tls.acme]"
);
let empty = MockEnv::new();
let (c, k) = static_tls_env_presence_in(&empty);
let (hc, hk) = static_tls_presence_from(None, c, k);
assert!(
check_tls_mode_impl(hc, hk, true).is_none(),
"no dotenv-supplied cert/key + acme is a valid ACME-only mode"
);
}
#[test]
fn static_presence_counts_empty_env_override() {
let (has_static_cert, has_static_key) = static_tls_presence_from(None, true, false);
assert!(
has_static_cert,
"an env cert override must count as present"
);
assert!(!has_static_key);
}
#[test]
fn profile_only_acme_skips_static_tls_check() {
let raw: toml::Table = toml::from_str(
"\
[server.tls]
[profile.prod.server.tls.acme]
domains = [\"app.example.com\"]
directory = \"production\"
",
)
.unwrap();
assert!(
resolve_acme_doctor_config(Some(&raw)).is_none(),
"raw top-level table must miss profile-scoped [server.tls.acme]"
);
let mut merged = toml::Table::new();
deep_merge(&mut merged, &raw);
let prof = raw
.get("profile")
.and_then(toml::Value::as_table)
.and_then(|p| p.get("prod"))
.and_then(toml::Value::as_table)
.expect("[profile.prod] table");
deep_merge(&mut merged, prof);
let acme = resolve_acme_doctor_config(Some(&merged))
.expect("merged/profile table must surface [server.tls.acme]");
assert_eq!(acme.domains, ["app.example.com"]);
assert_eq!(acme.directory_label, "production");
assert!(
!should_run_static_tls_check(
resolve_acme_doctor_config(Some(&merged)).is_some(),
false,
),
"profile-scoped ACME deployment must skip the static TLS check"
);
}
#[test]
fn online_probes_every_configured_domain() {
let mut config = acme_doctor_cfg(&["ok.example.com", "bad.example.com"], "ops@example.com");
config.directory_label = "production".to_owned();
let domains = acme_online_reachability_hosts(&config);
assert_eq!(
domains,
vec!["ok.example.com".to_owned(), "bad.example.com".to_owned()],
"every configured domain must be probed, not just the first"
);
let mut ports_checks = Vec::new();
let mut dns_checks = Vec::new();
for domain in &domains {
ports_checks.push(check_acme_ports_for_challenge(
domain,
PortReachability::Open,
PortReachability::Open,
false,
));
dns_checks.push(check_acme_dns_for_challenge(
domain,
&DnsPointsHere::Matches,
false,
));
}
assert_eq!(ports_checks.len(), 2, "one acme_ports check per domain");
assert_eq!(dns_checks.len(), 2, "one acme_dns check per domain");
for domain in &domains {
assert!(
ports_checks
.iter()
.any(|c| c.detail.as_deref().is_some_and(|d| d.contains(domain))),
"{domain} must be represented by a labeled acme_ports check"
);
assert!(
dns_checks
.iter()
.any(|c| c.detail.as_deref().is_some_and(|d| d.contains(domain))),
"{domain} must be represented by a labeled acme_dns check"
);
}
}
#[test]
fn acme_directory_label_matches_store() {
let label_of = |src: &str| {
let table: toml::Table = toml::from_str(src).unwrap();
acme_directory_label(&parse_acme_directory(&table).expect("valid directory"))
};
assert_eq!(label_of("directory = \"staging\""), "staging");
assert_eq!(label_of("directory = \"production\""), "production");
assert_eq!(
acme_directory_label(&parse_acme_directory(&toml::Table::new()).unwrap()),
"staging"
);
let label = label_of("directory = { custom = { url = \"https://pebble.test/dir\" } }");
assert!(label.starts_with("custom-"), "got {label}");
}
#[test]
fn acme_scan_inspects_configured_domain_cert() {
let dir = tempfile::tempdir().unwrap();
let label = "production";
let cert_dir = dir.path().join(label);
std::fs::create_dir_all(&cert_dir).unwrap();
let configured = vec!["app.example.com".to_owned()];
let old = vec!["old.example.com".to_owned()];
let configured_id = acme_cert_id(&configured);
let old_id = acme_cert_id(&old);
assert_ne!(configured_id, old_id);
let write_pair = |id: &str| {
std::fs::write(cert_dir.join(format!("{id}.chain.pem")), b"chain").unwrap();
std::fs::write(cert_dir.join(format!("{id}.key.pem")), b"key").unwrap();
};
write_pair(&old_id);
assert!(
configured_acme_cert_pair(dir.path(), label, &configured).is_none(),
"a cert for different domains must not be reported as the stored cert"
);
write_pair(&configured_id);
let picked = configured_acme_cert_pair(dir.path(), label, &configured)
.expect("configured-domains cert must be found");
assert!(
picked.0.to_string_lossy().contains(&configured_id),
"must inspect the configured-domains cert, got {:?}",
picked.0
);
assert!(
!picked.0.to_string_lossy().contains(&old_id),
"must NOT inspect the old different-domains cert, got {:?}",
picked.0
);
}
#[test]
fn acme_scan_absent_configured_cert_is_not_stored() {
let dir = tempfile::tempdir().unwrap();
let label = "production";
let cert_dir = dir.path().join(label);
std::fs::create_dir_all(&cert_dir).unwrap();
let domains = vec!["app.example.com".to_owned()];
let id = acme_cert_id(&domains);
assert!(configured_acme_cert_pair(dir.path(), label, &domains).is_none());
std::fs::write(cert_dir.join(format!("{id}.chain.pem")), b"chain").unwrap();
assert!(configured_acme_cert_pair(dir.path(), label, &domains).is_none());
assert_eq!(
resolve_acme_stored_cert_data(dir.path(), label, &domains),
TlsDoctorData::NotConfigured
);
}
#[cfg(feature = "tls")]
#[test]
fn acme_scan_occupied_directory_is_invalid_not_not_configured() {
let dir = tempfile::tempdir().unwrap();
let label = "production";
let cert_dir = dir.path().join(label);
let domains = vec!["app.example.com".to_owned()];
let id = acme_cert_id(&domains);
std::fs::create_dir_all(cert_dir.join(format!("{id}.chain.pem"))).unwrap();
std::fs::write(cert_dir.join(format!("{id}.key.pem")), b"key").unwrap();
assert!(
configured_acme_cert_pair(dir.path(), label, &domains).is_some(),
"an occupied path must be reported as present, not absent"
);
assert!(
matches!(
resolve_acme_stored_cert_data(dir.path(), label, &domains),
TlsDoctorData::Invalid { .. }
),
"an occupied-but-unreadable cert must FAIL --strict, not pass as NotConfigured"
);
}
#[cfg(feature = "tls")]
#[test]
fn doctor_cert_id_matches_store() {
let cases: &[&[&str]] = &[
&["app.example.com"],
&["b.example.com", "a.example.com"],
&["a.example.com", "b.example.com", "a.example.com"],
&["one.example.com", "two.example.net", "three.example.org"],
];
for case in cases {
let domains: Vec<String> = case.iter().map(|s| (*s).to_owned()).collect();
assert_eq!(
acme_cert_id(&domains),
autumn_web::acme::store::CertId::from_domains(&domains).as_str(),
"doctor cert-id replica drifted from CertId::from_domains for {domains:?}"
);
}
}
#[test]
fn alert_destination_warns_in_production_when_none() {
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert_eq!(r.name, "alert_destination");
assert!(matches!(r.status, CheckStatus::Warn));
assert!(r.hint.is_some());
}
#[test]
fn alert_destination_passes_in_production_with_native_transport_only() {
let r = check_alert_destination_impl(
true, "", false, "", true, true, "noreply@example.com",
true, false, true, );
assert!(
matches!(r.status, CheckStatus::Pass),
"a native-transport-only config must pass the destination gate"
);
assert!(
!r.detail
.as_deref()
.unwrap_or_default()
.contains("no operator alert destination"),
"must not warn about a missing destination when a native transport is configured"
);
let off = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(off.status, CheckStatus::Warn));
}
#[test]
fn native_alert_transport_configured_matches_runtime_predicate() {
assert!(!native_alert_transport_configured("", "", ""));
assert!(!native_alert_transport_configured(" ", "\t", " "));
assert!(native_alert_transport_configured("R0UT1NGKEY", "", ""));
assert!(native_alert_transport_configured(
"",
"https://hooks.slack.com/services/x",
""
));
assert!(native_alert_transport_configured(
"",
"",
"https://discord.com/api/webhooks/x/slack"
));
}
#[test]
fn alert_destination_passes_in_production_with_email() {
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn alert_destination_passes_in_production_with_webhook() {
let r = check_alert_destination_impl(
true,
"",
true,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn alert_destination_passes_in_dev_without_destination() {
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
false,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn alert_destination_email_only_with_disabled_transport_warns_in_prod() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(email, "the [alerts] email itself resolves as present");
assert!(!webhook);
let transport = resolve_effective_mail_transport(None, None, "prod");
assert_eq!(transport, "disabled");
let usable = transport != "disabled";
let r = check_alert_destination_impl(
true,
"ops@example.com",
webhook,
"",
usable,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"email with a disabled mail transport delivers nothing → must warn in prod"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("transport is disabled")),
"the dedicated warning must name the disabled transport as the cause"
);
}
#[test]
fn alert_destination_email_with_usable_transport_passes_in_prod() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(email, "the [alerts] email resolves as present");
let transport = resolve_effective_mail_transport(None, Some("smtp"), "prod");
assert_eq!(transport, "smtp");
let requires_from = mail_transport_requires_from(&transport);
let r = check_alert_destination_impl(
true,
"ops@example.com",
webhook,
"",
transport != "disabled",
true,
"noreply@example.com",
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"email with a usable (smtp) transport is a valid destination in prod"
);
}
#[test]
fn mail_transport_requires_from_only_for_smtp() {
assert!(mail_transport_requires_from("smtp"));
assert!(!mail_transport_requires_from("log"));
assert!(!mail_transport_requires_from("file"));
assert!(!mail_transport_requires_from("disabled"));
}
#[test]
fn alert_destination_email_smtp_with_from_passes_in_prod() {
let transport = resolve_effective_mail_transport(None, Some("smtp"), "prod");
assert_eq!(transport, "smtp");
let requires_from = mail_transport_requires_from(&transport);
assert!(requires_from, "smtp must be flagged as requiring a from");
let mail_from = "noreply@example.com";
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
true,
mail_from,
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"email + smtp + [mail] from is a valid destination in prod"
);
}
#[test]
fn alert_destination_email_smtp_without_from_warns_in_prod() {
let transport = resolve_effective_mail_transport(None, Some("smtp"), "prod");
let requires_from = mail_transport_requires_from(&transport);
let mail_from = "";
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
true,
mail_from,
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"email on smtp with no [mail] from delivers nothing → must warn in prod"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("`[mail] from` is not set")),
"the dedicated warning must name the missing [mail] from as the cause"
);
}
#[test]
fn alert_destination_email_log_transport_without_from_still_passes() {
let transport = resolve_effective_mail_transport(None, Some("log"), "prod");
assert_eq!(transport, "log");
let requires_from = mail_transport_requires_from(&transport);
assert!(
!requires_from,
"log must NOT be flagged as requiring a from"
);
let mail_from = "";
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
true,
mail_from,
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"email on a log transport delivers without a from → must not warn"
);
}
#[test]
fn alert_destination_email_smtp_with_invalid_from_warns_in_prod() {
let transport = resolve_effective_mail_transport(None, Some("smtp"), "prod");
let requires_from = mail_transport_requires_from(&transport);
assert!(requires_from, "smtp must require a from");
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
true,
"not-a-mailbox",
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"smtp with an unparsable [mail] from delivers nothing → must warn in prod"
);
let detail = r.detail.unwrap_or_default();
assert!(
detail.contains("`[mail] from`")
&& detail.contains("not a valid email address")
&& detail.contains("not-a-mailbox"),
"the dedicated invalid-from warning must fire and name the value, got: {detail}"
);
}
#[test]
fn alert_destination_email_smtp_with_display_name_from_passes_in_prod() {
let transport = resolve_effective_mail_transport(None, Some("smtp"), "prod");
let requires_from = mail_transport_requires_from(&transport);
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
true,
"Ops <ops@example.com>",
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"a valid display-name [mail] from is a working sender → must not warn"
);
}
#[test]
fn alert_destination_email_smtp_with_invalid_from_is_lenient_in_dev() {
let transport = resolve_effective_mail_transport(None, Some("smtp"), "prod");
let requires_from = mail_transport_requires_from(&transport);
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
false, "not-a-mailbox",
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"dev-mode leniency: an invalid [mail] from must not warn outside production"
);
}
#[test]
fn alert_destination_email_log_transport_with_invalid_from_still_passes() {
let transport = resolve_effective_mail_transport(None, Some("log"), "prod");
assert_eq!(transport, "log");
let requires_from = mail_transport_requires_from(&transport);
assert!(!requires_from, "log must NOT require a from");
let r = check_alert_destination_impl(
true,
"ops@example.com",
false,
"",
transport != "disabled",
true,
"not-a-mailbox",
requires_from,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"log transport ignores [mail] from → an invalid value must not warn"
);
}
#[test]
fn alert_destination_invalid_email_warns_in_prod() {
let r = check_alert_destination_impl(
true,
"not-an-address",
false,
"",
true, true, "noreply@example.com", true, false, false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"an invalid alert email delivers nothing at runtime → must warn in prod"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("not a valid email address")),
"the dedicated warning must name the invalid address as the cause"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("not-an-address")),
"the warning should echo the offending value"
);
}
#[test]
fn alert_destination_invalid_email_is_lenient_in_dev() {
let r = check_alert_destination_impl(
true,
"not-an-address",
false,
"",
true,
false, "noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"dev-mode leniency: an invalid alert email must not warn outside production"
);
}
#[test]
fn alert_destination_valid_plus_addressing_passes_in_prod() {
let r = check_alert_destination_impl(
true,
"a+b@sub.example.co",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"a valid `+`-addressed, multi-label-domain email is a working destination"
);
}
#[test]
fn alert_destination_mailto_uri_email_warns_in_prod() {
let r = check_alert_destination_impl(
true,
"mailto:oncall@example.com",
false,
"",
true, true, "noreply@example.com", true, false, false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"a mailto: URI alert email delivers nothing at runtime → must warn in prod"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("not a valid email address")),
"the dedicated invalid-email warning must fire for the mailto: URI form"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("mailto:oncall@example.com")),
"the warning should echo the offending value"
);
}
#[test]
fn alert_destination_invalid_email_not_a_destination_when_alerts_disabled() {
let r = check_alert_destination_impl(
false, "not-an-address",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("alerting is disabled")
&& !d.contains("even though a destination is configured")),
"an invalid email must not be counted as a configured destination"
);
}
#[test]
fn alert_destination_webhook_survives_disabled_transport() {
let table: toml::Table = toml::from_str(
"[alerts]\nemail = \"ops@example.com\"\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n[profile.prod.alerts]\nemail = \"\"\n",
)
.unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!email, "the prod profile cleared the email");
assert!(webhook, "the signed webhook remains configured");
let r = check_alert_destination_impl(
true,
"",
webhook,
"",
false,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"a signed webhook is a valid destination regardless of mail transport"
);
}
fn no_env(_: &str) -> Option<String> {
None
}
#[test]
fn resolve_alert_destination_base_email_is_detected() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"dev@example.com\"\n").unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(email, "base [alerts] email should resolve as configured");
assert!(!webhook);
}
#[test]
fn resolve_alert_destination_prod_profile_clears_base_email() {
let table: toml::Table = toml::from_str(
"[alerts]\nemail = \"dev@example.com\"\n[profile.prod.alerts]\nemail = \"\"\n",
)
.unwrap();
let (email, _webhook) =
resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(
!email,
"an empty prod-profile override must CLEAR the base email, not OR it back in"
);
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn resolve_alert_destination_dev_profile_does_not_clear_base_for_prod_run() {
let table: toml::Table = toml::from_str(
"[alerts]\nemail = \"dev@example.com\"\n[profile.dev.alerts]\nemail = \"\"\n",
)
.unwrap();
let (email, _webhook) =
resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(email, "dev-profile clear must not affect a prod run");
}
#[test]
fn resolve_alert_destination_empty_env_clears_base_email() {
let table: toml::Table = toml::from_str(
"[alerts]\nemail = \"dev@example.com\"\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let env = |key: &str| (key == "AUTUMN_ALERTS__EMAIL").then(String::new);
let (email, webhook) = resolve_alert_destination_from_sources(env, Some(&table), "prod");
assert!(
!email,
"empty AUTUMN_ALERTS__EMAIL must clear the base email"
);
assert!(webhook, "unset webhook env should fall through to base");
}
#[test]
fn resolve_alert_destination_nonempty_env_wins_over_absent_toml() {
let env = |key: &str| match key {
"AUTUMN_ALERTS__WEBHOOK_URL" => Some("https://hooks.example/y".to_owned()),
"AUTUMN_ALERTS__WEBHOOK_SECRET" => Some("s3cr3t".to_owned()),
_ => None,
};
let (email, webhook) = resolve_alert_destination_from_sources(env, None, "prod");
assert!(!email);
assert!(
webhook,
"env webhook should resolve as configured with no TOML"
);
}
#[test]
fn resolve_alert_destination_webhook_requires_secret() {
let table: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!email);
assert!(
webhook,
"webhook_url + webhook_secret must count as configured"
);
let r = check_alert_destination_impl(
true,
"",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn resolve_alert_destination_webhook_url_without_secret_is_not_configured() {
let table: toml::Table =
toml::from_str("[alerts]\nwebhook_url = \"https://hooks.example/x\"\n").unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!email);
assert!(
!webhook,
"a webhook_url with no signing secret is not a destination"
);
let r = check_alert_destination_impl(
true,
"",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"URL-without-secret in production must warn"
);
}
#[test]
fn resolve_alert_destination_prod_profile_clears_webhook_secret() {
let table: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n[profile.prod.alerts]\nwebhook_secret = \"\"\n",
)
.unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!email);
assert!(
!webhook,
"a prod-profile empty webhook_secret must clear the base secret"
);
let r = check_alert_destination_impl(
true,
"",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
let env = |key: &str| (key == "AUTUMN_ALERTS__WEBHOOK_SECRET").then(String::new);
let (_email, webhook_env) =
resolve_alert_destination_from_sources(env, Some(&table), "prod");
assert!(
!webhook_env,
"an empty AUTUMN_ALERTS__WEBHOOK_SECRET must clear the secret"
);
}
#[test]
fn resolve_alert_destination_absolute_webhook_url_with_secret_passes() {
let table: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!email);
assert!(webhook, "absolute https webhook_url + secret is configured");
let r = check_alert_destination_impl(
true,
"",
webhook,
"https://hooks.example/x",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn resolve_alert_destination_absolute_http_webhook_url_with_secret_passes() {
let table: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"http://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let (_email, webhook) =
resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(
webhook,
"an absolute http:// webhook_url + secret must count, matching runtime"
);
let r = check_alert_destination_impl(
true,
"",
webhook,
"http://hooks.example/x",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn resolve_alert_destination_relative_webhook_url_is_not_configured_and_warns() {
let table: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!email);
assert!(
!webhook,
"a relative webhook_url is not an absolute http(s) URL, so it is not a destination"
);
let r = check_alert_destination_impl(
true,
"",
webhook,
"hooks.example/x",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"a relative webhook_url in production must warn"
);
let detail = r.detail.unwrap_or_default();
assert!(
detail.contains("not an absolute http(s) URL") && detail.contains("hooks.example/x"),
"the warning must name the offending value and its cause, got: {detail}"
);
}
#[test]
fn alert_destination_relative_webhook_url_is_lenient_in_dev() {
let r = check_alert_destination_impl(
true,
"",
false,
"hooks.example/x",
true,
false, "noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn normalize_alert_profile_resolves_aliases() {
assert_eq!(normalize_alert_profile("production"), "prod");
assert_eq!(normalize_alert_profile("PRODUCTION"), "prod");
assert_eq!(normalize_alert_profile(" prod "), "prod");
assert_eq!(normalize_alert_profile("development"), "dev");
assert_eq!(normalize_alert_profile("DEV"), "dev");
assert_eq!(normalize_alert_profile("staging"), "staging");
assert_eq!(normalize_alert_profile(""), "");
assert_eq!(normalize_alert_profile(" "), "");
}
#[test]
fn resolve_alert_destination_merged_override_file_clears_base_email() {
let mut merged: toml::Table =
toml::from_str("[alerts]\nemail = \"dev@example.com\"\n").unwrap();
let override_file: toml::Table = toml::from_str("[alerts]\nemail = \"\"\n").unwrap();
deep_merge(&mut merged, &override_file);
let (email, _webhook) = resolve_alert_destination_from_sources(no_env, Some(&merged), "");
assert!(
!email,
"an autumn-prod.toml that clears [alerts].email must resolve as no destination"
);
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"a production deploy whose override file cleared the only alert channel must WARN"
);
}
#[test]
fn resolve_alert_destination_merged_ignores_stale_profile_section() {
let mut merged: toml::Table = toml::from_str(
"[alerts]\nemail = \"dev@example.com\"\n[profile.prod.alerts]\nemail = \"ops@example.com\"\n",
)
.unwrap();
let override_file: toml::Table = toml::from_str("[alerts]\nemail = \"\"\n").unwrap();
deep_merge(&mut merged, &override_file);
let (email, _webhook) = resolve_alert_destination_from_sources(no_env, Some(&merged), "");
assert!(
!email,
"the flattened top-level [alerts] wins; a stale [profile.prod.alerts] must not mask a cleared destination"
);
}
#[test]
fn resolve_alert_destination_merged_base_email_survives_without_override() {
let merged: toml::Table =
toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&merged), "");
assert!(email);
assert!(!webhook);
let r = check_alert_destination_impl(
true,
"ops@example.com",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn resolve_alert_destination_merged_webhook_requires_both_url_and_secret() {
let mut merged: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let override_file: toml::Table =
toml::from_str("[alerts]\nwebhook_secret = \"\"\n").unwrap();
deep_merge(&mut merged, &override_file);
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&merged), "");
assert!(!email);
assert!(
!webhook,
"a webhook whose secret was cleared by the override file is not a signed destination"
);
let r = check_alert_destination_impl(
true,
"",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
let intact: toml::Table = toml::from_str(
"[alerts]\nwebhook_url = \"https://hooks.example/x\"\nwebhook_secret = \"s3cr3t\"\n",
)
.unwrap();
let (_e, webhook_ok) = resolve_alert_destination_from_sources(no_env, Some(&intact), "");
assert!(
webhook_ok,
"url + secret through the merged view is a signed destination"
);
}
#[test]
fn resolve_alert_enabled_defaults_true_when_unset() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(no_env, Some(&table), "prod");
assert!(enabled);
assert!(resolve_alert_enabled_from_sources(no_env, None, "prod"));
}
#[test]
fn resolve_alert_enabled_base_false_is_detected() {
let table: toml::Table =
toml::from_str("[alerts]\nenabled = false\nemail = \"ops@example.com\"\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(no_env, Some(&table), "prod");
assert!(!enabled);
}
#[test]
fn resolve_alert_enabled_env_true_overrides_base_false() {
let env = |k: &str| (k == "AUTUMN_ALERTS__ENABLED").then(|| "true".to_owned());
let table: toml::Table = toml::from_str("[alerts]\nenabled = false\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(env, Some(&table), "prod");
assert!(enabled);
let env1 = |k: &str| (k == "AUTUMN_ALERTS__ENABLED").then(|| "1".to_owned());
let enabled_one = resolve_alert_enabled_from_sources(env1, Some(&table), "prod");
assert!(enabled_one);
}
#[test]
fn resolve_alert_enabled_invalid_env_falls_through_to_toml() {
let env = |k: &str| (k == "AUTUMN_ALERTS__ENABLED").then(|| "yes".to_owned());
let table: toml::Table = toml::from_str("[alerts]\nenabled = false\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(env, Some(&table), "prod");
assert!(!enabled);
}
#[test]
fn resolve_alert_enabled_profile_false_overrides_base_true() {
let table: toml::Table = toml::from_str(
"[alerts]\nemail = \"ops@example.com\"\n[profile.prod.alerts]\nenabled = false\n",
)
.unwrap();
let under_profile = resolve_alert_enabled_from_sources(no_env, Some(&table), "prod");
assert!(!under_profile);
let under_base = resolve_alert_enabled_from_sources(no_env, Some(&table), "");
assert!(under_base);
}
#[test]
fn alert_disabled_at_base_in_prod_with_email_warns() {
let table: toml::Table =
toml::from_str("[alerts]\nenabled = false\nemail = \"ops@example.com\"\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(no_env, Some(&table), "prod");
let (email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!enabled);
assert!(email, "the email itself is still configured");
let r = check_alert_destination_impl(
enabled,
"ops@example.com",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"disabled alerting with a configured destination must warn in prod"
);
assert!(
r.detail.as_deref().is_some_and(|d| d.contains("disabled")),
"the warning must name the disabled master switch"
);
}
#[test]
fn alert_disabled_via_env_in_prod_warns() {
let env = |k: &str| (k == "AUTUMN_ALERTS__ENABLED").then(|| "false".to_owned());
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(env, Some(&table), "prod");
let (_email, webhook) =
resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(!enabled);
let r = check_alert_destination_impl(
enabled,
"ops@example.com",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn alert_enabled_default_true_preserves_existing_prod_pass() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(no_env, Some(&table), "prod");
let (_email, webhook) =
resolve_alert_destination_from_sources(no_env, Some(&table), "prod");
assert!(enabled);
let r = check_alert_destination_impl(
enabled,
"ops@example.com",
webhook,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn alert_disabled_in_dev_does_not_warn() {
let table: toml::Table =
toml::from_str("[alerts]\nenabled = false\nemail = \"ops@example.com\"\n").unwrap();
let enabled = resolve_alert_enabled_from_sources(no_env, Some(&table), "dev");
let (_email, webhook) = resolve_alert_destination_from_sources(no_env, Some(&table), "dev");
assert!(!enabled);
let r = check_alert_destination_impl(
enabled,
"ops@example.com",
webhook,
"",
true,
false,
"noreply@example.com",
true,
false,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"dev-mode leniency: disabled alerting must not warn outside production"
);
}
#[test]
fn alert_destination_custom_channel_passes_in_prod_without_destination() {
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
true,
false,
);
assert!(
matches!(r.status, CheckStatus::Pass),
"custom_channel = true declares a code-registered channel → no-destination must pass"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("custom_channel")),
"the pass detail should name the custom_channel declaration"
);
}
#[test]
fn alert_destination_without_custom_channel_still_warns_in_prod() {
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
false,
false,
);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("custom_channel")),
"the no-destination warning should mention the custom_channel escape hatch"
);
}
#[test]
fn alert_destination_custom_channel_does_not_mask_broken_webhook() {
let r = check_alert_destination_impl(
true,
"",
false, "not-a-url/hooks", true,
true,
"noreply@example.com",
true,
true,
false,
);
assert!(
matches!(r.status, CheckStatus::Warn),
"a broken configured webhook_url must still warn even with custom_channel = true"
);
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("not an absolute")),
"the broken-webhook warning must fire, not the custom_channel pass"
);
}
#[test]
fn resolve_alert_custom_channel_defaults_false_when_unset() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
assert!(!resolve_alert_custom_channel_from_sources(
no_env,
Some(&table),
"prod"
));
assert!(!resolve_alert_custom_channel_from_sources(
no_env, None, "prod"
));
}
#[test]
fn resolve_alert_custom_channel_base_true_is_detected() {
let table: toml::Table = toml::from_str("[alerts]\ncustom_channel = true\n").unwrap();
assert!(resolve_alert_custom_channel_from_sources(
no_env,
Some(&table),
"prod"
));
}
#[test]
fn resolve_alert_custom_channel_env_true_overrides_base_false() {
let table: toml::Table = toml::from_str("[alerts]\ncustom_channel = false\n").unwrap();
let env = |k: &str| (k == "AUTUMN_ALERTS__CUSTOM_CHANNEL").then(|| "true".to_owned());
assert!(resolve_alert_custom_channel_from_sources(
env,
Some(&table),
"prod"
));
let env1 = |k: &str| (k == "AUTUMN_ALERTS__CUSTOM_CHANNEL").then(|| "1".to_owned());
assert!(resolve_alert_custom_channel_from_sources(
env1,
Some(&table),
"prod"
));
}
#[test]
fn resolve_alert_custom_channel_env_resolves_and_passes() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let env = |k: &str| (k == "AUTUMN_ALERTS__CUSTOM_CHANNEL").then(|| "true".to_owned());
let custom = resolve_alert_custom_channel_from_sources(env, Some(&table), "prod");
assert!(
custom,
"AUTUMN_ALERTS__CUSTOM_CHANNEL=true must resolve true"
);
let r = check_alert_destination_impl(
true,
"",
false,
"",
true,
true,
"noreply@example.com",
true,
custom,
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn resolve_alert_custom_channel_profile_true_overrides_base_false() {
let table: toml::Table = toml::from_str(
"[alerts]\ncustom_channel = false\n[profile.prod.alerts]\ncustom_channel = true\n",
)
.unwrap();
assert!(resolve_alert_custom_channel_from_sources(
no_env,
Some(&table),
"prod"
));
assert!(!resolve_alert_custom_channel_from_sources(
no_env,
Some(&table),
""
));
}
#[test]
fn alert_threshold_unset_passes() {
let r = check_alert_error_rate_threshold_impl("", true);
assert!(matches!(r.status, CheckStatus::Pass));
let r = check_alert_error_rate_threshold_impl(" ", true);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn alert_threshold_valid_in_range_passes() {
for v in ["0.05", "0.2", "1", "1.0", "0.001"] {
let r = check_alert_error_rate_threshold_impl(v, true);
assert!(
matches!(r.status, CheckStatus::Pass),
"{v} is a valid (0, 1] rate and must pass"
);
}
}
#[test]
fn alert_threshold_nan_warns_in_prod() {
let r = check_alert_error_rate_threshold_impl("nan", true);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail.as_deref().is_some_and(|d| d.contains("nan")),
"the warning must name the offending value"
);
assert!(r.hint.is_some());
}
#[test]
fn alert_threshold_out_of_range_warns_in_prod() {
for v in ["1.5", "0", "-0.1", "inf"] {
let r = check_alert_error_rate_threshold_impl(v, true);
assert!(
matches!(r.status, CheckStatus::Warn),
"{v} is out of (0, 1] and must warn in production"
);
}
}
#[test]
fn alert_threshold_unparsable_warns_in_prod() {
let r = check_alert_error_rate_threshold_impl("high", true);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn alert_threshold_invalid_is_lenient_outside_prod() {
for v in ["nan", "1.5", "0", "-0.1"] {
let r = check_alert_error_rate_threshold_impl(v, false);
assert!(
matches!(r.status, CheckStatus::Pass),
"{v} must pass quietly outside production"
);
}
}
#[test]
fn resolve_alert_error_rate_threshold_reads_base_float() {
let table: toml::Table = toml::from_str("[alerts]\nerror_rate_threshold = 0.2\n").unwrap();
let raw = resolve_alert_error_rate_threshold_from_sources(no_env, Some(&table), "prod");
assert_eq!(raw, "0.2");
let r = check_alert_error_rate_threshold_impl(&raw, true);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn resolve_alert_error_rate_threshold_out_of_range_float_warns() {
let table: toml::Table = toml::from_str("[alerts]\nerror_rate_threshold = 1.5\n").unwrap();
let raw = resolve_alert_error_rate_threshold_from_sources(no_env, Some(&table), "prod");
assert_eq!(raw, "1.5");
let r = check_alert_error_rate_threshold_impl(&raw, true);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn resolve_alert_error_rate_threshold_env_overrides_base() {
let table: toml::Table = toml::from_str("[alerts]\nerror_rate_threshold = 0.05\n").unwrap();
let env = |k: &str| (k == "AUTUMN_ALERTS__ERROR_RATE_THRESHOLD").then(|| "nan".to_owned());
let raw = resolve_alert_error_rate_threshold_from_sources(env, Some(&table), "prod");
assert_eq!(raw, "nan");
assert!(matches!(
check_alert_error_rate_threshold_impl(&raw, true).status,
CheckStatus::Warn
));
}
#[test]
fn resolve_alert_error_rate_threshold_profile_overrides_base() {
let table: toml::Table = toml::from_str(
"[alerts]\nerror_rate_threshold = 0.05\n[profile.prod.alerts]\nerror_rate_threshold = 2.0\n",
)
.unwrap();
let under_profile =
resolve_alert_error_rate_threshold_from_sources(no_env, Some(&table), "prod");
assert_eq!(under_profile, "2");
let under_base = resolve_alert_error_rate_threshold_from_sources(no_env, Some(&table), "");
assert_eq!(under_base, "0.05");
}
#[test]
fn resolve_alert_error_rate_threshold_unset_is_empty() {
let table: toml::Table = toml::from_str("[alerts]\nemail = \"ops@example.com\"\n").unwrap();
let raw = resolve_alert_error_rate_threshold_from_sources(no_env, Some(&table), "prod");
assert_eq!(raw, "");
assert!(matches!(
check_alert_error_rate_threshold_impl(&raw, true).status,
CheckStatus::Pass
));
}
#[test]
fn alert_transports_none_configured_passes() {
let r = check_alert_transports_impl("", "", "", "", true);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn alert_transports_all_valid_pass() {
let r = check_alert_transports_impl(
"R0UT1NGKEY0000000000000000000000",
"https://events.pagerduty.com/v2/enqueue",
"https://hooks.slack.com/services/T/B/x",
"https://discord.com/api/webhooks/1/abc/slack",
true,
);
assert!(matches!(r.status, CheckStatus::Pass), "{:?}", r.detail);
assert_eq!(r.name, "alert_transports");
}
#[test]
fn alert_transports_malformed_routing_key_warns_in_prod() {
let r = check_alert_transports_impl("bad key with spaces", "", "", "", true);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("routing key")),
"must name the malformed routing key"
);
}
#[test]
fn alert_transports_pagerduty_url_without_key_warns() {
let r = check_alert_transports_impl(
"",
"https://events.pagerduty.com/v2/enqueue",
"",
"",
true,
);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("routing_key"))
);
}
#[test]
fn alert_transports_non_absolute_pagerduty_url_warns() {
let r = check_alert_transports_impl(
"R0UT1NGKEY",
"events.pagerduty.com/v2/enqueue",
"",
"",
true,
);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn alert_transports_non_https_slack_url_warns() {
let r = check_alert_transports_impl("", "", "http://hooks.slack.com/services/x", "", true);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("slack_webhook_url"))
);
}
#[test]
fn alert_transports_relative_discord_url_warns() {
let r = check_alert_transports_impl("", "", "", "/webhooks/x/slack", true);
assert!(matches!(r.status, CheckStatus::Warn));
assert!(
r.detail
.as_deref()
.is_some_and(|d| d.contains("discord_webhook_url"))
);
}
#[test]
fn alert_transports_invalid_is_lenient_outside_prod() {
let r = check_alert_transports_impl(
"bad key",
"not-a-url",
"http://hooks.slack.com/x",
"/relative",
false,
);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn is_absolute_https_url_doctor_requires_https_and_host() {
assert!(is_absolute_https_url_doctor(
"https://hooks.slack.com/services/x"
));
assert!(!is_absolute_https_url_doctor(
"http://hooks.slack.com/services/x"
));
assert!(!is_absolute_https_url_doctor("hooks.slack.com/x"));
assert!(!is_absolute_https_url_doctor("https://"));
assert!(!is_absolute_https_url_doctor(""));
}
#[test]
fn compute_summary_all_pass() {
let results = vec![
CheckResult {
name: "a",
status: CheckStatus::Pass,
detail: None,
hint: None,
},
CheckResult {
name: "b",
status: CheckStatus::Pass,
detail: None,
hint: None,
},
];
let s = compute_summary(&results);
assert_eq!(s.passed, 2);
assert_eq!(s.warned, 0);
assert_eq!(s.failed, 0);
}
#[test]
fn compute_summary_mixed() {
let results = vec![
CheckResult {
name: "a",
status: CheckStatus::Pass,
detail: None,
hint: None,
},
CheckResult {
name: "b",
status: CheckStatus::Warn,
detail: None,
hint: None,
},
CheckResult {
name: "c",
status: CheckStatus::Fail,
detail: None,
hint: None,
},
];
let s = compute_summary(&results);
assert_eq!(s.passed, 1);
assert_eq!(s.warned, 1);
assert_eq!(s.failed, 1);
}
#[test]
fn compute_summary_empty() {
let s = compute_summary(&[]);
assert_eq!(s.passed, 0);
assert_eq!(s.warned, 0);
assert_eq!(s.failed, 0);
}
#[test]
fn exit_code_no_failures() {
let s = Summary {
passed: 3,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&s, false), 0);
}
#[test]
fn exit_code_with_failure() {
let s = Summary {
passed: 2,
warned: 0,
failed: 1,
};
assert_eq!(exit_code(&s, false), 1);
}
#[test]
fn exit_code_warn_non_strict() {
let s = Summary {
passed: 2,
warned: 1,
failed: 0,
};
assert_eq!(exit_code(&s, false), 0);
}
#[test]
fn exit_code_warn_strict() {
let s = Summary {
passed: 2,
warned: 1,
failed: 0,
};
assert_eq!(exit_code(&s, true), 1);
}
#[test]
fn exit_code_zero_when_all_pass_strict() {
let s = Summary {
passed: 5,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&s, true), 0);
}
#[test]
fn format_check_line_pass_contains_glyph_and_name() {
let r = CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: Some("1.88.0".into()),
hint: None,
};
let line = format_check_line(&r);
assert!(line.contains("✅"));
assert!(line.contains("rust_toolchain"));
}
#[test]
fn format_check_line_fail_includes_hint() {
let r = CheckResult {
name: "port_bindable",
status: CheckStatus::Fail,
detail: Some("port 3000 in use".into()),
hint: Some("Kill the process using port 3000 or change server.port in autumn.toml"),
};
let line = format_check_line(&r);
assert!(line.contains("❌"));
assert!(line.contains("port_bindable"));
assert!(line.contains("Kill the process"));
}
#[test]
fn format_check_line_pass_omits_hint() {
let r = CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: None,
hint: Some("some hint that should not appear on pass"),
};
let line = format_check_line(&r);
assert!(!line.contains("some hint"));
}
#[test]
fn format_check_line_warn_includes_hint() {
let r = CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some("patch skew".into()),
hint: Some("Update your dependency"),
};
let line = format_check_line(&r);
assert!(line.contains("⚠️"));
assert!(line.contains("Update your dependency"));
}
#[test]
fn format_summary_all_pass() {
let s = Summary {
passed: 7,
warned: 0,
failed: 0,
};
let line = format_summary_line(&s, 0);
assert!(line.contains("7 passed"));
assert!(line.contains("0 warnings"));
assert!(line.contains("0 failed"));
assert!(line.contains("all clear"));
}
#[test]
fn format_summary_with_failure() {
let s = Summary {
passed: 5,
warned: 1,
failed: 1,
};
let line = format_summary_line(&s, 1);
assert!(line.contains("5 passed"));
assert!(line.contains("1 warning"));
assert!(line.contains("1 failed"));
assert!(line.contains("problems found"));
}
#[test]
fn format_summary_singular_warning_label() {
let s = Summary {
passed: 3,
warned: 1,
failed: 0,
};
let line = format_summary_line(&s, 0);
assert!(line.contains("1 warning,"));
}
#[test]
fn format_summary_plural_warning_label() {
let s = Summary {
passed: 1,
warned: 2,
failed: 0,
};
let line = format_summary_line(&s, 0);
assert!(line.contains("2 warnings,"));
}
#[test]
fn check_toml_content_valid() {
let content = r#"
[server]
port = 3000
[database]
url = "postgres://localhost/mydb"
"#;
let r = check_toml_content(content);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_toml_content_syntax_error() {
let content = "[[[[invalid toml";
let r = check_toml_content(content);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_toml_content_unknown_key_warns() {
let content = r#"
[server]
port = 3000
[totally_unknown_section]
foo = "bar"
"#;
let r = check_toml_content(content);
assert_eq!(r.status, CheckStatus::Warn);
assert!(
r.detail
.as_deref()
.unwrap_or("")
.contains("totally_unknown_section"),
"detail should mention the unknown key"
);
}
#[test]
fn check_toml_content_empty_is_pass() {
let r = check_toml_content("");
assert_eq!(r.status, CheckStatus::Pass);
}
#[cfg(feature = "postgres")]
#[test]
fn check_toml_content_all_known_sections_pass() {
let content: String = KNOWN_TOML_SECTIONS
.iter()
.flat_map(|&s| ["[", s, "]\n"])
.collect();
let r = check_toml_content(&content);
assert_eq!(
r.status,
CheckStatus::Pass,
"Test failed with result: {r:?}"
);
}
#[test]
fn check_version_compat_matching() {
let r = check_version_compat("0.3.0", "0.3.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_minor_skew_fails() {
let r = check_version_compat("0.3.0", "0.4.0");
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_version_compat_patch_skew_warns() {
let r = check_version_compat("0.3.0", "0.3.1");
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn check_version_compat_caret_requirement_stripped() {
let r = check_version_compat("0.3.0", "^0.3");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_tilde_requirement_stripped() {
let r = check_version_compat("0.3.0", "~0.3.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_exact_requirement_stripped() {
let r = check_version_compat("0.3.0", "=0.3.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_port_bindable_impl_success() {
let r = check_port_bindable_impl(3000, |_port| true);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.as_deref().unwrap_or("").contains("available"));
}
#[test]
fn check_port_bindable_impl_failure() {
let r = check_port_bindable_impl(3000, |_port| false);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
assert!(r.detail.as_deref().unwrap_or("").contains("in use"));
}
#[test]
fn check_port_bindable_impl_reports_correct_port() {
let r = check_port_bindable_impl(8080, |_| true);
assert!(r.detail.as_deref().unwrap_or("").contains("8080"));
}
fn fn_body(source: &str, signature: &str) -> String {
let source = source.replace("\r\n", "\n");
let start = source.find(signature).expect("function present");
let end = source[start..].find("\n}\n").expect("function end");
source[start..start + end].to_owned()
}
#[test]
fn fn_body_reads_a_crlf_checkout() {
let source = "fn a() {\r\n body();\r\n}\r\nfn b() {}\r\n";
assert_eq!(fn_body(source, "fn a("), "fn a() {\n body();");
}
#[test]
fn doctor_checks_never_make_rustup_install_a_toolchain() {
let doctor = include_str!("doctor.rs");
assert!(
fn_body(doctor, "fn check_rust_toolchain(msrv").contains("no_toolchain_installs(&mut"),
"the rust_toolchain check must forbid a toolchain install"
);
let dev = include_str!("dev.rs");
assert!(
fn_body(dev, "pub fn try_resolve_target_directory(")
.contains("no_toolchain_installs(&mut"),
"doctor's target-dir lookup must forbid a toolchain install"
);
}
#[test]
fn a_missing_pinned_toolchain_points_at_rustup_toolchain_install() {
let r = rust_toolchain_failure(
"error: toolchain '1.88.0-x86_64-pc-windows-msvc' is not installed\n\
help: run `rustup toolchain install` to install it\n",
);
assert_eq!(r.status, CheckStatus::Fail);
assert_eq!(
r.detail.as_deref(),
Some(
"`rustc --version` failed: error: toolchain \
'1.88.0-x86_64-pc-windows-msvc' is not installed"
)
);
assert!(r.hint.expect("a hint").contains("rustup toolchain install"));
}
#[test]
fn any_other_rustc_failure_still_points_at_installing_rust() {
let r = rust_toolchain_failure("");
assert_eq!(r.detail.as_deref(), Some("`rustc --version` failed"));
assert_eq!(r.hint, Some("Install Rust via https://rustup.rs/"));
}
#[test]
fn check_rust_toolchain_impl_pass() {
let r = check_rust_toolchain_impl("rustc 1.88.0 (abc123 2026-04-01)", "1.88.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_rust_toolchain_impl_fail() {
let r = check_rust_toolchain_impl("rustc 1.80.0 (abc123 2025-01-01)", "1.88.0");
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_rust_toolchain_impl_newer_passes() {
let r = check_rust_toolchain_impl("rustc 1.90.0 (abc 2026-01-01)", "1.88.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_rust_toolchain_impl_exact_msrv_passes() {
let r = check_rust_toolchain_impl("1.88.0", "1.88.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_rust_toolchain_impl_unparseable_warns() {
let r = check_rust_toolchain_impl("not-a-version", "1.88.0");
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn parse_db_host_port_full_url() {
let (host, port) = parse_db_host_port("postgres://user:pass@localhost:5432/mydb").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_no_credentials() {
let (host, port) = parse_db_host_port("postgres://localhost/mydb").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_default_port() {
let (host, port) = parse_db_host_port("postgres://user:pass@db.example.com/mydb").unwrap();
assert_eq!(host, "db.example.com");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_custom_port() {
let (host, port) = parse_db_host_port("postgres://localhost:6543/test").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 6543);
}
#[test]
fn parse_db_host_port_unbracketed_ipv6_defaults_port() {
let (host, port) = parse_db_host_port("postgres://::1/db").unwrap();
assert_eq!(host, "::1");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_bracketed_ipv6_custom_port() {
let (host, port) = parse_db_host_port("postgres://[::1]:6543/db").unwrap();
assert_eq!(host, "::1");
assert_eq!(port, 6543);
}
#[test]
fn parse_db_host_port_postgresql_scheme() {
let (host, port) = parse_db_host_port("postgresql://localhost:5432/db").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_invalid_scheme() {
assert!(parse_db_host_port("mysql://localhost/db").is_none());
}
#[test]
fn database_topology_doctor_fails_replica_without_primary() {
let topology = DoctorDatabaseTopology {
primary_url: None,
replica_url: Some("postgres://user:secret@replica:5432/app".to_owned()),
legacy_url: None,
auto_migrate_in_production: false,
replica_fallback: DoctorReplicaFallback::FailReadiness,
};
let result = check_database_topology_contract(&topology, true);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("replica"));
assert!(result.hint.unwrap_or_default().contains("primary"));
}
#[test]
fn database_topology_doctor_fails_prod_replica_with_startup_migrations() {
let topology = DoctorDatabaseTopology {
primary_url: Some("postgres://user:secret@primary:5432/app".to_owned()),
replica_url: Some("postgres://user:secret@replica:5432/app".to_owned()),
legacy_url: None,
auto_migrate_in_production: true,
replica_fallback: DoctorReplicaFallback::FailReadiness,
};
let result = check_database_topology_contract(&topology, true);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("migration"));
assert!(!detail.contains("secret"));
}
#[test]
fn database_topology_doctor_fails_stale_replica_when_readiness_must_fail() {
let result = check_replica_migration_versions(
Some("20260510000000"),
Some("20260509000000"),
DoctorReplicaFallback::FailReadiness,
);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("replica"));
assert!(result.hint.unwrap_or_default().contains("replay"));
}
#[test]
fn database_topology_doctor_warns_stale_replica_when_falling_back_to_primary() {
let result = check_replica_migration_versions(
Some("20260510000000"),
Some("20260509000000"),
DoctorReplicaFallback::Primary,
);
assert_eq!(result.status, CheckStatus::Warn);
assert!(result.detail.unwrap_or_default().contains("primary"));
}
#[test]
fn split_topology_fails_on_web_role_with_local_backend() {
let result = check_split_topology_on_local(&ProcessRole::Web.into(), "local", None);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(
detail.contains("web"),
"detail must name the role: {detail}"
);
assert!(
detail.contains("local"),
"detail must name the backend: {detail}"
);
assert!(result.hint.unwrap_or_default().contains("postgres"));
}
#[test]
fn split_topology_fails_on_worker_role_with_local_backend() {
let result = check_split_topology_on_local(&ProcessRole::Worker.into(), "local", None);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("worker"));
}
#[test]
fn split_topology_passes_for_combined_role_on_local_backend() {
let result = check_split_topology_on_local(&ProcessRole::Combined.into(), "local", None);
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.detail.as_deref(), Some("role=combined"));
assert!(result.hint.is_none());
}
#[test]
fn split_topology_passes_for_web_role_on_postgres_backend() {
let result = check_split_topology_on_local(&ProcessRole::Web.into(), "postgres", None);
assert_eq!(result.status, CheckStatus::Pass);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("role=web"), "got: {detail}");
assert!(detail.contains("postgres"), "got: {detail}");
}
#[test]
fn split_topology_passes_for_worker_role_on_redis_backend() {
let result = check_split_topology_on_local(&ProcessRole::Worker.into(), "redis", None);
assert_eq!(result.status, CheckStatus::Pass);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("role=worker"), "got: {detail}");
assert!(detail.contains("redis"), "got: {detail}");
}
#[test]
fn split_topology_passes_for_worker_role_on_sqlite_backend() {
let result = check_split_topology_on_local(
&ProcessRole::Worker.into(),
"sqlite",
Some("sqlite:///var/lib/app.db"),
);
assert_eq!(result.status, CheckStatus::Pass);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("role=worker"), "got: {detail}");
assert!(detail.contains("sqlite"), "got: {detail}");
}
#[test]
fn split_topology_fails_for_sqlite_on_an_in_memory_database() {
let result = check_split_topology_on_local(
&ProcessRole::Web.into(),
"sqlite",
Some("sqlite::memory:"),
);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("in-memory"), "got: {detail}");
let hint = result.hint.unwrap_or_default();
assert!(hint.contains("FILE"), "the hint names the fix: {hint}");
}
#[test]
fn split_topology_fails_on_web_role_with_backend_typo() {
let result = check_split_topology_on_local(&ProcessRole::Web.into(), "postgresql", None);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(
detail.contains("web"),
"detail must name the role: {detail}"
);
assert!(
detail.contains("postgresql"),
"detail must name the offending backend: {detail}"
);
assert!(result.hint.unwrap_or_default().contains("postgres"));
}
#[test]
fn split_topology_fails_on_web_role_with_blank_backend() {
let result = check_split_topology_on_local(&ProcessRole::Web.into(), "", None);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("web"));
}
#[test]
fn resolve_process_role_prefers_env_over_toml() {
let table: toml::Table =
toml::from_str("role = \"web\"\n[jobs]\nbackend = \"local\"\n").expect("parse toml");
let (role, backend) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "worker".to_owned()),
Some(&table),
);
let role = role.expect("valid role");
assert_eq!(role, ProcessRole::Worker);
assert_eq!(backend, "local");
}
#[test]
fn resolve_process_role_defaults_to_combined_and_local() {
let (role, backend) = resolve_process_role_and_backend_from_sources(|_| None, None);
let role = role.expect("valid role");
assert_eq!(role, ProcessRole::Combined);
assert_eq!(backend, "local");
}
#[test]
fn queue_coverage_passes_when_pin_unset() {
let result = check_queue_coverage(
&ProcessRole::Combined.into(),
&["critical".into(), "low".into()],
&[],
);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn queue_coverage_passes_when_pin_covers_all_queues() {
let queues = vec!["critical".to_string(), "low".to_string()];
let pin = vec!["critical".to_string(), "low".to_string()];
assert_eq!(
check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin).status,
CheckStatus::Pass
);
}
#[test]
fn queue_coverage_passes_for_valid_subset_pin() {
let queues = vec!["critical".to_string(), "low".to_string()];
let pin = vec!["critical".to_string()];
let result = check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.detail.unwrap().contains("low"));
}
#[test]
fn queue_coverage_passes_for_web_role_even_with_failing_pin() {
let queues = vec!["default".to_string()];
let pin = vec!["critical".to_string()];
let web = check_queue_coverage(&ProcessRole::Web.into(), &queues, &pin);
assert_eq!(web.status, CheckStatus::Pass);
assert!(
web.detail
.as_deref()
.unwrap_or_default()
.contains("web-only role"),
"web-role detail should explain the check is not applicable: {:?}",
web.detail
);
let web_summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&web_summary, true), 0);
let worker = check_queue_coverage(&ProcessRole::Worker.into(), &queues, &pin);
assert_eq!(worker.status, CheckStatus::Pass);
let worker_summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&worker_summary, true), 0);
assert_eq!(
check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin).status,
CheckStatus::Pass
);
}
#[test]
fn resolve_queues_zero_config_uses_implicit_default_queue() {
let table: toml::Table = toml::from_str("[jobs]\npin = [\"critical\"]\n").expect("parse");
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&table));
assert_eq!(queues, vec!["default"]);
assert_eq!(pin, vec!["critical"]);
}
#[test]
fn queue_coverage_reports_default_when_zero_config_app_is_pinned_elsewhere() {
let table: toml::Table = toml::from_str("[jobs]\npin = [\"critical\"]\n").expect("parse");
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&table));
let result = check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
let detail = result.detail.unwrap();
assert!(
detail.contains("default"),
"detail should name the unclaimed configured queue: {detail}"
);
assert!(
detail.contains("critical"),
"detail should name the config-unknown pinned queue: {detail}"
);
assert!(
detail.contains("job(queue"),
"detail should note the pinned queue may be #[job(queue=…)]-declared: {detail}"
);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn queue_coverage_passes_for_zero_config_app_without_pin() {
let table: toml::Table = toml::from_str("").expect("parse");
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&table));
assert_eq!(queues, vec!["default"]);
assert!(pin.is_empty());
assert_eq!(
check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin).status,
CheckStatus::Pass
);
}
#[test]
fn queue_coverage_honors_profile_layer_jobs_config() {
let base: toml::Table = toml::from_str(
"[profile.prod.jobs]\npin = [\"critical\"]\nqueues = [\"critical\", \"bulk\"]\n",
)
.expect("parse toml");
let (raw_queues, raw_pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&base));
assert_eq!(raw_queues, vec!["default"]);
assert!(raw_pin.is_empty());
assert_eq!(
check_queue_coverage(&ProcessRole::Combined.into(), &raw_queues, &raw_pin).status,
CheckStatus::Pass
);
let mut merged = toml::Table::new();
deep_merge(&mut merged, &base);
for profile_name in inline_profile_lookup_names("prod") {
if let Some(prof) = base
.get("profile")
.and_then(toml::Value::as_table)
.and_then(|p| p.get(profile_name))
.and_then(toml::Value::as_table)
{
deep_merge(&mut merged, prof);
}
}
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&merged));
assert_eq!(queues, vec!["critical", "bulk"]);
assert_eq!(pin, vec!["critical"]);
let result = check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.detail.unwrap().contains("bulk"));
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn queue_coverage_resolves_web_role_from_profile_layer() {
let base: toml::Table = toml::from_str(
"[profile.prod]\nrole = \"web\"\n[profile.prod.jobs]\npin = [\"ghost\"]\nqueues = [\"critical\"]\n",
)
.expect("parse toml");
let (raw_role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&base));
let raw_role = raw_role.expect("valid role");
assert_eq!(raw_role, ProcessRole::Combined);
let (raw_queues, raw_pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&base));
let raw_result = check_queue_coverage(&raw_role, &raw_queues, &raw_pin);
assert!(raw_role.runs_workers());
let _ = raw_result;
let mut merged = toml::Table::new();
deep_merge(&mut merged, &base);
for profile_name in inline_profile_lookup_names("prod") {
if let Some(prof) = base
.get("profile")
.and_then(toml::Value::as_table)
.and_then(|p| p.get(profile_name))
.and_then(toml::Value::as_table)
{
deep_merge(&mut merged, prof);
}
}
let (merged_role, _) =
resolve_process_role_and_backend_from_sources(|_| None, Some(&merged));
let merged_role = merged_role.expect("valid role");
assert_eq!(
merged_role,
ProcessRole::Web,
"role must be resolved from the merged profile layer"
);
assert!(!merged_role.runs_workers());
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&merged));
let result = check_queue_coverage(&merged_role, &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
assert!(
result
.detail
.as_deref()
.unwrap_or_default()
.contains("web-only role"),
"web-role coverage detail should explain the check is skipped: {:?}",
result.detail
);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
let (env_role, _) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "worker".to_owned()),
Some(&merged),
);
let env_role = env_role.expect("valid role");
assert_eq!(env_role, ProcessRole::Worker);
assert_eq!(
check_queue_coverage(&env_role, &queues, &pin).status,
CheckStatus::Pass,
);
}
#[test]
fn queue_coverage_web_role_from_top_level_role_still_passes() {
let table: toml::Table =
toml::from_str("role = \"web\"\n[jobs]\npin = [\"ghost\"]\nqueues = [\"critical\"]\n")
.expect("parse toml");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let role = role.expect("valid role");
assert_eq!(role, ProcessRole::Web);
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&table));
let result = check_queue_coverage(&role, &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn queue_coverage_worker_role_from_profile_with_empty_schedule_is_informational() {
let base: toml::Table = toml::from_str(
"[profile.prod]\nrole = \"worker\"\n[profile.prod.jobs]\npin = [\"ghost\"]\nqueues = [\"critical\"]\n",
)
.expect("parse toml");
let mut merged = toml::Table::new();
deep_merge(&mut merged, &base);
for profile_name in inline_profile_lookup_names("prod") {
if let Some(prof) = base
.get("profile")
.and_then(toml::Value::as_table)
.and_then(|p| p.get(profile_name))
.and_then(toml::Value::as_table)
{
deep_merge(&mut merged, prof);
}
}
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&merged));
let role = role.expect("valid role");
assert_eq!(role, ProcessRole::Worker);
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&merged));
let result = check_queue_coverage(&role, &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn queue_coverage_passes_for_multi_tier_subset_pins() {
let queues = vec![
"critical".to_string(),
"bulk".to_string(),
"default".to_string(),
];
let critical_tier = check_queue_coverage(
&ProcessRole::Combined.into(),
&queues,
&["critical".to_string()],
);
assert_eq!(critical_tier.status, CheckStatus::Pass);
let detail = critical_tier.detail.unwrap();
assert!(detail.contains("bulk"), "detail should list bulk: {detail}");
assert!(
detail.contains("default"),
"detail should list default: {detail}"
);
let bulk_tier = check_queue_coverage(
&ProcessRole::Combined.into(),
&queues,
&["bulk".to_string(), "default".to_string()],
);
assert_eq!(bulk_tier.status, CheckStatus::Pass);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn topology_absent_delegates_to_informational_pass() {
let queues = vec!["critical".to_string(), "bulk".to_string()];
let pin = vec!["critical".to_string()];
let result = check_queue_coverage_topology(
&ProcessRole::Combined.into(),
&queues,
&pin,
&[], None, None, );
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.detail.unwrap().contains("bulk"));
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn topology_with_full_union_coverage_passes() {
let queues = vec![
"critical".to_string(),
"bulk".to_string(),
"default".to_string(),
];
let fleet = FleetTopology {
tiers: vec![
vec!["critical".to_string()],
vec!["bulk".to_string(), "default".to_string()],
],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Combined.into(),
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn topology_valid_multi_tier_subset_split_does_not_fail() {
let queues = vec![
"critical".to_string(),
"bulk".to_string(),
"default".to_string(),
];
let fleet = FleetTopology {
tiers: vec![
vec!["critical".to_string()],
vec!["bulk".to_string(), "default".to_string()],
],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
None,
);
assert_ne!(
result.status,
CheckStatus::Fail,
"a valid multi-tier subset split must not fail: {:?}",
result.detail
);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn topology_with_genuine_gap_fails_strict() {
let queues = vec![
"critical".to_string(),
"bulk".to_string(),
"default".to_string(),
];
let fleet = FleetTopology {
tiers: vec![vec!["critical".to_string()], vec!["default".to_string()]],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Fail, "{:?}", result.detail);
assert!(
result.detail.unwrap().contains("bulk"),
"the failure must name the uncovered queue"
);
assert!(result.hint.is_some());
let summary = Summary {
passed: 0,
warned: 0,
failed: 1,
};
assert_eq!(exit_code(&summary, true), 1);
}
#[test]
fn topology_unpinned_tier_covers_everything() {
let queues = vec!["critical".to_string(), "bulk".to_string()];
let fleet = FleetTopology {
tiers: vec![
vec!["critical".to_string()],
Vec::new(), ],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
}
#[test]
fn topology_job_declared_queue_pinned_but_absent_from_config_passes() {
let queues = vec!["default".to_string()];
let declared = vec!["email".to_string()];
let fleet = FleetTopology {
tiers: vec![vec!["default".to_string(), "email".to_string()]],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["default".to_string(), "email".to_string()],
&declared,
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
let result_no_manifest = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["default".to_string(), "email".to_string()],
&[],
Some(&fleet),
None,
);
assert_eq!(
result_no_manifest.status,
CheckStatus::Pass,
"{:?}",
result_no_manifest.detail
);
}
#[test]
fn documented_fleet_topology_examples_pass_the_coverage_check() {
const GUIDE: &str = include_str!("../../docs/guide/jobs.md");
const CONFIG_RS: &str = include_str!("../../autumn/src/config.rs");
let mut blocks: Vec<String> = GUIDE
.split("```toml")
.skip(1)
.filter_map(|rest| rest.split("```").next())
.filter(|block| block.contains("[jobs.fleet]"))
.map(str::to_owned)
.collect();
blocks.extend(
CONFIG_RS
.split("/// ```toml")
.skip(1)
.filter_map(|rest| rest.split("/// ```").next())
.filter(|block| block.contains("[jobs.fleet]"))
.map(|block| {
block
.lines()
.map(|l| l.trim_start().trim_start_matches("///").trim_start())
.collect::<Vec<_>>()
.join("\n")
}),
);
assert!(
blocks.len() >= 2,
"expected a [jobs.fleet] example in both docs/guide/jobs.md and the \
JobFleetConfig rustdoc, found {} — either an example was removed or the \
fence style changed (fix the extractor), but do not leave this test \
silently checking nothing",
blocks.len(),
);
for block in &blocks {
let block = block.as_str();
let table: toml::Table = toml::from_str(block).unwrap_or_else(|e| {
panic!("documented [jobs.fleet] example is not valid TOML: {e}\n{block}")
});
let fleet_value = table
.get("jobs")
.and_then(|j| j.get("fleet"))
.cloned()
.expect("block was selected for containing [jobs.fleet]");
let _: autumn_web::config::JobFleetConfig =
fleet_value.try_into().unwrap_or_else(|e| {
panic!("documented [jobs.fleet] example is not valid config: {e}\n{block}")
});
let fleet = resolve_fleet_topology(Some(&table))
.expect("a block containing [jobs.fleet] tiers declares a topology");
let declared = resolve_declared_queues_from_sources(|_| None, Some(&table))
.expect("docs example manifests are well-formed");
let configured: Vec<String> = table
.get("jobs")
.and_then(|j| j.get("queues"))
.and_then(toml::Value::as_table)
.map(|q| q.keys().cloned().collect())
.unwrap_or_default();
let pin = fleet.tiers.first().cloned().unwrap_or_default();
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&configured,
&pin,
&declared,
Some(&fleet),
None,
);
assert_eq!(
result.status,
CheckStatus::Pass,
"a [jobs.fleet] example in docs/guide/jobs.md fails `autumn doctor`: {:?}\n{block}",
result.detail,
);
}
}
#[test]
fn topology_declared_gap_on_job_declared_queue_fails() {
let queues = vec!["default".to_string()];
let declared = vec!["email".to_string()];
let fleet = FleetTopology {
tiers: vec![vec!["default".to_string()]],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["default".to_string()],
&declared,
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Fail, "{:?}", result.detail);
assert!(result.detail.unwrap().contains("email"));
}
#[test]
fn topology_empty_tiers_stays_informational() {
let queues = vec!["critical".to_string(), "bulk".to_string()];
let fleet = FleetTopology {
tiers: Vec::new(),
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
}
#[test]
fn resolve_fleet_topology_reads_tiers_array() {
let table: toml::Table =
toml::from_str("[jobs.fleet]\ntiers = [[\"critical\"], [\"bulk\", \"default\"], []]\n")
.expect("parse toml");
let fleet = resolve_fleet_topology(Some(&table)).expect("topology declared");
assert_eq!(
fleet.tiers,
vec![
vec!["critical".to_string()],
vec!["bulk".to_string(), "default".to_string()],
Vec::<String>::new(),
]
);
assert!(fleet.has_unpinned_tier());
}
#[test]
fn doctor_and_app_agree_on_the_fleet_topology_spelling() {
const DOC: &str = "[jobs.fleet]\n\
tiers = [[\"critical\"], [\"bulk\", \"default\"], []]\n\
manifest = \"target/autumn-jobs.toml\"\n\
declared_queues = [\"thumbnails\"]\n";
let table: toml::Table = toml::from_str(DOC).expect("parse toml");
let doctor_view = resolve_fleet_topology(Some(&table)).expect("topology declared");
let app_view: autumn_web::config::JobFleetConfig = toml::from_str::<toml::Table>(DOC)
.expect("parse toml")
.get("jobs")
.and_then(|j| j.get("fleet"))
.cloned()
.expect("jobs.fleet present")
.try_into()
.expect("app config accepts the same [jobs.fleet] doctor reads");
assert_eq!(
doctor_view.tiers, app_view.tiers,
"doctor and the app must read the same `tiers` from one autumn.toml",
);
assert_eq!(
resolve_declared_queues_from_sources(|_| None, Some(&table))
.expect("docs example has a well-formed manifest"),
app_view.declared_queues,
"doctor and the app must read the same `declared_queues`",
);
assert_eq!(
app_view.manifest.as_deref(),
Some("target/autumn-jobs.toml"),
"the app must accept the `manifest` key doctor resolves",
);
}
#[test]
fn a_malformed_tiers_fails_instead_of_silently_disabling_the_check() {
for doc in [
"[jobs.fleet]\ntiers = [\"critical\", \"bulk\"]\n",
"[jobs.fleet]\ntiers = \"critical\"\n",
"[jobs.fleet]\ntiers = 3\n",
"[jobs.fleet]\ntiers = [[\"critical\"], \"bulk\"]\n",
"[jobs.fleet]\ntiers = [[1]]\n",
"[jobs.fleet]\ntiers = [[\"critical\", 1]]\n",
"[jobs.fleet]\ntiers = [[\"critical\"], [[\"bulk\"]]]\n",
] {
let table: toml::Table = toml::from_str(doc).expect("parse toml");
let fleet = resolve_fleet_topology(Some(&table)).unwrap_or_else(|| {
panic!("a present-but-malformed tiers must be reported, not dropped: {doc}")
});
assert!(fleet.malformed, "{doc}");
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&["critical".to_string(), "bulk".to_string()],
&["critical".to_string()],
&[],
Some(&fleet),
None,
);
assert_eq!(
result.status,
CheckStatus::Fail,
"{doc}: {:?}",
result.detail
);
assert!(
result.detail.unwrap_or_default().contains("list of lists"),
"the failure must say what is wrong with the value: {doc}",
);
let fleet_value = table
.get("jobs")
.and_then(|j| j.get("fleet"))
.cloned()
.expect("jobs.fleet present");
let parsed: Result<autumn_web::config::JobFleetConfig, _> = fleet_value.try_into();
assert!(
parsed.is_err(),
"the typed config must reject it too: {doc}"
);
}
let unpinned = FleetTopology {
tiers: vec![Vec::new()],
malformed: false,
derived: false,
};
assert!(unpinned.has_unpinned_tier());
assert_eq!(
check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&["critical".to_string(), "bulk".to_string()],
&[],
&[],
Some(&unpinned),
None,
)
.status,
CheckStatus::Pass,
"an empty tier means 'drains everything' — so a malformed tier must never \
be allowed to collapse into one",
);
let table: toml::Table =
toml::from_str("[jobs]\nfleet = \"critical\"\n").expect("parse toml");
let fleet = resolve_fleet_topology(Some(&table))
.expect("a present-but-malformed [jobs.fleet] must be reported");
assert!(fleet.malformed);
let absent: toml::Table =
toml::from_str("[jobs]\npin = [\"critical\"]\n").expect("parse toml");
assert!(resolve_fleet_topology(Some(&absent)).is_none());
let no_tiers: toml::Table =
toml::from_str("[jobs.fleet]\ndeclared_queues = [\"a\"]\n").expect("parse toml");
assert!(resolve_fleet_topology(Some(&no_tiers)).is_none());
}
#[test]
fn resolve_fleet_topology_absent_is_none() {
let table: toml::Table =
toml::from_str("[jobs]\nqueues = [\"critical\"]\n").expect("parse toml");
assert!(resolve_fleet_topology(Some(&table)).is_none());
let empty: toml::Table = toml::from_str("[jobs.fleet]\ntiers = []\n").expect("parse toml");
assert!(resolve_fleet_topology(Some(&empty)).is_none());
}
#[test]
fn resolve_declared_queues_reads_inline_list_and_manifest() {
let inline: toml::Table = toml::from_str(
"[jobs.fleet]\ntiers = [[\"default\"]]\ndeclared_queues = [\"email\", \"sms\"]\n",
)
.expect("parse toml");
let declared = resolve_declared_queues_from_sources(|_| None, Some(&inline))
.expect("inline list is well-formed");
assert_eq!(declared, vec!["email".to_string(), "sms".to_string()]);
let with_manifest: toml::Table = toml::from_str(
"[jobs.fleet]\nmanifest = \"target/jobs-manifest.toml\"\ndeclared_queues = [\"stale\"]\n",
)
.expect("parse toml");
let declared_from_manifest = resolve_declared_queues_from_sources(
|path| {
(path == "target/jobs-manifest.toml")
.then(|| "queues = [\"critical\", \"email\"]\n".to_string())
},
Some(&with_manifest),
)
.expect("well-formed manifest is authoritative");
assert_eq!(
declared_from_manifest,
vec!["critical".to_string(), "email".to_string()]
);
let empty_manifest = resolve_declared_queues_from_sources(
|path| (path == "target/jobs-manifest.toml").then(|| "queues = []\n".to_string()),
Some(&with_manifest),
)
.expect("empty manifest is a real answer");
assert!(
empty_manifest.is_empty(),
"an empty manifest must win over declared_queues, got {empty_manifest:?}",
);
for (label, read) in [
("unreadable", None::<String>),
("unparseable", Some("this is not toml =".to_string())),
("no queues key", Some("other = 1\n".to_string())),
] {
let fell_through =
resolve_declared_queues_from_sources(|_| read.clone(), Some(&with_manifest))
.expect("a silent manifest falls through to the inline list");
assert_eq!(
fell_through,
vec!["stale".to_string()],
"a manifest that says nothing ({label}) must fall through to declared_queues",
);
}
let none: toml::Table =
toml::from_str("[jobs]\nqueues = [\"critical\"]\n").expect("parse toml");
assert!(
resolve_declared_queues_from_sources(|_| None, Some(&none))
.expect("absent section reads empty")
.is_empty()
);
}
#[test]
fn resolve_declared_queues_rejects_a_manifest_with_non_string_queues() {
let with_manifest: toml::Table = toml::from_str(
"[jobs.fleet]\nmanifest = \"target/jobs-manifest.toml\"\ndeclared_queues = [\"stale\"]\n",
)
.expect("parse toml");
for (label, manifest) in [
(
"non-string element",
"queues = [\"critical\", \"thumbnails\", 1]\n",
),
("all non-string", "queues = [1]\n"),
("non-array queues", "queues = \"critical\"\n"),
] {
let err = resolve_declared_queues_from_sources(
|path| (path == "target/jobs-manifest.toml").then(|| manifest.to_string()),
Some(&with_manifest),
)
.expect_err("a corrupt manifest must not be trusted");
assert_eq!(
err.manifest_path, "target/jobs-manifest.toml",
"the failure must name the manifest ({label})"
);
assert!(
!err.detail.is_empty(),
"the failure must say what was wrong ({label})"
);
}
}
#[test]
fn check_queue_coverage_topology_fails_on_corrupt_manifest() {
let err = DeclaredQueuesManifestError {
manifest_path: "target/jobs-manifest.toml".to_string(),
detail: "`queues` is not an array of strings".to_string(),
};
let fleet = FleetTopology {
tiers: vec![vec!["critical".to_string(), "thumbnails".to_string()]],
malformed: false,
derived: false,
};
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&["critical".to_string()],
&["critical".to_string()],
&[],
Some(&fleet),
Some(&err),
);
assert_eq!(result.status, CheckStatus::Fail, "{:?}", result.detail);
let detail = result.detail.expect("detail names the manifest");
assert!(
detail.contains("target/jobs-manifest.toml"),
"the failure must name the manifest, got: {detail}"
);
}
const EMITTED_MANIFEST: &str = "queues = [\"critical\", \"email\"]\n";
#[test]
fn manifest_emit_consume_loop_fails_on_genuine_gap() {
let temp = tempfile::tempdir().expect("temp dir");
let manifest_path = temp.path().join("jobs-manifest.toml");
std::fs::write(&manifest_path, EMITTED_MANIFEST).expect("write manifest");
let table: toml::Table = toml::from_str(&format!(
"[jobs]\nqueues = [\"critical\"]\n\n[jobs.fleet]\nmanifest = {path:?}\ntiers = [[\"critical\"]]\n",
path = manifest_path.to_str().expect("utf8 path"),
))
.expect("parse toml");
let declared =
resolve_declared_queues(Some(&table)).expect("the emitted manifest is well-formed");
assert_eq!(
declared,
vec!["critical".to_string(), "email".to_string()],
"the emitted manifest is the declared-queue source of truth"
);
let fleet = resolve_fleet_topology(Some(&table)).expect("topology declared");
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&["critical".to_string()],
&["critical".to_string()],
&declared,
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Fail, "{:?}", result.detail);
assert!(
result.detail.unwrap().contains("email"),
"the failure must name the manifest-surfaced uncovered queue"
);
let summary = Summary {
passed: 0,
warned: 0,
failed: 1,
};
assert_eq!(exit_code(&summary, true), 1);
}
#[test]
fn manifest_emit_consume_loop_passes_on_full_coverage() {
let temp = tempfile::tempdir().expect("temp dir");
let manifest_path = temp.path().join("jobs-manifest.toml");
std::fs::write(&manifest_path, EMITTED_MANIFEST).expect("write manifest");
let table: toml::Table = toml::from_str(&format!(
"[jobs]\nqueues = [\"critical\"]\n\n[jobs.fleet]\nmanifest = {path:?}\ntiers = [[\"critical\"], [\"email\"]]\n",
path = manifest_path.to_str().expect("utf8 path"),
))
.expect("parse toml");
let declared =
resolve_declared_queues(Some(&table)).expect("the emitted manifest is well-formed");
let fleet = resolve_fleet_topology(Some(&table)).expect("topology declared");
let result = check_queue_coverage_topology(
&ProcessRole::Worker.into(),
&["critical".to_string()],
&["critical".to_string()],
&declared,
Some(&fleet),
None,
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
}
#[test]
fn resolve_queues_empty_pin_env_overrides_toml_pin() {
let table: toml::Table = toml::from_str("[jobs]\npin = [\"critical\"]\n").expect("parse");
let (queues, pin) = resolve_queues_and_pin_from_sources(
|key| (key == "AUTUMN_JOBS__PIN").then(String::new),
Some(&table),
);
assert_eq!(queues, vec!["default"]);
assert!(pin.is_empty(), "empty env pin must clear the TOML pin");
let result = check_queue_coverage(&ProcessRole::Combined.into(), &queues, &pin);
assert_eq!(result.status, CheckStatus::Pass);
let summary = Summary {
passed: 1,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn resolve_queues_and_pin_reads_array_queues_and_toml_pin() {
let table: toml::Table =
toml::from_str("[jobs]\nqueues = [\"critical\", \"low\"]\npin = [\"critical\"]\n")
.expect("parse toml");
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&table));
assert_eq!(queues, vec!["critical", "low"]);
assert_eq!(pin, vec!["critical"]);
}
#[test]
fn resolve_queues_and_pin_reads_table_queue_names_and_env_pin() {
let table: toml::Table =
toml::from_str("[jobs.queues]\ncritical = 4\nlow = 1\n").expect("parse toml");
let (mut queues, pin) = resolve_queues_and_pin_from_sources(
|key| (key == "AUTUMN_JOBS__PIN").then(|| "critical, low".to_owned()),
Some(&table),
);
queues.sort();
assert_eq!(queues, vec!["critical", "low"]);
assert_eq!(pin, vec!["critical", "low"]);
}
#[test]
fn resolve_process_role_reads_backend_from_env() {
let table: toml::Table = toml::from_str("role = \"web\"\n").expect("parse toml");
let (role, backend) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_JOBS__BACKEND").then(|| "postgres".to_owned()),
Some(&table),
);
let role = role.expect("valid role");
assert_eq!(role, ProcessRole::Web);
assert_eq!(backend, "postgres");
}
#[test]
fn resolve_process_role_rejects_unknown_env_value() {
let table: toml::Table =
toml::from_str("role = \"worker\"\n[jobs]\nbackend = \"local\"\n").expect("parse toml");
let (role, backend) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "workers".to_owned()),
Some(&table),
);
let expected = "workers"
.parse::<ProcessRole>()
.expect_err("unknown")
.to_string();
let message = role.expect_err("unknown env role must fail");
assert!(message.contains("AUTUMN_ROLE"), "{message}");
assert!(message.contains(&expected), "{message}");
assert_eq!(backend, "local");
}
#[test]
fn resolve_process_role_rejects_unknown_toml_value() {
for source in ["role = \"wrker\"\n", "role = \"\"\n", "role = 5\n"] {
let table: toml::Table = toml::from_str(source).expect("parse toml");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let message = role.expect_err(source);
assert!(message.starts_with("role: "), "{source}: {message}");
assert!(
toml::from_str::<autumn_web::config::AutumnConfig>(source)
.map_err(|err| err.to_string())
.and_then(|config| config.validate().map_err(|err| err.to_string()))
.is_err(),
"{source}"
);
}
}
#[test]
fn resolve_process_role_accepts_table_form() {
let source = "role = { web = {} }\n";
let loaded: autumn_web::config::AutumnConfig =
toml::from_str(source).expect("loader accepts the table form");
let table: toml::Table = toml::from_str(source).expect("parse toml");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let role = role.expect("table form");
assert_eq!(role, loaded.resolve_role().expect("loader resolves"));
assert_eq!(role, ProcessRole::Web);
let table: toml::Table = toml::from_str("role = { wrker = {} }\n").expect("parse toml");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
assert!(role.expect_err("unknown").contains("\"wrker\""));
}
#[test]
fn resolve_process_role_rejects_bad_toml_role_under_valid_env() {
let table: toml::Table = toml::from_str("role = \"wrker\"\n").expect("parse toml");
let (role, _) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "worker".to_owned()),
Some(&table),
);
let message = role.expect_err("bad TOML role must fail");
assert!(message.contains("\"wrker\""), "{message}");
}
#[test]
fn resolve_process_role_reads_the_given_env() {
let env = autumn_web::config::MockEnv::new()
.with("AUTUMN_ROLE", "workers")
.with("AUTUMN_JOBS__BACKEND", "postgres");
let (role, backend) = resolve_process_role_and_backend(None, &env);
assert!(role.expect_err("unknown").contains("\"workers\""));
assert_eq!(backend, "postgres");
let env = autumn_web::config::MockEnv::new().with("AUTUMN_ROLE", "server");
let (role, _) = resolve_process_role_and_backend(None, &env);
assert_eq!(role.expect("valid role"), ProcessRole::Web);
}
const ROLES_3543: &str = "[jobs]\nbackend = \"local\"\nqueues = [\"critical\", \"bulk\", \"default\"]\n\
[roles.critical]\nbase = \"worker\"\npin = [\"critical\"]\nscheduler = false\n\
[roles.bulk]\nbase = \"worker\"\npin = [\"bulk\"]\n\
[roles.cron]\nbase = \"worker\"\npin = []\n\
[roles.api]\nbase = \"web\"\n";
fn roles_table_3543(extra: &str) -> toml::Table {
toml::from_str(&format!("{extra}{ROLES_3543}")).expect("parse toml")
}
#[test]
fn resolve_process_role_selects_a_named_role() {
let table = roles_table_3543("role = \"critical\"\n");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let role = role.expect("critical");
assert_eq!(role.name(), "critical");
assert_eq!(role.base(), ProcessRole::Worker);
assert!(!role.capabilities().scheduler);
let (role, _) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "BULK".to_owned()),
Some(&table),
);
assert_eq!(role.expect("bulk").name(), "bulk");
}
#[test]
fn resolve_process_role_rejects_an_undeclared_name() {
let table = roles_table_3543("");
let (role, _) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "critcal".to_owned()),
Some(&table),
);
let message = role.expect_err("undeclared");
assert!(message.contains("AUTUMN_ROLE"), "{message}");
assert!(message.contains("\"critcal\""), "{message}");
assert!(message.contains("critical"), "{message}");
}
#[test]
fn resolve_process_role_rejects_a_bad_preset() {
for body in [
"[roles.worker]\nbase = \"worker\"\n",
"[roles.api]\nbase = \"web\"\nscheduler = true\n",
"[roles.x]\nbase = \"worker\"\nschedular = false\n",
"roles = 5\n",
] {
let table: toml::Table = toml::from_str(body).expect("parse toml");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let message = role.expect_err(body);
assert!(
message.starts_with("roles: ") || message.starts_with("[roles."),
"{body}: {message}"
);
let mut config = toml::from_str::<autumn_web::config::AutumnConfig>(body);
if let Ok(config) = config.as_mut() {
assert!(config.validate().is_err(), "{body}");
}
}
}
#[test]
fn process_role_row_fails_for_named_split_role_on_local() {
for name in ["critical", "api"] {
let table = roles_table_3543(&format!("role = \"{name}\"\n"));
let (role, backend) =
resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let result = check_process_role(&role, &backend, None);
assert_eq!(result.status, CheckStatus::Fail, "{name}");
let detail = result.detail.unwrap_or_default();
assert!(detail.contains(&format!("role={name}")), "{detail}");
}
}
#[test]
fn strict_coverage_derives_the_fleet_from_roles() {
let table = roles_table_3543("");
let fleet = resolve_fleet_topology(Some(&table)).expect("derived topology");
assert!(fleet.derived);
assert_eq!(
fleet.tiers,
vec![vec!["bulk".to_owned()], vec!["critical".to_owned()]]
);
let (queues, pin) = resolve_queues_and_pin_from_sources(|_| None, Some(&table));
let role = autumn_web::config::Role::from(ProcessRole::Combined);
let result = check_queue_coverage_topology(&role, &queues, &pin, &[], Some(&fleet), None);
assert_eq!(result.status, CheckStatus::Warn, "{:?}", result.detail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("default"), "{detail}");
assert!(detail.contains("[roles.*]"), "{detail}");
let table = roles_table_3543("[roles.rest]\nbase = \"worker\"\npin = [\"default\"]\n");
let fleet = resolve_fleet_topology(Some(&table)).expect("derived topology");
let result = check_queue_coverage_topology(&role, &queues, &pin, &[], Some(&fleet), None);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
}
#[test]
fn fleet_without_tiers_derives_from_roles() {
for fleet in [
"[jobs.fleet]\ndeclared_queues = [\"thumbnails\"]\n",
"[jobs.fleet]\ntiers = []\n",
] {
let table = roles_table_3543(fleet);
let topology = resolve_fleet_topology(Some(&table)).expect(fleet);
assert!(topology.derived, "{fleet}");
assert_eq!(topology.tiers.len(), 2, "{fleet}");
}
}
#[test]
fn derived_tier_inherits_the_resolved_jobs_pin() {
let table: toml::Table =
toml::from_str("[roles.rest]\nbase = \"worker\"\n").expect("parse toml");
let pin = vec!["bulk".to_owned()];
let fleet = resolve_fleet_topology_with_pin(Some(&table), Some(&pin)).expect("derived");
assert_eq!(fleet.tiers, vec![pin]);
let fleet = resolve_fleet_topology(Some(&table)).expect("derived");
assert_eq!(fleet.tiers, vec![Vec::<String>::new()]);
}
#[test]
fn process_role_row_fails_for_a_non_draining_combined_preset() {
let table: toml::Table = toml::from_str(
"role = \"api\"\n[roles.api]\nbase = \"combined\"\njob_workers = false\n",
)
.expect("parse toml");
let (role, backend) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let result = check_process_role(&role, &backend, None);
assert_eq!(result.status, CheckStatus::Fail);
assert!(
result
.detail
.unwrap_or_default()
.contains("role=api (base combined)")
);
}
#[test]
fn explicit_fleet_tiers_win_over_roles() {
let table = roles_table_3543("[jobs.fleet]\ntiers = [[]]\n");
let fleet = resolve_fleet_topology(Some(&table)).expect("declared topology");
assert!(!fleet.derived);
assert_eq!(fleet.tiers, vec![Vec::<String>::new()]);
}
#[test]
fn roles_without_job_workers_derive_no_topology() {
let table: toml::Table = toml::from_str(
"[roles.api]\nbase = \"web\"\n[roles.cron]\nbase = \"worker\"\npin = []\n",
)
.expect("parse toml");
assert!(resolve_fleet_topology(Some(&table)).is_none());
}
#[test]
fn queue_coverage_uses_the_named_role_pin() {
let table = roles_table_3543("role = \"critical\"\n");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let role = role.expect("critical");
let result = check_queue_coverage(&role, &["critical".into(), "bulk".into()], &[]);
let detail = result.detail.unwrap_or_default();
assert!(
detail.contains("does not claim configured queue(s): bulk"),
"{detail}"
);
let table = roles_table_3543("role = \"cron\"\n");
let (role, _) = resolve_process_role_and_backend_from_sources(|_| None, Some(&table));
let result = check_queue_coverage(&role.expect("cron"), &["bulk".into()], &[]);
assert!(
result
.detail
.unwrap_or_default()
.contains("runs no job workers")
);
}
#[test]
fn resolve_process_role_treats_blank_env_as_unset() {
let table: toml::Table = toml::from_str("role = \"web\"\n").expect("parse toml");
for value in ["", " "] {
let (role, _) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| value.to_owned()),
Some(&table),
);
assert_eq!(role.expect("blank is unset"), ProcessRole::Web, "{value:?}");
}
let (role, _) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "jobs".to_owned()),
Some(&table),
);
assert_eq!(role.expect("jobs"), ProcessRole::Worker);
}
#[test]
fn process_role_check_fails_on_unknown_role() {
let (role, backend) = resolve_process_role_and_backend_from_sources(
|key| (key == "AUTUMN_ROLE").then(|| "workers".to_owned()),
None,
);
let result = check_process_role(&role, &backend, None);
assert_eq!(result.name, "process_role_backend");
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("\"workers\""), "{detail}");
assert!(result.hint.is_some());
let result = check_process_role(&Ok(ProcessRole::Worker.into()), "local", None);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("worker"));
let result = check_process_role(&Ok(ProcessRole::Combined.into()), "local", None);
assert_eq!(result.status, CheckStatus::Pass);
}
const GROUPS_3544: &str = r#"
[route_groups]
declared = ["marketing", "service", "app"]
[roles.marketing]
base = "web"
mount = ["marketing"]
[roles.service]
base = "web"
mount = ["service"]
"#;
fn groups_table_3544(extra: &str) -> toml::Table {
toml::from_str(&format!("{GROUPS_3544}{extra}")).expect("parse toml")
}
#[test]
fn route_group_mounts_warns_for_a_group_no_role_mounts() {
let table = groups_table_3544("");
let row = check_route_group_mounts(Some(&table), |_| None);
assert_eq!(row.name, "route_group_mounts");
assert_eq!(row.status, CheckStatus::Warn, "{:?}", row.detail);
let detail = row.detail.clone().unwrap_or_default();
assert!(detail.contains("\"app\""), "{detail}");
assert!(!detail.contains("\"marketing\""), "{detail}");
assert!(row.hint.is_some());
let summary = compute_summary(&[row]);
assert_eq!(exit_code(&summary, true), 1);
assert_eq!(exit_code(&summary, false), 0);
}
#[test]
fn route_group_mounts_passes_when_every_group_is_mounted() {
let table = groups_table_3544("[roles.core]\nbase = \"combined\"\nmount = [\"app\"]\n");
let row = check_route_group_mounts(Some(&table), |_| None);
assert_eq!(row.status, CheckStatus::Pass, "{:?}", row.detail);
let table = groups_table_3544("[roles.api]\nbase = \"web\"\n");
let row = check_route_group_mounts(Some(&table), |_| None);
assert_eq!(row.status, CheckStatus::Pass, "{:?}", row.detail);
}
#[test]
fn route_group_mounts_reads_groups_from_the_routes_manifest() {
let table: toml::Table = toml::from_str(
"[route_groups]\nmanifest = \"routes.json\"\n\
[roles.marketing]\nbase = \"web\"\nmount = [\"marketing\"]\n",
)
.expect("parse");
let manifest = r#"[
{"method": "GET", "path": "/landing", "handler": "landing", "source": "user", "middleware": [], "group": "marketing"},
{"method": "GET", "path": "/desk", "handler": "desk", "source": "user", "middleware": [], "group": "service"},
{"method": "GET", "path": "/live", "handler": "live", "source": "framework", "middleware": []}
]"#;
let row = check_route_group_mounts(Some(&table), |path| {
(path == "routes.json").then(|| manifest.to_owned())
});
assert_eq!(row.status, CheckStatus::Warn, "{:?}", row.detail);
let detail = row.detail.unwrap_or_default();
assert!(detail.contains("\"service\""), "{detail}");
let row = check_route_group_mounts(Some(&table), |_| Some("not json".to_owned()));
assert_eq!(row.status, CheckStatus::Fail, "{:?}", row.detail);
}
#[test]
fn route_group_mounts_warns_for_a_missing_or_groupless_manifest() {
let table: toml::Table = toml::from_str(
"[route_groups]\nmanifest = \"routes.json\"\ndeclared = [\"marketing\"]\n\
[roles.marketing]\nbase = \"web\"\nmount = [\"marketing\"]\n",
)
.expect("parse");
let row = check_route_group_mounts(Some(&table), |_| None);
assert_eq!(row.status, CheckStatus::Warn, "{:?}", row.detail);
assert!(row.detail.unwrap_or_default().contains("is not there"));
let old_dump = r#"[{"method": "GET", "path": "/x", "handler": "x", "source": "user", "middleware": []}]"#;
let row = check_route_group_mounts(Some(&table), |_| Some(old_dump.to_owned()));
assert_eq!(row.status, CheckStatus::Warn, "{:?}", row.detail);
assert!(
row.detail
.unwrap_or_default()
.contains("names no route group")
);
}
#[test]
fn doctor_run_registers_route_group_mounts() {
let source = include_str!("doctor.rs");
let run = source.find("pub fn run(opts").expect("run exists");
let end = run
+ source[run..]
.find("\nfn run_doctor_tasks")
.expect("tasks fn");
assert!(
source[run..end].contains("check_route_group_mounts(Some(&route_groups_toml)"),
"doctor run() must push the route_group_mounts row"
);
}
#[test]
fn route_group_mounts_passes_when_nothing_is_narrowed() {
let table: toml::Table = toml::from_str(
"[route_groups]\ndeclared = [\"marketing\"]\n[roles.api]\nbase = \"web\"\n",
)
.expect("parse");
assert_eq!(
check_route_group_mounts(Some(&table), |_| None).status,
CheckStatus::Pass
);
let table: toml::Table =
toml::from_str("[roles.m]\nbase = \"web\"\nmount = [\"marketing\"]\n").expect("parse");
let row = check_route_group_mounts(Some(&table), |_| None);
assert_eq!(row.status, CheckStatus::Pass);
assert!(
row.detail.unwrap_or_default().contains("[route_groups]"),
"the row says how to turn the check on"
);
assert_eq!(
check_route_group_mounts(None, |_| None).status,
CheckStatus::Pass
);
}
#[test]
fn database_topology_parses_latest_applied_migration_version() {
let output = r"
[X] 20260509000000_create_widgets
[ ] 20260510000000_add_widget_index
[X] 20260508000000_create_users
";
assert_eq!(
parse_latest_applied_migration_version(output).as_deref(),
Some("20260509000000_create_widgets")
);
}
#[test]
fn database_topology_connectivity_names_role_without_credentials() {
let result = check_db_role_connectivity(
"primary",
"postgres://user:secret@db:5432/app",
|host, port| {
assert_eq!(host, "db");
assert_eq!(port, 5432);
false
},
false,
);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("primary"));
assert!(detail.contains("db:5432"));
assert!(!detail.contains("user"));
assert!(!detail.contains("secret"));
}
#[test]
fn db_connectivity_sqlite_target_passes_without_postgres_hint() {
for url in ["sqlite://app.db", "sqlite:data/app.db", "file:app.db"] {
let result = check_db_role_connectivity(
"primary",
url,
|_, _| panic!("SQLite target must not attempt TCP reachability"),
true,
);
assert_eq!(result.status, CheckStatus::Pass, "url={url}");
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("SQLite"), "detail={detail}");
assert!(
result.hint.is_none(),
"SQLite target must not emit a postgres:// hint (url={url})"
);
}
}
#[test]
fn db_connectivity_postgres_target_still_evaluated() {
let result =
check_db_role_connectivity("primary", "postgres://db:5432/app", |_, _| false, false);
assert_eq!(result.status, CheckStatus::Fail);
}
#[test]
fn db_connectivity_sqlite_replica_or_mixed_backend_fails() {
let replica = check_db_role_connectivity(
"replica",
"sqlite://replica.db",
|_, _| panic!("SQLite target must not attempt TCP reachability"),
false,
);
assert_eq!(replica.status, CheckStatus::Fail);
let detail = replica.detail.unwrap_or_default();
assert!(detail.contains("replica"), "detail={detail}");
assert!(detail.contains("same backend"), "detail={detail}");
let primary = check_db_role_connectivity(
"primary",
"sqlite://app.db",
|_, _| panic!("SQLite target must not attempt TCP reachability"),
false,
);
assert_eq!(primary.status, CheckStatus::Fail);
}
#[test]
fn sqlite_primary_bootable_only_as_single_role() {
assert!(sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
None,
false
));
assert!(!sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
Some("postgres://db:5432/app"),
false
));
assert!(!sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
Some("sqlite://replica.db"),
false
));
assert!(!sqlite_primary_target_is_bootable(
None,
Some("postgres://db:5432/app"),
Some("sqlite://replica.db"),
false
));
assert!(!sqlite_primary_target_is_bootable(
None,
Some("postgres://db:5432/app"),
Some("postgres://replica:5432/app"),
false
));
}
#[test]
fn sqlite_primary_with_shards_is_not_bootable() {
assert!(!sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
None,
true
));
assert!(!sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
Some("sqlite://replica.db"),
true
));
assert!(sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
None,
false
));
}
#[test]
fn sqlite_primary_with_mismatched_legacy_url_is_not_bootable() {
assert!(!sqlite_primary_target_is_bootable(
Some("postgres://db:5432/app"),
Some("sqlite://app.db"),
None,
false
));
assert!(sqlite_primary_target_is_bootable(
Some("sqlite://app.db"),
Some("sqlite://app.db"),
None,
false
));
assert!(sqlite_primary_target_is_bootable(
None,
Some("sqlite://app.db"),
None,
false
));
}
#[test]
fn sqlite_primary_with_mismatched_legacy_url_connectivity_fails() {
let bootable = sqlite_primary_target_is_bootable(
Some("postgres://db:5432/app"),
Some("sqlite://app.db"),
None,
false,
);
assert!(!bootable, "mismatched legacy url must not be bootable");
let r = check_db_connectivity("sqlite://app.db", bootable);
assert_eq!(r.status, CheckStatus::Fail);
assert_eq!(
autumn_web::config::database_backend_consistency(
Some("postgres://db:5432/app"),
Some("sqlite://app.db"),
None,
false,
),
Err(
"database.url does not match the primary database backend (sqlite); \
every configured database role must use the same backend"
.to_owned()
)
);
}
#[test]
fn backend_consistency_check_catches_mismatch_for_any_primary() {
let r = check_database_backend_consistency(
Some("sqlite://app.db"),
Some("postgres://db:5432/app"),
None,
false,
);
assert_eq!(
r.status,
CheckStatus::Fail,
"F27: pg primary + sqlite legacy"
);
assert!(
r.detail.unwrap_or_default().contains("backend"),
"the FAIL detail must explain the backend mismatch"
);
let r = check_database_backend_consistency(
Some("postgres://db:5432/app"),
Some("sqlite://app.db"),
None,
false,
);
assert_eq!(
r.status,
CheckStatus::Fail,
"F26: sqlite primary + pg legacy"
);
let r = check_database_backend_consistency(
None,
Some("postgres://db:5432/app"),
Some("sqlite://replica.db"),
false,
);
assert_eq!(r.status, CheckStatus::Fail, "pg primary + sqlite replica");
let r = check_database_backend_consistency(None, Some("sqlite://app.db"), None, true);
assert_eq!(r.status, CheckStatus::Fail, "sqlite primary + shards");
let r = check_database_backend_consistency(
None,
Some("postgres://db:5432/app"),
Some("postgres://replica:5432/app"),
false,
);
assert_eq!(r.status, CheckStatus::Pass, "clean all-postgres");
let r = check_database_backend_consistency(None, Some("sqlite://app.db"), None, false);
assert_eq!(r.status, CheckStatus::Pass, "clean single sqlite");
let r = check_database_backend_consistency(
Some("postgres://db:5432/app"),
Some("postgres://db:5432/app"),
None,
false,
);
assert_eq!(
r.status,
CheckStatus::Pass,
"redundant pg legacy == pg primary"
);
}
#[test]
fn sqlite_primary_with_shards_connectivity_fails_and_cites_shards() {
let r = check_db_connectivity("sqlite://app.db", false);
assert_eq!(r.status, CheckStatus::Fail);
let detail = r.detail.unwrap_or_default();
assert!(detail.contains("shards"), "detail={detail}");
}
#[test]
fn pg_client_tools_sqlite_variant_passes_on_the_merits() {
let r = check_pg_client_tools_sqlite();
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.hint, None, "a working path needs no tracking-issue hint");
let detail = r.detail.unwrap_or_default();
assert!(
detail.contains("in-process") && !detail.contains("1909"),
"the detail must state the working mechanism, not a deferral: {detail}"
);
assert!(!detail.contains("not required for"), "{detail}");
}
#[test]
fn pending_migrations_sqlite_variant_passes_and_cites_1906() {
let r = check_pending_migrations_sqlite();
assert_eq!(r.status, CheckStatus::Pass);
let detail = r.detail.unwrap_or_default();
assert!(
detail.contains("not yet wired"),
"detail must be an honest deferred note, got {detail:?}"
);
assert!(
!detail.to_lowercase().contains("no pending"),
"must not claim migrations are up to date, got {detail:?}"
);
assert!(r.hint.unwrap_or_default().contains("1906"));
}
#[test]
fn sqlite_primary_in_active_profile_is_detected_as_sqlite() {
use autumn_web::config::DatabaseBackend;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[profile.prod.database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let topology = resolve_database_topology_from_sources(|_| None, Some(&merged));
assert_eq!(
topology.primary_url.as_deref(),
Some("sqlite://app.db"),
"the [profile.prod.database] primary_url must be resolved from the merged table"
);
assert_eq!(
topology
.primary_url
.as_deref()
.and_then(DatabaseBackend::detect),
Some(DatabaseBackend::Sqlite),
"profile-merged SQLite primary must be detected as SQLite, gating pg_client_tools \
and the pending-migration probe"
);
assert!(
sqlite_primary_target_is_bootable(
topology.legacy_url.as_deref(),
topology.primary_url.as_deref(),
None,
false
),
"a lone SQLite primary from the active profile is a bootable single-role target"
);
}
#[test]
fn sqlite_primary_in_override_file_is_detected_as_sqlite() {
use autumn_web::config::DatabaseBackend;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("autumn.toml"), "[server]\nport = 8080\n").unwrap();
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[database]\nprimary_url = \"sqlite://prod.db\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let topology = resolve_database_topology_from_sources(|_| None, Some(&merged));
assert_eq!(
topology
.primary_url
.as_deref()
.and_then(DatabaseBackend::detect),
Some(DatabaseBackend::Sqlite),
"autumn-prod.toml SQLite primary must be detected as SQLite"
);
}
#[test]
fn base_file_postgres_primary_still_detected_as_postgres() {
use autumn_web::config::DatabaseBackend;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://db:5432/app\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let topology = resolve_database_topology_from_sources(|_| None, Some(&merged));
let backend = topology
.primary_url
.as_deref()
.and_then(DatabaseBackend::detect);
assert_eq!(
backend,
Some(DatabaseBackend::Postgres),
"a base-file Postgres app must stay Postgres and run the Postgres checks"
);
assert!(
!sqlite_primary_target_is_bootable(
topology.legacy_url.as_deref(),
topology.primary_url.as_deref(),
None,
false
),
"a Postgres primary is not a SQLite target"
);
}
#[test]
fn postgres_primary_with_mismatched_sqlite_legacy_url_fails_consistency() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://db:5432/app\"\nurl = \"sqlite://app.db\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let topology = resolve_database_topology_from_sources(|_| None, Some(&merged));
assert_eq!(
topology.primary_url.as_deref(),
Some("postgres://db:5432/app"),
"primary_url wins for the effective primary"
);
assert_eq!(
topology.legacy_url.as_deref(),
Some("sqlite://app.db"),
"the legacy database.url must be resolved separately for the mismatch check"
);
assert!(!sqlite_primary_target_is_bootable(
topology.legacy_url.as_deref(),
topology.primary_url.as_deref(),
topology.replica_url.as_deref(),
false,
));
let r = check_database_backend_consistency(
topology.legacy_url.as_deref(),
topology.primary_url.as_deref(),
topology.replica_url.as_deref(),
false,
);
assert_eq!(
r.status,
CheckStatus::Fail,
"F27: pg primary + sqlite legacy url must Fail the unconditional check"
);
}
#[test]
fn sqlite_primary_with_malformed_shard_entry_fails() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n\n\
[[database.shards]]\nname = \"shard-a\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let shard_resolution = crate::migrate::try_resolve_shard_database_urls_from_sources(
|_| Err(std::env::VarError::NotPresent),
Some(&merged),
);
let err = shard_resolution
.expect_err("a shard entry missing primary_url must be rejected by the resolver");
assert!(
err.contains("primary_url"),
"the resolver error must name the missing field: {err}"
);
assert!(
sqlite_primary_target_is_bootable(None, Some("sqlite://app.db"), None, false),
"demonstrates the F28 trap: with the malformed entry dropped, the SQLite \
primary would wrongly read as bootable"
);
let r = check_shard_config_resolvable(&err);
assert_eq!(
r.status,
CheckStatus::Fail,
"a malformed [[database.shards]] entry must Fail doctor"
);
let detail = r.detail.unwrap_or_default();
assert!(
detail.contains("database.shards") && detail.contains("primary_url"),
"the Fail detail must name the malformed shard config: {detail}"
);
}
#[test]
fn sqlite_primary_with_wellformed_shard_still_fails_via_consistency() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n\n\
[[database.shards]]\nname = \"shard-a\"\nprimary_url = \"sqlite://shard-a.db\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let shards = crate::migrate::try_resolve_shard_database_urls_from_sources(
|_| Err(std::env::VarError::NotPresent),
Some(&merged),
)
.expect("a well-formed shard entry must resolve");
assert_eq!(shards.len(), 1, "the well-formed shard must be resolved");
let r = check_database_backend_consistency(None, Some("sqlite://app.db"), None, true);
assert_eq!(
r.status,
CheckStatus::Fail,
"a SQLite primary with shards must Fail: shards require postgres"
);
}
#[test]
fn clean_sqlite_primary_without_shards_still_passes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let shards = crate::migrate::try_resolve_shard_database_urls_from_sources(
|_| Err(std::env::VarError::NotPresent),
Some(&merged),
)
.expect("no shard table must resolve to an empty list, not an error");
assert!(shards.is_empty(), "a shard-free config has no shards");
let r = check_database_backend_consistency(None, Some("sqlite://app.db"), None, false);
assert_eq!(
r.status,
CheckStatus::Pass,
"a lone SQLite primary is a bootable single-role config"
);
assert!(
sqlite_primary_target_is_bootable(None, Some("sqlite://app.db"), None, false),
"the lone SQLite primary target is bootable"
);
}
#[test]
fn wellformed_postgres_shard_config_unchanged() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://db:5432/app\"\n\n\
[[database.shards]]\nname = \"shard-a\"\nprimary_url = \"postgres://db:5432/a\"\n\n\
[[database.shards]]\nname = \"shard-b\"\nprimary_url = \"postgres://db:5432/b\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let merged = get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
let shards = crate::migrate::try_resolve_shard_database_urls_from_sources(
|_| Err(std::env::VarError::NotPresent),
Some(&merged),
)
.expect("well-formed postgres shards must resolve without error");
assert_eq!(shards.len(), 2, "both postgres shards must resolve");
let r =
check_database_backend_consistency(None, Some("postgres://db:5432/app"), None, true);
assert_eq!(
r.status,
CheckStatus::Pass,
"a postgres primary with shards is a consistent, bootable topology"
);
}
fn temp_tailwind_path(temp: &tempfile::TempDir) -> std::path::PathBuf {
let tailwind_name = if cfg!(windows) {
"tailwindcss.exe"
} else {
"tailwindcss"
};
temp.path()
.join("target")
.join("autumn")
.join(tailwind_name)
}
fn create_executable_tailwind_fixture(path: &std::path::Path) {
let parent = path.parent().expect("tailwind path has parent");
std::fs::create_dir_all(parent).expect("create tailwind parent");
std::fs::copy(std::env::current_exe().expect("current test exe"), path)
.expect("copy executable fixture");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = std::fs::metadata(path)
.expect("tailwind fixture metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("make tailwind fixture executable");
}
}
#[test]
fn check_tailwind_expected_path_directory_fails() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
std::fs::create_dir_all(&tailwind_path).expect("create directory at tailwind path");
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Fail);
assert!(
r.detail.as_deref().unwrap_or("").contains("directory"),
"detail should identify directory path, got {r:?}"
);
}
#[test]
fn an_unknown_target_dir_leaves_the_tailwind_check_unevaluated() {
let r = tailwind_not_evaluated();
assert_eq!(r.status, CheckStatus::Pass);
let detail = r.detail.expect("a detail");
assert!(detail.contains("not evaluated"), "{detail}");
}
#[test]
fn check_tailwind_not_found() {
let temp = tempfile::tempdir().expect("temp dir");
let r = check_tailwind_binary_at(&temp_tailwind_path(&temp));
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("not found"));
assert!(r.hint.unwrap_or("").contains("autumn setup"));
}
#[test]
fn check_tailwind_regular_file_without_execute_permission_fails() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
std::fs::create_dir_all(tailwind_path.parent().expect("tailwind path has parent"))
.expect("create tailwind parent");
std::fs::write(&tailwind_path, "not an executable").expect("write non-executable file");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = std::fs::metadata(&tailwind_path)
.expect("tailwind file metadata")
.permissions();
permissions.set_mode(0o644);
std::fs::set_permissions(&tailwind_path, permissions)
.expect("clear executable permission");
}
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("not executable"));
assert!(r.hint.unwrap_or("").contains("--force"));
}
#[test]
fn check_tailwind_existing_file_fails_when_current_user_cannot_execute() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
create_executable_tailwind_fixture(&tailwind_path);
let r = check_tailwind_binary_at_with_executable_probe(&tailwind_path, |_, _| false);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("not executable"));
}
#[test]
fn check_tailwind_executable_file_passes() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
create_executable_tailwind_fixture(&tailwind_path);
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.as_deref().unwrap_or("").contains("executable"));
}
#[cfg(unix)]
#[test]
fn check_tailwind_broken_symlink_fails() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
std::fs::create_dir_all(tailwind_path.parent().expect("tailwind path has parent"))
.expect("create tailwind parent");
std::os::unix::fs::symlink(temp.path().join("missing-tailwind"), &tailwind_path)
.expect("create broken symlink");
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("broken symlink"));
}
#[test]
fn check_signing_secret_impl_name_is_signing_secret() {
let r = check_signing_secret_impl(None, false);
assert_eq!(r.name, "signing_secret");
}
#[test]
fn check_signing_secret_impl_dev_no_secret_warns() {
let r = check_signing_secret_impl(None, false);
assert_eq!(r.status, CheckStatus::Warn);
let detail = r.detail.as_deref().unwrap_or("");
assert!(detail.contains("unsigned"), "{detail}");
assert!(detail.contains("local-storage"), "{detail}");
}
#[test]
fn check_signing_secret_impl_dev_with_valid_secret_passes() {
let r = check_signing_secret_impl(Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"), false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_signing_secret_impl_prod_missing_fails() {
let r = check_signing_secret_impl(None, true);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_signing_secret_impl_prod_too_short_fails() {
let r = check_signing_secret_impl(Some("short"), true);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("short"));
}
#[test]
fn check_signing_secret_impl_prod_demo_value_fails() {
let r = check_signing_secret_impl(Some("changeme"), true);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_signing_secret_impl_prod_valid_secret_passes() {
let secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
let r = check_signing_secret_impl(Some(secret), true);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_signing_secret_impl_prod_hint_mentions_openssl() {
let r = check_signing_secret_impl(None, true);
let hint = r.hint.unwrap_or("");
assert!(hint.contains("openssl") || hint.contains("rand"));
}
#[test]
fn check_signing_secret_impl_dev_warn_has_hint() {
let r = check_signing_secret_impl(None, false);
assert!(r.hint.is_some());
}
#[test]
fn json_output_contains_checks_and_summary() {
let results = vec![
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: Some("1.88.0".into()),
hint: None,
},
CheckResult {
name: "port_bindable",
status: CheckStatus::Fail,
detail: None,
hint: Some("hint text"),
},
];
let summary = compute_summary(&results);
let json = to_json_output(&results, &summary);
assert!(json.contains("rust_toolchain"));
assert!(json.contains("port_bindable"));
assert!(json.contains("\"passed\": 1"));
assert!(json.contains("\"failed\": 1"));
}
#[test]
fn json_output_valid_json() {
let results = vec![CheckResult {
name: "test",
status: CheckStatus::Pass,
detail: None,
hint: None,
}];
let summary = compute_summary(&results);
let json = to_json_output(&results, &summary);
assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
}
#[test]
fn rate_limit_key_strategy_ip_always_passes() {
let r = check_rate_limit_key_strategy_impl("ip", false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_api_token_always_passes() {
let r = check_rate_limit_key_strategy_impl("api_token", false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_authenticated_principal_without_auth_warns_in_strict() {
let r = check_rate_limit_key_strategy_impl("authenticated_principal", false);
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.hint.is_some());
assert!(
r.detail.as_deref().unwrap_or("").contains("auth extractor"),
"detail should mention auth extractor"
);
}
#[test]
fn rate_limit_key_strategy_authenticated_principal_with_auth_passes() {
let r = check_rate_limit_key_strategy_impl("authenticated_principal", true);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_name_is_stable() {
let r = check_rate_limit_key_strategy_impl("authenticated_principal", false);
assert_eq!(r.name, "rate_limit_key_strategy");
}
#[test]
fn rate_limit_key_strategy_empty_strategy_passes() {
let r = check_rate_limit_key_strategy_impl("", false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_invalid_value_fails() {
let r = check_rate_limit_key_strategy_impl("authenticated_principals", false);
assert_eq!(r.status, CheckStatus::Fail);
assert!(
r.detail
.as_deref()
.unwrap_or("")
.contains("not a valid strategy")
);
}
#[test]
fn check_maintenance_mode_passes_when_off() {
let result = check_maintenance_mode();
assert!(
result.status == CheckStatus::Pass || result.status == CheckStatus::Warn,
"unexpected status: {:?}",
result.status
);
}
#[test]
fn check_oauth2_empty_client_id_in_production_fails() {
let result = check_oauth2_provider_impl("github", "", "real-secret-value", true);
assert_eq!(
result.status,
CheckStatus::Fail,
"empty client_id in production must fail: {result:?}",
);
assert!(
result.detail.as_deref().unwrap_or("").contains("client_id"),
"detail must mention client_id: {:?}",
result.detail
);
}
#[test]
fn check_oauth2_empty_client_secret_in_production_fails() {
let result = check_oauth2_provider_impl("github", "cid", "", true);
assert_eq!(
result.status,
CheckStatus::Fail,
"empty client_secret in production must fail: {result:?}",
);
assert!(
result
.detail
.as_deref()
.unwrap_or("")
.contains("client_secret"),
"detail must mention client_secret: {:?}",
result.detail
);
}
#[test]
fn check_oauth2_empty_client_secret_in_dev_warns() {
let result = check_oauth2_provider_impl("github", "cid", "", false);
assert_eq!(
result.status,
CheckStatus::Warn,
"empty client_secret outside production must warn: {result:?}",
);
}
#[test]
fn check_oauth2_non_empty_client_secret_passes() {
let result = check_oauth2_provider_impl("github", "cid", "real-secret-value", true);
assert_eq!(
result.status,
CheckStatus::Pass,
"non-empty client_secret must pass: {result:?}",
);
}
#[test]
fn check_oauth2_provider_name_appears_in_check_name() {
let result = check_oauth2_provider_impl("google", "cid", "", true);
assert!(
result.name.contains("oauth2") || result.name.contains("google"),
"check name must identify the provider: {}",
result.name
);
}
#[test]
fn resolve_oauth2_providers_prefers_env_var_over_empty_toml_secret() {
let toml: toml::Table = toml::from_str(
r#"
[auth.oauth2.github]
client_id = "cid"
client_secret = ""
authorize_url = "https://github.com/login/oauth/authorize"
token_url = "https://github.com/login/oauth/access_token"
redirect_uri = "http://localhost/callback"
"#,
)
.unwrap();
let providers = resolve_oauth2_providers_from_sources(
|key| {
if key == "AUTUMN_AUTH__OAUTH2__GITHUB__CLIENT_SECRET" {
Some("ghp_test_secret".to_owned())
} else {
None
}
},
Some(&toml),
"dev",
);
let p = providers.into_iter().find(|p| p.name == "github").unwrap();
assert_eq!(
p.client_secret, "ghp_test_secret",
"env var must override empty TOML client_secret"
);
}
#[test]
fn compression_warns_in_production_when_disabled() {
let result = check_compression_impl(false, true);
assert_eq!(result.name, "compression");
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
}
#[test]
fn compression_passes_in_production_when_enabled() {
let result = check_compression_impl(true, true);
assert_eq!(result.name, "compression");
assert!(
matches!(result.status, CheckStatus::Pass),
"expected Pass, got {:?}",
result.status
);
}
#[test]
fn compression_passes_in_dev_when_disabled() {
let result = check_compression_impl(false, false);
assert_eq!(result.name, "compression");
assert!(
matches!(result.status, CheckStatus::Pass),
"expected Pass in dev profile, got {:?}",
result.status
);
}
#[test]
fn dotenv_warns_when_example_present_without_env() {
let result = check_dotenv_impl(true, false, &[]);
assert_eq!(result.name, "dotenv");
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
assert!(result.detail.as_deref().unwrap().contains(".env.example"));
}
#[test]
fn dotenv_warns_when_env_present_but_not_gitignored() {
let result = check_dotenv_impl(true, true, &[".env".to_owned()]);
assert_eq!(result.name, "dotenv");
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
let detail = result.detail.as_deref().unwrap();
assert!(detail.contains("not gitignored"), "got: {detail:?}");
assert!(detail.contains(".env"), "got: {detail:?}");
}
#[test]
fn dotenv_warns_when_env_local_present_but_not_gitignored() {
let result = check_dotenv_impl(false, false, &[".env.local".to_owned()]);
assert_eq!(result.name, "dotenv");
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
assert!(
result.detail.as_deref().unwrap().contains(".env.local"),
"detail should name the offending file: {:?}",
result.detail
);
}
#[test]
fn dotenv_uncovered_secret_takes_priority_over_missing_env() {
let result = check_dotenv_impl(true, false, &[".env.local".to_owned()]);
assert_eq!(result.name, "dotenv");
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
let detail = result.detail.as_deref().unwrap();
assert!(
detail.contains(".env.local"),
"detail must name the uncovered secret file: {detail:?}"
);
assert!(
detail.contains("not gitignored"),
"detail must lead with the security issue: {detail:?}"
);
assert!(
result.hint.unwrap().contains(".gitignore"),
"hint should point at .gitignore: {:?}",
result.hint
);
}
#[test]
fn dotenv_warns_when_profile_local_present_but_not_gitignored() {
let result = check_dotenv_impl(false, false, &[".env.dev.local".to_owned()]);
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
assert!(
result.detail.as_deref().unwrap().contains(".env.dev.local"),
"got: {:?}",
result.detail
);
}
#[test]
fn dotenv_passes_when_all_present_secret_files_gitignored() {
let result = check_dotenv_impl(true, true, &[]);
assert_eq!(result.name, "dotenv");
assert!(
matches!(result.status, CheckStatus::Pass),
"expected Pass, got {:?}",
result.status
);
}
#[test]
fn dotenv_passes_when_nothing_configured() {
let result = check_dotenv_impl(false, false, &[]);
assert_eq!(result.name, "dotenv");
assert!(
matches!(result.status, CheckStatus::Pass),
"expected Pass, got {:?}",
result.status
);
}
#[test]
fn is_secret_dotenv_filename_classifies_local_vs_committable() {
assert!(is_secret_dotenv_filename(".env"));
assert!(is_secret_dotenv_filename(".env.local"));
assert!(is_secret_dotenv_filename(".env.dev.local"));
assert!(is_secret_dotenv_filename(".env.production.local"));
assert!(!is_secret_dotenv_filename(".env.dev"));
assert!(!is_secret_dotenv_filename(".env.production"));
assert!(!is_secret_dotenv_filename(".env.example"));
assert!(!is_secret_dotenv_filename("env.local"));
}
#[test]
fn gitignore_covers_truth_table() {
assert!(gitignore_covers(".env\n", ".env"));
assert!(!gitignore_covers(".env\n", ".env.local"));
assert!(gitignore_covers(".env.*\n", ".env.local"));
assert!(!gitignore_covers(".env.*\n", ".env"));
assert!(gitignore_covers(".env*\n", ".env"));
assert!(gitignore_covers(".env*\n", ".env.local"));
assert!(gitignore_covers("*.local\n", ".env.dev.local"));
assert!(gitignore_covers(".env.*.local\n", ".env.dev.local"));
assert!(!gitignore_covers(".env.example\n", ".env"));
assert!(!gitignore_covers(".env.example\n", ".env.local"));
assert!(gitignore_covers("**/.env\n", ".env"));
assert!(gitignore_covers("/target\n.env\n", ".env"));
assert!(gitignore_covers(" .env \n", ".env"));
assert!(!gitignore_covers("# .env\n\n", ".env"));
assert!(!gitignore_covers("!.env\n", ".env"));
assert!(!gitignore_covers("", ".env"));
assert!(!gitignore_covers("config/.env\n", ".env"));
assert!(!gitignore_covers(".env/\n", ".env"));
assert!(!gitignore_covers(".env*\n!.env\n", ".env"));
assert!(!gitignore_covers(".env\n!.env\n", ".env"));
assert!(gitignore_covers(".env*\n!.env\n", ".env.local"));
assert!(gitignore_covers("!.env\n.env\n", ".env"));
}
#[test]
fn parse_config_bool_handles_false_values() {
assert_eq!(parse_config_bool("false"), Some(false));
assert_eq!(parse_config_bool("0"), Some(false));
assert_eq!(parse_config_bool("no"), Some(false));
assert_eq!(parse_config_bool("off"), Some(false));
assert_eq!(parse_config_bool("true"), Some(true));
assert_eq!(parse_config_bool("1"), Some(true));
assert_eq!(parse_config_bool("yes"), Some(true));
assert_eq!(parse_config_bool("on"), Some(true));
assert_eq!(parse_config_bool("garbage"), None);
}
#[test]
fn proxy_conflict_passes_when_only_new_fields_set() {
let data = ProxyConflictData {
new_ranges: vec!["10.0.0.0/8".into()],
new_trust_fwd: true,
new_hops: None,
old_ranges: vec![],
old_trust_fwd: false,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn proxy_conflict_passes_when_only_old_fields_set() {
let data = ProxyConflictData {
new_ranges: vec![],
new_trust_fwd: false,
new_hops: None,
old_ranges: vec!["10.0.0.0/8".into()],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn proxy_conflict_warns_when_both_set_with_different_ranges() {
let data = ProxyConflictData {
new_ranges: vec!["10.0.0.0/8".into()],
new_trust_fwd: true,
new_hops: None,
old_ranges: vec!["192.168.0.0/16".into()],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.hint.is_some());
}
#[test]
fn proxy_conflict_warns_when_trusted_hops_set_alongside_old_fields() {
let data = ProxyConflictData {
new_ranges: vec![],
new_trust_fwd: true,
new_hops: Some(1),
old_ranges: vec![],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn proxy_conflict_passes_when_matching_values_no_hops() {
let data = ProxyConflictData {
new_ranges: vec!["10.0.0.0/8".into()],
new_trust_fwd: true,
new_hops: None,
old_ranges: vec!["10.0.0.0/8".into()],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn browser_check_not_found_is_warn_not_fail() {
let result = check_system_test_browser();
assert!(
result.status == CheckStatus::Pass || result.status == CheckStatus::Warn,
"browser check must be Pass or Warn, got {:?}",
result.status
);
}
#[test]
fn browser_candidate_paths_includes_common_locations() {
let paths = browser_candidate_paths();
let as_strs: Vec<_> = paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
as_strs
.iter()
.any(|s| s.contains("chromium") || s.contains("chrome")),
"candidate list must include common Chrome paths; got {as_strs:?}"
);
}
#[test]
fn browser_check_hint_mentions_apt_get() {
let result = check_system_test_browser();
if result.status == CheckStatus::Warn {
let hint = result.hint.unwrap_or("");
assert!(
hint.contains("apt-get") || hint.contains("AUTUMN_CHROMIUM"),
"hint must mention install command; got: {hint}"
);
}
}
#[test]
fn features_has_key_detects_bare_key() {
let toml = "[features]\nsystem-tests = [\"autumn-web/system-tests\"]\n";
assert!(cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_detects_quoted_key() {
let toml = "[features]\n\"system-tests\" = [\"autumn-web/system-tests\"]\n";
assert!(cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_ignores_dev_dependency_mention() {
let toml = "[dev-dependencies]\nautumn-web = { features = [\"system-tests\"] }\n";
assert!(!cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_no_features_section() {
let toml = "[package]\nname = \"x\"\n";
assert!(!cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_commented_header() {
let toml = "[features] # project features\nsystem-tests = []\n";
assert!(cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn gdpr_check_passes_when_no_auth_starter() {
let r = check_gdpr_export_registration_impl(false, &[]);
assert_eq!(
r.status,
CheckStatus::Pass,
"no auth starter → should always pass: {r:?}"
);
}
#[test]
fn gdpr_check_passes_when_no_auth_starter_even_with_unregistered_tables() {
let r = check_gdpr_export_registration_impl(false, &["posts", "comments"]);
assert_eq!(
r.status,
CheckStatus::Pass,
"no auth starter → must not warn about unregistered tables: {r:?}"
);
}
#[test]
fn gdpr_check_passes_when_all_tables_registered() {
let r = check_gdpr_export_registration_impl(true, &[]);
assert_eq!(
r.status,
CheckStatus::Pass,
"auth starter present + all tables registered → should pass: {r:?}"
);
}
#[test]
fn gdpr_check_warns_when_unregistered_tables_present() {
let r = check_gdpr_export_registration_impl(true, &["posts", "comments"]);
assert_eq!(
r.status,
CheckStatus::Warn,
"unregistered tables with auth starter must warn: {r:?}"
);
}
#[test]
fn gdpr_check_name_is_stable() {
let r = check_gdpr_export_registration_impl(false, &[]);
assert_eq!(r.name, "gdpr_export_registration");
}
#[test]
fn gdpr_check_detail_lists_unregistered_table_names() {
let r = check_gdpr_export_registration_impl(true, &["posts", "comments"]);
let detail = r.detail.as_deref().unwrap_or("");
assert!(
detail.contains("posts"),
"detail must mention unregistered table 'posts': {detail}"
);
assert!(
detail.contains("comments"),
"detail must mention unregistered table 'comments': {detail}"
);
}
#[test]
fn gdpr_check_hint_references_registry_api() {
let r = check_gdpr_export_registration_impl(true, &["orders"]);
let hint = r.hint.unwrap_or("");
assert!(
hint.contains("GdprRegistry") || hint.contains("gdpr"),
"hint must reference the GdprRegistry API: {hint}"
);
}
#[test]
fn gdpr_check_detail_mentions_count_of_unregistered_tables() {
let r = check_gdpr_export_registration_impl(true, &["t1", "t2", "t3"]);
let detail = r.detail.as_deref().unwrap_or("");
assert!(
detail.contains('3') || detail.contains("3 "),
"detail must mention the count of unregistered tables: {detail}"
);
}
#[test]
fn scanner_detects_single_line_hard_delete() {
let src = r#"registry.register(ModelRegistration::hard_delete("posts"));"#;
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("posts"),
"should detect single-line hard_delete: {found:?}"
);
}
#[test]
fn scanner_detects_multiline_retain_call() {
let src = "registry.register(ModelRegistration::retain(\n \"invoices\",\n \"7-year financial hold\",\n));";
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("invoices"),
"should detect multi-line retain registration: {found:?}"
);
}
#[test]
fn scanner_detects_multiline_anonymize_call() {
let src = "registry.register(ModelRegistration::anonymize(\n \"comments\",\n));";
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("comments"),
"should detect multi-line anonymize registration: {found:?}"
);
}
#[test]
fn scanner_ignores_string_before_token() {
let src = r#"let _x = "not_a_table"; ModelRegistration::hard_delete("real_table");"#;
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("real_table"),
"should contain real_table: {found:?}"
);
assert!(
!found.contains("not_a_table"),
"should not contain string before token: {found:?}"
);
}
#[test]
fn red_check_deprecated_keys_empty_is_pass() {
let result = check_deprecated_keys_impl(&[]);
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.name, "deprecated_keys");
}
#[test]
fn red_check_deprecated_keys_warns_one_line_per_key() {
let found = vec![
DoctorDeprecation {
path: "security.rate_limit.trusted_proxies".into(),
replacement: Some("security.trusted_proxies.ranges".into()),
since: "0.5.0".into(),
remove_in: "1.0.0".into(),
},
DoctorDeprecation {
path: "security.rate_limit.trust_forwarded_headers".into(),
replacement: Some("security.trusted_proxies.trust_forwarded_headers".into()),
since: "0.5.0".into(),
remove_in: "1.0.0".into(),
},
];
let result = check_deprecated_keys_impl(&found);
assert_eq!(result.status, CheckStatus::Warn);
assert_eq!(result.name, "deprecated_keys");
let detail = result.detail.unwrap();
let lines: Vec<&str> = detail.lines().collect();
assert_eq!(lines.len(), 2, "one line per key: {detail:?}");
assert!(lines[0].contains("security.rate_limit.trusted_proxies"));
assert!(lines[0].contains("security.trusted_proxies.ranges"));
assert!(
lines[0].contains("0.5.0"),
"detail must include since version"
);
assert!(lines[0].contains("1.0.0"));
assert!(lines[1].contains("security.rate_limit.trust_forwarded_headers"));
}
#[test]
fn red_check_deprecated_keys_json_includes_detail() {
let found = vec![DoctorDeprecation {
path: "security.rate_limit.trusted_proxies".into(),
replacement: Some("security.trusted_proxies.ranges".into()),
since: "0.5.0".into(),
remove_in: "1.0.0".into(),
}];
let results = vec![check_deprecated_keys_impl(&found)];
let summary = compute_summary(&results);
let json = to_json_output(&results, &summary);
assert!(
json.contains("\"deprecated_keys\""),
"JSON must name the check"
);
assert!(json.contains("\"warn\""), "JSON must include warn status");
assert!(
json.contains("security.rate_limit.trusted_proxies"),
"JSON must contain the deprecated key path"
);
}
#[test]
fn deploy_doctor_config_absent_is_skipped() {
use autumn_web::config::MockEnv;
let table: toml::Table = "".parse().unwrap();
let (cfg, check) = resolve_deploy_doctor_config(&table, &MockEnv::new());
assert!(cfg.is_none(), "absent [deploy] must not yield a config");
assert!(
check.is_none(),
"absent [deploy] must not emit a failing check"
);
}
#[test]
fn deploy_doctor_config_valid_parses() {
use autumn_web::config::MockEnv;
let table: toml::Table = "[deploy]\nhost = \"203.0.113.10\"\nssh_port = 2222\n"
.parse()
.unwrap();
let (cfg, check) = resolve_deploy_doctor_config(&table, &MockEnv::new());
assert!(check.is_none(), "valid [deploy] must not emit a fail check");
let cfg = cfg.expect("valid [deploy] must parse into a config");
assert_eq!(cfg.host.as_deref(), Some("203.0.113.10"));
assert_eq!(cfg.ssh_port, 2222);
}
#[test]
fn deploy_doctor_config_env_only_host_materializes_config() {
use autumn_web::config::MockEnv;
let table: toml::Table = "".parse().unwrap();
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__HOST", "203.0.113.99")
.with("AUTUMN_DEPLOY__SSH_PORT", "2201");
let (cfg, check) = resolve_deploy_doctor_config(&table, &env);
assert!(
check.is_none(),
"env-only deploy must not emit a fail check"
);
let cfg = cfg.expect("env-only AUTUMN_DEPLOY__HOST must materialize a config");
assert_eq!(cfg.host.as_deref(), Some("203.0.113.99"));
assert_eq!(cfg.ssh_port, 2201);
}
#[test]
fn deploy_doctor_config_env_overrides_toml_host() {
use autumn_web::config::MockEnv;
let table: toml::Table = "[deploy]\nhost = \"toml.example\"\nssh_port = 22\n"
.parse()
.unwrap();
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "env.example");
let (cfg, check) = resolve_deploy_doctor_config(&table, &env);
assert!(check.is_none());
let cfg = cfg.expect("config must be present");
assert_eq!(
cfg.host.as_deref(),
Some("env.example"),
"AUTUMN_DEPLOY__HOST must override the TOML host"
);
assert_eq!(cfg.ssh_port, 22);
}
#[test]
fn deploy_doctor_config_bad_type_fails_not_skipped() {
use autumn_web::config::MockEnv;
let table: toml::Table = "[deploy]\nhost = \"h\"\nssh_port = \"22\"\n"
.parse()
.unwrap();
let (cfg, check) = resolve_deploy_doctor_config(&table, &MockEnv::new());
assert!(
cfg.is_none(),
"an invalid [deploy] must not yield a config to preflight"
);
let check = check.expect("an invalid [deploy] must emit a failing check");
assert_eq!(check.name, "deploy_config");
assert_eq!(check.status, CheckStatus::Fail);
assert!(
check.detail.unwrap().contains("present but invalid"),
"detail must explain the [deploy] table is present but invalid"
);
}
#[test]
fn deploy_signing_secret_in_deploy_profile_is_resolved_under_that_profile() {
use autumn_web::config::MockEnv;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[deploy]\nhost = \"deploy.example.com\"\nprofile = \"prod\"\n\
[profile.prod.security.signing_secret]\n\
secret = \"prod-only-32-char-strong-secret-value-xyz\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let base_dev = base.clone();
let ambient_merged =
get_merged_toml_table_runtime_with("dev", "dev", move |f| base_dev.join(f));
let (deploy_cfg, config_check) =
resolve_deploy_doctor_config(&ambient_merged, &MockEnv::new());
assert!(
config_check.is_none(),
"the [deploy] table parses under the ambient profile"
);
let deploy_cfg = deploy_cfg.expect("[deploy] present");
assert_eq!(
crate::deploy::trimmed_deploy_profile(&deploy_cfg.profile),
"prod",
"deploy target profile is learned from the ambient [deploy] table"
);
assert_eq!(
resolve_deploy_signing_secret(&ambient_merged, &MockEnv::new()),
None,
"prod-only secret is invisible under the ambient dev profile (the bug)"
);
let deploy_profile_raw = crate::deploy::trimmed_deploy_profile(&deploy_cfg.profile);
let deploy_profile_canonical =
crate::deploy::canonicalize_deploy_profile(&deploy_profile_raw);
let deploy_merged = get_merged_toml_table_runtime_with(
&deploy_profile_canonical,
&deploy_profile_raw,
move |f| base.join(f),
);
assert_eq!(
resolve_deploy_signing_secret(&deploy_merged, &MockEnv::new()).as_deref(),
Some("prod-only-32-char-strong-secret-value-xyz"),
"the fix: doctor resolves the deploy signing secret under the [deploy] profile"
);
}
#[test]
fn deploy_database_url_in_deploy_profile_is_resolved_under_that_profile() {
use autumn_web::config::MockEnv;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[deploy]\nhost = \"deploy.example.com\"\nprofile = \"prod\"\n\
[profile.prod.database]\n\
primary_url = \"postgres://prod-host/appdb\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let base_dev = base.clone();
let ambient_merged =
get_merged_toml_table_runtime_with("dev", "dev", move |f| base_dev.join(f));
let (deploy_cfg, _) = resolve_deploy_doctor_config(&ambient_merged, &MockEnv::new());
let deploy_cfg = deploy_cfg.expect("[deploy] present");
let ambient_topology =
resolve_database_topology_from_sources(|_| None, Some(&ambient_merged));
assert_eq!(
ambient_topology.primary_url, None,
"prod-only DB URL is invisible under the ambient dev profile (the bug)"
);
let deploy_profile_raw = crate::deploy::trimmed_deploy_profile(&deploy_cfg.profile);
let deploy_profile_canonical =
crate::deploy::canonicalize_deploy_profile(&deploy_profile_raw);
let deploy_merged = get_merged_toml_table_runtime_with(
&deploy_profile_canonical,
&deploy_profile_raw,
move |f| base.join(f),
);
let deploy_topology =
resolve_database_topology_from_sources(|_| None, Some(&deploy_merged));
assert_eq!(
deploy_topology.primary_url.as_deref(),
Some("postgres://prod-host/appdb"),
"the fix: doctor resolves the deploy DB URL under the [deploy] profile"
);
}
#[test]
fn deploy_signing_secret_only_under_ambient_profile_is_not_keyed_by_deploy_grade() {
use autumn_web::config::MockEnv;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[deploy]\nhost = \"deploy.example.com\"\nprofile = \"prod\"\n\
[profile.dev.security.signing_secret]\n\
secret = \"dev-only-secret-not-for-prod-grade-xyz\"\n",
)
.unwrap();
let base = dir.path().to_path_buf();
let deploy_merged =
get_merged_toml_table_runtime_with("prod", "prod", move |f| base.join(f));
assert_eq!(
resolve_deploy_signing_secret(&deploy_merged, &MockEnv::new()),
None,
"a dev-only secret is not keyed by the deploy grade under the prod profile"
);
}
#[test]
fn malformed_deploy_profile_override_fails_deploy_config_load_check() {
use autumn_web::config::MockEnv;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[deploy]\nhost = \"deploy.example.com\"\nprofile = \"prod\"\n",
)
.unwrap();
std::fs::write(
dir.path().join("autumn-prod.toml"),
"this line has no equals sign and is not valid toml\n",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
.with("AUTUMN_ENV", "prod");
let check = deploy_profile_config_load_check("prod", &env)
.expect("a malformed autumn-prod.toml must fail the deploy_config load check");
assert_eq!(check.name, "deploy_config");
assert!(
matches!(check.status, CheckStatus::Fail),
"the malformed override must FAIL, not pass"
);
let detail = check.detail.unwrap_or_default();
assert!(
detail.contains("prod"),
"the failure names the deploy profile: {detail}"
);
assert!(
detail.contains("failed to load"),
"the failure surfaces the load error: {detail}"
);
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[server]\nport = 8443\n",
)
.unwrap();
assert!(
deploy_profile_config_load_check("prod", &env).is_none(),
"a well-formed autumn-prod.toml must not newly fail the deploy_config check"
);
}
#[test]
fn plugin_owned_root_does_not_fail_deploy_config_load_check_but_known_typo_does() {
use autumn_web::config::MockEnv;
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n\
[deploy]\nhost = \"deploy.example.com\"\nprofile = \"prod\"\n\n\
[media]\nmediamtx_host = \"cdn.example\"\n",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
.with("AUTUMN_ENV", "prod");
assert!(
deploy_profile_config_load_check("prod", &env).is_none(),
"a plugin-owned [media] top-level root under strict_config must not fail \
the deploy_config load check (it passes `autumn deploy check`)"
);
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n\
[deploy]\nhost = \"deploy.example.com\"\nprofile = \"prod\"\n\n\
[database]\nprimry_url = \"postgres://localhost/db\"\n",
)
.unwrap();
let check = deploy_profile_config_load_check("prod", &env)
.expect("a known-section typo must still fail the deploy_config load check");
assert_eq!(check.name, "deploy_config");
assert!(
matches!(check.status, CheckStatus::Fail),
"the known-section typo must FAIL, not pass"
);
}
#[test]
fn deploy_previous_secrets_non_string_element_is_malformed() {
let table: toml::Table = "[security.signing_secret]\nprevious_secrets = [123]\n"
.parse()
.unwrap();
assert!(
resolve_deploy_previous_signing_secrets(&table).is_err(),
"a non-string previous_secrets element must be malformed"
);
}
#[test]
fn deploy_previous_secrets_non_array_is_malformed() {
let table: toml::Table = "[security.signing_secret]\nprevious_secrets = \"nope\"\n"
.parse()
.unwrap();
assert!(
resolve_deploy_previous_signing_secrets(&table).is_err(),
"a non-array previous_secrets must be malformed"
);
}
#[test]
fn deploy_previous_secrets_absent_or_strings_ok() {
let empty: toml::Table = toml::Table::new();
assert_eq!(
resolve_deploy_previous_signing_secrets(&empty).unwrap(),
Vec::<String>::new()
);
let table: toml::Table = "[security.signing_secret]\nprevious_secrets = [\"a\", \"\"]\n"
.parse()
.unwrap();
assert_eq!(
resolve_deploy_previous_signing_secrets(&table).unwrap(),
vec!["a".to_owned(), String::new()]
);
}
#[test]
fn deploy_doctor_config_negative_keep_releases_fails() {
use autumn_web::config::MockEnv;
let table: toml::Table = "[deploy]\nhost = \"h\"\nkeep_releases = -1\n"
.parse()
.unwrap();
let (cfg, check) = resolve_deploy_doctor_config(&table, &MockEnv::new());
assert!(cfg.is_none());
assert_eq!(
check.expect("negative keep_releases must fail").status,
CheckStatus::Fail
);
}
fn wiring(crate_name: &str, dependency: bool, mount: bool) -> PluginWiring {
PluginWiring {
crate_name: crate_name.to_owned(),
community: false,
dependency,
mount,
mount_qualified: mount,
orphaned_migrations: Vec::new(),
}
}
#[test]
fn plugin_residue_passes_when_every_plugin_is_wired_consistently() {
let result = check_plugin_residue_impl(&[
wiring("autumn-admin-plugin", true, true),
wiring("autumn-search", false, false),
]);
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.name, "plugin_residue");
}
#[test]
fn plugin_residue_warns_on_a_dependency_with_no_mount() {
let result = check_plugin_residue_impl(&[wiring("autumn-admin-plugin", true, false)]);
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("autumn-admin-plugin"), "{detail}");
assert!(detail.contains("mount"), "{detail}");
}
#[test]
fn plugin_residue_fails_on_a_mount_with_no_dependency() {
let result = check_plugin_residue_impl(&[wiring("autumn-search", false, true)]);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("autumn-search"), "{detail}");
}
#[test]
fn plugin_residue_warns_on_migrations_whose_plugin_is_gone() {
let result = check_plugin_residue_impl(&[PluginWiring {
crate_name: "autumn-media-plugin".to_owned(),
community: false,
dependency: false,
mount: false,
mount_qualified: false,
orphaned_migrations: vec!["20260720000000".to_owned()],
}]);
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("20260720000000"), "{detail}");
assert!(detail.contains("autumn-media-plugin"), "{detail}");
}
#[test]
fn plugin_residue_ignores_migrations_of_an_installed_plugin() {
let result = check_plugin_residue_impl(&[PluginWiring {
crate_name: "autumn-media-plugin".to_owned(),
community: false,
dependency: true,
mount: true,
mount_qualified: true,
orphaned_migrations: vec!["20260720000000".to_owned()],
}]);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn plugin_residue_reports_every_finding_and_takes_the_worst_status() {
let result = check_plugin_residue_impl(&[
wiring("autumn-admin-plugin", true, false),
wiring("autumn-search", false, true),
]);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("autumn-admin-plugin"), "{detail}");
assert!(detail.contains("autumn-search"), "{detail}");
}
#[test]
fn plugin_residue_serializes_under_the_json_contract() {
let results = vec![check_plugin_residue_impl(&[wiring(
"autumn-admin-plugin",
true,
false,
)])];
let summary = compute_summary(&results);
let json = to_json_output(&results, &summary);
let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
let check = value["checks"]
.as_array()
.expect("checks")
.iter()
.find(|c| c["name"] == "plugin_residue")
.expect("plugin_residue row");
assert_eq!(check["status"], "warn");
assert!(check["detail"].as_str().is_some_and(|d| !d.is_empty()));
}
#[test]
fn plugin_residue_warnings_are_strict_failures() {
let results = vec![check_plugin_residue_impl(&[wiring(
"autumn-admin-plugin",
true,
false,
)])];
let summary = compute_summary(&results);
assert_eq!(summary.warned, 1);
assert_eq!(summary.failed, 0);
assert_eq!(exit_code(&summary, true), 1);
assert_eq!(exit_code(&summary, false), 0);
}
#[test]
fn plugin_residue_gives_community_crates_their_own_advice() {
let result = check_plugin_residue_impl(&[PluginWiring {
crate_name: "autumn-plugin-live-feed".to_owned(),
community: true,
dependency: true,
mount: false,
mount_qualified: false,
orphaned_migrations: Vec::new(),
}]);
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("README"), "{detail}");
assert!(!detail.contains("to finish the install"), "{detail}");
}
#[test]
fn plugin_residue_does_not_fail_on_an_unqualified_constructor_collision() {
let result = check_plugin_residue_impl(&[PluginWiring {
crate_name: "autumn-search".to_owned(),
community: false,
dependency: false,
mount: true,
mount_qualified: false,
orphaned_migrations: Vec::new(),
}]);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn plugin_wirings_are_resolved_from_the_whole_src_tree() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\nautumn-admin-plugin = \"0.7.0\"\nautumn-plugin-live-feed = \"0.3.1\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() { app::build(); }\n").unwrap();
std::fs::write(
root.join("src/app.rs"),
"pub fn build() {\n autumn_web::app().plugin(autumn_admin_plugin::AdminPlugin::new());\n}\n",
)
.unwrap();
let wirings = resolve_plugin_wirings(root, Vec::new);
let admin = wirings
.iter()
.find(|w| w.crate_name == "autumn-admin-plugin")
.expect("admin wiring");
assert!(admin.dependency && admin.mount, "{admin:?}");
assert!(admin.mount_qualified, "{admin:?}");
let feed = wirings
.iter()
.find(|w| w.crate_name == "autumn-plugin-live-feed")
.expect("community wiring");
assert!(feed.community && feed.dependency, "{feed:?}");
assert!(!feed.mount, "{feed:?}");
assert_eq!(
check_plugin_residue_impl(&wirings).status,
CheckStatus::Warn,
"the unmounted community crate is the only finding"
);
}
#[test]
fn plugin_wirings_match_declared_migrations_against_applied_versions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
let wirings = resolve_plugin_wirings(root, || vec!["20260720000000".to_owned()]);
let media = wirings
.iter()
.find(|w| w.crate_name == "autumn-media-plugin")
.expect("media wiring");
assert_eq!(media.orphaned_migrations, vec!["20260720000000".to_owned()]);
assert_eq!(
check_plugin_residue_impl(&wirings).status,
CheckStatus::Warn
);
}
#[test]
fn plugin_wirings_do_not_read_the_migration_history_without_a_reason() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let schema_owners: Vec<&crate::plugin::catalog::CatalogEntry> =
crate::plugin::catalog::FIRST_PARTY
.iter()
.filter(|entry| !entry.migrations.is_empty())
.collect();
assert!(
!schema_owners.is_empty(),
"no schema-owning plugin to install"
);
let mut manifest =
String::from("[package]\nname = \"demo\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n");
let mut main_rs = String::from("fn main() {\n autumn_web::app()\n");
for entry in &schema_owners {
manifest.push_str(entry.crate_name);
manifest.push_str(" = \"0.7.0\"\n");
main_rs.push_str(entry.mount);
}
main_rs.push_str(" ;\n}\n");
std::fs::write(root.join("Cargo.toml"), manifest).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), main_rs).unwrap();
let wirings = resolve_plugin_wirings(root, || panic!("must not query the database"));
assert_eq!(
check_plugin_residue_impl(&wirings).status,
CheckStatus::Pass
);
}
#[test]
fn applied_migration_versions_are_parsed_from_the_diesel_listing() {
let output = "Migrations:\n [X] 20260101000000_create_users\n [ ] 20260202000000_pending\n [X] 20260720000000_media_rooms\n";
let versions = parse_applied_migration_versions(output);
assert_eq!(versions, vec!["20260101000000", "20260720000000"]);
}
mod dependencies {
use super::super::*;
use crate::deps::{Evaluation, Finding, Severity};
fn finding(
id: &str,
code: &str,
severity: Severity,
waived: bool,
blocking: bool,
) -> Finding {
Finding {
id: id.to_owned(),
code: code.to_owned(),
package: "badcrate 1.2.3".to_owned(),
title: "boom".to_owned(),
severity,
waived,
blocking,
}
}
fn audited(findings: Vec<Finding>, db_age_days: Option<u64>) -> Evaluation {
Evaluation::Audited {
findings,
checks: vec!["advisories".to_owned()],
db_age_days,
auditor: "cargo-deny 0.20.2".to_owned(),
}
}
#[test]
fn a_clean_tree_is_exactly_one_passing_line() {
let result = check_dependencies_impl(&audited(Vec::new(), Some(2)));
assert_eq!(result.name, "dependencies");
assert_eq!(result.status, CheckStatus::Pass);
let line = format_check_line(&result);
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.contains("dependencies"), "{line}");
}
#[test]
fn a_blocking_finding_fails_and_names_its_id_and_severity() {
let result = check_dependencies_impl(&audited(
vec![finding(
"RUSTSEC-2099-0001",
"vulnerability",
Severity::Critical,
false,
true,
)],
Some(1),
));
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.expect("detail");
assert!(detail.contains("RUSTSEC-2099-0001"), "{detail}");
assert!(detail.contains("critical"), "{detail}");
assert!(detail.contains("badcrate 1.2.3"), "{detail}");
}
#[test]
fn a_policy_violation_fails_and_names_its_violation_id() {
let result = check_dependencies_impl(&audited(
vec![finding("banned", "banned", Severity::High, false, true)],
Some(1),
));
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.expect("detail");
assert!(detail.contains("banned"), "{detail}");
assert!(detail.contains("high"), "{detail}");
}
#[test]
fn findings_the_policy_only_warns_about_warn() {
let result = check_dependencies_impl(&audited(
vec![finding("yanked", "yanked", Severity::Low, false, false)],
Some(1),
));
assert_eq!(result.status, CheckStatus::Warn);
}
#[test]
fn a_waived_finding_displays_as_waived_and_never_fails() {
let result = check_dependencies_impl(&audited(
vec![finding(
"RUSTSEC-2023-0071",
"vulnerability",
Severity::Critical,
true,
false,
)],
Some(1),
));
assert_eq!(
result.status,
CheckStatus::Pass,
"a waived finding is not a failure"
);
let detail = result.detail.expect("detail");
assert!(detail.contains("waived"), "{detail}");
assert!(detail.contains("RUSTSEC-2023-0071"), "{detail}");
}
#[test]
fn a_waived_finding_alongside_a_live_one_still_fails_on_the_live_one() {
let result = check_dependencies_impl(&audited(
vec![
finding(
"RUSTSEC-2023-0071",
"vulnerability",
Severity::Low,
true,
false,
),
finding(
"RUSTSEC-2099-0001",
"vulnerability",
Severity::High,
false,
true,
),
],
Some(1),
));
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.expect("detail");
let live = detail
.find("RUSTSEC-2099-0001")
.expect("live finding listed");
let waived = detail
.find("RUSTSEC-2023-0071")
.expect("waived finding listed");
assert!(live < waived, "{detail}");
}
#[test]
fn a_missing_policy_file_warns_and_says_ci_needs_one() {
let result = check_dependencies_impl(&Evaluation::NoPolicy);
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.expect("detail");
assert!(detail.contains("deny.toml"), "{detail}");
}
#[test]
fn a_machine_without_the_auditor_does_not_fail_doctor_strict() {
let result = check_dependencies_impl(&Evaluation::AuditorMissing {
checks: vec!["advisories".to_owned()],
});
assert_eq!(result.status, CheckStatus::Pass);
let summary = compute_summary(std::slice::from_ref(&result));
assert_eq!(exit_code(&summary, true), 0);
let detail = result.detail.expect("detail");
assert!(
detail.contains("not evaluated"),
"a pass must not read as a verdict: {detail}"
);
assert!(
detail.contains("cargo install"),
"the detail must say how to enable it: {detail}"
);
}
#[test]
fn a_missing_auditor_says_the_policy_is_not_validated_either() {
let result = check_dependencies_impl(&Evaluation::AuditorMissing {
checks: vec!["advisories".to_owned()],
});
assert_eq!(result.status, CheckStatus::Pass);
let detail = result.detail.expect("detail");
assert!(detail.contains("not installed"), "{detail}");
assert!(detail.contains("valid"), "{detail}");
assert!(detail.contains("CI can still reject"), "{detail}");
}
#[test]
fn an_unfetched_database_does_not_fail_doctor_strict_either() {
let result = check_dependencies_impl(&Evaluation::DatabaseMissing {
checks: vec!["advisories".to_owned()],
});
assert_eq!(result.status, CheckStatus::Pass);
let summary = compute_summary(std::slice::from_ref(&result));
assert_eq!(exit_code(&summary, true), 0);
let detail = result.detail.expect("detail");
assert!(detail.contains("not evaluated"), "{detail}");
assert!(
detail.contains("cargo deny fetch db"),
"the detail must say how to fetch it: {detail}"
);
}
#[test]
fn an_unevaluated_policy_still_names_the_checks_it_would_run() {
let result = check_dependencies_impl(&Evaluation::AuditorMissing {
checks: vec!["advisories".to_owned(), "licenses".to_owned()],
});
let detail = result.detail.expect("detail");
assert!(detail.contains("advisories"), "{detail}");
assert!(detail.contains("licenses"), "{detail}");
}
#[test]
fn a_stale_database_warns_once_and_reports_the_data_age() {
let age = crate::deps::STALE_AFTER_DAYS + 5;
let result = check_dependencies_impl(&audited(Vec::new(), Some(age)));
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.clone().expect("detail");
assert!(
detail.contains(&age.to_string()),
"the age must appear: {detail}"
);
assert_eq!(
format_check_line(&result).lines().count(),
2,
"one warning line plus its hint"
);
}
#[test]
fn fresh_data_reports_its_age_without_warning() {
let result = check_dependencies_impl(&audited(Vec::new(), Some(3)));
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.detail.expect("detail").contains('3'));
}
#[test]
fn an_auditor_that_produced_no_verdict_warns_with_the_reason() {
let result = check_dependencies_impl(&Evaluation::Unavailable {
reason: "cargo metadata failed".to_owned(),
checks: Vec::new(),
});
assert_eq!(result.status, CheckStatus::Warn);
assert!(
result
.detail
.expect("detail")
.contains("cargo metadata failed"),
"the reason must be surfaced"
);
}
#[test]
fn a_long_finding_list_is_capped() {
let findings: Vec<Finding> = (0..40)
.map(|n| {
finding(
&format!("RUSTSEC-2099-{n:04}"),
"vulnerability",
Severity::High,
false,
true,
)
})
.collect();
let result = check_dependencies_impl(&audited(findings, Some(1)));
let detail = result.detail.expect("detail");
assert!(
detail.lines().count() <= 12,
"detail must stay bounded, got {} lines",
detail.lines().count()
);
assert!(
detail.contains("40"),
"the total must still be reported: {detail}"
);
assert!(detail.contains("more"), "{detail}");
}
#[test]
fn a_multi_line_title_cannot_break_the_cap() {
let findings: Vec<Finding> = (0..40)
.map(|n| {
let mut f = finding(
&format!("RUSTSEC-2099-{n:04}"),
"duplicate",
Severity::Low,
false,
false,
);
f.title = "lock entries:\nfirst\nsecond\nthird".to_owned();
f
})
.collect();
let result = check_dependencies_impl(&audited(findings, Some(1)));
let detail = result.detail.expect("detail");
assert!(
detail.lines().count() <= 12,
"one line per finding, got {} lines",
detail.lines().count()
);
}
#[test]
fn a_finding_line_never_repeats_its_own_identifier() {
let result = check_dependencies_impl(&audited(
vec![finding(
"duplicate",
"duplicate",
Severity::Low,
false,
false,
)],
Some(1),
));
let detail = result.detail.expect("detail");
assert_eq!(
detail.matches("duplicate").count(),
1,
"the identifier is named once: {detail}"
);
}
#[test]
fn finding_lines_are_indented_under_the_check() {
let result = check_dependencies_impl(&audited(
vec![finding(
"RUSTSEC-2099-0001",
"vulnerability",
Severity::High,
false,
true,
)],
Some(1),
));
let detail = result.detail.expect("detail");
let mut lines = detail.lines();
assert!(
!lines.next().expect("summary").starts_with(' '),
"the summary shares the check's own line"
);
for line in lines {
assert!(line.starts_with(" "), "unindented finding line: {line:?}");
}
}
#[test]
fn the_check_survives_the_json_contract() {
let result = check_dependencies_impl(&audited(
vec![finding(
"RUSTSEC-2099-0001",
"vulnerability",
Severity::Critical,
false,
true,
)],
Some(1),
));
let summary = compute_summary(std::slice::from_ref(&result));
let json = to_json_output(std::slice::from_ref(&result), &summary);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
let check = &parsed["checks"][0];
assert_eq!(check["name"], "dependencies");
assert_eq!(check["status"], "fail");
assert!(
check["detail"]
.as_str()
.expect("detail")
.contains("RUSTSEC-2099-0001"),
"--json must carry the advisory id"
);
}
#[test]
fn the_checks_the_policy_activates_are_reported() {
let result = check_dependencies_impl(&Evaluation::Audited {
findings: Vec::new(),
checks: vec!["advisories".to_owned(), "licenses".to_owned()],
db_age_days: Some(1),
auditor: "cargo-deny 0.20.2".to_owned(),
});
let detail = result.detail.expect("detail");
assert!(detail.contains("advisories"), "{detail}");
assert!(detail.contains("licenses"), "{detail}");
}
}
}
#[cfg(test)]
mod table_boundaries_tests {
use autumn_web::graph::boundaries::{Baseline, BoundaryConfig, CrateGraph};
use autumn_web::graph::manifest::build;
use autumn_web::graph::{ModelGraphDescriptor, RouteGraphDescriptor};
use super::{CheckStatus, check_table_boundaries_impl};
use crate::boundaries::CargoFacts;
const MODELS: &[ModelGraphDescriptor] = &[ModelGraphDescriptor {
model: "Contact",
model_path: "core::Contact",
table: "contacts",
relations: &[],
module_path: "core::models",
file: "src/models.rs",
line: 1,
crate_version: "0.1.0",
}];
const ROUTES: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
handler: "report",
module_path: "marketing::routes",
method: "GET",
path: "/report",
static_route: false,
file: "src/routes.rs",
line: 1,
crate_version: "0.1.0",
signature_symbols: &[],
body_symbols: &["contacts"],
sql_symbols: &[],
}];
fn config() -> BoundaryConfig {
BoundaryConfig::parse(
"[boundaries.core]\nowns = [\"contacts\"]\nmodules = [\"core\"]\n\
[boundaries.marketing]\nmodules = [\"marketing\"]\n",
)
.expect("valid")
}
fn facts() -> CargoFacts {
CargoFacts {
crates: CrateGraph::default(),
plugins: Vec::new(),
}
}
fn graph() -> autumn_web::graph::manifest::ArchitectureGraph {
build(&[], 0, ROUTES, MODELS, &[], &[])
}
#[test]
fn a_violation_warns_so_strict_fails() {
let result =
check_table_boundaries_impl(config(), Some(Ok(graph())), Ok(facts()), Ok(None));
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.expect("detail");
assert!(detail.contains("marketing -> core"), "{detail}");
assert!(detail.contains("GET /report"), "{detail}");
}
#[test]
fn a_baselined_violation_passes() {
let report =
autumn_web::graph::boundaries::check(&config(), Some(&graph()), None).expect("runs");
let baseline = Baseline::from_violations(&report.violations);
let result = check_table_boundaries_impl(
config(),
Some(Ok(graph())),
Ok(facts()),
Ok(Some(baseline)),
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
}
#[test]
fn a_rule_that_cannot_run_warns() {
let result = check_table_boundaries_impl(config(), None, Ok(facts()), Ok(None));
assert_eq!(result.status, CheckStatus::Warn);
assert!(
result
.detail
.expect("detail")
.contains("graph rule not run")
);
let result = check_table_boundaries_impl(
config(),
Some(Ok(graph())),
Err("no cargo".into()),
Ok(None),
);
assert!(
result
.detail
.expect("detail")
.contains("crate rule not run")
);
}
#[test]
fn a_baseline_without_a_graph_lists_nothing_as_fixed() {
let report =
autumn_web::graph::boundaries::check(&config(), Some(&graph()), None).expect("runs");
let baseline = Baseline::from_violations(&report.violations);
let result = check_table_boundaries_impl(config(), None, Ok(facts()), Ok(Some(baseline)));
let detail = result.detail.expect("detail");
assert!(!detail.contains("no longer occur"), "{detail}");
assert!(detail.contains("graph rule not run"), "{detail}");
}
#[test]
fn the_wrapper_reads_paths_relative_to_the_config() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("boundaries.toml");
std::fs::write(
&config_path,
"graph = \"architecture-graph.json\"\nbaseline = \"baseline.json\"\n\
[boundaries.core]\nowns = [\"contacts\"]\nmodules = [\"core\"]\n\
[boundaries.marketing]\nmodules = [\"marketing\"]\n",
)
.expect("write config");
std::fs::write(
dir.path().join("architecture-graph.json"),
graph().to_json(),
)
.expect("write graph");
let result = super::check_table_boundaries(&config_path);
assert_eq!(result.status, CheckStatus::Warn);
let detail = result.detail.expect("detail");
assert!(detail.contains("GET /report"), "{detail}");
assert!(detail.contains("crate rule not run"), "{detail}");
assert!(!detail.contains("graph rule not run"), "{detail}");
}
#[test]
fn a_bad_config_file_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("boundaries.toml");
std::fs::write(&config_path, "[boundaries.core]\nowns = 3\n").expect("write");
let result = super::check_table_boundaries(&config_path);
assert_eq!(result.status, CheckStatus::Fail);
}
#[test]
fn an_ambiguous_config_fails() {
let config = BoundaryConfig::parse(
"[boundaries.a]\nowns = [\"contacts\"]\n[boundaries.b]\nowns = [\"contacts\"]\n",
)
.expect("valid");
let result = check_table_boundaries_impl(config, Some(Ok(graph())), Ok(facts()), Ok(None));
assert_eq!(result.status, CheckStatus::Fail);
}
}