//! TOML configuration loading and types. use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::error::{PomError, Result}; use crate::peer::OnMissing; use crate::types::DnsRecordType; #[derive(Debug, Clone, Deserialize)] pub struct Config { /// Serve-mode settings (intervals, listen address, pruning). #[serde(default)] pub serve: ServeConfig, /// This PoM instance's identity (name, optional fixed ID). #[serde(default)] pub instance: InstanceConfig, /// Monitored targets, keyed by short name (e.g. "mnw", "go"). #[serde(default)] pub targets: HashMap, /// Peer PoM instances for mesh monitoring, keyed by peer name. #[serde(default)] pub peers: HashMap, /// Where this instance keeps its database and instance ID. #[serde(default)] pub storage: StorageConfig, /// Email alert configuration via Postmark. `None` disables alerting. pub alerts: Option, } #[derive(Debug, Clone, Default, Deserialize)] pub struct StorageConfig { /// Absolute path to `pom.db`. Unset falls back to the XDG data directory, /// which is what the service unit and a hand-run CLI disagree about: set /// this on any instance whose database is not in the invoking user's own /// `~/.local/share`, and the two agree by construction. pub db_path: Option, } // Manual Debug (below) redacts the token, keep field lists in sync when adding // fields. Secrets in a derived Debug are a latent leak on any future log/panic. #[derive(Clone, Deserialize)] pub struct AlertConfig { /// Postmark server API token. Can also be set via `POM_POSTMARK_TOKEN` env var. pub postmark_token: Option, /// Recipient email address for alert notifications. pub to: String, /// Sender email address for alert notifications. #[serde(default = "default_alert_from")] pub from: String, /// Minimum seconds between repeated alerts for the same target. #[serde(default = "default_cooldown_secs")] pub cooldown_secs: u64, /// WAM ticket manager URL (tailnet). When set, alerts also create WAM tickets. pub wam_url: Option, /// Bearer token for the WAM ticket API. WAM fails closed (`MNW/wam` requires /// `Authorization: Bearer ` on every request), so without this a /// configured `wam_url` still gets 401s and ticket creation silently falls /// back to email. Can also be set via the `POM_WAM_TOKEN` env var. pub wam_token: Option, /// MNW base URL (e.g. "https://makenot.work"). When set together with /// `alerts_ingest_token`, alerts are also pushed to MNW's operator log via /// `POST /api/internal/alerts`. Either unset disables the MNW sink. pub mnw_url: Option, /// Bearer token for MNW's alert-ingestion endpoint. Can also be set via the /// `POM_ALERTS_INGEST_TOKEN` env var. Distinct from any CLI service token. pub alerts_ingest_token: Option, } impl std::fmt::Debug for AlertConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AlertConfig") .field( "postmark_token", &self.postmark_token.as_ref().map(|_| "***"), ) .field("to", &self.to) .field("from", &self.from) .field("cooldown_secs", &self.cooldown_secs) .field("wam_url", &self.wam_url) .field("wam_token", &self.wam_token.as_ref().map(|_| "***")) .field("mnw_url", &self.mnw_url) .field( "alerts_ingest_token", &self.alerts_ingest_token.as_ref().map(|_| "***"), ) .finish() } } #[derive(Debug, Clone, Default, Deserialize)] pub struct InstanceConfig { /// Human-readable instance name. Falls back to OS hostname if unset. pub name: Option, /// Fixed instance UUID. Auto-generated and persisted to disk if unset. pub id: Option, } #[derive(Clone, Deserialize)] pub struct PeerConfig { /// Network address of the peer (host:port). pub address: String, /// Action to take when the peer is declared missing. #[serde(default)] pub on_missing: OnMissing, /// Number of consecutive heartbeat failures before declaring the peer missing. /// Defaults to 3 at runtime if unset. pub grace_count: Option, /// Bearer token for authenticating with this peer's API. pub token: Option, } impl std::fmt::Debug for PeerConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PeerConfig") .field("address", &self.address) .field("on_missing", &self.on_missing) .field("grace_count", &self.grace_count) .field("token", &self.token.as_ref().map(|_| "***")) .finish() } } // Manual Debug (below) redacts `api_token`; keep field lists in sync. #[derive(Clone, Deserialize)] pub struct ServeConfig { /// Seconds between health check cycles for all targets. #[serde(default = "default_serve_interval")] pub interval_secs: u64, /// Number of days of history to retain before pruning. #[serde(default = "default_prune_days")] pub prune_days: i64, /// Socket address the API server binds to (e.g. "127.0.0.1:9100"). #[serde(default = "default_listen")] pub listen: String, /// Seconds between peer heartbeat probes. #[serde(default = "default_peer_heartbeat")] pub peer_heartbeat_secs: u64, /// Seconds between TLS certificate checks. #[serde(default = "default_tls_check_interval")] pub tls_check_interval_secs: u64, /// Seconds between route accessibility checks for all targets. #[serde(default = "default_route_check_interval")] pub route_check_interval_secs: u64, /// Seconds between DNS record verification checks. #[serde(default = "default_dns_check_interval")] pub dns_check_interval_secs: u64, /// Seconds between CORS preflight verification checks. #[serde(default = "default_cors_check_interval")] pub cors_check_interval_secs: u64, /// Seconds between WHOIS domain expiry checks. #[serde(default = "default_whois_check_interval")] pub whois_check_interval_secs: u64, /// Seconds between staleness sweeps of the configured test suites. This is /// not how often tests run: the sweep runs a suite only when its last run is /// older than that target's `tests.staleness_days`, or when it has never run. #[serde(default = "default_test_sweep_interval")] pub test_sweep_interval_secs: u64, /// Bearer token required for API access. If set, all /api/* requests must /// include `Authorization: Bearer `. Can also be set via POM_API_TOKEN env var. pub api_token: Option, /// Enable the HTML dashboard at `GET /`. Disabled by default. #[serde(default)] pub dashboard: bool, /// Consecutive checks that must agree on a new status before a health/ssh /// transition fires an alert or opens an incident (N-of-M debounce). Default /// 2: a single transient blip no longer pages or logs a false incident. Set 1 /// to alert on the first differing check (the pre-debounce behavior). #[serde(default = "default_confirmations")] pub confirmations: u32, } impl std::fmt::Debug for ServeConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ServeConfig") .field("interval_secs", &self.interval_secs) .field("prune_days", &self.prune_days) .field("listen", &self.listen) .field("peer_heartbeat_secs", &self.peer_heartbeat_secs) .field("tls_check_interval_secs", &self.tls_check_interval_secs) .field("route_check_interval_secs", &self.route_check_interval_secs) .field("dns_check_interval_secs", &self.dns_check_interval_secs) .field("cors_check_interval_secs", &self.cors_check_interval_secs) .field("whois_check_interval_secs", &self.whois_check_interval_secs) .field("test_sweep_interval_secs", &self.test_sweep_interval_secs) .field("api_token", &self.api_token.as_ref().map(|_| "***")) .field("dashboard", &self.dashboard) .field("confirmations", &self.confirmations) .finish() } } fn default_confirmations() -> u32 { 2 } impl Default for ServeConfig { fn default() -> Self { Self { interval_secs: 300, prune_days: 30, listen: default_listen(), peer_heartbeat_secs: 60, tls_check_interval_secs: 3600, route_check_interval_secs: 300, dns_check_interval_secs: 3600, cors_check_interval_secs: 3600, whois_check_interval_secs: 86400, test_sweep_interval_secs: default_test_sweep_interval(), api_token: None, dashboard: false, confirmations: default_confirmations(), } } } fn default_peer_heartbeat() -> u64 { // 1 minute: detects peer failures within the grace period 60 } fn default_tls_check_interval() -> u64 { // 1 hour: certificates change slowly, no need to probe frequently 3600 } fn default_route_check_interval() -> u64 { // 5 minutes: same cadence as health checks, catches broken pages quickly 300 } fn default_dns_check_interval() -> u64 { // 1 hour: DNS records change infrequently, same cadence as TLS checks 3600 } fn default_cors_check_interval() -> u64 { // 1 hour: CORS policies change infrequently 3600 } fn default_whois_check_interval() -> u64 { // 24 hours: domain registration data changes on the order of days/months 86400 } fn default_test_sweep_interval() -> u64 { // 1 hour. The sweep is cheap (one DB read per target); the suite it may // trigger is not, which is why `staleness_days` gates the run itself. 3600 } fn default_serve_interval() -> u64 { // 5 minutes: frequent enough to catch outages within an SLA window, // infrequent enough to avoid noise 300 } fn default_prune_days() -> i64 { // 30 days: enough history for monthly reporting, keeps DB small 30 } fn default_listen() -> String { "127.0.0.1:9100".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct TargetConfig { /// Human-readable display name for this target. pub label: String, /// HTTP health check configuration. `None` disables health monitoring. pub health: Option, /// Remote test runner configuration. `None` disables test execution. pub tests: Option, /// TLS certificate monitoring configuration. `None` disables TLS checks. pub tls: Option, /// Expected routes to check for accessibility. Empty disables route checks. /// Requires `health` config for base URL derivation. #[serde(default)] pub expected_routes: Vec, /// DNS records to verify. Empty disables DNS checks. #[serde(default)] pub dns: Vec, /// WHOIS domain expiry monitoring. `None` disables WHOIS checks. pub whois: Option, /// CORS preflight checks. Empty disables CORS checks. #[serde(default)] pub cors: Vec, /// Local filesystem backup verification. `None` disables backup checks. pub backups: Option, /// SSH banner check (TCP connect + verify "SSH-" banner). `None` disables. pub ssh_banner: Option, /// Scan-pipeline health check against `/admin/uploads/health.json`. /// `None` disables. pub scan_pipeline: Option, /// SyncKit field-version readout against /// `/api/internal/synckit/client-versions`. `None` disables. pub synckit_fleet: Option, /// Local checkout of the code this target runs, used by `pom versions` to /// count how far the live build is behind. `None` leaves that column blank. pub repo: Option, /// Local systemd daemon liveness / crash-loop / failed-unit check. `None` /// disables. Probes the host PoM runs on, not a remote target. pub systemd: Option, /// Local CA-bundle freshness check. `None` disables. Like `systemd`, this /// probes the host PoM runs on rather than a remote target. pub ca_bundle: Option, } /// Local CA-bundle freshness monitoring for a host target. /// /// The trailing indicator for this already exists: the TLS check validates every /// target against the host trust store as well as the web PKI, so a bundle that /// has gone thin shows up as a chain the host rejects. This is the leading one. /// It answers "is the bundle drifting" before anything fails, which matters /// because multithreaded has no in-binary root fallback on any outbound path and /// would otherwise report the problem as a login outage. #[derive(Debug, Clone, Deserialize)] pub struct CaBundleConfig { /// Package providing the trust anchors. Defaults to "ca-certificates". #[serde(default = "default_ca_bundle_package")] pub package: String, /// Concatenated PEM bundle to count certificates in. Defaults to /// "/etc/ssl/certs/ca-certificates.crt". #[serde(default = "default_ca_bundle_path")] pub bundle_path: PathBuf, /// Fewer certificates than this reads as a truncated or emptied bundle. /// Defaults to 80, comfortably under the ~120 a stock Ubuntu carries and /// well above anything that would still be a working trust store. #[serde(default = "default_ca_bundle_min_certs")] pub min_certs: usize, /// Stamp file whose mtime records the last successful `apt update`. /// Defaults to "/var/lib/apt/periodic/update-success-stamp". #[serde(default = "default_ca_bundle_stamp")] pub update_stamp: PathBuf, /// A stamp older than this means the package lists are stale, so /// installed-equals-candidate proves nothing. Defaults to 48 hours. #[serde(default = "default_ca_bundle_stamp_max_age_hours")] pub update_stamp_max_age_hours: i64, /// Seconds between checks. Defaults to 1 hour. #[serde(default = "default_ca_bundle_interval")] pub interval_secs: u64, } fn default_ca_bundle_package() -> String { "ca-certificates".to_string() } fn default_ca_bundle_path() -> PathBuf { PathBuf::from("/etc/ssl/certs/ca-certificates.crt") } fn default_ca_bundle_min_certs() -> usize { // Ubuntu 24.04 ships ~121. The floor is not a freshness signal, it catches a // truncated or half-written bundle, which a version comparison calls fine. 80 } fn default_ca_bundle_stamp() -> PathBuf { PathBuf::from("/var/lib/apt/periodic/update-success-stamp") } fn default_ca_bundle_stamp_max_age_hours() -> i64 { // apt-daily.timer runs daily, so 48h allows one missed run before saying so. 48 } fn default_ca_bundle_interval() -> u64 { // 1 hour. The package moves a few times a year; this is about noticing // within a day, not within a minute. 3600 } /// A local git checkout to measure a target's live build against. /// /// Local-only by design: the count is taken against whatever the checkout has /// on HEAD right now, on the machine running `pom versions`. On a host without /// the repo the column goes blank instead of the command failing. #[derive(Debug, Clone, Deserialize)] pub struct RepoConfig { /// Absolute path to the checkout. pub path: PathBuf, /// Path within the repo the count is scoped to (e.g. "server" in a repo /// holding several deployables). `None` counts every commit on HEAD. pub subdir: Option, } /// Local systemd unit monitoring for a host target. Watches named daemons for /// liveness and crash-loops, and optionally sweeps the host for any failed unit. #[derive(Debug, Clone, Deserialize)] pub struct SystemdConfig { /// Units to watch for liveness (e.g. sandod, bentod, wam, pom). #[serde(default)] pub units: Vec, /// Also alert on any unit in the failed state on the host, including /// oneshot/timer-driven units a liveness watch would never enumerate /// (the sandod-backup-fetch class). Defaults to true. #[serde(default = "default_systemd_check_failed")] pub check_failed: bool, /// `NRestarts` at or above this reads as a crash-loop even while the unit /// still shows `activating`. Defaults to 5. #[serde(default = "default_systemd_restart_threshold")] pub restart_threshold: i64, /// Seconds between checks. Defaults to 60. #[serde(default = "default_systemd_interval")] pub interval_secs: u64, } /// One watched systemd unit. #[derive(Debug, Clone, Deserialize)] pub struct SystemdUnit { /// Unit name including its suffix (e.g. "sandod.service"). pub name: String, /// Whether the unit lives on the `--user` bus rather than the system bus. /// bentod runs under `systemd --user`, so this must be set for it. #[serde(default)] pub user: bool, } fn default_systemd_check_failed() -> bool { true } fn default_systemd_restart_threshold() -> i64 { // 5 automatic restarts: a healthy long-lived daemon does not flap this much, // and it is well below the thousands bentod racked up while going unnoticed. 5 } fn default_systemd_interval() -> u64 { // 1 minute: a crash-loop should surface fast, and a local systemctl probe is // cheap. 60 } #[derive(Debug, Clone, Deserialize)] pub struct ScanPipelineConfig { /// Base URL of the makenotwork instance (e.g. "https://makenot.work"). pub base_url: String, /// Check interval. Defaults to 300s (5 min). #[serde(default = "default_scan_pipeline_interval")] pub interval_secs: u64, /// HTTP request timeout. Defaults to 10s. #[serde(default = "default_scan_pipeline_timeout")] pub timeout_secs: u64, } fn default_scan_pipeline_interval() -> u64 { 300 } fn default_scan_pipeline_timeout() -> u64 { 10 } /// SyncKit field-version readout for a target. /// /// Carries no token of its own: the endpoint is authed with the same /// `alerts.alerts_ingest_token` PoM already holds for pushing alerts to MNW, and /// copying that secret into a second config block would mean a rotation has two /// places to miss. Without an `alerts_ingest_token`, this check does not spawn. #[derive(Debug, Clone, Deserialize)] pub struct SyncKitFleetConfig { /// Base URL of the makenotwork instance (e.g. "https://makenot.work"). pub base_url: String, /// Activity window handed to the server as `?days=`. Defaults to 30, the /// server's own default; it clamps anything outside 1..=365. #[serde(default = "default_synckit_fleet_window_days")] pub window_days: u32, /// Check interval. Defaults to 3600s. #[serde(default = "default_synckit_fleet_interval")] pub interval_secs: u64, /// HTTP request timeout. Defaults to 10s. #[serde(default = "default_synckit_fleet_timeout")] pub timeout_secs: u64, } fn default_synckit_fleet_window_days() -> u32 { 30 } fn default_synckit_fleet_interval() -> u64 { // Hourly. A fleet's version mix moves when users update, which is days-scale; // polling it as often as a liveness check would buy nothing and put a // GROUP BY over sync_devices on a five-minute timer. 3600 } fn default_synckit_fleet_timeout() -> u64 { 10 } #[derive(Debug, Clone, Deserialize)] pub struct DnsRecord { /// Hostname to resolve (e.g. "makenot.work"). pub name: String, /// DNS record type: A, AAAA, CNAME, MX, TXT. pub record_type: DnsRecordType, /// Expected values (order-independent set comparison). pub expected: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct WhoisConfig { /// Domain to check (e.g. "makenot.work"). pub domain: String, /// Alert when registration expires within this many days. Defaults to 30. #[serde(default = "default_whois_warn_days")] pub warn_days: u32, } fn default_whois_warn_days() -> u32 { 30 } #[derive(Debug, Clone, Deserialize)] pub struct CorsCheck { /// URL to send the preflight OPTIONS request to. pub url: String, /// Expected `Access-Control-Allow-Origin` value. pub origin: String, /// HTTP method to include in `Access-Control-Request-Method`. #[serde(default = "default_cors_method")] pub method: String, } fn default_cors_method() -> String { "PUT".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct BackupConfig { /// Filesystem directory containing backup files (e.g. "/opt/backups/postgres"). pub directory: String, /// Database names to check for backups (e.g. `["makenotwork", "multithreaded"]`). pub databases: Vec, /// Maximum age in hours before a backup is considered stale. #[serde(default = "default_max_age_hours")] pub max_age_hours: u64, /// Seconds between backup verification checks. #[serde(default = "default_backup_interval")] pub interval_secs: u64, } fn default_max_age_hours() -> u64 { // 25 hours: allows for some cron drift from the daily 03:00 UTC schedule 25 } fn default_backup_interval() -> u64 { // 1 hour: backups are daily, hourly checks are sufficient 3600 } /// SSH banner check, TCP connect and verify the server responds with "SSH-". #[derive(Debug, Clone, Deserialize)] pub struct SshBannerConfig { /// Hostname or IP to connect to. pub host: String, /// TCP port (defaults to 22). #[serde(default = "default_ssh_banner_port")] pub port: u16, /// Connection timeout in seconds. #[serde(default = "default_ssh_banner_timeout")] pub timeout_secs: u64, } fn default_ssh_banner_port() -> u16 { 22 } fn default_ssh_banner_timeout() -> u64 { 5 } #[derive(Debug, Clone, Deserialize)] pub struct TlsConfig { /// Hostname to connect to for the TLS check. pub host: String, /// TCP port for the TLS connection. #[serde(default = "default_tls_port")] pub port: u16, /// Days before expiry at which to start warning. #[serde(default = "default_tls_warn_days")] pub warn_days: u32, } fn default_tls_port() -> u16 { 443 } fn default_tls_warn_days() -> u32 { // 2 weeks: enough lead time to renew before expiry 14 } #[derive(Debug, Clone, Deserialize)] pub struct HealthConfig { /// URL of the health endpoint to check. pub url: String, /// HTTP request timeout in seconds for this health check. #[serde(default = "default_health_timeout")] pub timeout_secs: u64, /// Per-target interval override for serve mode. pub interval_secs: Option, /// Response validation expectations. pub expect: Option, /// Latency trending and drift detection. pub trending: Option, } #[derive(Debug, Clone, Deserialize)] pub struct TrendingConfig { /// Number of hours of history used to compute the baseline average latency. #[serde(default = "default_baseline_window_hours")] pub baseline_window_hours: u64, /// Multiplier over the baseline average that constitutes a latency spike. #[serde(default = "default_spike_threshold")] pub spike_threshold: f64, } fn default_baseline_window_hours() -> u64 { // 7 days: captures weekly traffic patterns for stable baseline 168 } fn default_spike_threshold() -> f64 { // 2x baseline average: significant deviation without false positives // from normal variance 2.0 } #[derive(Debug, Clone, Deserialize, Default)] pub struct HealthExpectation { /// Expected HTTP status code (e.g. 200). `None` accepts any 2xx. pub status_code: Option, /// JSON field paths and their expected string values (e.g. `{"status": "operational"}`). #[serde(default)] pub json_fields: HashMap, /// Substring that must appear in the response body. pub body_contains: Option, } #[derive(Debug, Clone, Deserialize)] pub struct TestsConfig { /// SSH host alias (from `~/.ssh/config`) for the test runner machine. /// /// Omit when the runner is the host PoM runs on: the command is then /// executed locally. Pointing this at the local machine's own address /// instead only works if a regular sshd is listening, which is not a given /// on a Tailscale-SSH host (tailscaled does not intercept a node /// connecting to itself). #[serde(default)] pub ssh: Option, /// Shell command to run the tests, remotely or locally per `ssh`. pub command: String, /// Maximum seconds to wait for the test command before killing it. #[serde(default = "default_test_timeout")] pub timeout_secs: u64, /// Number of days after which a test run is considered stale. #[serde(default = "default_staleness_days")] pub staleness_days: u64, } fn default_staleness_days() -> u64 { // 1 week: tests older than a week may not reflect current code 7 } fn default_health_timeout() -> u64 { // 10 seconds: generous for most HTTP endpoints, avoids false positives // on slow networks 10 } fn default_test_timeout() -> u64 { // 10 minutes: full CI suites can take time, especially on slow machines 600 } fn default_alert_from() -> String { "PoM Alerts ".to_string() } fn default_cooldown_secs() -> u64 { // 5 minutes: prevents alert storms during sustained outages 300 } impl Config { pub fn load(path: Option<&Path>) -> Result { let config_path = match path { Some(p) => p.to_path_buf(), None => default_config_path()?, }; if !config_path.exists() { return Err(PomError::Config(format!( "Config file not found: {}", config_path.display() ))); } let contents = std::fs::read_to_string(&config_path)?; let mut config: Config = toml::from_str(&contents)?; // Environment variables take precedence over the config file, so a token // can be rotated without editing pom.toml. This is the documented // behavior; the earlier code applied env only when the config value was // absent, so a stale pom.toml token silently won over the env rotation. if let Ok(token) = std::env::var("POM_POSTMARK_TOKEN") && let Some(ref mut alerts) = config.alerts { alerts.postmark_token = Some(token); } if let Ok(token) = std::env::var("POM_ALERTS_INGEST_TOKEN") && let Some(ref mut alerts) = config.alerts { alerts.alerts_ingest_token = Some(token); } if let Ok(token) = std::env::var("POM_WAM_TOKEN") && let Some(ref mut alerts) = config.alerts { alerts.wam_token = Some(token); } if let Ok(token) = std::env::var("POM_API_TOKEN") { config.serve.api_token = Some(token); } // Validate no duplicate target labels let mut seen_labels = std::collections::HashSet::new(); for (name, target) in &config.targets { if !seen_labels.insert(target.label.to_lowercase()) { return Err(PomError::Config(format!( "duplicate target label: \"{}\" (on target \"{name}\")", target.label ))); } } // Validate expected_routes: they need `health` for base-URL derivation, // so a target that lists routes but has no `[health]` would silently never // check them. Fail loudly instead of no-op'ing. Each path must be rooted. for (name, target) in &config.targets { if !target.expected_routes.is_empty() && target.health.is_none() { return Err(PomError::Config(format!( "target {name}: expected_routes requires a [health] config \ (the route base URL is derived from it)" ))); } for route in &target.expected_routes { if !route.starts_with('/') { return Err(PomError::Config(format!( "target {name}: expected_route \"{route}\" must start with '/'" ))); } } // A relative repo path would resolve against whatever directory pom // happened to be launched from, so the same config would measure a // different checkout under systemd than it does from a shell. if let Some(repo) = &target.repo && !repo.path.is_absolute() { return Err(PomError::Config(format!( "target {name}: repo.path \"{}\" must be absolute", repo.path.display() ))); } } // Same rationale as repo.path: a relative db_path would resolve against // the launch directory, so the service and a shell would open different // files from the same config. if let Some(db) = &config.storage.db_path && !db.is_absolute() { return Err(PomError::Config(format!( "storage.db_path \"{}\" must be absolute", db.display() ))); } Ok(config) } /// Resolve where this instance's database lives, and say how it was decided. /// /// Configured wins over the environment. The unconfigured path reads /// `XDG_DATA_HOME`, which is how `pom serve` under systemd and `pom test` in /// a shell came to open two different databases on the same host: the unit /// sets the variable and an interactive login does not. pub fn db_path(&self) -> Result { match &self.storage.db_path { Some(path) => Ok(DbLocation { path: path.clone(), source: DbPathSource::Config, }), None => Ok(DbLocation { path: xdg_db_path()?, source: DbPathSource::XdgDataHome, }), } } pub fn get_target(&self, name: &str) -> Option<&TargetConfig> { self.targets.get(name) } pub fn target_names(&self) -> Vec { let mut names: Vec<_> = self.targets.keys().cloned().collect(); names.sort(); names } pub fn instance_name(&self) -> String { self.instance.name.clone().unwrap_or_else(|| { hostname::get().map_or_else( |_| "unknown".to_string(), |h| h.to_string_lossy().into_owned(), ) }) } } pub fn default_config_path() -> Result { let config_dir = dirs::config_dir() .ok_or_else(|| PomError::Config("Could not determine config directory".into())); Ok(config_dir?.join("pom").join("pom.toml")) } /// A resolved database location, carrying how it was resolved so an error can /// name the thing the operator has to change. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DbLocation { pub path: PathBuf, pub source: DbPathSource, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DbPathSource { /// `storage.db_path` in the config file. Config, /// The XDG data directory, i.e. `XDG_DATA_HOME` or its per-platform default. XdgDataHome, } impl DbLocation { /// The directory the database and the instance ID share. pub fn dir(&self) -> &Path { self.path.parent().unwrap_or(Path::new(".")) } } impl std::fmt::Display for DbLocation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} (from {})", self.path.display(), self.source) } } impl std::fmt::Display for DbPathSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Config => f.write_str("storage.db_path"), Self::XdgDataHome => f.write_str("the XDG data directory"), } } } /// The unconfigured database path: `$XDG_DATA_HOME/pom/pom.db` or the /// per-platform equivalent. Resolution only — it creates nothing. pub fn xdg_db_path() -> Result { let data_dir = dirs::data_local_dir() .ok_or_else(|| PomError::Config("Could not determine data directory".into()))?; Ok(data_dir.join("pom").join("pom.db")) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_full_config() { let toml = r#" [serve] interval_secs = 120 listen = "127.0.0.1:9100" peer_heartbeat_secs = 30 [instance] name = "hetzner" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" timeout_secs = 5 [targets.mnw.tests] ssh = "hetzner" command = "cd /srv/mnw && ./ci.sh" [peers.astra] address = "100.0.0.1:9100" on_missing = "alert" grace_count = 5 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.interval_secs, 120); assert_eq!(config.serve.listen, "127.0.0.1:9100"); assert_eq!(config.serve.peer_heartbeat_secs, 30); assert_eq!(config.instance.name.as_deref(), Some("hetzner")); assert_eq!(config.target_names(), vec!["mnw"]); let mnw = config.get_target("mnw").unwrap(); assert_eq!(mnw.label, "MakeNotWork"); assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5); assert_eq!(mnw.tests.as_ref().unwrap().ssh.as_deref(), Some("hetzner")); let astra = config.peers.get("astra").unwrap(); assert_eq!(astra.address, "100.0.0.1:9100"); assert_eq!(astra.on_missing, OnMissing::Alert); assert_eq!(astra.grace_count, Some(5)); } #[test] fn empty_config_uses_defaults() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.interval_secs, 300); assert_eq!(config.serve.prune_days, 30); assert_eq!(config.serve.listen, "127.0.0.1:9100"); assert_eq!(config.serve.peer_heartbeat_secs, 60); assert!(config.targets.is_empty()); assert!(config.peers.is_empty()); assert!(config.instance.name.is_none()); } #[test] fn peer_on_missing_defaults_to_log() { let toml = r#" [peers.test] address = "10.0.0.1:9100" "#; let config: Config = toml::from_str(toml).unwrap(); let peer = config.peers.get("test").unwrap(); assert_eq!(peer.on_missing, OnMissing::Log); assert_eq!(peer.grace_count, None); assert!(peer.token.is_none()); } #[test] fn peer_with_token() { let toml = r#" [peers.test] address = "10.0.0.1:9100" token = "peer-secret-123" "#; let config: Config = toml::from_str(toml).unwrap(); let peer = config.peers.get("test").unwrap(); assert_eq!(peer.token.as_deref(), Some("peer-secret-123")); } #[test] fn serve_api_token_from_config() { let toml = r#" [serve] api_token = "my-api-secret" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.api_token.as_deref(), Some("my-api-secret")); } #[test] fn serve_api_token_defaults_to_none() { let config: Config = toml::from_str("").unwrap(); assert!(config.serve.api_token.is_none()); } #[test] fn instance_name_falls_back_to_hostname() { let config: Config = toml::from_str("").unwrap(); let name = config.instance_name(); assert!(!name.is_empty()); } #[test] fn config_without_alerts_section() { let config: Config = toml::from_str("").unwrap(); assert!(config.alerts.is_none()); } #[test] fn config_with_alerts_section() { let toml = r#" [alerts] postmark_token = "test-token" to = "alerts@example.com" "#; let config: Config = toml::from_str(toml).unwrap(); let alerts = config.alerts.unwrap(); assert_eq!(alerts.postmark_token.as_deref(), Some("test-token")); assert_eq!(alerts.to, "alerts@example.com"); assert_eq!(alerts.from, "PoM Alerts "); assert_eq!(alerts.cooldown_secs, 300); } #[test] fn config_alerts_wam_token() { let toml = r#" [alerts] to = "alerts@example.com" wam_url = "http://wam.tailnet:9000" wam_token = "test-wam-token" "#; let config: Config = toml::from_str(toml).unwrap(); let alerts = config.alerts.unwrap(); assert_eq!(alerts.wam_url.as_deref(), Some("http://wam.tailnet:9000")); assert_eq!(alerts.wam_token.as_deref(), Some("test-wam-token")); } #[test] fn config_alerts_wam_token_defaults_to_none() { let toml = r#" [alerts] to = "alerts@example.com" wam_url = "http://wam.tailnet:9000" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.alerts.unwrap().wam_token.is_none()); } #[test] fn config_with_tls() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tls] host = "makenot.work" port = 8443 warn_days = 30 "#; let config: Config = toml::from_str(toml).unwrap(); let mnw = config.get_target("mnw").unwrap(); let tls = mnw.tls.as_ref().unwrap(); assert_eq!(tls.host, "makenot.work"); assert_eq!(tls.port, 8443); assert_eq!(tls.warn_days, 30); } #[test] fn config_tls_defaults() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tls] host = "makenot.work" "#; let config: Config = toml::from_str(toml).unwrap(); let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap(); assert_eq!(tls.port, 443); assert_eq!(tls.warn_days, 14); } #[test] fn config_without_tls() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().tls.is_none()); } #[test] fn config_tls_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.tls_check_interval_secs, 3600); } #[test] fn config_tls_check_interval_custom() { let toml = r" [serve] tls_check_interval_secs = 1800 "; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.tls_check_interval_secs, 1800); } #[test] fn config_with_health_expect() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.expect] status_code = 200 body_contains = "operational" json_fields = { "status" = "operational", "checks.db" = "ok" } "#; let config: Config = toml::from_str(toml).unwrap(); let expect = config .get_target("mnw") .unwrap() .health .as_ref() .unwrap() .expect .as_ref() .unwrap(); assert_eq!(expect.status_code, Some(200)); assert_eq!(expect.body_contains.as_deref(), Some("operational")); assert_eq!(expect.json_fields.get("status").unwrap(), "operational"); assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok"); } #[test] fn config_health_without_expect() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" "#; let config: Config = toml::from_str(toml).unwrap(); assert!( config .get_target("mnw") .unwrap() .health .as_ref() .unwrap() .expect .is_none() ); } #[test] fn config_with_trending() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.trending] baseline_window_hours = 48 spike_threshold = 1.5 "#; let config: Config = toml::from_str(toml).unwrap(); let trending = config .get_target("mnw") .unwrap() .health .as_ref() .unwrap() .trending .as_ref() .unwrap(); assert_eq!(trending.baseline_window_hours, 48); assert!((trending.spike_threshold - 1.5).abs() < f64::EPSILON); } #[test] fn config_trending_defaults() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.trending] "#; let config: Config = toml::from_str(toml).unwrap(); let trending = config .get_target("mnw") .unwrap() .health .as_ref() .unwrap() .trending .as_ref() .unwrap(); assert_eq!(trending.baseline_window_hours, 168); assert!((trending.spike_threshold - 2.0).abs() < f64::EPSILON); } #[test] fn config_without_trending() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" "#; let config: Config = toml::from_str(toml).unwrap(); assert!( config .get_target("mnw") .unwrap() .health .as_ref() .unwrap() .trending .is_none() ); } #[test] fn config_health_expect_empty() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.health] url = "https://makenot.work/health" [targets.mnw.health.expect] "#; let config: Config = toml::from_str(toml).unwrap(); let expect = config .get_target("mnw") .unwrap() .health .as_ref() .unwrap() .expect .as_ref() .unwrap(); assert_eq!(expect.status_code, None); assert!(expect.json_fields.is_empty()); assert_eq!(expect.body_contains, None); } #[test] fn config_staleness_days_default() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tests] ssh = "host" command = "./ci.sh" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!( config .get_target("mnw") .unwrap() .tests .as_ref() .unwrap() .staleness_days, 7 ); } #[test] fn config_staleness_days_custom() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.tests] ssh = "host" command = "./ci.sh" staleness_days = 14 "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!( config .get_target("mnw") .unwrap() .tests .as_ref() .unwrap() .staleness_days, 14 ); } #[test] fn config_with_alerts_custom_defaults() { let toml = r#" [alerts] to = "alerts@example.com" from = "Custom " cooldown_secs = 60 "#; let config: Config = toml::from_str(toml).unwrap(); let alerts = config.alerts.unwrap(); assert!(alerts.postmark_token.is_none()); assert_eq!(alerts.from, "Custom "); assert_eq!(alerts.cooldown_secs, 60); } #[test] fn config_expected_routes() { let toml = r#" [targets.mnw] label = "MakeNotWork" expected_routes = ["/", "/discover", "/login", "/docs"] [targets.mnw.health] url = "https://makenot.work/api/health" "#; let config: Config = toml::from_str(toml).unwrap(); let mnw = config.get_target("mnw").unwrap(); assert_eq!( mnw.expected_routes, vec!["/", "/discover", "/login", "/docs"] ); } #[test] fn config_expected_routes_default_empty() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().expected_routes.is_empty()); } #[test] fn config_route_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.route_check_interval_secs, 300); } #[test] fn config_route_check_interval_custom() { let toml = r" [serve] route_check_interval_secs = 600 "; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.route_check_interval_secs, 600); } #[test] fn config_dns_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.dns_check_interval_secs, 3600); } #[test] fn config_dns_check_interval_custom() { let toml = r" [serve] dns_check_interval_secs = 1800 "; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.dns_check_interval_secs, 1800); } #[test] fn config_with_dns_records() { let toml = r#" [targets.mnw] label = "MakeNotWork" [[targets.mnw.dns]] name = "makenot.work" record_type = "A" expected = ["5.78.144.244"] [[targets.mnw.dns]] name = "git.makenot.work" record_type = "A" expected = ["5.78.144.244"] "#; let config: Config = toml::from_str(toml).unwrap(); let mnw = config.get_target("mnw").unwrap(); assert_eq!(mnw.dns.len(), 2); assert_eq!(mnw.dns[0].name, "makenot.work"); assert_eq!(mnw.dns[0].record_type, DnsRecordType::A); assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]); assert_eq!(mnw.dns[1].name, "git.makenot.work"); } #[test] fn config_dns_default_empty() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().dns.is_empty()); } #[test] fn config_with_whois() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.whois] domain = "makenot.work" warn_days = 60 "#; let config: Config = toml::from_str(toml).unwrap(); let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap(); assert_eq!(whois.domain, "makenot.work"); assert_eq!(whois.warn_days, 60); } #[test] fn config_whois_default_warn_days() { let toml = r#" [targets.mnw] label = "MakeNotWork" [targets.mnw.whois] domain = "makenot.work" "#; let config: Config = toml::from_str(toml).unwrap(); let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap(); assert_eq!(whois.warn_days, 30); } #[test] fn config_without_whois() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().whois.is_none()); } #[test] fn config_with_systemd() { let toml = r#" [targets.fw13] label = "fw13 daemons" [targets.fw13.systemd] restart_threshold = 3 interval_secs = 30 check_failed = false [[targets.fw13.systemd.units]] name = "sandod.service" [[targets.fw13.systemd.units]] name = "bentod.service" user = true "#; let config: Config = toml::from_str(toml).unwrap(); let sd = config.get_target("fw13").unwrap().systemd.as_ref().unwrap(); assert_eq!(sd.restart_threshold, 3); assert_eq!(sd.interval_secs, 30); assert!(!sd.check_failed); assert_eq!(sd.units.len(), 2); assert_eq!(sd.units[0].name, "sandod.service"); assert!(!sd.units[0].user, "system bus by default"); assert_eq!(sd.units[1].name, "bentod.service"); assert!(sd.units[1].user, "bentod is on the --user bus"); } #[test] fn config_systemd_defaults() { let toml = r#" [targets.fw13] label = "fw13 daemons" [targets.fw13.systemd] [[targets.fw13.systemd.units]] name = "pom.service" "#; let config: Config = toml::from_str(toml).unwrap(); let sd = config.get_target("fw13").unwrap().systemd.as_ref().unwrap(); assert!(sd.check_failed, "failed-unit sweep on by default"); assert_eq!(sd.restart_threshold, 5); assert_eq!(sd.interval_secs, 60); } #[test] fn config_without_systemd() { let toml = r#" [targets.mnw] label = "MakeNotWork" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.get_target("mnw").unwrap().systemd.is_none()); } #[test] fn defaults_systemd() { assert!(default_systemd_check_failed(), "failed sweep on by default"); assert_eq!(default_systemd_restart_threshold(), 5); assert_eq!(default_systemd_interval(), 60, "1-minute liveness cadence"); } #[test] fn config_dashboard_default_false() { let config: Config = toml::from_str("").unwrap(); assert!(!config.serve.dashboard); } #[test] fn config_dashboard_enabled() { let toml = r" [serve] dashboard = true "; let config: Config = toml::from_str(toml).unwrap(); assert!(config.serve.dashboard); } #[test] fn config_expected_routes_without_slash_detected() { let toml = r#" [targets.mnw] label = "MakeNotWork" expected_routes = ["discover", "/login"] "#; let config: Config = toml::from_str(toml).unwrap(); let bad_routes: Vec<_> = config .get_target("mnw") .unwrap() .expected_routes .iter() .filter(|r| !r.starts_with('/')) .collect(); assert_eq!(bad_routes, vec!["discover"]); } #[test] fn config_whois_check_interval_default() { let config: Config = toml::from_str("").unwrap(); assert_eq!(config.serve.whois_check_interval_secs, 86400); } #[test] fn config_whois_check_interval_custom() { let toml = r" [serve] whois_check_interval_secs = 43200 "; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.serve.whois_check_interval_secs, 43200); } // Defaults-pin tests, every `default_*` constant function is pinned to // its expected value. Catches `replace fn -> u64 with 0/1` mutations and // accidental drift when defaults are tweaked. These constants encode // operational policy (check cadence, retention, etc.) so changes should // be deliberate. #[test] fn defaults_numeric_intervals() { assert_eq!(default_peer_heartbeat(), 60, "peer heartbeat = 1 min"); assert_eq!(default_tls_check_interval(), 3600, "tls = 1 hour"); assert_eq!(default_route_check_interval(), 300, "routes = 5 min"); assert_eq!(default_dns_check_interval(), 3600, "dns = 1 hour"); assert_eq!(default_cors_check_interval(), 3600, "cors = 1 hour"); assert_eq!(default_whois_check_interval(), 86400, "whois = 24 hours"); assert_eq!(default_serve_interval(), 300, "serve = 5 min"); assert_eq!(default_prune_days(), 30, "prune = 30 days"); } #[test] fn defaults_listen_address() { assert_eq!(default_listen(), "127.0.0.1:9100"); } #[test] fn defaults_warn_thresholds() { assert_eq!(default_whois_warn_days(), 30, "whois 30 days lead time"); assert_eq!(default_tls_warn_days(), 14, "tls 14 days lead time"); assert_eq!(default_tls_port(), 443); } #[test] fn defaults_cors() { assert_eq!(default_cors_method(), "PUT"); assert_eq!(default_max_age_hours(), 25, "25h allows cron drift"); } #[test] fn defaults_backup() { assert_eq!(default_backup_interval(), 3600, "hourly backup check"); } #[test] fn defaults_ssh_banner() { assert_eq!(default_ssh_banner_port(), 22); assert_eq!(default_ssh_banner_timeout(), 5); } #[test] fn defaults_latency_baseline() { assert_eq!(default_baseline_window_hours(), 168, "7 days"); // Spike threshold compares as f64; pin with bit-exact match. assert_eq!(default_spike_threshold().to_bits(), 2.0_f64.to_bits()); } #[test] fn defaults_health_and_test_timeouts() { assert_eq!(default_health_timeout(), 10); assert_eq!(default_test_timeout(), 600, "10-minute CI suite budget"); assert_eq!(default_staleness_days(), 7); } #[test] fn defaults_alerts() { assert_eq!(default_alert_from(), "PoM Alerts "); assert_eq!(default_cooldown_secs(), 300, "5-minute alert cooldown"); } // Config method tests #[test] fn instance_name_returns_configured_value() { let toml = r#" [serve] [instance] name = "test-host" [targets.x] label = "X" [targets.x.health] url = "https://example.com" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.instance_name(), "test-host"); } #[test] fn instance_name_falls_back_to_non_empty() { // When `name` is None, fall back to hostname or "unknown", must not be // empty regardless. Catches the `instance_name -> String with "xyzzy"` // mutant and the empty-string variant. let toml = r#" [serve] [instance] [targets.x] label = "X" [targets.x.health] url = "https://example.com" "#; let config: Config = toml::from_str(toml).unwrap(); let name = config.instance_name(); assert!(!name.is_empty(), "fallback must produce a non-empty name"); // It also must not be the cargo-mutants sentinel. assert_ne!(name, "xyzzy"); } #[test] fn default_config_path_ends_in_pom_toml() { // The exact dir varies per OS, but the suffix is stable. // Catches `default_config_path -> Ok(Default::default())` (which would // return an empty PathBuf and fail the ends_with check). let path = default_config_path().unwrap(); assert!( path.ends_with("pom/pom.toml") || path.ends_with("pom\\pom.toml"), "expected .../pom/pom.toml, got {path:?}" ); } #[test] fn xdg_db_path_ends_in_pom_db() { // Same rationale as default_config_path: the exact dir varies per OS, // the suffix is stable. let path = xdg_db_path().unwrap(); assert!( path.ends_with("pom/pom.db") || path.ends_with("pom\\pom.db"), "expected .../pom/pom.db, got {path:?}" ); } #[test] fn configured_db_path_wins_over_the_environment() { // The whole point of storage.db_path: the service unit and a hand-run // CLI must resolve to the same file whatever XDG_DATA_HOME says. let toml = r#" [serve] [storage] db_path = "/var/lib/pom/pom.db" "#; let config: Config = toml::from_str(toml).unwrap(); let loc = config.db_path().unwrap(); assert_eq!(loc.path, PathBuf::from("/var/lib/pom/pom.db")); assert_eq!(loc.source, DbPathSource::Config); assert_eq!(loc.dir(), Path::new("/var/lib/pom")); } #[test] fn unconfigured_db_path_falls_back_to_xdg() { let toml = "[serve]\n"; let config: Config = toml::from_str(toml).unwrap(); let loc = config.db_path().unwrap(); assert_eq!(loc.path, xdg_db_path().unwrap()); assert_eq!(loc.source, DbPathSource::XdgDataHome); } #[test] fn config_load_rejects_relative_db_path() { let toml = r#" [serve] [storage] db_path = "pom.db" "#; let dir = std::env::temp_dir().join(format!("pom-cfg-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("relative-db.toml"); std::fs::write(&path, toml).unwrap(); let err = Config::load(Some(&path)).unwrap_err(); assert!( err.to_string().contains("must be absolute"), "expected an absolute-path complaint, got {err}" ); std::fs::remove_file(&path).ok(); } #[test] fn config_load_rejects_route_without_leading_slash() { // Catches `delete ! in Config::load` (L431): without the `!`, the // validator would only reject routes that DO start with '/', wrong. let toml = r#" [serve] [targets.bad] label = "Bad" expected_routes = ["no-leading-slash"] [targets.bad.health] url = "https://example.com" "#; let tmp = std::env::temp_dir().join(format!("pom_test_{}.toml", std::process::id())); std::fs::write(&tmp, toml).unwrap(); let result = Config::load(Some(tmp.as_path())); let _ = std::fs::remove_file(&tmp); assert!( matches!(result, Err(PomError::Config(_))), "expected Config error rejecting bad route; got {result:?}" ); } #[test] fn config_load_rejects_expected_routes_without_health() { // expected_routes needs [health] for base-URL derivation, a target that // lists routes but has no health config would silently never check them. let toml = r#" [serve] [targets.noroutes] label = "NoHealth" expected_routes = ["/status"] "#; let tmp = std::env::temp_dir().join(format!("pom_test_nh_{}.toml", std::process::id())); std::fs::write(&tmp, toml).unwrap(); let result = Config::load(Some(tmp.as_path())); let _ = std::fs::remove_file(&tmp); assert!( matches!(result, Err(PomError::Config(_))), "expected_routes without [health] must be rejected; got {result:?}" ); } #[test] fn debug_redacts_secrets() { let alerts = AlertConfig { postmark_token: Some("super-secret-token".to_string()), to: "a@b.c".to_string(), from: "PoM".to_string(), cooldown_secs: 300, wam_url: None, wam_token: Some("super-secret-wam-token".to_string()), mnw_url: None, alerts_ingest_token: Some("super-secret-ingest-token".to_string()), }; let rendered = format!("{alerts:?}"); assert!( !rendered.contains("super-secret-token"), "token must be redacted in Debug" ); assert!( !rendered.contains("super-secret-wam-token"), "wam token must be redacted in Debug" ); assert!( !rendered.contains("super-secret-ingest-token"), "ingest token must be redacted in Debug" ); assert!(rendered.contains("***"), "redaction marker expected"); let serve = ServeConfig { api_token: Some("api-secret-xyz".to_string()), ..ServeConfig::default() }; assert!( !format!("{serve:?}").contains("api-secret-xyz"), "api_token must be redacted" ); } }