use autumn_web::config::{DeployConfig, ProcessRole};
use serde::Serialize;
use std::collections::{BTreeSet, HashMap, HashSet};
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,
}
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(
"using an ephemeral per-process signing secret (dev/test only; \
sessions and signed URLs will not survive restarts or be shared across replicas)"
.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,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortReachability {
Open,
Refused,
TimedOut,
Error,
}
#[must_use]
pub fn check_acme_ports_impl(
domain: &str,
port_80: PortReachability,
port_443: PortReachability,
) -> CheckResult {
match port_80 {
PortReachability::Refused | PortReachability::TimedOut | PortReachability::Error => {
return 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_impl(domain: &str, outcome: &DnsPointsHere) -> CheckResult {
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,
},
}
}
#[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,
},
}
}
#[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,
}
}
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 {
use std::fmt::Write as _;
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 {
match std::process::Command::new("rustc")
.arg("--version")
.output()
{
Ok(out) if out.status.success() => {
let ver = String::from_utf8_lossy(&out.stdout).into_owned();
check_rust_toolchain_impl(ver.trim(), msrv)
}
_ => CheckResult {
name: "rust_toolchain",
status: CheckStatus::Fail,
detail: Some("`rustc --version` failed".into()),
hint: Some("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 path = if cfg!(windows) {
std::path::PathBuf::from("target/autumn/tailwindcss.exe")
} else {
std::path::PathBuf::from("target/autumn/tailwindcss")
};
check_tailwind_binary_at(&path)
}
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: PostgreSQL client tools (pg_dump/pg_restore) are not required".into(),
),
hint: Some(
"SQLite backup/restore is tracked in https://github.com/madmax983/autumn/issues/1909",
),
}
}
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> {
let content = std::fs::read_to_string("Cargo.toml").ok()?;
let table: toml::Table = toml::from_str(&content).ok()?;
let find_in_deps = |deps: &toml::Value| -> Option<String> {
let entry = deps.get("autumn-web")?;
match entry {
toml::Value::String(v) => Some(v.clone()),
toml::Value::Table(t) => t
.get("version")?
.as_str()
.map(std::borrow::ToOwned::to_owned),
_ => None,
}
};
table
.get("dependencies")
.and_then(find_in_deps)
.or_else(|| {
table
.get("workspace")
.and_then(|w| w.get("dependencies"))
.and_then(find_in_deps)
})
}
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_split_topology_on_local(role: ProcessRole, jobs_backend: &str) -> CheckResult {
let backend = jobs_backend.trim();
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={} with jobs.backend=\"{backend}\": a split web/worker role needs a durable \
(postgres/redis) 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",
role.as_str(),
)),
hint: Some(
"Set jobs.backend = \"postgres\" (or redis) for split web/worker roles, or run the combined role",
),
};
}
let detail = if role == ProcessRole::Combined {
format!("role={}", role.as_str())
} else {
format!("role={}, jobs.backend={backend}", role.as_str())
};
CheckResult {
name: "process_role_backend",
status: CheckStatus::Pass,
detail: Some(detail),
hint: None,
}
}
fn check_queue_coverage(
role: ProcessRole,
configured_queues: &[String],
pin: &[String],
) -> CheckResult {
if !role.runs_workers() {
return CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Pass,
detail: Some(
"web-only role runs no job workers; queue pinning not applicable".to_string(),
),
hint: None,
};
}
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>>,
}
impl FleetTopology {
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: ProcessRole,
configured_queues: &[String],
pin: &[String],
declared_queues: &[String],
fleet: Option<&FleetTopology>,
) -> CheckResult {
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();
if gap.is_empty() {
CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Pass,
detail: Some(format!(
"declared fleet topology ({tier_count} tier(s)) drains every needed queue {:?} \
(configured + #[job(queue)]-declared); coverage is provable topology-wide",
needed.iter().collect::<Vec<_>>()
)),
hint: None,
}
} else {
CheckResult {
name: "jobs_queue_coverage",
status: CheckStatus::Fail,
detail: Some(format!(
"declared fleet topology ({tier_count} tier(s)) 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/madmax983/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> {
let output = std::process::Command::new("diesel")
.args(["migration", "list"])
.env("DATABASE_URL", database_url)
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_latest_applied_migration_version(&String::from_utf8_lossy(&output.stdout))
}
fn parse_latest_applied_migration_version(output: &str) -> Option<String> {
output
.lines()
.filter_map(|line| {
let trimmed = line.trim();
let version = trimmed
.strip_prefix("[X]")
.or_else(|| trimmed.strip_prefix("[x]"))?
.split_whitespace()
.next()?;
Some(version.to_owned())
})
.max()
}
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>) -> (ProcessRole, String) {
resolve_process_role_and_backend_from_sources(
|key| std::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>,
) -> (ProcessRole, String)
where
F: Fn(&str) -> Option<String>,
{
let role = first_env(&env_var, &["AUTUMN_ROLE"])
.as_deref()
.and_then(ProcessRole::from_env_value)
.or_else(|| {
first_toml_string(table, &["role"])
.as_deref()
.and_then(ProcessRole::from_env_value)
})
.unwrap_or(ProcessRole::Combined);
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_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(table: Option<&toml::Table>) -> Option<FleetTopology> {
let 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)?;
let tiers: Vec<Vec<String>> = fleet
.get("tiers")
.and_then(toml::Value::as_array)?
.iter()
.filter_map(toml::Value::as_array)
.map(|tier| {
tier.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect::<Vec<String>>()
})
.collect();
if tiers.is_empty() {
return None;
}
Some(FleetTopology { tiers })
}
fn resolve_declared_queues(table: Option<&toml::Table>) -> Vec<String> {
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>) -> Vec<String>
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 Vec::new();
};
if let Some(path) = fleet.get("manifest").and_then(toml::Value::as_str)
&& let Some(contents) = read_file(path)
&& let Ok(manifest) = toml::from_str::<toml::Table>(&contents)
{
let queues: Vec<String> = manifest
.get("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();
if !queues.is_empty() {
return queues;
}
}
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_postgres = first_env(&env_var, &["AUTUMN_JOBS__BACKEND"])
.or_else(|| first_toml_string(jobs, &["backend"]))
.as_deref()
.map(str::trim)
== Some("postgres");
let scheduler = merged.get("scheduler").and_then(toml::Value::as_table);
let scheduler_postgres = 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| backend == autumn_web::config::SchedulerBackend::Postgres);
jobs_postgres || scheduler_postgres
}
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
}
}
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 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_http_challenge_port(acme: &toml::Table) -> Result<u16, String> {
acme.get("http_challenge_port").cloned().map_or_else(
|| Ok(80),
|value| {
let rendered = value.to_string();
value
.try_into::<u16>()
.map_err(|_| rendered.trim().to_owned())
},
)
}
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] {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
fn resolve_acme_doctor_config(toml_table: Option<&toml::Table>) -> Option<AcmeDoctorConfig> {
let acme = toml_table?
.get("server")
.and_then(toml::Value::as_table)?
.get("tls")
.and_then(toml::Value::as_table)?
.get("acme")
.and_then(toml::Value::as_table)?;
let mut domains = Vec::new();
let mut domains_error = None;
if let Some(array) = acme.get("domains").and_then(toml::Value::as_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());
}
}
let contact_email = acme
.get("contact_email")
.and_then(toml::Value::as_str)
.unwrap_or_default()
.to_owned();
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 = acme
.get("renew_before_days")
.and_then(toml::Value::as_integer)
.map_or(30, |v| u32::try_from(v).unwrap_or(u32::MAX));
let cache_dir = acme
.get("cache_dir")
.and_then(toml::Value::as_str)
.map_or_else(|| std::path::PathBuf::from("config/acme"), Into::into);
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)),
};
Some(AcmeDoctorConfig {
domains,
contact_email,
http_challenge_port,
renew_before_days,
cache_dir,
directory_label,
directory_error,
port_error,
domains_error,
})
}
#[must_use]
pub fn check_acme_config_impl(
domains: &[String],
contact_email: &str,
http_challenge_port: u16,
renew_before_days: u32,
directory_error: Option<&str>,
port_error: Option<&str>,
domains_error: Option<&str>,
) -> Option<CheckResult> {
let fail = |detail: String, hint: &'static str| {
Some(CheckResult {
name: "acme_config",
status: CheckStatus::Fail,
detail: Some(detail),
hint: Some(hint),
})
};
if let Some(bad_value) = domains_error {
return fail(
format!(
"[server.tls.acme] domains {bad_value}: every entry must be a string hostname. \
The runtime deserializes domains as a list of strings and fails to boot on a \
non-string entry"
),
"List only string hostnames in [server.tls.acme] domains",
);
}
if let Some(bad_value) = directory_error {
return 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 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 domains.is_empty() {
return 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 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 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 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)",
);
}
check_acme_domain_entries(domains)
}
fn check_acme_domain_entries(domains: &[String]) -> Option<CheckResult> {
let fail = |detail: String, hint: &'static str| {
Some(CheckResult {
name: "acme_config",
status: CheckStatus::Fail,
detail: Some(detail),
hint: Some(hint),
})
};
for (index, domain) in domains.iter().enumerate() {
let trimmed = domain.trim();
if trimmed.is_empty() {
return 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.starts_with("*.") {
return fail(
format!(
"[server.tls.acme] wildcard domain `{trimmed}` is not supported: wildcards \
require the DNS-01 challenge, which is out of scope here (tracked in #1620). \
List explicit hostnames instead"
),
"Remove wildcard domains; list explicit hostnames (DNS-01 tracked in #1620)",
);
}
}
None
}
fn acme_online_probe_domains(config: &AcmeDoctorConfig) -> Vec<String> {
config.domains.clone()
}
fn resolve_acme_stored_cert_data(cert_dir: &std::path::Path, domains: &[String]) -> TlsDoctorData {
let Some((chain, key)) = configured_acme_cert_pair(cert_dir, 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
}
}
fn configured_acme_cert_pair(
cert_dir: &std::path::Path,
domains: &[String],
) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
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
}
}
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] {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
#[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,
}
}
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`)",
),
}),
}
}
#[allow(clippy::too_many_lines)]
pub fn run(opts: DoctorOptions) {
use std::thread;
type Task = Box<dyn FnOnce() -> CheckResult + Send>;
let cli_version = env!("CARGO_PKG_VERSION");
if !opts.json {
println!("\u{1F342} autumn doctor\n");
}
let msrv = read_msrv().unwrap_or_else(|| "1.88.0".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));
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(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)
}));
tasks.push(Box::new(move || {
check_split_topology_on_local(process_role, &jobs_backend)
}));
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));
let fleet_topology = resolve_fleet_topology(Some(&merged_jobs_toml));
let declared_queues = resolve_declared_queues(Some(&merged_jobs_toml));
tasks.push(Box::new(move || {
check_queue_coverage_topology(
queue_coverage_role,
&configured_queues,
&jobs_pin,
&declared_queues,
fleet_topology.as_ref(),
)
}));
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 host = deploy_cfg.host.clone();
tasks.push(Box::new({
let host = host.clone();
move || {
deploy_preflight_result(
"deploy_host",
crate::deploy::grade_deploy_host_present(host.as_deref()),
)
}
}));
if opts.online {
let ssh_port = deploy_cfg.ssh_port;
tasks.push(Box::new(move || {
deploy_preflight_result(
"deploy_ssh_reachability",
crate::deploy::grade_ssh_reachability(
host.as_deref(),
ssh_port,
std::time::Duration::from_secs(5),
),
)
}));
}
match deploy_previous_signing_result {
Ok(deploy_previous_signing) => {
tasks.push(Box::new(move || {
deploy_preflight_result(
"deploy_signing_secret",
crate::deploy::grade_signing_secret(
deploy_signing.as_deref(),
&deploy_previous_signing,
deploy_is_production,
),
)
}));
}
Err(msg) => {
tasks.push(Box::new(move || CheckResult {
name: "deploy_signing_secret",
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(
"deploy_database_url",
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: "deploy_database_url",
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(
"deploy_migrate_check",
crate::deploy::grade_migrate_check(std::path::Path::new("migrations")),
)
}));
}
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 (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)));
}
if let Some(acme) = acme_config {
if let Some(config_fail) = check_acme_config_impl(
&acme.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
) {
tasks.push(Box::new(move || config_fail));
} else {
let cert_dir = acme.cache_dir.join(&acme.directory_label);
let stored = resolve_acme_stored_cert_data(&cert_dir, &acme.domains);
tasks.push(Box::new(move || check_acme_stored_cert_impl(&stored)));
if opts.online {
for domain in acme_online_probe_domains(&acme) {
let d80 = domain.clone();
tasks.push(Box::new(move || {
let p80 = probe_port(&d80, 80);
let p443 = probe_port(&d80, 443);
check_acme_ports_impl(&d80, p80, p443)
}));
let d_dns = domain;
tasks.push(Box::new(move || {
let outcome = resolve_dns_points_here(&d_dns);
check_acme_dns_impl(&d_dns, &outcome)
}));
}
}
}
}
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)
}));
#[allow(clippy::needless_collect)]
let handles: Vec<thread::JoinHandle<CheckResult>> =
tasks.into_iter().map(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> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Ok(p) = std::env::var("AUTUMN_CHROMIUM") {
candidates.push(std::path::PathBuf::from(p));
}
if let Ok(base) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
let base = std::path::PathBuf::from(base);
if let Ok(entries) = std::fs::read_dir(&base) {
let mut pw_paths: Vec<_> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-"))
.map(|e| {
if cfg!(target_os = "macos") {
e.path()
.join("chrome-mac")
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium")
} else if cfg!(target_os = "windows") {
e.path().join("chrome-win").join("chrome.exe")
} else {
e.path().join("chrome-linux").join("chrome")
}
})
.collect();
pw_paths.sort();
pw_paths.reverse();
candidates.extend(pw_paths);
}
}
candidates.extend(
[
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
.map(std::path::PathBuf::from),
);
candidates
}
fn probe_browser_version(path: &std::path::Path) -> Option<String> {
std::process::Command::new(path)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
}
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 path.is_file()
&& 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())
}
#[cfg(test)]
mod tests {
use super::*;
#[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));
}
#[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_impl(
"app.example.com",
PortReachability::Open,
PortReachability::Open,
);
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_impl("app.example.com", p80, PortReachability::Open);
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_impl(
"app.example.com",
PortReachability::Open,
PortReachability::Refused,
);
assert!(matches!(r.status, CheckStatus::Warn));
}
#[test]
fn acme_dns_pass_when_points_here() {
let r = check_acme_dns_impl("app.example.com", &DnsPointsHere::Matches);
assert!(matches!(r.status, CheckStatus::Pass));
}
#[test]
fn acme_dns_fail_when_resolves_elsewhere() {
let r = check_acme_dns_impl(
"app.example.com",
&DnsPointsHere::ResolvesElsewhere {
resolved: vec!["203.0.113.7".to_owned()],
},
);
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_impl("app.example.com", &outcome);
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_impl("app.example.com", &outcome);
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_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));
}
#[test]
fn acme_config_fail_when_domains_empty() {
let r = check_acme_config_impl(&[], "ops@example.com", 80, 30, None, None, None)
.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(
&["app.example.com".to_owned()],
" ",
80,
30,
None,
None,
None,
)
.expect("blank contact_email must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
}
#[test]
fn acme_config_fail_when_wildcard_domain() {
let r = check_acme_config_impl(
&["*.example.com".to_owned()],
"ops@example.com",
80,
30,
None,
None,
None,
)
.expect("wildcard domain must be a FAIL");
assert!(matches!(r.status, CheckStatus::Fail));
assert!(r.detail.as_deref().unwrap_or_default().contains("#1620"));
}
#[test]
fn acme_config_ok_when_valid() {
assert!(
check_acme_config_impl(
&["app.example.com".to_owned()],
"ops@example.com",
80,
30,
None,
None,
None
)
.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(
&[String::new()],
"ops@example.com",
80,
30,
None,
None,
None,
)
.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(
&[" ".to_owned()],
"ops@example.com",
80,
30,
None,
None,
None,
)
.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 r = check_acme_config_impl(
&["app.example.com".to_owned()],
"ops@example.com",
0,
30,
None,
None,
None,
)
.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
);
}
#[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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.is_none(),
"a valid renew_before_days must not raise an acme_config FAIL"
);
}
#[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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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.domains,
&acme.contact_email,
acme.http_challenge_port,
acme.renew_before_days,
acme.directory_error.as_deref(),
acme.port_error.as_deref(),
acme.domains_error.as_deref(),
)
.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 config = AcmeDoctorConfig {
domains: vec!["ok.example.com".to_owned(), "bad.example.com".to_owned()],
contact_email: "ops@example.com".to_owned(),
http_challenge_port: 80,
renew_before_days: 30,
cache_dir: std::path::PathBuf::from("config/acme"),
directory_label: "production".to_owned(),
directory_error: None,
port_error: None,
domains_error: None,
};
let domains = acme_online_probe_domains(&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_impl(
domain,
PortReachability::Open,
PortReachability::Open,
));
dns_checks.push(check_acme_dns_impl(domain, &DnsPointsHere::Matches));
}
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 cert_dir = dir.path().join("production");
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(&cert_dir, &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(&cert_dir, &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 cert_dir = dir.path().join("production");
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(&cert_dir, &domains).is_none());
std::fs::write(cert_dir.join(format!("{id}.chain.pem")), b"chain").unwrap();
assert!(configured_acme_cert_pair(&cert_dir, &domains).is_none());
assert_eq!(
resolve_acme_stored_cert_data(&cert_dir, &domains),
TlsDoctorData::NotConfigured
);
}
#[cfg(feature = "acme")]
#[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"));
}
#[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, "local");
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, "local");
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, "local");
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, "postgres");
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, "redis");
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_fails_on_web_role_with_backend_typo() {
let result = check_split_topology_on_local(ProcessRole::Web, "postgresql");
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, "");
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),
);
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);
assert_eq!(role, ProcessRole::Combined);
assert_eq!(backend, "local");
}
#[test]
fn queue_coverage_passes_when_pin_unset() {
let result = check_queue_coverage(
ProcessRole::Combined,
&["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, &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, &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, &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, &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, &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, &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, &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, &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, &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));
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));
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),
);
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));
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));
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, &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,
&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,
&queues,
&pin,
&[], 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()],
],
};
let result = check_queue_coverage_topology(
ProcessRole::Combined,
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
);
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()],
],
};
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
);
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()]],
};
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
);
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(), ],
};
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
);
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()]],
};
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["default".to_string(), "email".to_string()],
&declared,
Some(&fleet),
);
assert_eq!(result.status, CheckStatus::Pass, "{:?}", result.detail);
let result_no_manifest = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["default".to_string(), "email".to_string()],
&[],
Some(&fleet),
);
assert_eq!(
result_no_manifest.status,
CheckStatus::Pass,
"{:?}",
result_no_manifest.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()]],
};
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["default".to_string()],
&declared,
Some(&fleet),
);
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() };
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&queues,
&["critical".to_string()],
&[],
Some(&fleet),
);
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 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));
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),
);
assert_eq!(
declared_from_manifest,
vec!["critical".to_string(), "email".to_string()]
);
let none: toml::Table =
toml::from_str("[jobs]\nqueues = [\"critical\"]\n").expect("parse toml");
assert!(resolve_declared_queues_from_sources(|_| None, Some(&none)).is_empty());
}
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));
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,
&["critical".to_string()],
&["critical".to_string()],
&declared,
Some(&fleet),
);
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));
let fleet = resolve_fleet_topology(Some(&table)).expect("topology declared");
let result = check_queue_coverage_topology(
ProcessRole::Worker,
&["critical".to_string()],
&["critical".to_string()],
&declared,
Some(&fleet),
);
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, &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),
);
assert_eq!(role, ProcessRole::Web);
assert_eq!(backend, "postgres");
}
#[test]
fn resolve_process_role_falls_back_to_toml_when_env_role_invalid() {
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(|| "garbage".to_owned()),
Some(&table),
);
assert_eq!(role, ProcessRole::Worker);
assert_eq!(backend, "local");
let result = check_split_topology_on_local(role, &backend);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("worker"));
}
#[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_and_cites_1909() {
let r = check_pg_client_tools_sqlite();
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.hint.unwrap_or_default().contains("1909"));
}
#[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 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);
assert!(r.detail.as_deref().unwrap_or("").contains("ephemeral"));
}
#[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
);
}
}