Skip to main content

max / makenotwork

35.2 KB · 1143 lines History Blame Raw
1 //! TOML configuration loading and types.
2
3 use serde::Deserialize;
4 use std::collections::HashMap;
5 use std::path::{Path, PathBuf};
6
7 use crate::error::{PomError, Result};
8 use crate::peer::OnMissing;
9 use crate::types::DnsRecordType;
10
11 #[derive(Debug, Clone, Deserialize)]
12 pub struct Config {
13 /// Serve-mode settings (intervals, listen address, pruning).
14 #[serde(default)]
15 pub serve: ServeConfig,
16 /// This PoM instance's identity (name, optional fixed ID).
17 #[serde(default)]
18 pub instance: InstanceConfig,
19 /// Monitored targets, keyed by short name (e.g. "mnw", "go").
20 #[serde(default)]
21 pub targets: HashMap<String, TargetConfig>,
22 /// Peer PoM instances for mesh monitoring, keyed by peer name.
23 #[serde(default)]
24 pub peers: HashMap<String, PeerConfig>,
25 /// Email alert configuration via Postmark. `None` disables alerting.
26 pub alerts: Option<AlertConfig>,
27 }
28
29 #[derive(Debug, Clone, Deserialize)]
30 pub struct AlertConfig {
31 /// Postmark server API token. Can also be set via `POM_POSTMARK_TOKEN` env var.
32 pub postmark_token: Option<String>,
33 /// Recipient email address for alert notifications.
34 pub to: String,
35 /// Sender email address for alert notifications.
36 #[serde(default = "default_alert_from")]
37 pub from: String,
38 /// Minimum seconds between repeated alerts for the same target.
39 #[serde(default = "default_cooldown_secs")]
40 pub cooldown_secs: u64,
41 /// WAM ticket manager URL (tailnet). When set, alerts also create WAM tickets.
42 pub wam_url: Option<String>,
43 }
44
45 #[derive(Debug, Clone, Default, Deserialize)]
46 pub struct InstanceConfig {
47 /// Human-readable instance name. Falls back to OS hostname if unset.
48 pub name: Option<String>,
49 /// Fixed instance UUID. Auto-generated and persisted to disk if unset.
50 pub id: Option<String>,
51 }
52
53 #[derive(Debug, Clone, Deserialize)]
54 pub struct PeerConfig {
55 /// Network address of the peer (host:port).
56 pub address: String,
57 /// Action to take when the peer is declared missing.
58 #[serde(default)]
59 pub on_missing: OnMissing,
60 /// Number of consecutive heartbeat failures before declaring the peer missing.
61 /// Defaults to 3 at runtime if unset.
62 pub grace_count: Option<u32>,
63 /// Bearer token for authenticating with this peer's API.
64 pub token: Option<String>,
65 }
66
67 #[derive(Debug, Clone, Deserialize)]
68 pub struct ServeConfig {
69 /// Seconds between health check cycles for all targets.
70 #[serde(default = "default_serve_interval")]
71 pub interval_secs: u64,
72 /// Number of days of history to retain before pruning.
73 #[serde(default = "default_prune_days")]
74 pub prune_days: i64,
75 /// Socket address the API server binds to (e.g. "127.0.0.1:9100").
76 #[serde(default = "default_listen")]
77 pub listen: String,
78 /// Seconds between peer heartbeat probes.
79 #[serde(default = "default_peer_heartbeat")]
80 pub peer_heartbeat_secs: u64,
81 /// Seconds between TLS certificate checks.
82 #[serde(default = "default_tls_check_interval")]
83 pub tls_check_interval_secs: u64,
84 /// Seconds between route accessibility checks for all targets.
85 #[serde(default = "default_route_check_interval")]
86 pub route_check_interval_secs: u64,
87 /// Seconds between DNS record verification checks.
88 #[serde(default = "default_dns_check_interval")]
89 pub dns_check_interval_secs: u64,
90 /// Seconds between CORS preflight verification checks.
91 #[serde(default = "default_cors_check_interval")]
92 pub cors_check_interval_secs: u64,
93 /// Seconds between WHOIS domain expiry checks.
94 #[serde(default = "default_whois_check_interval")]
95 pub whois_check_interval_secs: u64,
96 /// Bearer token required for API access. If set, all /api/* requests must
97 /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var.
98 pub api_token: Option<String>,
99 /// Enable the HTML dashboard at `GET /`. Disabled by default.
100 #[serde(default)]
101 pub dashboard: bool,
102 }
103
104 impl Default for ServeConfig {
105 fn default() -> Self {
106 Self {
107 interval_secs: 300,
108 prune_days: 30,
109 listen: default_listen(),
110 peer_heartbeat_secs: 60,
111 tls_check_interval_secs: 3600,
112 route_check_interval_secs: 300,
113 dns_check_interval_secs: 3600,
114 cors_check_interval_secs: 3600,
115 whois_check_interval_secs: 86400,
116 api_token: None,
117 dashboard: false,
118 }
119 }
120 }
121
122 fn default_peer_heartbeat() -> u64 {
123 // 1 minute: detects peer failures within the grace period
124 60
125 }
126
127 fn default_tls_check_interval() -> u64 {
128 // 1 hour: certificates change slowly, no need to probe frequently
129 3600
130 }
131
132 fn default_route_check_interval() -> u64 {
133 // 5 minutes: same cadence as health checks, catches broken pages quickly
134 300
135 }
136
137 fn default_dns_check_interval() -> u64 {
138 // 1 hour: DNS records change infrequently, same cadence as TLS checks
139 3600
140 }
141
142 fn default_cors_check_interval() -> u64 {
143 // 1 hour: CORS policies change infrequently
144 3600
145 }
146
147 fn default_whois_check_interval() -> u64 {
148 // 24 hours: domain registration data changes on the order of days/months
149 86400
150 }
151
152 fn default_serve_interval() -> u64 {
153 // 5 minutes: frequent enough to catch outages within an SLA window,
154 // infrequent enough to avoid noise
155 300
156 }
157
158 fn default_prune_days() -> i64 {
159 // 30 days: enough history for monthly reporting, keeps DB small
160 30
161 }
162
163 fn default_listen() -> String {
164 "127.0.0.1:9100".to_string()
165 }
166
167 #[derive(Debug, Clone, Deserialize)]
168 pub struct TargetConfig {
169 /// Human-readable display name for this target.
170 pub label: String,
171 /// HTTP health check configuration. `None` disables health monitoring.
172 pub health: Option<HealthConfig>,
173 /// Remote test runner configuration. `None` disables test execution.
174 pub tests: Option<TestsConfig>,
175 /// TLS certificate monitoring configuration. `None` disables TLS checks.
176 pub tls: Option<TlsConfig>,
177 /// Expected routes to check for accessibility. Empty disables route checks.
178 /// Requires `health` config for base URL derivation.
179 #[serde(default)]
180 pub expected_routes: Vec<String>,
181 /// DNS records to verify. Empty disables DNS checks.
182 #[serde(default)]
183 pub dns: Vec<DnsRecord>,
184 /// WHOIS domain expiry monitoring. `None` disables WHOIS checks.
185 pub whois: Option<WhoisConfig>,
186 /// CORS preflight checks. Empty disables CORS checks.
187 #[serde(default)]
188 pub cors: Vec<CorsCheck>,
189 /// Local filesystem backup verification. `None` disables backup checks.
190 pub backups: Option<BackupConfig>,
191 /// SSH banner check (TCP connect + verify "SSH-" banner). `None` disables.
192 pub ssh_banner: Option<SshBannerConfig>,
193 /// Scan-pipeline health check against `<base_url>/admin/uploads/health.json`.
194 /// `None` disables.
195 pub scan_pipeline: Option<ScanPipelineConfig>,
196 }
197
198 #[derive(Debug, Clone, Deserialize)]
199 pub struct ScanPipelineConfig {
200 /// Base URL of the makenotwork instance (e.g. "https://makenot.work").
201 pub base_url: String,
202 /// Check interval. Defaults to 300s (5 min).
203 #[serde(default = "default_scan_pipeline_interval")]
204 pub interval_secs: u64,
205 /// HTTP request timeout. Defaults to 10s.
206 #[serde(default = "default_scan_pipeline_timeout")]
207 pub timeout_secs: u64,
208 }
209
210 fn default_scan_pipeline_interval() -> u64 { 300 }
211 fn default_scan_pipeline_timeout() -> u64 { 10 }
212
213 #[derive(Debug, Clone, Deserialize)]
214 pub struct DnsRecord {
215 /// Hostname to resolve (e.g. "makenot.work").
216 pub name: String,
217 /// DNS record type: A, AAAA, CNAME, MX, TXT.
218 pub record_type: DnsRecordType,
219 /// Expected values (order-independent set comparison).
220 pub expected: Vec<String>,
221 }
222
223 #[derive(Debug, Clone, Deserialize)]
224 pub struct WhoisConfig {
225 /// Domain to check (e.g. "makenot.work").
226 pub domain: String,
227 /// Alert when registration expires within this many days. Defaults to 30.
228 #[serde(default = "default_whois_warn_days")]
229 pub warn_days: u32,
230 }
231
232 fn default_whois_warn_days() -> u32 {
233 30
234 }
235
236 #[derive(Debug, Clone, Deserialize)]
237 pub struct CorsCheck {
238 /// URL to send the preflight OPTIONS request to.
239 pub url: String,
240 /// Expected `Access-Control-Allow-Origin` value.
241 pub origin: String,
242 /// HTTP method to include in `Access-Control-Request-Method`.
243 #[serde(default = "default_cors_method")]
244 pub method: String,
245 }
246
247 fn default_cors_method() -> String {
248 "PUT".to_string()
249 }
250
251 #[derive(Debug, Clone, Deserialize)]
252 pub struct BackupConfig {
253 /// Filesystem directory containing backup files (e.g. "/opt/backups/postgres").
254 pub directory: String,
255 /// Database names to check for backups (e.g. ["makenotwork", "multithreaded"]).
256 pub databases: Vec<String>,
257 /// Maximum age in hours before a backup is considered stale.
258 #[serde(default = "default_max_age_hours")]
259 pub max_age_hours: u64,
260 /// Seconds between backup verification checks.
261 #[serde(default = "default_backup_interval")]
262 pub interval_secs: u64,
263 }
264
265 fn default_max_age_hours() -> u64 {
266 // 25 hours: allows for some cron drift from the daily 03:00 UTC schedule
267 25
268 }
269
270 fn default_backup_interval() -> u64 {
271 // 1 hour: backups are daily, hourly checks are sufficient
272 3600
273 }
274
275 /// SSH banner check — TCP connect and verify the server responds with "SSH-".
276 #[derive(Debug, Clone, Deserialize)]
277 pub struct SshBannerConfig {
278 /// Hostname or IP to connect to.
279 pub host: String,
280 /// TCP port (defaults to 22).
281 #[serde(default = "default_ssh_banner_port")]
282 pub port: u16,
283 /// Connection timeout in seconds.
284 #[serde(default = "default_ssh_banner_timeout")]
285 pub timeout_secs: u64,
286 }
287
288 fn default_ssh_banner_port() -> u16 {
289 22
290 }
291
292 fn default_ssh_banner_timeout() -> u64 {
293 5
294 }
295
296 #[derive(Debug, Clone, Deserialize)]
297 pub struct TlsConfig {
298 /// Hostname to connect to for the TLS check.
299 pub host: String,
300 /// TCP port for the TLS connection.
301 #[serde(default = "default_tls_port")]
302 pub port: u16,
303 /// Days before expiry at which to start warning.
304 #[serde(default = "default_tls_warn_days")]
305 pub warn_days: u32,
306 }
307
308 fn default_tls_port() -> u16 {
309 443
310 }
311
312 fn default_tls_warn_days() -> u32 {
313 // 2 weeks: enough lead time to renew before expiry
314 14
315 }
316
317 #[derive(Debug, Clone, Deserialize)]
318 pub struct HealthConfig {
319 /// URL of the health endpoint to check.
320 pub url: String,
321 /// HTTP request timeout in seconds for this health check.
322 #[serde(default = "default_health_timeout")]
323 pub timeout_secs: u64,
324 /// Per-target interval override for serve mode.
325 pub interval_secs: Option<u64>,
326 /// Response validation expectations.
327 pub expect: Option<HealthExpectation>,
328 /// Latency trending and drift detection.
329 pub trending: Option<TrendingConfig>,
330 }
331
332 #[derive(Debug, Clone, Deserialize)]
333 pub struct TrendingConfig {
334 /// Number of hours of history used to compute the baseline average latency.
335 #[serde(default = "default_baseline_window_hours")]
336 pub baseline_window_hours: u64,
337 /// Multiplier over the baseline average that constitutes a latency spike.
338 #[serde(default = "default_spike_threshold")]
339 pub spike_threshold: f64,
340 }
341
342 fn default_baseline_window_hours() -> u64 {
343 // 7 days: captures weekly traffic patterns for stable baseline
344 168
345 }
346
347 fn default_spike_threshold() -> f64 {
348 // 2x baseline average: significant deviation without false positives
349 // from normal variance
350 2.0
351 }
352
353 #[derive(Debug, Clone, Deserialize, Default)]
354 pub struct HealthExpectation {
355 /// Expected HTTP status code (e.g. 200). `None` accepts any 2xx.
356 pub status_code: Option<u16>,
357 /// JSON field paths and their expected string values (e.g. `{"status": "operational"}`).
358 #[serde(default)]
359 pub json_fields: HashMap<String, String>,
360 /// Substring that must appear in the response body.
361 pub body_contains: Option<String>,
362 }
363
364 #[derive(Debug, Clone, Deserialize)]
365 pub struct TestsConfig {
366 /// SSH host alias (from ~/.ssh/config) for the test runner machine.
367 pub ssh: String,
368 /// Shell command to execute on the remote host to run tests.
369 pub command: String,
370 /// Maximum seconds to wait for the test command before killing it.
371 #[serde(default = "default_test_timeout")]
372 pub timeout_secs: u64,
373 /// Number of days after which a test run is considered stale.
374 #[serde(default = "default_staleness_days")]
375 pub staleness_days: u64,
376 }
377
378 fn default_staleness_days() -> u64 {
379 // 1 week: tests older than a week may not reflect current code
380 7
381 }
382
383 fn default_health_timeout() -> u64 {
384 // 10 seconds: generous for most HTTP endpoints, avoids false positives
385 // on slow networks
386 10
387 }
388
389 fn default_test_timeout() -> u64 {
390 // 10 minutes: full CI suites can take time, especially on slow machines
391 600
392 }
393
394 fn default_alert_from() -> String {
395 "PoM Alerts <pom-alerts@makenot.work>".to_string()
396 }
397
398 fn default_cooldown_secs() -> u64 {
399 // 5 minutes: prevents alert storms during sustained outages
400 300
401 }
402
403 impl Config {
404 pub fn load(path: Option<&Path>) -> Result<Self> {
405 let config_path = match path {
406 Some(p) => p.to_path_buf(),
407 None => default_config_path()?,
408 };
409
410 if !config_path.exists() {
411 return Err(PomError::Config(format!(
412 "Config file not found: {}",
413 config_path.display()
414 )));
415 }
416
417 let contents = std::fs::read_to_string(&config_path)?;
418 let mut config: Config = toml::from_str(&contents)?;
419
420 // Allow postmark_token from environment variable (preferred over config file)
421 if let Some(ref mut alerts) = config.alerts
422 && alerts.postmark_token.is_none()
423 && let Ok(token) = std::env::var("POM_POSTMARK_TOKEN")
424 {
425 alerts.postmark_token = Some(token);
426 }
427
428 // Allow api_token from environment variable (preferred over config file)
429 if config.serve.api_token.is_none()
430 && let Ok(token) = std::env::var("POM_API_TOKEN")
431 {
432 config.serve.api_token = Some(token);
433 }
434
435 // Validate no duplicate target labels
436 let mut seen_labels = std::collections::HashSet::new();
437 for (name, target) in &config.targets {
438 if !seen_labels.insert(target.label.to_lowercase()) {
439 return Err(PomError::Config(format!(
440 "duplicate target label: \"{}\" (on target \"{name}\")",
441 target.label
442 )));
443 }
444 }
445
446 // Validate expected_routes paths start with '/'
447 for (name, target) in &config.targets {
448 for route in &target.expected_routes {
449 if !route.starts_with('/') {
450 return Err(PomError::Config(format!(
451 "target {name}: expected_route \"{route}\" must start with '/'"
452 )));
453 }
454 }
455 }
456
457 Ok(config)
458 }
459
460 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
461 self.targets.get(name)
462 }
463
464 pub fn target_names(&self) -> Vec<String> {
465 let mut names: Vec<_> = self.targets.keys().cloned().collect();
466 names.sort();
467 names
468 }
469
470 pub fn instance_name(&self) -> String {
471 self.instance
472 .name
473 .clone()
474 .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|_| "unknown".to_string()))
475 }
476 }
477
478 pub fn default_config_path() -> Result<PathBuf> {
479 let config_dir =
480 dirs::config_dir().ok_or_else(|| PomError::Config("Could not determine config directory".into()));
481 Ok(config_dir?.join("pom").join("pom.toml"))
482 }
483
484 pub fn db_path() -> Result<PathBuf> {
485 let data_dir =
486 dirs::data_local_dir().ok_or_else(|| PomError::Config("Could not determine data directory".into()));
487 let pom_dir = data_dir?.join("pom");
488 std::fs::create_dir_all(&pom_dir)?;
489 Ok(pom_dir.join("pom.db"))
490 }
491
492 #[cfg(test)]
493 mod tests {
494 use super::*;
495
496 #[test]
497 fn parse_full_config() {
498 let toml = r#"
499 [serve]
500 interval_secs = 120
501 listen = "127.0.0.1:9100"
502 peer_heartbeat_secs = 30
503
504 [instance]
505 name = "hetzner"
506
507 [targets.mnw]
508 label = "MakeNotWork"
509 [targets.mnw.health]
510 url = "https://makenot.work/health"
511 timeout_secs = 5
512 [targets.mnw.tests]
513 ssh = "hetzner"
514 command = "cd /srv/mnw && ./ci.sh"
515
516 [peers.astra]
517 address = "100.0.0.1:9100"
518 on_missing = "alert"
519 grace_count = 5
520 "#;
521
522 let config: Config = toml::from_str(toml).unwrap();
523 assert_eq!(config.serve.interval_secs, 120);
524 assert_eq!(config.serve.listen, "127.0.0.1:9100");
525 assert_eq!(config.serve.peer_heartbeat_secs, 30);
526 assert_eq!(config.instance.name.as_deref(), Some("hetzner"));
527 assert_eq!(config.target_names(), vec!["mnw"]);
528
529 let mnw = config.get_target("mnw").unwrap();
530 assert_eq!(mnw.label, "MakeNotWork");
531 assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5);
532 assert_eq!(mnw.tests.as_ref().unwrap().ssh, "hetzner");
533
534 let astra = config.peers.get("astra").unwrap();
535 assert_eq!(astra.address, "100.0.0.1:9100");
536 assert_eq!(astra.on_missing, OnMissing::Alert);
537 assert_eq!(astra.grace_count, Some(5));
538 }
539
540 #[test]
541 fn empty_config_uses_defaults() {
542 let config: Config = toml::from_str("").unwrap();
543 assert_eq!(config.serve.interval_secs, 300);
544 assert_eq!(config.serve.prune_days, 30);
545 assert_eq!(config.serve.listen, "127.0.0.1:9100");
546 assert_eq!(config.serve.peer_heartbeat_secs, 60);
547 assert!(config.targets.is_empty());
548 assert!(config.peers.is_empty());
549 assert!(config.instance.name.is_none());
550 }
551
552 #[test]
553 fn peer_on_missing_defaults_to_log() {
554 let toml = r#"
555 [peers.test]
556 address = "10.0.0.1:9100"
557 "#;
558 let config: Config = toml::from_str(toml).unwrap();
559 let peer = config.peers.get("test").unwrap();
560 assert_eq!(peer.on_missing, OnMissing::Log);
561 assert_eq!(peer.grace_count, None);
562 assert!(peer.token.is_none());
563 }
564
565 #[test]
566 fn peer_with_token() {
567 let toml = r#"
568 [peers.test]
569 address = "10.0.0.1:9100"
570 token = "peer-secret-123"
571 "#;
572 let config: Config = toml::from_str(toml).unwrap();
573 let peer = config.peers.get("test").unwrap();
574 assert_eq!(peer.token.as_deref(), Some("peer-secret-123"));
575 }
576
577 #[test]
578 fn serve_api_token_from_config() {
579 let toml = r#"
580 [serve]
581 api_token = "my-api-secret"
582 "#;
583 let config: Config = toml::from_str(toml).unwrap();
584 assert_eq!(config.serve.api_token.as_deref(), Some("my-api-secret"));
585 }
586
587 #[test]
588 fn serve_api_token_defaults_to_none() {
589 let config: Config = toml::from_str("").unwrap();
590 assert!(config.serve.api_token.is_none());
591 }
592
593 #[test]
594 fn instance_name_falls_back_to_hostname() {
595 let config: Config = toml::from_str("").unwrap();
596 let name = config.instance_name();
597 assert!(!name.is_empty());
598 }
599
600 #[test]
601 fn config_without_alerts_section() {
602 let config: Config = toml::from_str("").unwrap();
603 assert!(config.alerts.is_none());
604 }
605
606 #[test]
607 fn config_with_alerts_section() {
608 let toml = r#"
609 [alerts]
610 postmark_token = "test-token"
611 to = "alerts@example.com"
612 "#;
613 let config: Config = toml::from_str(toml).unwrap();
614 let alerts = config.alerts.unwrap();
615 assert_eq!(alerts.postmark_token.as_deref(), Some("test-token"));
616 assert_eq!(alerts.to, "alerts@example.com");
617 assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
618 assert_eq!(alerts.cooldown_secs, 300);
619 }
620
621 #[test]
622 fn config_with_tls() {
623 let toml = r#"
624 [targets.mnw]
625 label = "MakeNotWork"
626 [targets.mnw.tls]
627 host = "makenot.work"
628 port = 8443
629 warn_days = 30
630 "#;
631 let config: Config = toml::from_str(toml).unwrap();
632 let mnw = config.get_target("mnw").unwrap();
633 let tls = mnw.tls.as_ref().unwrap();
634 assert_eq!(tls.host, "makenot.work");
635 assert_eq!(tls.port, 8443);
636 assert_eq!(tls.warn_days, 30);
637 }
638
639 #[test]
640 fn config_tls_defaults() {
641 let toml = r#"
642 [targets.mnw]
643 label = "MakeNotWork"
644 [targets.mnw.tls]
645 host = "makenot.work"
646 "#;
647 let config: Config = toml::from_str(toml).unwrap();
648 let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap();
649 assert_eq!(tls.port, 443);
650 assert_eq!(tls.warn_days, 14);
651 }
652
653 #[test]
654 fn config_without_tls() {
655 let toml = r#"
656 [targets.mnw]
657 label = "MakeNotWork"
658 "#;
659 let config: Config = toml::from_str(toml).unwrap();
660 assert!(config.get_target("mnw").unwrap().tls.is_none());
661 }
662
663 #[test]
664 fn config_tls_check_interval_default() {
665 let config: Config = toml::from_str("").unwrap();
666 assert_eq!(config.serve.tls_check_interval_secs, 3600);
667 }
668
669 #[test]
670 fn config_tls_check_interval_custom() {
671 let toml = r#"
672 [serve]
673 tls_check_interval_secs = 1800
674 "#;
675 let config: Config = toml::from_str(toml).unwrap();
676 assert_eq!(config.serve.tls_check_interval_secs, 1800);
677 }
678
679 #[test]
680 fn config_with_health_expect() {
681 let toml = r#"
682 [targets.mnw]
683 label = "MakeNotWork"
684 [targets.mnw.health]
685 url = "https://makenot.work/health"
686 [targets.mnw.health.expect]
687 status_code = 200
688 body_contains = "operational"
689 json_fields = { "status" = "operational", "checks.db" = "ok" }
690 "#;
691 let config: Config = toml::from_str(toml).unwrap();
692 let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
693 assert_eq!(expect.status_code, Some(200));
694 assert_eq!(expect.body_contains.as_deref(), Some("operational"));
695 assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
696 assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok");
697 }
698
699 #[test]
700 fn config_health_without_expect() {
701 let toml = r#"
702 [targets.mnw]
703 label = "MakeNotWork"
704 [targets.mnw.health]
705 url = "https://makenot.work/health"
706 "#;
707 let config: Config = toml::from_str(toml).unwrap();
708 assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.is_none());
709 }
710
711 #[test]
712 fn config_with_trending() {
713 let toml = r#"
714 [targets.mnw]
715 label = "MakeNotWork"
716 [targets.mnw.health]
717 url = "https://makenot.work/health"
718 [targets.mnw.health.trending]
719 baseline_window_hours = 48
720 spike_threshold = 1.5
721 "#;
722 let config: Config = toml::from_str(toml).unwrap();
723 let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
724 assert_eq!(trending.baseline_window_hours, 48);
725 assert_eq!(trending.spike_threshold, 1.5);
726 }
727
728 #[test]
729 fn config_trending_defaults() {
730 let toml = r#"
731 [targets.mnw]
732 label = "MakeNotWork"
733 [targets.mnw.health]
734 url = "https://makenot.work/health"
735 [targets.mnw.health.trending]
736 "#;
737 let config: Config = toml::from_str(toml).unwrap();
738 let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
739 assert_eq!(trending.baseline_window_hours, 168);
740 assert_eq!(trending.spike_threshold, 2.0);
741 }
742
743 #[test]
744 fn config_without_trending() {
745 let toml = r#"
746 [targets.mnw]
747 label = "MakeNotWork"
748 [targets.mnw.health]
749 url = "https://makenot.work/health"
750 "#;
751 let config: Config = toml::from_str(toml).unwrap();
752 assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.is_none());
753 }
754
755 #[test]
756 fn config_health_expect_empty() {
757 let toml = r#"
758 [targets.mnw]
759 label = "MakeNotWork"
760 [targets.mnw.health]
761 url = "https://makenot.work/health"
762 [targets.mnw.health.expect]
763 "#;
764 let config: Config = toml::from_str(toml).unwrap();
765 let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
766 assert_eq!(expect.status_code, None);
767 assert!(expect.json_fields.is_empty());
768 assert_eq!(expect.body_contains, None);
769 }
770
771 #[test]
772 fn config_staleness_days_default() {
773 let toml = r#"
774 [targets.mnw]
775 label = "MakeNotWork"
776 [targets.mnw.tests]
777 ssh = "host"
778 command = "./ci.sh"
779 "#;
780 let config: Config = toml::from_str(toml).unwrap();
781 assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 7);
782 }
783
784 #[test]
785 fn config_staleness_days_custom() {
786 let toml = r#"
787 [targets.mnw]
788 label = "MakeNotWork"
789 [targets.mnw.tests]
790 ssh = "host"
791 command = "./ci.sh"
792 staleness_days = 14
793 "#;
794 let config: Config = toml::from_str(toml).unwrap();
795 assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 14);
796 }
797
798 #[test]
799 fn config_with_alerts_custom_defaults() {
800 let toml = r#"
801 [alerts]
802 to = "alerts@example.com"
803 from = "Custom <custom@example.com>"
804 cooldown_secs = 60
805 "#;
806 let config: Config = toml::from_str(toml).unwrap();
807 let alerts = config.alerts.unwrap();
808 assert!(alerts.postmark_token.is_none());
809 assert_eq!(alerts.from, "Custom <custom@example.com>");
810 assert_eq!(alerts.cooldown_secs, 60);
811 }
812
813 #[test]
814 fn config_expected_routes() {
815 let toml = r#"
816 [targets.mnw]
817 label = "MakeNotWork"
818 expected_routes = ["/", "/discover", "/login", "/docs"]
819 [targets.mnw.health]
820 url = "https://makenot.work/api/health"
821 "#;
822 let config: Config = toml::from_str(toml).unwrap();
823 let mnw = config.get_target("mnw").unwrap();
824 assert_eq!(mnw.expected_routes, vec!["/", "/discover", "/login", "/docs"]);
825 }
826
827 #[test]
828 fn config_expected_routes_default_empty() {
829 let toml = r#"
830 [targets.mnw]
831 label = "MakeNotWork"
832 "#;
833 let config: Config = toml::from_str(toml).unwrap();
834 assert!(config.get_target("mnw").unwrap().expected_routes.is_empty());
835 }
836
837 #[test]
838 fn config_route_check_interval_default() {
839 let config: Config = toml::from_str("").unwrap();
840 assert_eq!(config.serve.route_check_interval_secs, 300);
841 }
842
843 #[test]
844 fn config_route_check_interval_custom() {
845 let toml = r#"
846 [serve]
847 route_check_interval_secs = 600
848 "#;
849 let config: Config = toml::from_str(toml).unwrap();
850 assert_eq!(config.serve.route_check_interval_secs, 600);
851 }
852
853 #[test]
854 fn config_dns_check_interval_default() {
855 let config: Config = toml::from_str("").unwrap();
856 assert_eq!(config.serve.dns_check_interval_secs, 3600);
857 }
858
859 #[test]
860 fn config_dns_check_interval_custom() {
861 let toml = r#"
862 [serve]
863 dns_check_interval_secs = 1800
864 "#;
865 let config: Config = toml::from_str(toml).unwrap();
866 assert_eq!(config.serve.dns_check_interval_secs, 1800);
867 }
868
869 #[test]
870 fn config_with_dns_records() {
871 let toml = r#"
872 [targets.mnw]
873 label = "MakeNotWork"
874
875 [[targets.mnw.dns]]
876 name = "makenot.work"
877 record_type = "A"
878 expected = ["5.78.144.244"]
879
880 [[targets.mnw.dns]]
881 name = "git.makenot.work"
882 record_type = "A"
883 expected = ["5.78.144.244"]
884 "#;
885 let config: Config = toml::from_str(toml).unwrap();
886 let mnw = config.get_target("mnw").unwrap();
887 assert_eq!(mnw.dns.len(), 2);
888 assert_eq!(mnw.dns[0].name, "makenot.work");
889 assert_eq!(mnw.dns[0].record_type, DnsRecordType::A);
890 assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
891 assert_eq!(mnw.dns[1].name, "git.makenot.work");
892 }
893
894 #[test]
895 fn config_dns_default_empty() {
896 let toml = r#"
897 [targets.mnw]
898 label = "MakeNotWork"
899 "#;
900 let config: Config = toml::from_str(toml).unwrap();
901 assert!(config.get_target("mnw").unwrap().dns.is_empty());
902 }
903
904 #[test]
905 fn config_with_whois() {
906 let toml = r#"
907 [targets.mnw]
908 label = "MakeNotWork"
909
910 [targets.mnw.whois]
911 domain = "makenot.work"
912 warn_days = 60
913 "#;
914 let config: Config = toml::from_str(toml).unwrap();
915 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
916 assert_eq!(whois.domain, "makenot.work");
917 assert_eq!(whois.warn_days, 60);
918 }
919
920 #[test]
921 fn config_whois_default_warn_days() {
922 let toml = r#"
923 [targets.mnw]
924 label = "MakeNotWork"
925
926 [targets.mnw.whois]
927 domain = "makenot.work"
928 "#;
929 let config: Config = toml::from_str(toml).unwrap();
930 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
931 assert_eq!(whois.warn_days, 30);
932 }
933
934 #[test]
935 fn config_without_whois() {
936 let toml = r#"
937 [targets.mnw]
938 label = "MakeNotWork"
939 "#;
940 let config: Config = toml::from_str(toml).unwrap();
941 assert!(config.get_target("mnw").unwrap().whois.is_none());
942 }
943
944 #[test]
945 fn config_dashboard_default_false() {
946 let config: Config = toml::from_str("").unwrap();
947 assert!(!config.serve.dashboard);
948 }
949
950 #[test]
951 fn config_dashboard_enabled() {
952 let toml = r#"
953 [serve]
954 dashboard = true
955 "#;
956 let config: Config = toml::from_str(toml).unwrap();
957 assert!(config.serve.dashboard);
958 }
959
960 #[test]
961 fn config_expected_routes_without_slash_detected() {
962 let toml = r#"
963 [targets.mnw]
964 label = "MakeNotWork"
965 expected_routes = ["discover", "/login"]
966 "#;
967 let config: Config = toml::from_str(toml).unwrap();
968 let bad_routes: Vec<_> = config.get_target("mnw").unwrap()
969 .expected_routes.iter()
970 .filter(|r| !r.starts_with('/'))
971 .collect();
972 assert_eq!(bad_routes, vec!["discover"]);
973 }
974
975 #[test]
976 fn config_whois_check_interval_default() {
977 let config: Config = toml::from_str("").unwrap();
978 assert_eq!(config.serve.whois_check_interval_secs, 86400);
979 }
980
981 #[test]
982 fn config_whois_check_interval_custom() {
983 let toml = r#"
984 [serve]
985 whois_check_interval_secs = 43200
986 "#;
987 let config: Config = toml::from_str(toml).unwrap();
988 assert_eq!(config.serve.whois_check_interval_secs, 43200);
989 }
990
991 // ─────────────────────────────────────────────────────────────────────
992 // Defaults-pin tests — every `default_*` constant function is pinned to
993 // its expected value. Catches `replace fn -> u64 with 0/1` mutations and
994 // accidental drift when defaults are tweaked. These constants encode
995 // operational policy (check cadence, retention, etc.) so changes should
996 // be deliberate.
997 // ─────────────────────────────────────────────────────────────────────
998
999 #[test]
1000 fn defaults_numeric_intervals() {
1001 assert_eq!(default_peer_heartbeat(), 60, "peer heartbeat = 1 min");
1002 assert_eq!(default_tls_check_interval(), 3600, "tls = 1 hour");
1003 assert_eq!(default_route_check_interval(), 300, "routes = 5 min");
1004 assert_eq!(default_dns_check_interval(), 3600, "dns = 1 hour");
1005 assert_eq!(default_cors_check_interval(), 3600, "cors = 1 hour");
1006 assert_eq!(default_whois_check_interval(), 86400, "whois = 24 hours");
1007 assert_eq!(default_serve_interval(), 300, "serve = 5 min");
1008 assert_eq!(default_prune_days(), 30, "prune = 30 days");
1009 }
1010
1011 #[test]
1012 fn defaults_listen_address() {
1013 assert_eq!(default_listen(), "127.0.0.1:9100");
1014 }
1015
1016 #[test]
1017 fn defaults_warn_thresholds() {
1018 assert_eq!(default_whois_warn_days(), 30, "whois 30 days lead time");
1019 assert_eq!(default_tls_warn_days(), 14, "tls 14 days lead time");
1020 assert_eq!(default_tls_port(), 443);
1021 }
1022
1023 #[test]
1024 fn defaults_cors() {
1025 assert_eq!(default_cors_method(), "PUT");
1026 assert_eq!(default_max_age_hours(), 25, "25h allows cron drift");
1027 }
1028
1029 #[test]
1030 fn defaults_backup() {
1031 assert_eq!(default_backup_interval(), 3600, "hourly backup check");
1032 }
1033
1034 #[test]
1035 fn defaults_ssh_banner() {
1036 assert_eq!(default_ssh_banner_port(), 22);
1037 assert_eq!(default_ssh_banner_timeout(), 5);
1038 }
1039
1040 #[test]
1041 fn defaults_latency_baseline() {
1042 assert_eq!(default_baseline_window_hours(), 168, "7 days");
1043 // Spike threshold compares as f64; pin with bit-exact match.
1044 assert_eq!(default_spike_threshold().to_bits(), 2.0_f64.to_bits());
1045 }
1046
1047 #[test]
1048 fn defaults_health_and_test_timeouts() {
1049 assert_eq!(default_health_timeout(), 10);
1050 assert_eq!(default_test_timeout(), 600, "10-minute CI suite budget");
1051 assert_eq!(default_staleness_days(), 7);
1052 }
1053
1054 #[test]
1055 fn defaults_alerts() {
1056 assert_eq!(default_alert_from(), "PoM Alerts <pom-alerts@makenot.work>");
1057 assert_eq!(default_cooldown_secs(), 300, "5-minute alert cooldown");
1058 }
1059
1060 // ── Config method tests ──
1061
1062 #[test]
1063 fn instance_name_returns_configured_value() {
1064 let toml = r#"
1065 [serve]
1066 [instance]
1067 name = "test-host"
1068 [targets.x]
1069 label = "X"
1070 [targets.x.health]
1071 url = "https://example.com"
1072 "#;
1073 let config: Config = toml::from_str(toml).unwrap();
1074 assert_eq!(config.instance_name(), "test-host");
1075 }
1076
1077 #[test]
1078 fn instance_name_falls_back_to_non_empty() {
1079 // When `name` is None, fall back to hostname or "unknown" — must not be
1080 // empty regardless. Catches the `instance_name -> String with "xyzzy"`
1081 // mutant and the empty-string variant.
1082 let toml = r#"
1083 [serve]
1084 [instance]
1085 [targets.x]
1086 label = "X"
1087 [targets.x.health]
1088 url = "https://example.com"
1089 "#;
1090 let config: Config = toml::from_str(toml).unwrap();
1091 let name = config.instance_name();
1092 assert!(!name.is_empty(), "fallback must produce a non-empty name");
1093 // It also must not be the cargo-mutants sentinel.
1094 assert_ne!(name, "xyzzy");
1095 }
1096
1097 #[test]
1098 fn default_config_path_ends_in_pom_toml() {
1099 // The exact dir varies per OS, but the suffix is stable.
1100 // Catches `default_config_path -> Ok(Default::default())` (which would
1101 // return an empty PathBuf and fail the ends_with check).
1102 let path = default_config_path().unwrap();
1103 assert!(
1104 path.ends_with("pom/pom.toml") || path.ends_with("pom\\pom.toml"),
1105 "expected …/pom/pom.toml, got {path:?}"
1106 );
1107 }
1108
1109 #[test]
1110 fn db_path_ends_in_pom_db() {
1111 // Same rationale as default_config_path. db_path also has a side
1112 // effect (creates the parent dir) so we can't easily mock it; the
1113 // suffix check is the cleanest pin.
1114 let path = db_path().unwrap();
1115 assert!(
1116 path.ends_with("pom/pom.db") || path.ends_with("pom\\pom.db"),
1117 "expected …/pom/pom.db, got {path:?}"
1118 );
1119 }
1120
1121 #[test]
1122 fn config_load_rejects_route_without_leading_slash() {
1123 // Catches `delete ! in Config::load` (L431): without the `!`, the
1124 // validator would only reject routes that DO start with '/' — wrong.
1125 let toml = r#"
1126 [serve]
1127 [targets.bad]
1128 label = "Bad"
1129 expected_routes = ["no-leading-slash"]
1130 [targets.bad.health]
1131 url = "https://example.com"
1132 "#;
1133 let tmp = std::env::temp_dir().join(format!("pom_test_{}.toml", std::process::id()));
1134 std::fs::write(&tmp, toml).unwrap();
1135 let result = Config::load(Some(tmp.as_path()));
1136 let _ = std::fs::remove_file(&tmp);
1137 assert!(
1138 matches!(result, Err(PomError::Config(_))),
1139 "expected Config error rejecting bad route; got {result:?}"
1140 );
1141 }
1142 }
1143