Skip to main content

max / pom

26.5 KB · 897 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 }
42
43 #[derive(Debug, Clone, Default, Deserialize)]
44 pub struct InstanceConfig {
45 /// Human-readable instance name. Falls back to OS hostname if unset.
46 pub name: Option<String>,
47 /// Fixed instance UUID. Auto-generated and persisted to disk if unset.
48 pub id: Option<String>,
49 }
50
51 #[derive(Debug, Clone, Deserialize)]
52 pub struct PeerConfig {
53 /// Network address of the peer (host:port).
54 pub address: String,
55 /// Action to take when the peer is declared missing.
56 #[serde(default)]
57 pub on_missing: OnMissing,
58 /// Number of consecutive heartbeat failures before declaring the peer missing.
59 /// Defaults to 3 at runtime if unset.
60 pub grace_count: Option<u32>,
61 /// Bearer token for authenticating with this peer's API.
62 pub token: Option<String>,
63 }
64
65 #[derive(Debug, Clone, Deserialize)]
66 pub struct ServeConfig {
67 /// Seconds between health check cycles for all targets.
68 #[serde(default = "default_serve_interval")]
69 pub interval_secs: u64,
70 /// Number of days of history to retain before pruning.
71 #[serde(default = "default_prune_days")]
72 pub prune_days: i64,
73 /// Socket address the API server binds to (e.g. "127.0.0.1:9100").
74 #[serde(default = "default_listen")]
75 pub listen: String,
76 /// Seconds between peer heartbeat probes.
77 #[serde(default = "default_peer_heartbeat")]
78 pub peer_heartbeat_secs: u64,
79 /// Seconds between TLS certificate checks.
80 #[serde(default = "default_tls_check_interval")]
81 pub tls_check_interval_secs: u64,
82 /// Seconds between route accessibility checks for all targets.
83 #[serde(default = "default_route_check_interval")]
84 pub route_check_interval_secs: u64,
85 /// Seconds between DNS record verification checks.
86 #[serde(default = "default_dns_check_interval")]
87 pub dns_check_interval_secs: u64,
88 /// Seconds between CORS preflight verification checks.
89 #[serde(default = "default_cors_check_interval")]
90 pub cors_check_interval_secs: u64,
91 /// Bearer token required for API access. If set, all /api/* requests must
92 /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var.
93 pub api_token: Option<String>,
94 /// Enable the HTML dashboard at `GET /`. Disabled by default.
95 #[serde(default)]
96 pub dashboard: bool,
97 }
98
99 impl Default for ServeConfig {
100 fn default() -> Self {
101 Self {
102 interval_secs: 300,
103 prune_days: 30,
104 listen: default_listen(),
105 peer_heartbeat_secs: 60,
106 tls_check_interval_secs: 3600,
107 route_check_interval_secs: 300,
108 dns_check_interval_secs: 3600,
109 cors_check_interval_secs: 3600,
110 api_token: None,
111 dashboard: false,
112 }
113 }
114 }
115
116 fn default_peer_heartbeat() -> u64 {
117 // 1 minute: detects peer failures within the grace period
118 60
119 }
120
121 fn default_tls_check_interval() -> u64 {
122 // 1 hour: certificates change slowly, no need to probe frequently
123 3600
124 }
125
126 fn default_route_check_interval() -> u64 {
127 // 5 minutes: same cadence as health checks, catches broken pages quickly
128 300
129 }
130
131 fn default_dns_check_interval() -> u64 {
132 // 1 hour: DNS records change infrequently, same cadence as TLS checks
133 3600
134 }
135
136 fn default_cors_check_interval() -> u64 {
137 // 1 hour: CORS policies change infrequently
138 3600
139 }
140
141 fn default_serve_interval() -> u64 {
142 // 5 minutes: frequent enough to catch outages within an SLA window,
143 // infrequent enough to avoid noise
144 300
145 }
146
147 fn default_prune_days() -> i64 {
148 // 30 days: enough history for monthly reporting, keeps DB small
149 30
150 }
151
152 fn default_listen() -> String {
153 "127.0.0.1:9100".to_string()
154 }
155
156 #[derive(Debug, Clone, Deserialize)]
157 pub struct TargetConfig {
158 /// Human-readable display name for this target.
159 pub label: String,
160 /// HTTP health check configuration. `None` disables health monitoring.
161 pub health: Option<HealthConfig>,
162 /// Remote test runner configuration. `None` disables test execution.
163 pub tests: Option<TestsConfig>,
164 /// TLS certificate monitoring configuration. `None` disables TLS checks.
165 pub tls: Option<TlsConfig>,
166 /// Expected routes to check for accessibility. Empty disables route checks.
167 /// Requires `health` config for base URL derivation.
168 #[serde(default)]
169 pub expected_routes: Vec<String>,
170 /// DNS records to verify. Empty disables DNS checks.
171 #[serde(default)]
172 pub dns: Vec<DnsRecord>,
173 /// WHOIS domain expiry monitoring. `None` disables WHOIS checks.
174 pub whois: Option<WhoisConfig>,
175 /// CORS preflight checks. Empty disables CORS checks.
176 #[serde(default)]
177 pub cors: Vec<CorsCheck>,
178 }
179
180 #[derive(Debug, Clone, Deserialize)]
181 pub struct DnsRecord {
182 /// Hostname to resolve (e.g. "makenot.work").
183 pub name: String,
184 /// DNS record type: A, AAAA, CNAME, MX, TXT.
185 pub record_type: DnsRecordType,
186 /// Expected values (order-independent set comparison).
187 pub expected: Vec<String>,
188 }
189
190 #[derive(Debug, Clone, Deserialize)]
191 pub struct WhoisConfig {
192 /// Domain to check (e.g. "makenot.work").
193 pub domain: String,
194 /// Alert when registration expires within this many days. Defaults to 30.
195 #[serde(default = "default_whois_warn_days")]
196 pub warn_days: u32,
197 }
198
199 fn default_whois_warn_days() -> u32 {
200 30
201 }
202
203 #[derive(Debug, Clone, Deserialize)]
204 pub struct CorsCheck {
205 /// URL to send the preflight OPTIONS request to.
206 pub url: String,
207 /// Expected `Access-Control-Allow-Origin` value.
208 pub origin: String,
209 /// HTTP method to include in `Access-Control-Request-Method`.
210 #[serde(default = "default_cors_method")]
211 pub method: String,
212 }
213
214 fn default_cors_method() -> String {
215 "PUT".to_string()
216 }
217
218 #[derive(Debug, Clone, Deserialize)]
219 pub struct TlsConfig {
220 /// Hostname to connect to for the TLS check.
221 pub host: String,
222 /// TCP port for the TLS connection.
223 #[serde(default = "default_tls_port")]
224 pub port: u16,
225 /// Days before expiry at which to start warning.
226 #[serde(default = "default_tls_warn_days")]
227 pub warn_days: u32,
228 }
229
230 fn default_tls_port() -> u16 {
231 443
232 }
233
234 fn default_tls_warn_days() -> u32 {
235 // 2 weeks: enough lead time to renew before expiry
236 14
237 }
238
239 #[derive(Debug, Clone, Deserialize)]
240 pub struct HealthConfig {
241 /// URL of the health endpoint to check.
242 pub url: String,
243 /// HTTP request timeout in seconds for this health check.
244 #[serde(default = "default_health_timeout")]
245 pub timeout_secs: u64,
246 /// Per-target interval override for serve mode.
247 pub interval_secs: Option<u64>,
248 /// Response validation expectations.
249 pub expect: Option<HealthExpectation>,
250 /// Latency trending and drift detection.
251 pub trending: Option<TrendingConfig>,
252 }
253
254 #[derive(Debug, Clone, Deserialize)]
255 pub struct TrendingConfig {
256 /// Number of hours of history used to compute the baseline average latency.
257 #[serde(default = "default_baseline_window_hours")]
258 pub baseline_window_hours: u64,
259 /// Multiplier over the baseline average that constitutes a latency spike.
260 #[serde(default = "default_spike_threshold")]
261 pub spike_threshold: f64,
262 }
263
264 fn default_baseline_window_hours() -> u64 {
265 // 7 days: captures weekly traffic patterns for stable baseline
266 168
267 }
268
269 fn default_spike_threshold() -> f64 {
270 // 2x baseline average: significant deviation without false positives
271 // from normal variance
272 2.0
273 }
274
275 #[derive(Debug, Clone, Deserialize, Default)]
276 pub struct HealthExpectation {
277 /// Expected HTTP status code (e.g. 200). `None` accepts any 2xx.
278 pub status_code: Option<u16>,
279 /// JSON field paths and their expected string values (e.g. `{"status": "operational"}`).
280 #[serde(default)]
281 pub json_fields: HashMap<String, String>,
282 /// Substring that must appear in the response body.
283 pub body_contains: Option<String>,
284 }
285
286 #[derive(Debug, Clone, Deserialize)]
287 pub struct TestsConfig {
288 /// SSH host alias (from ~/.ssh/config) for the test runner machine.
289 pub ssh: String,
290 /// Shell command to execute on the remote host to run tests.
291 pub command: String,
292 /// Maximum seconds to wait for the test command before killing it.
293 #[serde(default = "default_test_timeout")]
294 pub timeout_secs: u64,
295 /// Number of days after which a test run is considered stale.
296 #[serde(default = "default_staleness_days")]
297 pub staleness_days: u64,
298 }
299
300 fn default_staleness_days() -> u64 {
301 // 1 week: tests older than a week may not reflect current code
302 7
303 }
304
305 fn default_health_timeout() -> u64 {
306 // 10 seconds: generous for most HTTP endpoints, avoids false positives
307 // on slow networks
308 10
309 }
310
311 fn default_test_timeout() -> u64 {
312 // 10 minutes: full CI suites can take time, especially on slow machines
313 600
314 }
315
316 fn default_alert_from() -> String {
317 "PoM Alerts <pom-alerts@makenot.work>".to_string()
318 }
319
320 fn default_cooldown_secs() -> u64 {
321 // 5 minutes: prevents alert storms during sustained outages
322 300
323 }
324
325 impl Config {
326 pub fn load(path: Option<&Path>) -> Result<Self> {
327 let config_path = match path {
328 Some(p) => p.to_path_buf(),
329 None => default_config_path()?,
330 };
331
332 if !config_path.exists() {
333 return Err(PomError::Config(format!(
334 "Config file not found: {}",
335 config_path.display()
336 )));
337 }
338
339 let contents = std::fs::read_to_string(&config_path)?;
340 let mut config: Config = toml::from_str(&contents)?;
341
342 // Allow postmark_token from environment variable (preferred over config file)
343 if let Some(ref mut alerts) = config.alerts
344 && alerts.postmark_token.is_none()
345 && let Ok(token) = std::env::var("POM_POSTMARK_TOKEN")
346 {
347 alerts.postmark_token = Some(token);
348 }
349
350 // Allow api_token from environment variable (preferred over config file)
351 if config.serve.api_token.is_none()
352 && let Ok(token) = std::env::var("POM_API_TOKEN")
353 {
354 config.serve.api_token = Some(token);
355 }
356
357 // Validate no duplicate target labels
358 let mut seen_labels = std::collections::HashSet::new();
359 for (name, target) in &config.targets {
360 if !seen_labels.insert(target.label.to_lowercase()) {
361 return Err(PomError::Config(format!(
362 "duplicate target label: \"{}\" (on target \"{name}\")",
363 target.label
364 )));
365 }
366 }
367
368 // Validate expected_routes paths start with '/'
369 for (name, target) in &config.targets {
370 for route in &target.expected_routes {
371 if !route.starts_with('/') {
372 return Err(PomError::Config(format!(
373 "target {name}: expected_route \"{route}\" must start with '/'"
374 )));
375 }
376 }
377 }
378
379 Ok(config)
380 }
381
382 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
383 self.targets.get(name)
384 }
385
386 pub fn target_names(&self) -> Vec<String> {
387 let mut names: Vec<_> = self.targets.keys().cloned().collect();
388 names.sort();
389 names
390 }
391
392 pub fn instance_name(&self) -> String {
393 self.instance
394 .name
395 .clone()
396 .unwrap_or_else(|| hostname::get().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|_| "unknown".to_string()))
397 }
398 }
399
400 pub fn default_config_path() -> Result<PathBuf> {
401 let config_dir =
402 dirs::config_dir().ok_or_else(|| PomError::Config("Could not determine config directory".into()));
403 Ok(config_dir?.join("pom").join("pom.toml"))
404 }
405
406 pub fn db_path() -> Result<PathBuf> {
407 let data_dir =
408 dirs::data_local_dir().ok_or_else(|| PomError::Config("Could not determine data directory".into()));
409 let pom_dir = data_dir?.join("pom");
410 std::fs::create_dir_all(&pom_dir)?;
411 Ok(pom_dir.join("pom.db"))
412 }
413
414 #[cfg(test)]
415 mod tests {
416 use super::*;
417
418 #[test]
419 fn parse_full_config() {
420 let toml = r#"
421 [serve]
422 interval_secs = 120
423 listen = "127.0.0.1:9100"
424 peer_heartbeat_secs = 30
425
426 [instance]
427 name = "hetzner"
428
429 [targets.mnw]
430 label = "MakeNotWork"
431 [targets.mnw.health]
432 url = "https://makenot.work/health"
433 timeout_secs = 5
434 [targets.mnw.tests]
435 ssh = "hetzner"
436 command = "cd /srv/mnw && ./ci.sh"
437
438 [peers.astra]
439 address = "100.0.0.1:9100"
440 on_missing = "alert"
441 grace_count = 5
442 "#;
443
444 let config: Config = toml::from_str(toml).unwrap();
445 assert_eq!(config.serve.interval_secs, 120);
446 assert_eq!(config.serve.listen, "127.0.0.1:9100");
447 assert_eq!(config.serve.peer_heartbeat_secs, 30);
448 assert_eq!(config.instance.name.as_deref(), Some("hetzner"));
449 assert_eq!(config.target_names(), vec!["mnw"]);
450
451 let mnw = config.get_target("mnw").unwrap();
452 assert_eq!(mnw.label, "MakeNotWork");
453 assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5);
454 assert_eq!(mnw.tests.as_ref().unwrap().ssh, "hetzner");
455
456 let astra = config.peers.get("astra").unwrap();
457 assert_eq!(astra.address, "100.0.0.1:9100");
458 assert_eq!(astra.on_missing, OnMissing::Alert);
459 assert_eq!(astra.grace_count, Some(5));
460 }
461
462 #[test]
463 fn empty_config_uses_defaults() {
464 let config: Config = toml::from_str("").unwrap();
465 assert_eq!(config.serve.interval_secs, 300);
466 assert_eq!(config.serve.prune_days, 30);
467 assert_eq!(config.serve.listen, "127.0.0.1:9100");
468 assert_eq!(config.serve.peer_heartbeat_secs, 60);
469 assert!(config.targets.is_empty());
470 assert!(config.peers.is_empty());
471 assert!(config.instance.name.is_none());
472 }
473
474 #[test]
475 fn peer_on_missing_defaults_to_log() {
476 let toml = r#"
477 [peers.test]
478 address = "10.0.0.1:9100"
479 "#;
480 let config: Config = toml::from_str(toml).unwrap();
481 let peer = config.peers.get("test").unwrap();
482 assert_eq!(peer.on_missing, OnMissing::Log);
483 assert_eq!(peer.grace_count, None);
484 assert!(peer.token.is_none());
485 }
486
487 #[test]
488 fn peer_with_token() {
489 let toml = r#"
490 [peers.test]
491 address = "10.0.0.1:9100"
492 token = "peer-secret-123"
493 "#;
494 let config: Config = toml::from_str(toml).unwrap();
495 let peer = config.peers.get("test").unwrap();
496 assert_eq!(peer.token.as_deref(), Some("peer-secret-123"));
497 }
498
499 #[test]
500 fn serve_api_token_from_config() {
501 let toml = r#"
502 [serve]
503 api_token = "my-api-secret"
504 "#;
505 let config: Config = toml::from_str(toml).unwrap();
506 assert_eq!(config.serve.api_token.as_deref(), Some("my-api-secret"));
507 }
508
509 #[test]
510 fn serve_api_token_defaults_to_none() {
511 let config: Config = toml::from_str("").unwrap();
512 assert!(config.serve.api_token.is_none());
513 }
514
515 #[test]
516 fn instance_name_falls_back_to_hostname() {
517 let config: Config = toml::from_str("").unwrap();
518 let name = config.instance_name();
519 assert!(!name.is_empty());
520 }
521
522 #[test]
523 fn config_without_alerts_section() {
524 let config: Config = toml::from_str("").unwrap();
525 assert!(config.alerts.is_none());
526 }
527
528 #[test]
529 fn config_with_alerts_section() {
530 let toml = r#"
531 [alerts]
532 postmark_token = "test-token"
533 to = "alerts@example.com"
534 "#;
535 let config: Config = toml::from_str(toml).unwrap();
536 let alerts = config.alerts.unwrap();
537 assert_eq!(alerts.postmark_token.as_deref(), Some("test-token"));
538 assert_eq!(alerts.to, "alerts@example.com");
539 assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
540 assert_eq!(alerts.cooldown_secs, 300);
541 }
542
543 #[test]
544 fn config_with_tls() {
545 let toml = r#"
546 [targets.mnw]
547 label = "MakeNotWork"
548 [targets.mnw.tls]
549 host = "makenot.work"
550 port = 8443
551 warn_days = 30
552 "#;
553 let config: Config = toml::from_str(toml).unwrap();
554 let mnw = config.get_target("mnw").unwrap();
555 let tls = mnw.tls.as_ref().unwrap();
556 assert_eq!(tls.host, "makenot.work");
557 assert_eq!(tls.port, 8443);
558 assert_eq!(tls.warn_days, 30);
559 }
560
561 #[test]
562 fn config_tls_defaults() {
563 let toml = r#"
564 [targets.mnw]
565 label = "MakeNotWork"
566 [targets.mnw.tls]
567 host = "makenot.work"
568 "#;
569 let config: Config = toml::from_str(toml).unwrap();
570 let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap();
571 assert_eq!(tls.port, 443);
572 assert_eq!(tls.warn_days, 14);
573 }
574
575 #[test]
576 fn config_without_tls() {
577 let toml = r#"
578 [targets.mnw]
579 label = "MakeNotWork"
580 "#;
581 let config: Config = toml::from_str(toml).unwrap();
582 assert!(config.get_target("mnw").unwrap().tls.is_none());
583 }
584
585 #[test]
586 fn config_tls_check_interval_default() {
587 let config: Config = toml::from_str("").unwrap();
588 assert_eq!(config.serve.tls_check_interval_secs, 3600);
589 }
590
591 #[test]
592 fn config_tls_check_interval_custom() {
593 let toml = r#"
594 [serve]
595 tls_check_interval_secs = 1800
596 "#;
597 let config: Config = toml::from_str(toml).unwrap();
598 assert_eq!(config.serve.tls_check_interval_secs, 1800);
599 }
600
601 #[test]
602 fn config_with_health_expect() {
603 let toml = r#"
604 [targets.mnw]
605 label = "MakeNotWork"
606 [targets.mnw.health]
607 url = "https://makenot.work/health"
608 [targets.mnw.health.expect]
609 status_code = 200
610 body_contains = "operational"
611 json_fields = { "status" = "operational", "checks.db" = "ok" }
612 "#;
613 let config: Config = toml::from_str(toml).unwrap();
614 let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
615 assert_eq!(expect.status_code, Some(200));
616 assert_eq!(expect.body_contains.as_deref(), Some("operational"));
617 assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
618 assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok");
619 }
620
621 #[test]
622 fn config_health_without_expect() {
623 let toml = r#"
624 [targets.mnw]
625 label = "MakeNotWork"
626 [targets.mnw.health]
627 url = "https://makenot.work/health"
628 "#;
629 let config: Config = toml::from_str(toml).unwrap();
630 assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.is_none());
631 }
632
633 #[test]
634 fn config_with_trending() {
635 let toml = r#"
636 [targets.mnw]
637 label = "MakeNotWork"
638 [targets.mnw.health]
639 url = "https://makenot.work/health"
640 [targets.mnw.health.trending]
641 baseline_window_hours = 48
642 spike_threshold = 1.5
643 "#;
644 let config: Config = toml::from_str(toml).unwrap();
645 let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
646 assert_eq!(trending.baseline_window_hours, 48);
647 assert_eq!(trending.spike_threshold, 1.5);
648 }
649
650 #[test]
651 fn config_trending_defaults() {
652 let toml = r#"
653 [targets.mnw]
654 label = "MakeNotWork"
655 [targets.mnw.health]
656 url = "https://makenot.work/health"
657 [targets.mnw.health.trending]
658 "#;
659 let config: Config = toml::from_str(toml).unwrap();
660 let trending = config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.as_ref().unwrap();
661 assert_eq!(trending.baseline_window_hours, 168);
662 assert_eq!(trending.spike_threshold, 2.0);
663 }
664
665 #[test]
666 fn config_without_trending() {
667 let toml = r#"
668 [targets.mnw]
669 label = "MakeNotWork"
670 [targets.mnw.health]
671 url = "https://makenot.work/health"
672 "#;
673 let config: Config = toml::from_str(toml).unwrap();
674 assert!(config.get_target("mnw").unwrap().health.as_ref().unwrap().trending.is_none());
675 }
676
677 #[test]
678 fn config_health_expect_empty() {
679 let toml = r#"
680 [targets.mnw]
681 label = "MakeNotWork"
682 [targets.mnw.health]
683 url = "https://makenot.work/health"
684 [targets.mnw.health.expect]
685 "#;
686 let config: Config = toml::from_str(toml).unwrap();
687 let expect = config.get_target("mnw").unwrap().health.as_ref().unwrap().expect.as_ref().unwrap();
688 assert_eq!(expect.status_code, None);
689 assert!(expect.json_fields.is_empty());
690 assert_eq!(expect.body_contains, None);
691 }
692
693 #[test]
694 fn config_staleness_days_default() {
695 let toml = r#"
696 [targets.mnw]
697 label = "MakeNotWork"
698 [targets.mnw.tests]
699 ssh = "host"
700 command = "./ci.sh"
701 "#;
702 let config: Config = toml::from_str(toml).unwrap();
703 assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 7);
704 }
705
706 #[test]
707 fn config_staleness_days_custom() {
708 let toml = r#"
709 [targets.mnw]
710 label = "MakeNotWork"
711 [targets.mnw.tests]
712 ssh = "host"
713 command = "./ci.sh"
714 staleness_days = 14
715 "#;
716 let config: Config = toml::from_str(toml).unwrap();
717 assert_eq!(config.get_target("mnw").unwrap().tests.as_ref().unwrap().staleness_days, 14);
718 }
719
720 #[test]
721 fn config_with_alerts_custom_defaults() {
722 let toml = r#"
723 [alerts]
724 to = "alerts@example.com"
725 from = "Custom <custom@example.com>"
726 cooldown_secs = 60
727 "#;
728 let config: Config = toml::from_str(toml).unwrap();
729 let alerts = config.alerts.unwrap();
730 assert!(alerts.postmark_token.is_none());
731 assert_eq!(alerts.from, "Custom <custom@example.com>");
732 assert_eq!(alerts.cooldown_secs, 60);
733 }
734
735 #[test]
736 fn config_expected_routes() {
737 let toml = r#"
738 [targets.mnw]
739 label = "MakeNotWork"
740 expected_routes = ["/", "/discover", "/login", "/docs"]
741 [targets.mnw.health]
742 url = "https://makenot.work/api/health"
743 "#;
744 let config: Config = toml::from_str(toml).unwrap();
745 let mnw = config.get_target("mnw").unwrap();
746 assert_eq!(mnw.expected_routes, vec!["/", "/discover", "/login", "/docs"]);
747 }
748
749 #[test]
750 fn config_expected_routes_default_empty() {
751 let toml = r#"
752 [targets.mnw]
753 label = "MakeNotWork"
754 "#;
755 let config: Config = toml::from_str(toml).unwrap();
756 assert!(config.get_target("mnw").unwrap().expected_routes.is_empty());
757 }
758
759 #[test]
760 fn config_route_check_interval_default() {
761 let config: Config = toml::from_str("").unwrap();
762 assert_eq!(config.serve.route_check_interval_secs, 300);
763 }
764
765 #[test]
766 fn config_route_check_interval_custom() {
767 let toml = r#"
768 [serve]
769 route_check_interval_secs = 600
770 "#;
771 let config: Config = toml::from_str(toml).unwrap();
772 assert_eq!(config.serve.route_check_interval_secs, 600);
773 }
774
775 #[test]
776 fn config_dns_check_interval_default() {
777 let config: Config = toml::from_str("").unwrap();
778 assert_eq!(config.serve.dns_check_interval_secs, 3600);
779 }
780
781 #[test]
782 fn config_dns_check_interval_custom() {
783 let toml = r#"
784 [serve]
785 dns_check_interval_secs = 1800
786 "#;
787 let config: Config = toml::from_str(toml).unwrap();
788 assert_eq!(config.serve.dns_check_interval_secs, 1800);
789 }
790
791 #[test]
792 fn config_with_dns_records() {
793 let toml = r#"
794 [targets.mnw]
795 label = "MakeNotWork"
796
797 [[targets.mnw.dns]]
798 name = "makenot.work"
799 record_type = "A"
800 expected = ["5.78.144.244"]
801
802 [[targets.mnw.dns]]
803 name = "git.makenot.work"
804 record_type = "A"
805 expected = ["5.78.144.244"]
806 "#;
807 let config: Config = toml::from_str(toml).unwrap();
808 let mnw = config.get_target("mnw").unwrap();
809 assert_eq!(mnw.dns.len(), 2);
810 assert_eq!(mnw.dns[0].name, "makenot.work");
811 assert_eq!(mnw.dns[0].record_type, DnsRecordType::A);
812 assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
813 assert_eq!(mnw.dns[1].name, "git.makenot.work");
814 }
815
816 #[test]
817 fn config_dns_default_empty() {
818 let toml = r#"
819 [targets.mnw]
820 label = "MakeNotWork"
821 "#;
822 let config: Config = toml::from_str(toml).unwrap();
823 assert!(config.get_target("mnw").unwrap().dns.is_empty());
824 }
825
826 #[test]
827 fn config_with_whois() {
828 let toml = r#"
829 [targets.mnw]
830 label = "MakeNotWork"
831
832 [targets.mnw.whois]
833 domain = "makenot.work"
834 warn_days = 60
835 "#;
836 let config: Config = toml::from_str(toml).unwrap();
837 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
838 assert_eq!(whois.domain, "makenot.work");
839 assert_eq!(whois.warn_days, 60);
840 }
841
842 #[test]
843 fn config_whois_default_warn_days() {
844 let toml = r#"
845 [targets.mnw]
846 label = "MakeNotWork"
847
848 [targets.mnw.whois]
849 domain = "makenot.work"
850 "#;
851 let config: Config = toml::from_str(toml).unwrap();
852 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
853 assert_eq!(whois.warn_days, 30);
854 }
855
856 #[test]
857 fn config_without_whois() {
858 let toml = r#"
859 [targets.mnw]
860 label = "MakeNotWork"
861 "#;
862 let config: Config = toml::from_str(toml).unwrap();
863 assert!(config.get_target("mnw").unwrap().whois.is_none());
864 }
865
866 #[test]
867 fn config_dashboard_default_false() {
868 let config: Config = toml::from_str("").unwrap();
869 assert!(!config.serve.dashboard);
870 }
871
872 #[test]
873 fn config_dashboard_enabled() {
874 let toml = r#"
875 [serve]
876 dashboard = true
877 "#;
878 let config: Config = toml::from_str(toml).unwrap();
879 assert!(config.serve.dashboard);
880 }
881
882 #[test]
883 fn config_expected_routes_without_slash_detected() {
884 let toml = r#"
885 [targets.mnw]
886 label = "MakeNotWork"
887 expected_routes = ["discover", "/login"]
888 "#;
889 let config: Config = toml::from_str(toml).unwrap();
890 let bad_routes: Vec<_> = config.get_target("mnw").unwrap()
891 .expected_routes.iter()
892 .filter(|r| !r.starts_with('/'))
893 .collect();
894 assert_eq!(bad_routes, vec!["discover"]);
895 }
896 }
897