1use std::path::PathBuf;
19use std::time::{Duration, Instant};
20
21use serde::{Deserialize, Serialize};
22use tokio::io::{AsyncReadExt, AsyncWriteExt};
23use tokio::net::TcpStream;
24use tokio::time::timeout;
25
26use crate::config::SeerConfig;
27use crate::dns::{DnsResolver, RecordType};
28
29const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
32
33const DEFAULT_WHOIS_ADDR: &str = "whois.iana.org:43";
37
38const DEFAULT_PROBE_DOMAIN: &str = "example.com";
41
42const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
46
47const CHECK_CONFIG: &str = "config";
49const CHECK_DNS: &str = "dns";
50const CHECK_WHOIS: &str = "whois";
51const CHECK_RDAP_BOOTSTRAP: &str = "rdap-bootstrap";
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum CheckStatus {
60 Pass,
62 Warn,
64 Fail,
66}
67
68impl std::fmt::Display for CheckStatus {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.write_str(match self {
71 CheckStatus::Pass => "PASS",
72 CheckStatus::Warn => "WARN",
73 CheckStatus::Fail => "FAIL",
74 })
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct DoctorCheck {
81 pub name: String,
84 pub status: CheckStatus,
86 pub detail: String,
88 pub latency_ms: Option<u64>,
90}
91
92impl DoctorCheck {
93 fn new(
94 name: &str,
95 status: CheckStatus,
96 detail: impl Into<String>,
97 latency_ms: Option<u64>,
98 ) -> Self {
99 Self {
100 name: name.to_string(),
101 status,
102 detail: detail.into(),
103 latency_ms,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct DoctorReport {
111 pub checks: Vec<DoctorCheck>,
114 pub overall: CheckStatus,
116}
117
118impl DoctorReport {
119 pub fn from_checks(checks: Vec<DoctorCheck>) -> Self {
122 let overall = checks
123 .iter()
124 .map(|check| check.status)
125 .max()
126 .unwrap_or(CheckStatus::Pass);
127 Self { checks, overall }
128 }
129}
130
131#[derive(Debug, Clone)]
138pub struct Doctor {
139 config: SeerConfig,
140 config_path: Option<PathBuf>,
143 resolver: DnsResolver,
145 probe_domain: String,
147 whois_addr: String,
149 bootstrap_url: String,
151 probe_timeout: Duration,
153}
154
155impl Doctor {
156 pub fn from_config(config: &SeerConfig) -> Self {
159 Self {
160 config: config.clone(),
161 config_path: SeerConfig::config_path(),
162 resolver: DnsResolver::from_config(config),
163 probe_domain: DEFAULT_PROBE_DOMAIN.to_string(),
164 whois_addr: DEFAULT_WHOIS_ADDR.to_string(),
165 bootstrap_url: IANA_BOOTSTRAP_DNS.to_string(),
166 probe_timeout: DEFAULT_PROBE_TIMEOUT,
167 }
168 }
169
170 #[cfg(test)]
176 pub(crate) fn with_config_path(mut self, path: PathBuf) -> Self {
177 self.config_path = Some(path);
178 self
179 }
180
181 #[cfg(test)]
184 pub(crate) fn with_resolver(mut self, resolver: DnsResolver) -> Self {
185 self.resolver = resolver;
186 self
187 }
188
189 #[cfg(test)]
191 pub(crate) fn with_probe_domain(mut self, domain: &str) -> Self {
192 self.probe_domain = domain.to_string();
193 self
194 }
195
196 #[cfg(test)]
198 pub(crate) fn with_whois_addr(mut self, addr: String) -> Self {
199 self.whois_addr = addr;
200 self
201 }
202
203 #[cfg(test)]
205 pub(crate) fn with_bootstrap_url(mut self, url: String) -> Self {
206 self.bootstrap_url = url;
207 self
208 }
209
210 #[cfg(test)]
212 pub(crate) fn with_probe_timeout(mut self, timeout: Duration) -> Self {
213 self.probe_timeout = timeout;
214 self
215 }
216
217 pub async fn run(&self) -> DoctorReport {
235 let (config, dns, whois, rdap) = tokio::join!(
236 self.check_config(),
237 self.check_dns(),
238 self.check_whois(),
239 self.check_rdap_bootstrap(),
240 );
241 DoctorReport::from_checks(vec![config, dns, whois, rdap])
242 }
243
244 async fn check_config(&self) -> DoctorCheck {
251 let Some(path) = self.config_path.as_ref() else {
252 return DoctorCheck::new(
253 CHECK_CONFIG,
254 CheckStatus::Pass,
255 "no home directory found; using built-in defaults",
256 None,
257 );
258 };
259 if !path.exists() {
260 return DoctorCheck::new(
261 CHECK_CONFIG,
262 CheckStatus::Pass,
263 format!("{} not present; using built-in defaults", path.display()),
264 None,
265 );
266 }
267 match std::fs::read_to_string(path) {
270 Ok(content) => match toml::from_str::<SeerConfig>(&content) {
271 Ok(_) => DoctorCheck::new(
272 CHECK_CONFIG,
273 CheckStatus::Pass,
274 format!("{} parsed OK", path.display()),
275 None,
276 ),
277 Err(e) => DoctorCheck::new(
278 CHECK_CONFIG,
279 CheckStatus::Warn,
280 format!(
281 "{} is malformed TOML (seer runs on defaults): {}",
282 path.display(),
283 one_line(&e.to_string())
284 ),
285 None,
286 ),
287 },
288 Err(e) => DoctorCheck::new(
289 CHECK_CONFIG,
290 CheckStatus::Warn,
291 format!(
292 "could not read {} (seer runs on defaults): {}",
293 path.display(),
294 e
295 ),
296 None,
297 ),
298 }
299 }
300
301 async fn check_dns(&self) -> DoctorCheck {
304 let nameserver = self.config.nameserver.as_deref();
305 let ns_label = nameserver.unwrap_or("8.8.8.8 (default)");
306 let start = Instant::now();
307 let outcome = timeout(
308 self.probe_timeout,
309 self.resolver
310 .resolve(&self.probe_domain, RecordType::A, nameserver),
311 )
312 .await;
313 let latency = Some(latency_since(start));
314 match outcome {
315 Ok(Ok(records)) if !records.is_empty() => DoctorCheck::new(
316 CHECK_DNS,
317 CheckStatus::Pass,
318 format!(
319 "resolved {} A via {} ({} record{})",
320 self.probe_domain,
321 ns_label,
322 records.len(),
323 plural(records.len())
324 ),
325 latency,
326 ),
327 Ok(Ok(_)) => DoctorCheck::new(
332 CHECK_DNS,
333 CheckStatus::Warn,
334 format!(
335 "{} returned no A records via {} — resolver reachable, but a well-known name came back empty (possible filtering or captive portal)",
336 self.probe_domain, ns_label
337 ),
338 latency,
339 ),
340 Ok(Err(e)) => DoctorCheck::new(
341 CHECK_DNS,
342 CheckStatus::Fail,
343 format!("resolving {} via {} failed: {}", self.probe_domain, ns_label, e),
344 latency,
345 ),
346 Err(_) => DoctorCheck::new(
347 CHECK_DNS,
348 CheckStatus::Fail,
349 format!(
350 "resolving {} via {} timed out after {:?}",
351 self.probe_domain, ns_label, self.probe_timeout
352 ),
353 latency,
354 ),
355 }
356 }
357
358 async fn check_whois(&self) -> DoctorCheck {
361 let start = Instant::now();
362 let outcome = timeout(self.probe_timeout, self.whois_probe()).await;
363 let latency = Some(latency_since(start));
364 match outcome {
365 Ok(Ok(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Pass, detail, latency),
366 Ok(Err(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Fail, detail, latency),
367 Err(_) => DoctorCheck::new(
368 CHECK_WHOIS,
369 CheckStatus::Fail,
370 format!(
371 "WHOIS probe to {} timed out after {:?} (port 43 may be blocked)",
372 self.whois_addr, self.probe_timeout
373 ),
374 latency,
375 ),
376 }
377 }
378
379 async fn whois_probe(&self) -> Result<String, String> {
383 let mut stream = TcpStream::connect(&self.whois_addr)
384 .await
385 .map_err(|e| format!("connect to {} failed: {}", self.whois_addr, e))?;
386 stream
387 .write_all(format!("{}\r\n", self.probe_domain).as_bytes())
388 .await
389 .map_err(|e| format!("write to {} failed: {}", self.whois_addr, e))?;
390 let mut buf = [0u8; 256];
391 let n = stream
392 .read(&mut buf)
393 .await
394 .map_err(|e| format!("read from {} failed: {}", self.whois_addr, e))?;
395 if n == 0 {
396 return Err(format!(
397 "{} closed the connection without sending data",
398 self.whois_addr
399 ));
400 }
401 Ok(format!(
402 "queried {} via {} ({} byte{} received)",
403 self.probe_domain,
404 self.whois_addr,
405 n,
406 plural(n)
407 ))
408 }
409
410 async fn check_rdap_bootstrap(&self) -> DoctorCheck {
413 let start = Instant::now();
414 let outcome = timeout(self.probe_timeout, self.rdap_bootstrap_probe()).await;
415 let latency = Some(latency_since(start));
416 match outcome {
417 Ok(Ok(detail)) => {
418 DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Pass, detail, latency)
419 }
420 Ok(Err(detail)) => {
421 DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Fail, detail, latency)
422 }
423 Err(_) => DoctorCheck::new(
424 CHECK_RDAP_BOOTSTRAP,
425 CheckStatus::Fail,
426 format!(
427 "GET {} timed out after {:?}",
428 self.bootstrap_url, self.probe_timeout
429 ),
430 latency,
431 ),
432 }
433 }
434
435 async fn rdap_bootstrap_probe(&self) -> Result<String, String> {
439 let client = reqwest::Client::builder()
442 .timeout(self.probe_timeout)
443 .build()
444 .map_err(|e| format!("could not build HTTP client: {}", e))?;
445 let response = client
446 .get(&self.bootstrap_url)
447 .send()
448 .await
449 .map_err(|e| format!("GET {} failed: {}", self.bootstrap_url, e))?;
450 let status = response.status();
451 if status != reqwest::StatusCode::OK {
452 return Err(format!(
453 "GET {} returned HTTP {}",
454 self.bootstrap_url, status
455 ));
456 }
457 let body = response
458 .bytes()
459 .await
460 .map_err(|e| format!("reading {} body failed: {}", self.bootstrap_url, e))?;
461 if body.is_empty() {
462 return Err(format!("GET {} returned an empty body", self.bootstrap_url));
463 }
464 Ok(format!(
465 "fetched {} ({} bytes)",
466 self.bootstrap_url,
467 body.len()
468 ))
469 }
470}
471
472fn latency_since(start: Instant) -> u64 {
474 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
475}
476
477fn plural(n: usize) -> &'static str {
479 if n == 1 {
480 ""
481 } else {
482 "s"
483 }
484}
485
486fn one_line(s: &str) -> String {
489 s.split_whitespace().collect::<Vec<_>>().join(" ")
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 use std::sync::atomic::{AtomicU32, Ordering};
497
498 use wiremock::matchers::method;
499 use wiremock::{Mock, MockServer, ResponseTemplate};
500
501 use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
502
503 struct TmpFile(PathBuf);
507
508 impl TmpFile {
509 fn unique(tag: &str) -> Self {
510 static COUNTER: AtomicU32 = AtomicU32::new(0);
511 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
512 Self(std::env::temp_dir().join(format!(
513 "seer-doctor-{}-{}-{}.toml",
514 tag,
515 std::process::id(),
516 n
517 )))
518 }
519
520 fn with_content(tag: &str, content: &str) -> Self {
521 let file = Self::unique(tag);
522 std::fs::write(&file.0, content).expect("write temp config");
523 file
524 }
525 }
526
527 impl Drop for TmpFile {
528 fn drop(&mut self) {
529 let _ = std::fs::remove_file(&self.0);
530 }
531 }
532
533 async fn spawn_whois_responder(response: &'static [u8]) -> u16 {
535 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
536 .await
537 .expect("bind mock whois");
538 let port = listener.local_addr().expect("local addr").port();
539 tokio::spawn(async move {
540 loop {
541 let Ok((mut sock, _)) = listener.accept().await else {
542 return;
543 };
544 let mut buf = [0u8; 128];
545 let _ = sock.read(&mut buf).await; let _ = sock.write_all(response).await;
547 }
548 });
549 port
550 }
551
552 async fn refused_port() -> u16 {
554 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
555 .await
556 .expect("bind for refused port");
557 let port = listener.local_addr().expect("local addr").port();
558 drop(listener);
559 port
560 }
561
562 fn base_doctor(config: &SeerConfig) -> Doctor {
563 Doctor::from_config(config)
566 .with_config_path(TmpFile::unique("unused").0.clone())
567 .with_probe_timeout(Duration::from_secs(2))
568 }
569
570 async fn dns_doctor(mode: MockMode) -> Doctor {
572 let port = spawn_mock_dns(mode).await;
573 let config = SeerConfig {
574 nameserver: Some("127.0.0.1".to_string()),
575 ..SeerConfig::default()
576 };
577 base_doctor(&config)
578 .with_resolver(mock_dns_resolver(port))
579 .with_probe_domain("seer.test")
580 }
581
582 fn check(status: CheckStatus) -> DoctorCheck {
585 DoctorCheck::new("x", status, "detail", None)
586 }
587
588 #[test]
589 fn overall_is_worst_of_checks() {
590 use CheckStatus::{Fail, Pass, Warn};
591 let cases = [
592 (vec![], Pass),
593 (vec![check(Pass), check(Pass)], Pass),
594 (vec![check(Pass), check(Warn)], Warn),
595 (vec![check(Warn), check(Pass), check(Fail)], Fail),
596 (vec![check(Fail), check(Warn)], Fail),
597 ];
598 for (checks, expected) in cases {
599 assert_eq!(DoctorReport::from_checks(checks).overall, expected);
600 }
601 }
602
603 #[test]
604 fn report_serde_roundtrips_with_lowercase_statuses() {
605 let report = DoctorReport::from_checks(vec![DoctorCheck::new(
606 "dns",
607 CheckStatus::Fail,
608 "boom",
609 Some(12),
610 )]);
611 let json = serde_json::to_string(&report).expect("serialize report");
612 assert!(json.contains("\"fail\""), "got: {json}");
613 let back: DoctorReport = serde_json::from_str(&json).expect("deserialize report");
614 assert_eq!(back, report);
615 }
616
617 #[test]
618 fn doctor_defaults_target_real_endpoints() {
619 let doctor = Doctor::from_config(&SeerConfig::default());
622 assert_eq!(doctor.whois_addr, "whois.iana.org:43");
623 assert_eq!(doctor.bootstrap_url, "https://data.iana.org/rdap/dns.json");
624 assert_eq!(doctor.probe_domain, "example.com");
625 assert_eq!(doctor.probe_timeout, Duration::from_secs(5));
626 }
627
628 #[tokio::test]
631 async fn config_check_absent_file_passes() {
632 let missing = TmpFile::unique("absent");
633 let doctor =
634 Doctor::from_config(&SeerConfig::default()).with_config_path(missing.0.clone());
635 let check = doctor.check_config().await;
636 assert_eq!(check.status, CheckStatus::Pass);
637 assert!(check.detail.contains("defaults"), "got: {}", check.detail);
638 assert_eq!(check.latency_ms, None);
639 }
640
641 #[tokio::test]
642 async fn config_check_valid_file_passes() {
643 let file = TmpFile::with_content("valid", "output_format = \"json\"\n");
644 let doctor = Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
645 let check = doctor.check_config().await;
646 assert_eq!(check.status, CheckStatus::Pass);
647 assert!(check.detail.contains("parsed OK"), "got: {}", check.detail);
648 }
649
650 #[tokio::test]
651 async fn config_check_malformed_file_warns() {
652 for content in ["output_format = [not toml", "output_format = 42"] {
655 let file = TmpFile::with_content("malformed", content);
656 let doctor =
657 Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
658 let check = doctor.check_config().await;
659 assert_eq!(check.status, CheckStatus::Warn, "content: {content}");
660 assert!(check.detail.contains("defaults"), "got: {}", check.detail);
661 assert!(!check.detail.contains('\n'), "got: {}", check.detail);
663 }
664 }
665
666 #[tokio::test]
669 async fn whois_check_passes_against_responding_listener() {
670 let port = spawn_whois_responder(b"% IANA WHOIS server\r\n").await;
671 let doctor =
672 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
673 let check = doctor.check_whois().await;
674 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
675 assert!(check.latency_ms.is_some());
676 }
677
678 #[tokio::test]
679 async fn whois_check_fails_on_connection_refused() {
680 let port = refused_port().await;
681 let doctor =
682 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
683 let check = doctor.check_whois().await;
684 assert_eq!(check.status, CheckStatus::Fail);
685 assert!(
690 check.detail.contains(&format!("127.0.0.1:{port}")),
691 "got: {}",
692 check.detail
693 );
694 }
695
696 #[tokio::test]
697 async fn whois_check_fails_on_eof_without_data() {
698 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
701 .await
702 .expect("bind mock whois");
703 let port = listener.local_addr().expect("local addr").port();
704 tokio::spawn(async move {
705 let Ok((mut sock, _)) = listener.accept().await else {
706 return;
707 };
708 let mut buf = [0u8; 128];
709 let _ = sock.read(&mut buf).await;
710 });
712 let doctor =
713 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
714 let check = doctor.check_whois().await;
715 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
716 }
717
718 #[tokio::test]
719 async fn whois_check_fails_on_timeout() {
720 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
722 .await
723 .expect("bind mock whois");
724 let port = listener.local_addr().expect("local addr").port();
725 tokio::spawn(async move {
726 let Ok((_sock, _)) = listener.accept().await else {
727 return;
728 };
729 tokio::time::sleep(Duration::from_secs(30)).await;
730 });
731 let doctor = base_doctor(&SeerConfig::default())
732 .with_whois_addr(format!("127.0.0.1:{port}"))
733 .with_probe_timeout(Duration::from_millis(200));
734 let check = doctor.check_whois().await;
735 assert_eq!(check.status, CheckStatus::Fail);
736 assert!(check.detail.contains("timed out"), "got: {}", check.detail);
737 }
738
739 #[tokio::test]
742 async fn rdap_check_passes_on_200_with_body() {
743 let server = MockServer::start().await;
744 Mock::given(method("GET"))
745 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
746 .mount(&server)
747 .await;
748 let doctor = base_doctor(&SeerConfig::default())
749 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
750 let check = doctor.check_rdap_bootstrap().await;
751 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
752 assert!(check.latency_ms.is_some());
753 }
754
755 #[tokio::test]
756 async fn rdap_check_fails_on_500() {
757 let server = MockServer::start().await;
758 Mock::given(method("GET"))
759 .respond_with(ResponseTemplate::new(500))
760 .mount(&server)
761 .await;
762 let doctor = base_doctor(&SeerConfig::default())
763 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
764 let check = doctor.check_rdap_bootstrap().await;
765 assert_eq!(check.status, CheckStatus::Fail);
766 assert!(check.detail.contains("500"), "got: {}", check.detail);
767 }
768
769 #[tokio::test]
770 async fn rdap_check_fails_on_empty_body() {
771 let server = MockServer::start().await;
772 Mock::given(method("GET"))
773 .respond_with(ResponseTemplate::new(200))
774 .mount(&server)
775 .await;
776 let doctor = base_doctor(&SeerConfig::default())
777 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
778 let check = doctor.check_rdap_bootstrap().await;
779 assert_eq!(check.status, CheckStatus::Fail);
780 assert!(check.detail.contains("empty body"), "got: {}", check.detail);
781 }
782
783 #[tokio::test]
784 async fn rdap_check_fails_on_timeout() {
785 let server = MockServer::start().await;
786 Mock::given(method("GET"))
787 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
788 .mount(&server)
789 .await;
790 let doctor = base_doctor(&SeerConfig::default())
791 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()))
792 .with_probe_timeout(Duration::from_millis(200));
793 let check = doctor.check_rdap_bootstrap().await;
794 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
795 }
796
797 #[tokio::test]
800 async fn dns_check_passes_with_mock_zone() {
801 let doctor = dns_doctor(MockMode::Zone).await;
802 let check = doctor.check_dns().await;
803 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
804 assert!(check.detail.contains("127.0.0.1"), "got: {}", check.detail);
806 assert!(check.latency_ms.is_some());
807 }
808
809 #[tokio::test]
810 async fn dns_check_warns_on_nxdomain_for_probe_name() {
811 let doctor = dns_doctor(MockMode::Nxdomain).await;
812 let check = doctor.check_dns().await;
813 assert_eq!(check.status, CheckStatus::Warn, "got: {}", check.detail);
814 }
815
816 #[tokio::test]
817 async fn dns_check_fails_when_server_unresponsive() {
818 let doctor = dns_doctor(MockMode::Ignore)
821 .await
822 .with_probe_timeout(Duration::from_secs(1));
823 let check = doctor.check_dns().await;
824 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
825 }
826
827 #[tokio::test]
830 async fn run_reports_all_four_checks_in_order_and_passes() {
831 let config_file = TmpFile::with_content("run-valid", "output_format = \"yaml\"\n");
832 let whois_port = spawn_whois_responder(b"% ok\r\n").await;
833 let rdap = MockServer::start().await;
834 Mock::given(method("GET"))
835 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
836 .mount(&rdap)
837 .await;
838
839 let doctor = dns_doctor(MockMode::Zone)
840 .await
841 .with_config_path(config_file.0.clone())
842 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
843 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
844
845 let report = doctor.run().await;
846 let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
847 assert_eq!(names, ["config", "dns", "whois", "rdap-bootstrap"]);
848 for check in &report.checks {
849 assert_eq!(
850 check.status,
851 CheckStatus::Pass,
852 "{}: {}",
853 check.name,
854 check.detail
855 );
856 }
857 assert_eq!(report.overall, CheckStatus::Pass);
858 }
859
860 #[tokio::test]
861 async fn run_isolates_probe_failures() {
862 let missing_config = TmpFile::unique("run-absent");
865 let whois_port = refused_port().await;
866 let rdap = MockServer::start().await;
867 Mock::given(method("GET"))
868 .respond_with(ResponseTemplate::new(500))
869 .mount(&rdap)
870 .await;
871
872 let doctor = dns_doctor(MockMode::Zone)
873 .await
874 .with_config_path(missing_config.0.clone())
875 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
876 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
877
878 let report = doctor.run().await;
879 let by_name = |name: &str| {
880 report
881 .checks
882 .iter()
883 .find(|c| c.name == name)
884 .unwrap_or_else(|| panic!("missing check {name}"))
885 };
886 assert_eq!(by_name("config").status, CheckStatus::Pass);
887 assert_eq!(by_name("dns").status, CheckStatus::Pass);
888 assert_eq!(by_name("whois").status, CheckStatus::Fail);
889 assert_eq!(by_name("rdap-bootstrap").status, CheckStatus::Fail);
890 assert_eq!(report.overall, CheckStatus::Fail);
891 }
892
893 #[tokio::test]
894 async fn run_with_malformed_config_only_warns_overall() {
895 let config_file = TmpFile::with_content("run-malformed", "output_format = [broken");
898 let whois_port = spawn_whois_responder(b"% ok\r\n").await;
899 let rdap = MockServer::start().await;
900 Mock::given(method("GET"))
901 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
902 .mount(&rdap)
903 .await;
904
905 let doctor = dns_doctor(MockMode::Zone)
906 .await
907 .with_config_path(config_file.0.clone())
908 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
909 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
910
911 let report = doctor.run().await;
912 assert_eq!(report.overall, CheckStatus::Warn);
913 }
914}