max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+1096 insertions,
-69 deletions
| @@ -140,6 +140,11 @@ | |||
| 140 | 140 | /// Seconds between WHOIS domain expiry checks. | |
| 141 | 141 | #[serde(default = "default_whois_check_interval")] | |
| 142 | 142 | pub whois_check_interval_secs: u64, | |
| 143 | + | /// Seconds between staleness sweeps of the configured test suites. This is | |
| 144 | + | /// not how often tests run: the sweep runs a suite only when its last run is | |
| 145 | + | /// older than that target's `tests.staleness_days`, or when it has never run. | |
| 146 | + | #[serde(default = "default_test_sweep_interval")] | |
| 147 | + | pub test_sweep_interval_secs: u64, | |
| 143 | 148 | /// Bearer token required for API access. If set, all /api/* requests must | |
| 144 | 149 | /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var. | |
| 145 | 150 | pub api_token: Option<String>, | |
| @@ -166,6 +171,7 @@ | |||
| 166 | 171 | .field("dns_check_interval_secs", &self.dns_check_interval_secs) | |
| 167 | 172 | .field("cors_check_interval_secs", &self.cors_check_interval_secs) | |
| 168 | 173 | .field("whois_check_interval_secs", &self.whois_check_interval_secs) | |
| 174 | + | .field("test_sweep_interval_secs", &self.test_sweep_interval_secs) | |
| 169 | 175 | .field("api_token", &self.api_token.as_ref().map(|_| "***")) | |
| 170 | 176 | .field("dashboard", &self.dashboard) | |
| 171 | 177 | .field("confirmations", &self.confirmations) | |
| @@ -189,6 +195,7 @@ | |||
| 189 | 195 | dns_check_interval_secs: 3600, | |
| 190 | 196 | cors_check_interval_secs: 3600, | |
| 191 | 197 | whois_check_interval_secs: 86400, | |
| 198 | + | test_sweep_interval_secs: default_test_sweep_interval(), | |
| 192 | 199 | api_token: None, | |
| 193 | 200 | dashboard: false, | |
| 194 | 201 | confirmations: default_confirmations(), | |
| @@ -226,6 +233,12 @@ | |||
| 226 | 233 | 86400 | |
| 227 | 234 | } | |
| 228 | 235 | ||
| 236 | + | fn default_test_sweep_interval() -> u64 { | |
| 237 | + | // 1 hour. The sweep is cheap (one DB read per target); the suite it may | |
| 238 | + | // trigger is not, which is why `staleness_days` gates the run itself. | |
| 239 | + | 3600 | |
| 240 | + | } | |
| 241 | + | ||
| 229 | 242 | fn default_serve_interval() -> u64 { | |
| 230 | 243 | // 5 minutes: frequent enough to catch outages within an SLA window, | |
| 231 | 244 | // infrequent enough to avoid noise | |
| @@ -617,9 +630,16 @@ | |||
| 617 | 630 | ||
| 618 | 631 | #[derive(Debug, Clone, Deserialize)] | |
| 619 | 632 | pub struct TestsConfig { | |
| 620 | - | /// SSH host alias (from ~/.ssh/config) for the test runner machine. | |
| 621 | - | pub ssh: String, | |
| 622 | - | /// Shell command to execute on the remote host to run tests. | |
| 633 | + | /// SSH host alias (from `~/.ssh/config`) for the test runner machine. | |
| 634 | + | /// | |
| 635 | + | /// Omit when the runner is the host PoM runs on: the command is then | |
| 636 | + | /// executed locally. Pointing this at the local machine's own address | |
| 637 | + | /// instead only works if a regular sshd is listening, which is not a given | |
| 638 | + | /// on a Tailscale-SSH host (tailscaled does not intercept a node | |
| 639 | + | /// connecting to itself). | |
| 640 | + | #[serde(default)] | |
| 641 | + | pub ssh: Option<String>, | |
| 642 | + | /// Shell command to run the tests, remotely or locally per `ssh`. | |
| 623 | 643 | pub command: String, | |
| 624 | 644 | /// Maximum seconds to wait for the test command before killing it. | |
| 625 | 645 | #[serde(default = "default_test_timeout")] | |
| @@ -812,7 +832,7 @@ | |||
| 812 | 832 | let mnw = config.get_target("mnw").unwrap(); | |
| 813 | 833 | assert_eq!(mnw.label, "MakeNotWork"); | |
| 814 | 834 | assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5); | |
| 815 | - | assert_eq!(mnw.tests.as_ref().unwrap().ssh, "hetzner"); | |
| 835 | + | assert_eq!(mnw.tests.as_ref().unwrap().ssh.as_deref(), Some("hetzner")); | |
| 816 | 836 | ||
| 817 | 837 | let astra = config.peers.get("astra").unwrap(); | |
| 818 | 838 | assert_eq!(astra.address, "100.0.0.1:9100"); |
| @@ -25,6 +25,8 @@ | |||
| 25 | 25 | LatencyDrift, | |
| 26 | 26 | LatencyRecovery, | |
| 27 | 27 | TestDurationDrift, | |
| 28 | + | TestFailure, | |
| 29 | + | TestRecovery, | |
| 28 | 30 | CorsFailure, | |
| 29 | 31 | CorsRecovery, | |
| 30 | 32 | BackupStale, | |
| @@ -58,6 +60,8 @@ | |||
| 58 | 60 | Self::LatencyDrift => write!(f, "latency_drift"), | |
| 59 | 61 | Self::LatencyRecovery => write!(f, "latency_recovery"), | |
| 60 | 62 | Self::TestDurationDrift => write!(f, "test_duration_drift"), | |
| 63 | + | Self::TestFailure => write!(f, "test_failure"), | |
| 64 | + | Self::TestRecovery => write!(f, "test_recovery"), | |
| 61 | 65 | Self::CorsFailure => write!(f, "cors_failure"), | |
| 62 | 66 | Self::CorsRecovery => write!(f, "cors_recovery"), | |
| 63 | 67 | Self::BackupStale => write!(f, "backup_stale"), | |
| @@ -94,6 +98,8 @@ | |||
| 94 | 98 | "latency_drift" => Ok(Self::LatencyDrift), | |
| 95 | 99 | "latency_recovery" => Ok(Self::LatencyRecovery), | |
| 96 | 100 | "test_duration_drift" => Ok(Self::TestDurationDrift), | |
| 101 | + | "test_failure" => Ok(Self::TestFailure), | |
| 102 | + | "test_recovery" => Ok(Self::TestRecovery), | |
| 97 | 103 | "cors_failure" => Ok(Self::CorsFailure), | |
| 98 | 104 | "cors_recovery" => Ok(Self::CorsRecovery), | |
| 99 | 105 | "backup_stale" => Ok(Self::BackupStale), |
| @@ -22,6 +22,7 @@ | |||
| 22 | 22 | mod scan; | |
| 23 | 23 | mod systemd; | |
| 24 | 24 | mod test_duration; | |
| 25 | + | mod test_suite; | |
| 25 | 26 | mod tls; | |
| 26 | 27 | mod whois; | |
| 27 | 28 | ||
| @@ -87,10 +88,15 @@ | |||
| 87 | 88 | DnsMismatch, DnsRecovery, Health, LatencyDrift, LatencyRecovery, MonitoringOffline, | |
| 88 | 89 | MonitoringRecovery, PeerMissing, PeerRecovery, Recovery, RouteFailure, RouteRecovery, | |
| 89 | 90 | ScanPipelineDegraded, ScanPipelineRecovery, SystemdFailure, SystemdRecovery, | |
| 90 | - | TestDurationDrift, TlsError, TlsExpiry, TlsRecovery, WhoisError, WhoisExpiry, | |
| 91 | + | TestDurationDrift, TestFailure, TestRecovery, TlsError, TlsExpiry, TlsRecovery, WhoisError, | |
| 92 | + | WhoisExpiry, | |
| 91 | 93 | }; | |
| 92 | 94 | match category { | |
| 93 | - | Health | Recovery => "health", | |
| 95 | + | // A red test suite folds onto "health": it is a statement about the | |
| 96 | + | // target being sick, and MNW has no `test` AlertKind (an unknown kind | |
| 97 | + | // is a 422 from the ingest endpoint). The sub-condition rides in the | |
| 98 | + | // dedup key, the same way `Tls` already folds three PoM categories. | |
| 99 | + | Health | Recovery | TestFailure | TestRecovery => "health", | |
| 94 | 100 | // The host CA bundle folds onto the "tls" domain: it is a trust-anchor | |
| 95 | 101 | // problem, and every symptom it produces is a failed handshake. Adding a | |
| 96 | 102 | // domain would need a matching MNW `AlertKind` or the ingest 422s. |
| @@ -1,6 +1,7 @@ | |||
| 1 | - | //! Check implementations, one module per probe kind: health, TLS, DNS, WHOIS, | |
| 2 | - | //! routes, CORS, backups, SSH, port scans, local systemd units, the local CA | |
| 3 | - | //! bundle, the SyncKit field-version readout, plus latency-drift analysis. | |
| 1 | + | //! Check implementations, one module per probe kind: health, TLS, DNS, domain | |
| 2 | + | //! registration (RDAP with a WHOIS fallback), routes, CORS, backups, SSH, port | |
| 3 | + | //! scans, local systemd units, the local CA bundle, the SyncKit field-version | |
| 4 | + | //! readout, plus latency-drift analysis. | |
| 4 | 5 | ||
| 5 | 6 | pub mod backup; | |
| 6 | 7 | pub mod ca_bundle; | |
| @@ -9,6 +10,7 @@ | |||
| 9 | 10 | pub mod drift; | |
| 10 | 11 | pub mod http; | |
| 11 | 12 | pub mod parse; | |
| 13 | + | pub mod rdap; | |
| 12 | 14 | pub mod routes; | |
| 13 | 15 | pub mod scan_pipeline; | |
| 14 | 16 | pub mod ssh; |
| @@ -16,6 +16,36 @@ | |||
| 16 | 16 | .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '-') | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | /// Build the process that runs the suite: an `ssh` invocation when the target | |
| 20 | + | /// names a runner host, a local shell otherwise. | |
| 21 | + | /// | |
| 22 | + | /// Local execution exists because the runner is often the machine PoM already | |
| 23 | + | /// runs on. Routing that through `ssh` to its own address needs a regular sshd | |
| 24 | + | /// listening, and a Tailscale-SSH host has none: tailscaled does not intercept | |
| 25 | + | /// a node connecting to itself, so the hop fails with `Connection refused` | |
| 26 | + | /// while every other SSH path into the box keeps working. | |
| 27 | + | fn build_command(config: &TestsConfig, cmd_str: &str) -> Command { | |
| 28 | + | match config.ssh.as_deref() { | |
| 29 | + | Some(host) => { | |
| 30 | + | let mut command = Command::new("ssh"); | |
| 31 | + | command | |
| 32 | + | .arg("-o") | |
| 33 | + | .arg("BatchMode=yes") | |
| 34 | + | .arg("-o") | |
| 35 | + | .arg(format!("ConnectTimeout={}", config.timeout_secs)) | |
| 36 | + | .arg(host) | |
| 37 | + | .arg("--") | |
| 38 | + | .arg(cmd_str); | |
| 39 | + | command | |
| 40 | + | } | |
| 41 | + | None => { | |
| 42 | + | let mut command = Command::new("sh"); | |
| 43 | + | command.arg("-c").arg(cmd_str); | |
| 44 | + | command | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 19 | 49 | #[instrument(skip_all)] | |
| 20 | 50 | pub async fn run_tests(target_name: &str, config: &TestsConfig, filter: Option<&str>) -> TestRun { | |
| 21 | 51 | let started_at = chrono::Utc::now().to_rfc3339(); | |
| @@ -60,16 +90,8 @@ | |||
| 60 | 90 | // handshake, not a connected-then-hung remote command (fuzz-2026-07-06). Wrap | |
| 61 | 91 | // the whole run in a total timeout and `kill_on_drop` so the ssh child is | |
| 62 | 92 | // reaped when it elapses, honoring the field's contract. | |
| 63 | - | let mut command = Command::new("ssh"); | |
| 64 | - | command | |
| 65 | - | .arg("-o") | |
| 66 | - | .arg("BatchMode=yes") | |
| 67 | - | .arg("-o") | |
| 68 | - | .arg(format!("ConnectTimeout={}", config.timeout_secs)) | |
| 69 | - | .arg(&config.ssh) | |
| 70 | - | .arg("--") | |
| 71 | - | .arg(&cmd_str) | |
| 72 | - | .kill_on_drop(true); | |
| 93 | + | let mut command = build_command(config, &cmd_str); | |
| 94 | + | command.kill_on_drop(true); | |
| 73 | 95 | ||
| 74 | 96 | let total_timeout = std::time::Duration::from_secs(config.timeout_secs.max(1)); | |
| 75 | 97 | let result = tokio::time::timeout(total_timeout, command.output()).await; | |
| @@ -93,7 +115,7 @@ | |||
| 93 | 115 | details: vec![], | |
| 94 | 116 | }, | |
| 95 | 117 | raw_output: format!( | |
| 96 | - | "SSH test command timed out after {}s (killed)", | |
| 118 | + | "test command timed out after {}s (killed)", | |
| 97 | 119 | config.timeout_secs | |
| 98 | 120 | ), | |
| 99 | 121 | filter: filter.map(String::from), | |
| @@ -134,7 +156,10 @@ | |||
| 134 | 156 | total_failed: None, | |
| 135 | 157 | details: vec![], | |
| 136 | 158 | }, | |
| 137 | - | raw_output: format!("SSH connection failed: {e}"), | |
| 159 | + | raw_output: match config.ssh.as_deref() { | |
| 160 | + | Some(host) => format!("SSH connection to {host} failed: {e}"), | |
| 161 | + | None => format!("local test command failed to spawn: {e}"), | |
| 162 | + | }, | |
| 138 | 163 | filter: filter.map(String::from), | |
| 139 | 164 | }, | |
| 140 | 165 | } | |
| @@ -144,6 +169,53 @@ | |||
| 144 | 169 | mod tests { | |
| 145 | 170 | use super::*; | |
| 146 | 171 | ||
| 172 | + | fn config_with_ssh(ssh: Option<&str>) -> TestsConfig { | |
| 173 | + | TestsConfig { | |
| 174 | + | ssh: ssh.map(String::from), | |
| 175 | + | command: "cargo test".to_string(), | |
| 176 | + | timeout_secs: 42, | |
| 177 | + | staleness_days: 7, | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// The program plus its args, which is what distinguishes the two paths. | |
| 182 | + | fn command_line(command: &Command) -> Vec<String> { | |
| 183 | + | let std = command.as_std(); | |
| 184 | + | std::iter::once(std.get_program()) | |
| 185 | + | .chain(std.get_args()) | |
| 186 | + | .map(|s| s.to_string_lossy().into_owned()) | |
| 187 | + | .collect() | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn build_command_uses_ssh_when_a_host_is_named() { | |
| 192 | + | let config = config_with_ssh(Some("astra")); | |
| 193 | + | let line = command_line(&build_command(&config, "cargo test")); | |
| 194 | + | assert_eq!(line[0], "ssh"); | |
| 195 | + | assert!(line.contains(&"astra".to_string())); | |
| 196 | + | assert!(line.contains(&"BatchMode=yes".to_string())); | |
| 197 | + | assert!(line.contains(&"ConnectTimeout=42".to_string())); | |
| 198 | + | assert_eq!(line.last().unwrap(), "cargo test"); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn build_command_runs_locally_when_no_host_is_named() { | |
| 203 | + | // Omitting `ssh` must not degrade into an SSH call to localhost: a | |
| 204 | + | // Tailscale-SSH host has no sshd, so that hop is refused outright. | |
| 205 | + | let config = config_with_ssh(None); | |
| 206 | + | let line = command_line(&build_command(&config, "cargo test")); | |
| 207 | + | assert_eq!(line, vec!["sh", "-c", "cargo test"]); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn build_command_passes_the_whole_command_as_one_argument() { | |
| 212 | + | // The command is a shell string, `cd x && cargo test` among them. Split | |
| 213 | + | // on whitespace it would run `cd` with the rest as arguments. | |
| 214 | + | let config = config_with_ssh(None); | |
| 215 | + | let line = command_line(&build_command(&config, "cd /srv/app && cargo test")); | |
| 216 | + | assert_eq!(line.last().unwrap(), "cd /srv/app && cargo test"); | |
| 217 | + | } | |
| 218 | + | ||
| 147 | 219 | #[test] | |
| 148 | 220 | fn validate_test_filter_valid_simple() { | |
| 149 | 221 | assert!(validate_test_filter("foo")); |
| @@ -1,4 +1,8 @@ | |||
| 1 | - | //! WHOIS domain expiry checking, raw TCP to WHOIS servers. | |
| 1 | + | //! Domain registration checking: RDAP first, raw port-43 WHOIS as fallback. | |
| 2 | + | //! | |
| 3 | + | //! RDAP is the protocol registries are actually maintaining, so it is tried | |
| 4 | + | //! first for every domain (see [`super::rdap`]). WHOIS remains for the TLDs | |
| 5 | + | //! with no RDAP service, `.io` and `.me` among them. | |
| 2 | 6 | ||
| 3 | 7 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
| 4 | 8 | use tokio::net::TcpStream; | |
| @@ -7,60 +11,97 @@ | |||
| 7 | 11 | use crate::config::WhoisConfig; | |
| 8 | 12 | use crate::types::WhoisResult; | |
| 9 | 13 | ||
| 10 | - | /// Query WHOIS for domain registration info. | |
| 14 | + | use super::rdap; | |
| 15 | + | ||
| 16 | + | /// Query domain registration info, over RDAP where the TLD supports it. | |
| 11 | 17 | #[instrument(skip_all)] | |
| 12 | 18 | pub async fn check_whois(target: &str, config: &WhoisConfig) -> WhoisResult { | |
| 13 | - | let now = chrono::Utc::now().to_rfc3339(); | |
| 19 | + | match rdap::lookup(&config.domain).await { | |
| 20 | + | Ok(Some(domain)) => { | |
| 21 | + | return build_result( | |
| 22 | + | target, | |
| 23 | + | config, | |
| 24 | + | domain.registrar, | |
| 25 | + | domain.expiry_date, | |
| 26 | + | domain.nameservers, | |
| 27 | + | ); | |
| 28 | + | } | |
| 29 | + | Ok(None) => {} | |
| 30 | + | Err(e) => { | |
| 31 | + | // An RDAP service exists but did not answer. WHOIS is worth a try | |
| 32 | + | // before calling the domain unverifiable. | |
| 33 | + | tracing::warn!( | |
| 34 | + | "{target}: RDAP lookup for {} failed, falling back to WHOIS: {e}", | |
| 35 | + | config.domain | |
| 36 | + | ); | |
| 37 | + | } | |
| 38 | + | } | |
| 14 | 39 | ||
| 15 | 40 | let Some(server) = whois_server_for_tld(&config.domain) else { | |
| 16 | - | return WhoisResult { | |
| 17 | - | target: target.to_string(), | |
| 18 | - | domain: config.domain.clone(), | |
| 19 | - | registrar: None, | |
| 20 | - | expiry_date: None, | |
| 21 | - | days_remaining: None, | |
| 22 | - | nameservers: vec![], | |
| 23 | - | checked_at: now, | |
| 24 | - | error: Some(format!( | |
| 25 | - | "no WHOIS server known for TLD of {}", | |
| 41 | + | return error_result( | |
| 42 | + | target, | |
| 43 | + | config, | |
| 44 | + | format!( | |
| 45 | + | "no RDAP service or WHOIS server known for {}", | |
| 26 | 46 | config.domain | |
| 27 | - | )), | |
| 28 | - | }; | |
| 47 | + | ), | |
| 48 | + | ); | |
| 29 | 49 | }; | |
| 30 | 50 | ||
| 31 | 51 | match query_whois(server, &config.domain).await { | |
| 32 | 52 | Ok(response) => { | |
| 33 | 53 | let parsed = parse_whois_response(&response); | |
| 34 | - | let days_remaining = parsed | |
| 35 | - | .expiry_date | |
| 36 | - | .as_deref() | |
| 37 | - | .and_then(compute_days_remaining); | |
| 38 | - | ||
| 39 | - | WhoisResult { | |
| 40 | - | target: target.to_string(), | |
| 41 | - | domain: config.domain.clone(), | |
| 42 | - | registrar: parsed.registrar, | |
| 43 | - | expiry_date: parsed.expiry_date, | |
| 44 | - | days_remaining, | |
| 45 | - | nameservers: parsed.nameservers, | |
| 46 | - | checked_at: now, | |
| 47 | - | error: None, | |
| 48 | - | } | |
| 54 | + | build_result( | |
| 55 | + | target, | |
| 56 | + | config, | |
| 57 | + | parsed.registrar, | |
| 58 | + | parsed.expiry_date, | |
| 59 | + | parsed.nameservers, | |
| 60 | + | ) | |
| 49 | 61 | } | |
| 50 | - | Err(e) => WhoisResult { | |
| 51 | - | target: target.to_string(), | |
| 52 | - | domain: config.domain.clone(), | |
| 53 | - | registrar: None, | |
| 54 | - | expiry_date: None, | |
| 55 | - | days_remaining: None, | |
| 56 | - | nameservers: vec![], | |
| 57 | - | checked_at: now, | |
| 58 | - | error: Some(e), | |
| 59 | - | }, | |
| 62 | + | Err(e) => error_result(target, config, e), | |
| 60 | 63 | } | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | - | /// Determine the WHOIS server for a domain based on its TLD. | |
| 66 | + | fn build_result( | |
| 67 | + | target: &str, | |
| 68 | + | config: &WhoisConfig, | |
| 69 | + | registrar: Option<String>, | |
| 70 | + | expiry_date: Option<String>, | |
| 71 | + | nameservers: Vec<String>, | |
| 72 | + | ) -> WhoisResult { | |
| 73 | + | let days_remaining = expiry_date.as_deref().and_then(compute_days_remaining); | |
| 74 | + | ||
| 75 | + | WhoisResult { | |
| 76 | + | target: target.to_string(), | |
| 77 | + | domain: config.domain.clone(), | |
| 78 | + | registrar, | |
| 79 | + | expiry_date, | |
| 80 | + | days_remaining, | |
| 81 | + | nameservers, | |
| 82 | + | checked_at: chrono::Utc::now().to_rfc3339(), | |
| 83 | + | error: None, | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | fn error_result(target: &str, config: &WhoisConfig, error: String) -> WhoisResult { | |
| 88 | + | WhoisResult { | |
| 89 | + | target: target.to_string(), | |
| 90 | + | domain: config.domain.clone(), | |
| 91 | + | registrar: None, | |
| 92 | + | expiry_date: None, | |
| 93 | + | days_remaining: None, | |
| 94 | + | nameservers: vec![], | |
| 95 | + | checked_at: chrono::Utc::now().to_rfc3339(), | |
| 96 | + | error: Some(error), | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Determine the port-43 WHOIS server for a domain based on its TLD. | |
| 101 | + | /// | |
| 102 | + | /// Only TLDs whose WHOIS server is still answering belong here. `.app` and | |
| 103 | + | /// `.dev` used to map to `whois.nic.google`, which Google Registry retired in | |
| 104 | + | /// favour of RDAP; the hostname is now NXDOMAIN, so those two are RDAP-only. | |
| 64 | 105 | pub fn whois_server_for_tld(domain: &str) -> Option<&'static str> { | |
| 65 | 106 | let tld = domain.rsplit('.').next()?; | |
| 66 | 107 | match tld { | |
| @@ -68,8 +109,6 @@ | |||
| 68 | 109 | "net" => Some("whois.verisign-grs.com"), | |
| 69 | 110 | "org" => Some("whois.pir.org"), | |
| 70 | 111 | "work" => Some("whois.nic.work"), | |
| 71 | - | "app" => Some("whois.nic.google"), | |
| 72 | - | "dev" => Some("whois.nic.google"), | |
| 73 | 112 | "io" => Some("whois.nic.io"), | |
| 74 | 113 | "me" => Some("whois.nic.me"), | |
| 75 | 114 | "info" => Some("whois.afilias.net"), | |
| @@ -198,10 +237,6 @@ | |||
| 198 | 237 | #[test] | |
| 199 | 238 | fn whois_server_known_tlds() { | |
| 200 | 239 | assert_eq!(whois_server_for_tld("example.work"), Some("whois.nic.work")); | |
| 201 | - | assert_eq!( | |
| 202 | - | whois_server_for_tld("example.app"), | |
| 203 | - | Some("whois.nic.google") | |
| 204 | - | ); | |
| 205 | 240 | assert_eq!( | |
| 206 | 241 | whois_server_for_tld("example.com"), | |
| 207 | 242 | Some("whois.verisign-grs.com") | |
| @@ -218,6 +253,15 @@ | |||
| 218 | 253 | assert_eq!(whois_server_for_tld("example.xyz"), None); | |
| 219 | 254 | } | |
| 220 | 255 | ||
| 256 | + | #[test] | |
| 257 | + | fn whois_server_omits_retired_google_registry() { | |
| 258 | + | // `whois.nic.google` is NXDOMAIN since Google Registry moved to RDAP. | |
| 259 | + | // Mapping .app/.dev to it produced a permanent degraded state on the | |
| 260 | + | // htpy target; these two must stay RDAP-only. | |
| 261 | + | assert_eq!(whois_server_for_tld("htpy.app"), None); | |
| 262 | + | assert_eq!(whois_server_for_tld("example.dev"), None); | |
| 263 | + | } | |
| 264 | + | ||
| 221 | 265 | #[test] | |
| 222 | 266 | fn parse_whois_verisign_response() { | |
| 223 | 267 | let response = r" | |
| @@ -332,7 +376,6 @@ | |||
| 332 | 376 | fn whois_server_all_known_tlds() { | |
| 333 | 377 | // Pins every match arm, a mutation swapping or removing an arm would | |
| 334 | 378 | // surface as Some→None or wrong host. | |
| 335 | - | assert_eq!(whois_server_for_tld("x.dev"), Some("whois.nic.google")); | |
| 336 | 379 | assert_eq!(whois_server_for_tld("x.io"), Some("whois.nic.io")); | |
| 337 | 380 | assert_eq!(whois_server_for_tld("x.me"), Some("whois.nic.me")); | |
| 338 | 381 | assert_eq!(whois_server_for_tld("x.info"), Some("whois.afilias.net")); |
| @@ -121,6 +121,12 @@ | |||
| 121 | 121 | &token, | |
| 122 | 122 | alerter.as_ref(), | |
| 123 | 123 | )); | |
| 124 | + | handles.extend(tasks::spawn_test_tasks( | |
| 125 | + | config, | |
| 126 | + | pool, | |
| 127 | + | &token, | |
| 128 | + | alerter.as_ref(), | |
| 129 | + | )); | |
| 124 | 130 | // No alerter: the fleet readout has nothing to alert on. See the task module. | |
| 125 | 131 | handles.extend(tasks::spawn_synckit_fleet_tasks(config, pool, &token)); | |
| 126 | 132 | handles.push(tasks::spawn_prune_task(pool, prune_days, &token)); |
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | mod scan_pipeline; | |
| 12 | 12 | mod synckit_fleet; | |
| 13 | 13 | mod systemd; | |
| 14 | + | mod test_suite; | |
| 14 | 15 | mod tls; | |
| 15 | 16 | mod whois; | |
| 16 | 17 | ||
| @@ -89,5 +90,6 @@ | |||
| 89 | 90 | pub(crate) use scan_pipeline::spawn_scan_pipeline_tasks; | |
| 90 | 91 | pub(crate) use synckit_fleet::spawn_synckit_fleet_tasks; | |
| 91 | 92 | pub(crate) use systemd::spawn_systemd_tasks; | |
| 93 | + | pub(crate) use test_suite::spawn_test_tasks; | |
| 92 | 94 | pub(crate) use tls::spawn_tls_tasks; | |
| 93 | 95 | pub(crate) use whois::spawn_whois_tasks; |
| @@ -1,0 +1,89 @@ | |||
| 1 | + | //! Test-suite alert/recovery messages. Domain half of the Alerter split; | |
| 2 | + | //! shared dispatch/cooldown plumbing lives in the parent module. | |
| 3 | + | //! | |
| 4 | + | //! Distinct from [`super::test_duration`], which fires on a suite that still | |
| 5 | + | //! passes but has slowed down. These fire on pass/fail transitions. | |
| 6 | + | ||
| 7 | + | use tracing::instrument; | |
| 8 | + | ||
| 9 | + | use super::{AlertCategory, AlertMeta, Alerter}; | |
| 10 | + | ||
| 11 | + | impl Alerter { | |
| 12 | + | #[instrument(skip_all)] | |
| 13 | + | pub async fn send_test_failure_alert( | |
| 14 | + | &self, | |
| 15 | + | target: &str, | |
| 16 | + | label: &str, | |
| 17 | + | failed: Option<i64>, | |
| 18 | + | exit_code: Option<i64>, | |
| 19 | + | detail: &str, | |
| 20 | + | ) { | |
| 21 | + | let alert_key = format!("tests:{target}"); | |
| 22 | + | let subject = match failed { | |
| 23 | + | Some(n) => format!("[PoM] {label}: {n} test(s) failing"), | |
| 24 | + | None => format!("[PoM] {label}: test suite failed to complete"), | |
| 25 | + | }; | |
| 26 | + | let body = format!( | |
| 27 | + | "Target: {label} ({target})\n\ | |
| 28 | + | Failed: {}\n\ | |
| 29 | + | Exit code: {}\n\ | |
| 30 | + | Detail: {detail}\n\ | |
| 31 | + | Instance: {}\n\ | |
| 32 | + | Time: {}\n\n\ | |
| 33 | + | - PoM", | |
| 34 | + | failed.map_or_else(|| "unknown".to_string(), |n| n.to_string()), | |
| 35 | + | exit_code.map_or_else(|| "none".to_string(), |c| c.to_string()), | |
| 36 | + | self.instance_name, | |
| 37 | + | chrono::Utc::now().to_rfc3339(), | |
| 38 | + | ); | |
| 39 | + | ||
| 40 | + | // A suite that never produced a count did not run at all (SSH refused, | |
| 41 | + | // missing checkout, timeout), which is worse than a known count of | |
| 42 | + | // failures: it means the target is unverified rather than broken. | |
| 43 | + | let priority = if failed.is_some() { "high" } else { "critical" }; | |
| 44 | + | ||
| 45 | + | self.fire_failure( | |
| 46 | + | &subject, | |
| 47 | + | &body, | |
| 48 | + | priority, | |
| 49 | + | "pom-tests", | |
| 50 | + | Some(target), | |
| 51 | + | AlertMeta { | |
| 52 | + | key: &alert_key, | |
| 53 | + | category: AlertCategory::TestFailure, | |
| 54 | + | from: None, | |
| 55 | + | to: None, | |
| 56 | + | error: Some(detail), | |
| 57 | + | }, | |
| 58 | + | ) | |
| 59 | + | .await; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[instrument(skip_all)] | |
| 63 | + | pub async fn send_test_recovery(&self, target: &str, label: &str) { | |
| 64 | + | let alert_key = format!("tests:{target}"); | |
| 65 | + | let subject = format!("[PoM] {label}: test suite passing again"); | |
| 66 | + | let body = format!( | |
| 67 | + | "Target: {label} ({target})\n\ | |
| 68 | + | The test suite passed on its latest scheduled run.\n\ | |
| 69 | + | Instance: {}\n\ | |
| 70 | + | Time: {}\n\n\ | |
| 71 | + | - PoM", | |
| 72 | + | self.instance_name, | |
| 73 | + | chrono::Utc::now().to_rfc3339(), | |
| 74 | + | ); | |
| 75 | + | ||
| 76 | + | self.fire_recovery( | |
| 77 | + | &subject, | |
| 78 | + | &body, | |
| 79 | + | AlertMeta { | |
| 80 | + | key: &alert_key, | |
| 81 | + | category: AlertCategory::TestRecovery, | |
| 82 | + | from: None, | |
| 83 | + | to: None, | |
| 84 | + | error: None, | |
| 85 | + | }, | |
| 86 | + | ) | |
| 87 | + | .await; | |
| 88 | + | } | |
| 89 | + | } |
| @@ -1,0 +1,455 @@ | |||
| 1 | + | //! RDAP domain lookups, the successor protocol to WHOIS. | |
| 2 | + | //! | |
| 3 | + | //! Registries are retiring port-43 WHOIS one TLD at a time. Google Registry | |
| 4 | + | //! already has: `whois.nic.google` is NXDOMAIN, which took `.app` and `.dev` | |
| 5 | + | //! down with it. RDAP is the replacement, and unlike WHOIS it has a registry | |
| 6 | + | //! of registries, so the server for a TLD is discoverable rather than | |
| 7 | + | //! hardcoded. | |
| 8 | + | //! | |
| 9 | + | //! The base URL for a TLD comes from IANA's bootstrap file, cached in process | |
| 10 | + | //! for [`BOOTSTRAP_TTL`]. [`FALLBACK_BASES`] covers the TLDs we actually | |
| 11 | + | //! monitor so a bootstrap outage does not blind the check. A TLD in neither | |
| 12 | + | //! place has no RDAP path and falls back to WHOIS (see [`super::whois`]). | |
| 13 | + | ||
| 14 | + | use std::collections::HashMap; | |
| 15 | + | use std::sync::{Arc, RwLock}; | |
| 16 | + | use std::time::{Duration, Instant}; | |
| 17 | + | ||
| 18 | + | /// IANA's TLD-to-RDAP-service map. | |
| 19 | + | const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json"; | |
| 20 | + | ||
| 21 | + | /// How long a fetched bootstrap stays good. The file changes when a registry | |
| 22 | + | /// joins or moves, which is a matter of months, so a day is generous. | |
| 23 | + | const BOOTSTRAP_TTL: Duration = Duration::from_hours(24); | |
| 24 | + | ||
| 25 | + | /// Bases for the TLDs under monitoring, used when the bootstrap is | |
| 26 | + | /// unreachable. Values match IANA's file as of 2026-08-06. | |
| 27 | + | const FALLBACK_BASES: &[(&str, &str)] = &[ | |
| 28 | + | ("app", "https://pubapi.registry.google/rdap/"), | |
| 29 | + | ("dev", "https://pubapi.registry.google/rdap/"), | |
| 30 | + | ("work", "https://rdap.nic.work/"), | |
| 31 | + | ("com", "https://rdap.verisign.com/com/v1/"), | |
| 32 | + | ("net", "https://rdap.verisign.com/net/v1/"), | |
| 33 | + | ("org", "https://rdap.publicinterestregistry.org/rdap/"), | |
| 34 | + | ("info", "https://rdap.identitydigital.services/rdap/"), | |
| 35 | + | ]; | |
| 36 | + | ||
| 37 | + | static BOOTSTRAP_CACHE: RwLock<Option<CachedBootstrap>> = RwLock::new(None); | |
| 38 | + | ||
| 39 | + | struct CachedBootstrap { | |
| 40 | + | fetched_at: Instant, | |
| 41 | + | bases: Arc<HashMap<String, String>>, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Domain facts an RDAP response carries, in the same shape the WHOIS parser | |
| 45 | + | /// produces so both paths feed one result type. | |
| 46 | + | pub struct RdapDomain { | |
| 47 | + | pub registrar: Option<String>, | |
| 48 | + | pub expiry_date: Option<String>, | |
| 49 | + | pub nameservers: Vec<String>, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Look up a domain over RDAP. `Ok(None)` means no RDAP service is known for | |
| 53 | + | /// the TLD, which is a signal to try WHOIS, not a failure. | |
| 54 | + | pub async fn lookup(domain: &str) -> Result<Option<RdapDomain>, String> { | |
| 55 | + | let Some(tld) = domain.rsplit('.').next().filter(|t| !t.is_empty()) else { | |
| 56 | + | return Ok(None); | |
| 57 | + | }; | |
| 58 | + | let Some(base) = base_for_tld(tld).await else { | |
| 59 | + | return Ok(None); | |
| 60 | + | }; | |
| 61 | + | query(&base, domain).await.map(Some) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Resolve a TLD to its RDAP base URL, preferring the IANA bootstrap and | |
| 65 | + | /// falling back to the built-in table. | |
| 66 | + | async fn base_for_tld(tld: &str) -> Option<String> { | |
| 67 | + | let tld = tld.to_lowercase(); | |
| 68 | + | ||
| 69 | + | if let Some(bases) = bootstrap().await | |
| 70 | + | && let Some(base) = bases.get(&tld) | |
| 71 | + | { | |
| 72 | + | return Some(base.clone()); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | FALLBACK_BASES | |
| 76 | + | .iter() | |
| 77 | + | .find(|(t, _)| *t == tld) | |
| 78 | + | .map(|(_, base)| (*base).to_string()) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// The cached bootstrap map, refetched once past [`BOOTSTRAP_TTL`]. A failed | |
| 82 | + | /// fetch returns the stale map if there is one, since a map from last week | |
| 83 | + | /// beats no map at all. | |
| 84 | + | async fn bootstrap() -> Option<Arc<HashMap<String, String>>> { | |
| 85 | + | if let Ok(guard) = BOOTSTRAP_CACHE.read() | |
| 86 | + | && let Some(cached) = guard.as_ref() | |
| 87 | + | && cached.fetched_at.elapsed() < BOOTSTRAP_TTL | |
| 88 | + | { | |
| 89 | + | return Some(Arc::clone(&cached.bases)); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | match fetch_bootstrap().await { | |
| 93 | + | Ok(bases) => { | |
| 94 | + | let bases = Arc::new(bases); | |
| 95 | + | if let Ok(mut guard) = BOOTSTRAP_CACHE.write() { | |
| 96 | + | *guard = Some(CachedBootstrap { | |
| 97 | + | fetched_at: Instant::now(), | |
| 98 | + | bases: Arc::clone(&bases), | |
| 99 | + | }); | |
| 100 | + | } | |
| 101 | + | Some(bases) | |
| 102 | + | } | |
| 103 | + | Err(e) => { | |
| 104 | + | tracing::warn!("RDAP bootstrap fetch failed, using fallback table: {e}"); | |
| 105 | + | BOOTSTRAP_CACHE | |
| 106 | + | .read() | |
| 107 | + | .ok() | |
| 108 | + | .and_then(|guard| guard.as_ref().map(|c| Arc::clone(&c.bases))) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | async fn fetch_bootstrap() -> Result<HashMap<String, String>, String> { | |
| 114 | + | let client = crate::tls::https_client_builder() | |
| 115 | + | .timeout(Duration::from_secs(15)) | |
| 116 | + | .build() | |
| 117 | + | .map_err(|e| format!("RDAP client build failed: {e}"))?; | |
| 118 | + | ||
| 119 | + | let body = client | |
| 120 | + | .get(BOOTSTRAP_URL) | |
| 121 | + | .send() | |
| 122 | + | .await | |
| 123 | + | .map_err(|e| format!("RDAP bootstrap request failed: {e}"))? | |
| 124 | + | .error_for_status() | |
| 125 | + | .map_err(|e| format!("RDAP bootstrap returned {e}"))? | |
| 126 | + | .text() | |
| 127 | + | .await | |
| 128 | + | .map_err(|e| format!("RDAP bootstrap body read failed: {e}"))?; | |
| 129 | + | ||
| 130 | + | parse_bootstrap(&body) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Flatten the bootstrap's `services` array into a TLD-to-base map. | |
| 134 | + | /// | |
| 135 | + | /// Each service is `[[tld, ...], [url, ...]]`; the first HTTPS URL wins, and | |
| 136 | + | /// a service with no HTTPS URL is skipped rather than downgraded. | |
| 137 | + | pub fn parse_bootstrap(body: &str) -> Result<HashMap<String, String>, String> { | |
| 138 | + | let doc: serde_json::Value = | |
| 139 | + | serde_json::from_str(body).map_err(|e| format!("RDAP bootstrap not valid JSON: {e}"))?; | |
| 140 | + | ||
| 141 | + | let services = doc | |
| 142 | + | .get("services") | |
| 143 | + | .and_then(|s| s.as_array()) | |
| 144 | + | .ok_or_else(|| "RDAP bootstrap has no services array".to_string())?; | |
| 145 | + | ||
| 146 | + | let mut bases = HashMap::new(); | |
| 147 | + | for service in services { | |
| 148 | + | let Some(entry) = service.as_array() else { | |
| 149 | + | continue; | |
| 150 | + | }; | |
| 151 | + | let (Some(tlds), Some(urls)) = ( | |
| 152 | + | entry.first().and_then(|v| v.as_array()), | |
| 153 | + | entry.get(1).and_then(|v| v.as_array()), | |
| 154 | + | ) else { | |
| 155 | + | continue; | |
| 156 | + | }; | |
| 157 | + | ||
| 158 | + | let Some(url) = urls | |
| 159 | + | .iter() | |
| 160 | + | .filter_map(|u| u.as_str()) | |
| 161 | + | .find(|u| u.starts_with("https://")) | |
| 162 | + | else { | |
| 163 | + | continue; | |
| 164 | + | }; | |
| 165 | + | ||
| 166 | + | for tld in tlds.iter().filter_map(|t| t.as_str()) { | |
| 167 | + | bases.insert(tld.to_lowercase(), url.to_string()); | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if bases.is_empty() { | |
| 172 | + | return Err("RDAP bootstrap contained no usable services".to_string()); | |
| 173 | + | } | |
| 174 | + | Ok(bases) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | async fn query(base: &str, domain: &str) -> Result<RdapDomain, String> { | |
| 178 | + | let url = format!("{}domain/{domain}", ensure_trailing_slash(base)); | |
| 179 | + | ||
| 180 | + | let client = crate::tls::https_client_builder() | |
| 181 | + | .timeout(Duration::from_secs(10)) | |
| 182 | + | .build() | |
| 183 | + | .map_err(|e| format!("RDAP client build failed: {e}"))?; | |
| 184 | + | ||
| 185 | + | let response = client | |
| 186 | + | .get(&url) | |
| 187 | + | .header("Accept", "application/rdap+json") | |
| 188 | + | .send() | |
| 189 | + | .await | |
| 190 | + | .map_err(|e| format!("RDAP request to {url} failed: {e}"))?; | |
| 191 | + | ||
| 192 | + | let status = response.status(); | |
| 193 | + | if !status.is_success() { | |
| 194 | + | return Err(format!("RDAP query to {url} returned HTTP {status}")); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | let body = response | |
| 198 | + | .text() | |
| 199 | + | .await | |
| 200 | + | .map_err(|e| format!("RDAP body read failed: {e}"))?; | |
| 201 | + | ||
| 202 | + | parse_domain(&body) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn ensure_trailing_slash(base: &str) -> String { | |
| 206 | + | if base.ends_with('/') { | |
| 207 | + | base.to_string() | |
| 208 | + | } else { | |
| 209 | + | format!("{base}/") | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// Pull registrar, expiry and nameservers out of an RDAP domain object. | |
| 214 | + | pub fn parse_domain(body: &str) -> Result<RdapDomain, String> { | |
| 215 | + | let doc: serde_json::Value = | |
| 216 | + | serde_json::from_str(body).map_err(|e| format!("RDAP response not valid JSON: {e}"))?; | |
| 217 | + | ||
| 218 | + | let expiry_date = doc | |
| 219 | + | .get("events") | |
| 220 | + | .and_then(|e| e.as_array()) | |
| 221 | + | .and_then(|events| { | |
| 222 | + | events | |
| 223 | + | .iter() | |
| 224 | + | .find(|e| e.get("eventAction").and_then(|a| a.as_str()) == Some("expiration")) | |
| 225 | + | }) | |
| 226 | + | .and_then(|e| e.get("eventDate")) | |
| 227 | + | .and_then(|d| d.as_str()) | |
| 228 | + | .map(str::to_string); | |
| 229 | + | ||
| 230 | + | let registrar = doc | |
| 231 | + | .get("entities") | |
| 232 | + | .and_then(|e| e.as_array()) | |
| 233 | + | .and_then(|entities| { | |
| 234 | + | entities.iter().find(|e| { | |
| 235 | + | e.get("roles") | |
| 236 | + | .and_then(|r| r.as_array()) | |
| 237 | + | .is_some_and(|roles| roles.iter().any(|r| r.as_str() == Some("registrar"))) | |
| 238 | + | }) | |
| 239 | + | }) | |
| 240 | + | .and_then(vcard_full_name); | |
| 241 | + | ||
| 242 | + | let mut nameservers = Vec::new(); | |
| 243 | + | if let Some(list) = doc.get("nameservers").and_then(|n| n.as_array()) { | |
| 244 | + | for ns in list { | |
| 245 | + | let Some(name) = ns.get("ldhName").and_then(|n| n.as_str()) else { | |
| 246 | + | continue; | |
| 247 | + | }; | |
| 248 | + | let name = name.trim_end_matches('.').to_lowercase(); | |
| 249 | + | if !name.is_empty() && !nameservers.contains(&name) { | |
| 250 | + | nameservers.push(name); | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | Ok(RdapDomain { | |
| 256 | + | registrar, | |
| 257 | + | expiry_date, | |
| 258 | + | nameservers, | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | /// Extract the `fn` (formatted name) property from an entity's jCard. | |
| 263 | + | /// | |
| 264 | + | /// jCard is `["vcard", [[name, params, type, value], ...]]`, so the value | |
| 265 | + | /// sits at index 3 of the property whose index 0 is `"fn"`. | |
| 266 | + | fn vcard_full_name(entity: &serde_json::Value) -> Option<String> { | |
| 267 | + | let properties = entity.get("vcardArray")?.as_array()?.get(1)?.as_array()?; | |
| 268 | + | ||
| 269 | + | properties | |
| 270 | + | .iter() | |
| 271 | + | .filter_map(|p| p.as_array()) | |
| 272 | + | .find(|p| p.first().and_then(|k| k.as_str()) == Some("fn")) | |
| 273 | + | .and_then(|p| p.get(3)) | |
| 274 | + | .and_then(|v| v.as_str()) | |
| 275 | + | .filter(|v| !v.is_empty()) | |
| 276 | + | .map(str::to_string) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[cfg(test)] | |
| 280 | + | mod tests { | |
| 281 | + | use super::*; | |
| 282 | + | ||
| 283 | + | const HTPY_RDAP: &str = r#"{ | |
| 284 | + | "objectClassName": "domain", | |
| 285 | + | "ldhName": "htpy.app", | |
| 286 | + | "events": [ | |
| 287 | + | {"eventAction": "registration", "eventDate": "2026-03-11T19:13:33.195Z"}, | |
| 288 | + | {"eventAction": "expiration", "eventDate": "2027-03-11T19:13:33.195Z"}, | |
| 289 | + | {"eventAction": "last changed", "eventDate": "2026-03-16T19:13:33.195Z"} | |
| 290 | + | ], | |
| 291 | + | "entities": [ | |
| 292 | + | { | |
| 293 | + | "roles": ["registrar"], | |
| 294 | + | "vcardArray": ["vcard", [ | |
| 295 | + | ["version", {}, "text", "4.0"], | |
| 296 | + | ["fn", {}, "text", "CloudFlare, Inc."] | |
| 297 | + | ]] | |
| 298 | + | } | |
| 299 | + | ], | |
| 300 | + | "nameservers": [ | |
| 301 | + | {"ldhName": "PAM.NS.CLOUDFLARE.COM."}, | |
| 302 | + | {"ldhName": "simon.ns.cloudflare.com"} | |
| 303 | + | ] | |
| 304 | + | }"#; | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn parse_domain_extracts_expiry_registrar_nameservers() { | |
| 308 | + | let parsed = parse_domain(HTPY_RDAP).unwrap(); | |
| 309 | + | assert_eq!( | |
| 310 | + | parsed.expiry_date.as_deref(), | |
| 311 | + | Some("2027-03-11T19:13:33.195Z") | |
| 312 | + | ); | |
| 313 | + | assert_eq!(parsed.registrar.as_deref(), Some("CloudFlare, Inc.")); | |
| 314 | + | assert_eq!( | |
| 315 | + | parsed.nameservers, | |
| 316 | + | vec![ | |
| 317 | + | "pam.ns.cloudflare.com".to_string(), | |
| 318 | + | "simon.ns.cloudflare.com".to_string() | |
| 319 | + | ] | |
| 320 | + | ); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[test] | |
| 324 | + | fn parse_domain_ignores_non_expiration_events() { | |
| 325 | + | // Pins the eventAction filter: registration comes first in the array, | |
| 326 | + | // so a missing filter would return the wrong date. | |
| 327 | + | let parsed = parse_domain(HTPY_RDAP).unwrap(); | |
| 328 | + | assert_ne!( | |
| 329 | + | parsed.expiry_date.as_deref(), | |
| 330 | + | Some("2026-03-11T19:13:33.195Z") | |
| 331 | + | ); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn parse_domain_skips_entities_without_registrar_role() { | |
| 336 | + | let body = r#"{ | |
| 337 | + | "entities": [ | |
| 338 | + | {"roles": ["technical"], "vcardArray": ["vcard", [["fn", {}, "text", "Tech Co"]]]}, | |
| 339 | + | {"roles": ["registrar"], "vcardArray": ["vcard", [["fn", {}, "text", "Real Registrar"]]]} | |
| 340 | + | ] | |
| 341 | + | }"#; | |
| 342 | + | let parsed = parse_domain(body).unwrap(); | |
| 343 | + | assert_eq!(parsed.registrar.as_deref(), Some("Real Registrar")); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn parse_domain_tolerates_missing_sections() { | |
| 348 | + | let parsed = parse_domain(r#"{"objectClassName": "domain"}"#).unwrap(); | |
| 349 | + | assert!(parsed.expiry_date.is_none()); | |
| 350 | + | assert!(parsed.registrar.is_none()); | |
| 351 | + | assert!(parsed.nameservers.is_empty()); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[test] | |
| 355 | + | fn parse_domain_deduplicates_nameservers() { | |
| 356 | + | let body = | |
| 357 | + | r#"{"nameservers": [{"ldhName": "ns1.example.com."}, {"ldhName": "NS1.example.com"}]}"#; | |
| 358 | + | let parsed = parse_domain(body).unwrap(); | |
| 359 | + | assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | #[test] | |
| 363 | + | fn parse_domain_rejects_non_json() { | |
| 364 | + | assert!(parse_domain("not json at all").is_err()); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[test] | |
| 368 | + | fn parse_bootstrap_flattens_services() { | |
| 369 | + | let body = r#"{ | |
| 370 | + | "version": "1.0", | |
| 371 | + | "services": [ | |
| 372 | + | [["app", "dev"], ["https://pubapi.registry.google/rdap/"]], | |
| 373 | + | [["work"], ["https://rdap.nic.work/"]] | |
| 374 | + | ] | |
| 375 | + | }"#; | |
| 376 | + | let bases = parse_bootstrap(body).unwrap(); | |
| 377 | + | assert_eq!( | |
| 378 | + | bases.get("app").map(String::as_str), | |
| 379 | + | Some("https://pubapi.registry.google/rdap/") | |
| 380 | + | ); | |
| 381 | + | assert_eq!( | |
| 382 | + | bases.get("dev").map(String::as_str), | |
| 383 | + | Some("https://pubapi.registry.google/rdap/") | |
| 384 | + | ); | |
| 385 | + | assert_eq!( | |
| 386 | + | bases.get("work").map(String::as_str), | |
| 387 | + | Some("https://rdap.nic.work/") | |
| 388 | + | ); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | #[test] | |
| 392 | + | fn parse_bootstrap_prefers_https() { | |
| 393 | + | let body = r#"{"services": [[["test"], ["http://insecure.example/", "https://secure.example/"]]]}"#; | |
| 394 | + | let bases = parse_bootstrap(body).unwrap(); | |
| 395 | + | assert_eq!( | |
| 396 | + | bases.get("test").map(String::as_str), | |
| 397 | + | Some("https://secure.example/") | |
| 398 | + | ); | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | #[test] | |
| 402 | + | fn parse_bootstrap_skips_http_only_services() { | |
| 403 | + | let body = r#"{ | |
| 404 | + | "services": [ | |
| 405 | + | [["insecure"], ["http://only.example/"]], | |
| 406 | + | [["fine"], ["https://ok.example/"]] | |
| 407 | + | ] | |
| 408 | + | }"#; | |
| 409 | + | let bases = parse_bootstrap(body).unwrap(); | |
| 410 | + | assert!(!bases.contains_key("insecure")); | |
| 411 | + | assert!(bases.contains_key("fine")); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | #[test] | |
| 415 | + | fn parse_bootstrap_lowercases_tlds() { | |
| 416 | + | let body = r#"{"services": [[["APP"], ["https://example/"]]]}"#; | |
| 417 | + | let bases = parse_bootstrap(body).unwrap(); | |
| 418 | + | assert!(bases.contains_key("app")); | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | #[test] | |
| 422 | + | fn parse_bootstrap_rejects_missing_services() { | |
| 423 | + | assert!(parse_bootstrap(r#"{"version": "1.0"}"#).is_err()); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[test] | |
| 427 | + | fn parse_bootstrap_rejects_empty_result() { | |
| 428 | + | assert!(parse_bootstrap(r#"{"services": []}"#).is_err()); | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | #[test] | |
| 432 | + | fn fallback_covers_monitored_tlds() { | |
| 433 | + | // The point of the fallback table: an IANA outage must not blind the | |
| 434 | + | // check for a TLD we actually watch. | |
| 435 | + | for tld in ["app", "dev", "work", "com", "net", "org", "info"] { | |
| 436 | + | assert!( | |
| 437 | + | FALLBACK_BASES.iter().any(|(t, _)| *t == tld), | |
| 438 | + | "no fallback RDAP base for .{tld}" | |
| 439 | + | ); | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn fallback_bases_are_https() { | |
| 445 | + | for (tld, base) in FALLBACK_BASES { | |
| 446 | + | assert!(base.starts_with("https://"), ".{tld} fallback is not HTTPS"); | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | #[test] | |
| 451 | + | fn ensure_trailing_slash_is_idempotent() { | |
| 452 | + | assert_eq!(ensure_trailing_slash("https://x/rdap/"), "https://x/rdap/"); | |
| 453 | + | assert_eq!(ensure_trailing_slash("https://x/rdap"), "https://x/rdap/"); | |
| 454 | + | } | |
| 455 | + | } |
| @@ -1,0 +1,326 @@ | |||
| 1 | + | //! Background test-suite task. | |
| 2 | + | //! | |
| 3 | + | //! Every target with a `[tests]` block used to sit at `pending` forever: the | |
| 4 | + | //! serve loop had no test task, so `staleness_days` was only ever read by the | |
| 5 | + | //! status readout to grade a run that nothing scheduled. This closes that loop. | |
| 6 | + | //! | |
| 7 | + | //! The sweep interval is not the test cadence. Each tick asks | |
| 8 | + | //! [`drift::compute_test_staleness`] whether a target's last run is still good | |
| 9 | + | //! (never run / older than `staleness_days` / deployed version changed since); | |
| 10 | + | //! only a stale target actually pays for a run. Reusing the same predicate the | |
| 11 | + | //! status readout uses is deliberate: the scheduler and the dashboard cannot | |
| 12 | + | //! disagree about what "stale" means. | |
| 13 | + | ||
| 14 | + | use tokio::task::JoinHandle; | |
| 15 | + | use tracing::{error, info}; | |
| 16 | + | ||
| 17 | + | use pom::alerts::Alerter; | |
| 18 | + | use pom::checks::{drift, ssh}; | |
| 19 | + | use pom::config::{Config, TestsConfig}; | |
| 20 | + | use pom::db; | |
| 21 | + | use pom::types::TestRun; | |
| 22 | + | ||
| 23 | + | use super::{CheckInterval, configured_targets}; | |
| 24 | + | ||
| 25 | + | pub(crate) fn spawn_test_tasks( | |
| 26 | + | config: &Config, | |
| 27 | + | pool: &sqlx::SqlitePool, | |
| 28 | + | cancel: &tokio_util::sync::CancellationToken, | |
| 29 | + | alerter: Option<&Alerter>, | |
| 30 | + | ) -> Vec<JoinHandle<()>> { | |
| 31 | + | let sweep_secs = config.serve.test_sweep_interval_secs; | |
| 32 | + | let mut handles = Vec::new(); | |
| 33 | + | ||
| 34 | + | for (name, target_config) in configured_targets(config) { | |
| 35 | + | let Some(tests_config) = target_config.tests else { | |
| 36 | + | continue; | |
| 37 | + | }; | |
| 38 | + | let label = target_config.label.clone(); | |
| 39 | + | let pool = pool.clone(); | |
| 40 | + | let alerter = alerter.cloned(); | |
| 41 | + | let cancel = cancel.clone(); | |
| 42 | + | ||
| 43 | + | info!( | |
| 44 | + | "{name}: test staleness sweep every {sweep_secs}s (threshold={}d, command={})", | |
| 45 | + | tests_config.staleness_days, tests_config.command | |
| 46 | + | ); | |
| 47 | + | ||
| 48 | + | handles.push(tokio::spawn(async move { | |
| 49 | + | let mut ticks = CheckInterval::new(sweep_secs, cancel); | |
| 50 | + | ||
| 51 | + | while ticks.next().await { | |
| 52 | + | let Some(reason) = staleness_reason(&pool, &name, &tests_config).await else { | |
| 53 | + | continue; | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | info!("{name}: running tests ({reason})"); | |
| 57 | + | let run = ssh::run_tests(&name, &tests_config, None).await; | |
| 58 | + | let previous_passed = db::get_latest_test_run(&pool, &name) | |
| 59 | + | .await | |
| 60 | + | .ok() | |
| 61 | + | .flatten() | |
| 62 | + | .map(|r| r.passed); | |
| 63 | + | ||
| 64 | + | if let Err(e) = store_run(&pool, &name, &run).await { | |
| 65 | + | error!("{name}: failed to store test run: {e}"); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | info!( | |
| 69 | + | "{}: tests {} ({} passed, {} failed, {}s)", | |
| 70 | + | name, | |
| 71 | + | if run.passed { "passed" } else { "FAILED" }, | |
| 72 | + | run.summary.total_passed.unwrap_or(-1), | |
| 73 | + | run.summary.total_failed.unwrap_or(-1), | |
| 74 | + | run.duration_secs.unwrap_or(-1), | |
| 75 | + | ); | |
| 76 | + | ||
| 77 | + | if let Some(ref alerter) = alerter { | |
| 78 | + | alert_on_transition(alerter, &name, &label, &run, previous_passed).await; | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | })); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | handles | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Why this target's tests need running, or `None` if the last run still counts. | |
| 88 | + | async fn staleness_reason( | |
| 89 | + | pool: &sqlx::SqlitePool, | |
| 90 | + | name: &str, | |
| 91 | + | tests_config: &TestsConfig, | |
| 92 | + | ) -> Option<String> { | |
| 93 | + | let current_version = db::get_health_history(pool, Some(name), 1) | |
| 94 | + | .await | |
| 95 | + | .unwrap_or_default() | |
| 96 | + | .first() | |
| 97 | + | .and_then(|s| s.details.as_ref()) | |
| 98 | + | .and_then(|d| d.version.clone()); | |
| 99 | + | ||
| 100 | + | let latest_test = db::get_latest_test_run(pool, name).await.unwrap_or(None); | |
| 101 | + | ||
| 102 | + | let tested_version = match latest_test { | |
| 103 | + | Some(ref test) => db::get_version_at_time(pool, name, &test.started_at) | |
| 104 | + | .await | |
| 105 | + | .unwrap_or(None), | |
| 106 | + | None => None, | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | let staleness = drift::compute_test_staleness( | |
| 110 | + | current_version.as_deref(), | |
| 111 | + | tested_version.as_deref(), | |
| 112 | + | latest_test.as_ref().map(|t| t.started_at.as_str()), | |
| 113 | + | tests_config.staleness_days, | |
| 114 | + | ); | |
| 115 | + | ||
| 116 | + | staleness | |
| 117 | + | .stale | |
| 118 | + | .then(|| staleness.reason.unwrap_or_else(|| "stale".to_string())) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Persist a run and its per-test details. Details are best-effort: losing them | |
| 122 | + | /// costs regression detection on the next run, not the run record itself. | |
| 123 | + | async fn store_run( | |
| 124 | + | pool: &sqlx::SqlitePool, | |
| 125 | + | name: &str, | |
| 126 | + | run: &TestRun, | |
| 127 | + | ) -> Result<(), pom::error::PomError> { | |
| 128 | + | let run_id = db::insert_test_run(pool, run).await?; | |
| 129 | + | ||
| 130 | + | if !run.summary.details.is_empty() | |
| 131 | + | && let Err(e) = db::insert_test_details(pool, run_id, &run.summary.details).await | |
| 132 | + | { | |
| 133 | + | error!("{name}: failed to store test details: {e}"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | Ok(()) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// What a sweep's result is worth telling someone about. | |
| 140 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 141 | + | enum Transition { | |
| 142 | + | Broke, | |
| 143 | + | Recovered, | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Alert on pass/fail transitions only, so a suite that stays red does not page | |
| 147 | + | /// once per sweep. A first-ever run that fails counts as broken: there is no | |
| 148 | + | /// prior state, and silence would be indistinguishable from passing. | |
| 149 | + | fn transition(previous_passed: Option<bool>, now_passed: bool) -> Option<Transition> { | |
| 150 | + | match (previous_passed.unwrap_or(true), now_passed) { | |
| 151 | + | (true, false) => Some(Transition::Broke), | |
| 152 | + | (false, true) => Some(Transition::Recovered), | |
| 153 | + | _ => None, | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | async fn alert_on_transition( | |
| 158 | + | alerter: &Alerter, | |
| 159 | + | name: &str, | |
| 160 | + | label: &str, | |
| 161 | + | run: &TestRun, | |
| 162 | + | previous_passed: Option<bool>, | |
| 163 | + | ) { | |
| 164 | + | match transition(previous_passed, run.passed) { | |
| 165 | + | Some(Transition::Broke) => { | |
| 166 | + | let detail = failure_detail(run); | |
| 167 | + | alerter | |
| 168 | + | .send_test_failure_alert( | |
| 169 | + | name, | |
| 170 | + | label, | |
| 171 | + | run.summary.total_failed, | |
| 172 | + | run.exit_code.map(i64::from), | |
| 173 | + | &detail, | |
| 174 | + | ) | |
| 175 | + | .await; | |
| 176 | + | } | |
| 177 | + | Some(Transition::Recovered) => alerter.send_test_recovery(name, label).await, | |
| 178 | + | None => {} | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// A short, alert-sized reason for a red suite: the failing test names when the | |
| 183 | + | /// output parsed, otherwise the tail of the raw output, which is where an SSH | |
| 184 | + | /// or missing-checkout error lands. | |
| 185 | + | fn failure_detail(run: &TestRun) -> String { | |
| 186 | + | let failed: Vec<&str> = run | |
| 187 | + | .summary | |
| 188 | + | .details | |
| 189 | + | .iter() | |
| 190 | + | .filter(|d| !d.passed) | |
| 191 | + | .map(|d| d.test_name.as_str()) | |
| 192 | + | .take(10) | |
| 193 | + | .collect(); | |
| 194 | + | ||
| 195 | + | if !failed.is_empty() { | |
| 196 | + | return failed.join(", "); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | let tail: Vec<&str> = run | |
| 200 | + | .raw_output | |
| 201 | + | .lines() | |
| 202 | + | .filter(|l| !l.trim().is_empty()) | |
| 203 | + | .rev() | |
| 204 | + | .take(5) | |
| 205 | + | .collect(); | |
| 206 | + | ||
| 207 | + | if tail.is_empty() { | |
| 208 | + | "no output captured".to_string() | |
| 209 | + | } else { | |
| 210 | + | tail.into_iter().rev().collect::<Vec<_>>().join(" | ") | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[cfg(test)] | |
| 215 | + | mod tests { | |
| 216 | + | use super::*; | |
| 217 | + | use pom::types::{TestDetail, TestSummary}; | |
| 218 | + | ||
| 219 | + | fn run_with(passed: bool, details: Vec<TestDetail>, raw_output: &str) -> TestRun { | |
| 220 | + | TestRun { | |
| 221 | + | id: None, | |
| 222 | + | target: "mnw".to_string(), | |
| 223 | + | started_at: "2026-08-06T00:00:00Z".to_string(), | |
| 224 | + | finished_at: None, | |
| 225 | + | duration_secs: Some(1), | |
| 226 | + | exit_code: Some(if passed { 0 } else { 101 }), | |
| 227 | + | passed, | |
| 228 | + | summary: TestSummary { | |
| 229 | + | steps: vec![], | |
| 230 | + | total_passed: None, | |
| 231 | + | total_failed: None, | |
| 232 | + | details, | |
| 233 | + | }, | |
| 234 | + | raw_output: raw_output.to_string(), | |
| 235 | + | filter: None, | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | fn detail(name: &str, passed: bool) -> TestDetail { | |
| 240 | + | TestDetail { | |
| 241 | + | test_name: name.to_string(), | |
| 242 | + | passed, | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[test] | |
| 247 | + | fn failure_detail_prefers_failing_test_names() { | |
| 248 | + | let run = run_with( | |
| 249 | + | false, | |
| 250 | + | vec![ | |
| 251 | + | detail("checks::whois::ok", true), | |
| 252 | + | detail("checks::rdap::broken", false), | |
| 253 | + | detail("db::also_broken", false), | |
| 254 | + | ], | |
| 255 | + | "irrelevant output", | |
| 256 | + | ); | |
| 257 | + | assert_eq!( | |
| 258 | + | failure_detail(&run), | |
| 259 | + | "checks::rdap::broken, db::also_broken" | |
| 260 | + | ); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[test] | |
| 264 | + | fn failure_detail_caps_the_name_list() { | |
| 265 | + | let details: Vec<TestDetail> = (0..20).map(|i| detail(&format!("t{i}"), false)).collect(); | |
| 266 | + | let run = run_with(false, details, ""); | |
| 267 | + | assert_eq!(failure_detail(&run).split(", ").count(), 10); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn failure_detail_falls_back_to_output_tail_in_order() { | |
| 272 | + | // The suite never ran, so there are no test names. This is the shape an | |
| 273 | + | // SSH refusal or a missing checkout produces, and it is the case that | |
| 274 | + | // matters most: the target is unverified, not merely broken. | |
| 275 | + | let run = run_with( | |
| 276 | + | false, | |
| 277 | + | vec![], | |
| 278 | + | "ssh: connect to host 100.106.221.39 port 22: Connection refused\n\ | |
| 279 | + | exit status 255\n", | |
| 280 | + | ); | |
| 281 | + | let detail = failure_detail(&run); | |
| 282 | + | assert!(detail.starts_with("ssh: connect to host"), "got {detail}"); | |
| 283 | + | assert!(detail.ends_with("exit status 255"), "got {detail}"); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn failure_detail_handles_no_output() { | |
| 288 | + | assert_eq!( | |
| 289 | + | failure_detail(&run_with(false, vec![], " \n\n")), | |
| 290 | + | "no output captured" | |
| 291 | + | ); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn transition_first_ever_run_failing_is_a_break() { | |
| 296 | + | // `None` means nothing has run before. Treating it as previously-passing | |
| 297 | + | // is what makes the first red run alert instead of being swallowed as | |
| 298 | + | // no-change, which is exactly the state every astra target is in today. | |
| 299 | + | assert_eq!(transition(None, false), Some(Transition::Broke)); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | #[test] | |
| 303 | + | fn transition_first_ever_run_passing_is_silent() { | |
| 304 | + | assert_eq!(transition(None, true), None); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[test] | |
| 308 | + | fn transition_still_red_does_not_repage() { | |
| 309 | + | assert_eq!(transition(Some(false), false), None); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn transition_still_green_is_silent() { | |
| 314 | + | assert_eq!(transition(Some(true), true), None); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[test] | |
| 318 | + | fn transition_red_to_green_recovers() { | |
| 319 | + | assert_eq!(transition(Some(false), true), Some(Transition::Recovered)); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn transition_green_to_red_breaks() { | |
| 324 | + | assert_eq!(transition(Some(true), false), Some(Transition::Broke)); | |
| 325 | + | } | |
| 326 | + | } |