1use std::net::SocketAddr;
19use std::path::PathBuf;
20use std::time::{Duration, Instant};
21
22use serde::{Deserialize, Serialize};
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::TcpStream;
25use tokio::time::timeout;
26
27use crate::config::SeerConfig;
28use crate::dns::{DnsResolver, RecordType};
29
30const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
33
34const DEFAULT_WHOIS_ADDR: &str = "whois.iana.org:43";
38
39const DEFAULT_PROBE_DOMAIN: &str = "example.com";
42
43const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
47
48const CHECK_CONFIG: &str = "config";
50const CHECK_DNS: &str = "dns";
51const CHECK_WHOIS: &str = "whois";
52const CHECK_RDAP_BOOTSTRAP: &str = "rdap-bootstrap";
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum CheckStatus {
61 Pass,
63 Warn,
65 Fail,
67}
68
69impl std::fmt::Display for CheckStatus {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.write_str(match self {
72 CheckStatus::Pass => "PASS",
73 CheckStatus::Warn => "WARN",
74 CheckStatus::Fail => "FAIL",
75 })
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct DoctorCheck {
82 pub name: String,
85 pub status: CheckStatus,
87 pub detail: String,
89 pub latency_ms: Option<u64>,
91}
92
93impl DoctorCheck {
94 fn new(
95 name: &str,
96 status: CheckStatus,
97 detail: impl Into<String>,
98 latency_ms: Option<u64>,
99 ) -> Self {
100 Self {
101 name: name.to_string(),
102 status,
103 detail: detail.into(),
104 latency_ms,
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct DoctorReport {
112 pub checks: Vec<DoctorCheck>,
115 pub overall: CheckStatus,
117}
118
119impl DoctorReport {
120 pub fn from_checks(checks: Vec<DoctorCheck>) -> Self {
123 let overall = checks
124 .iter()
125 .map(|check| check.status)
126 .max()
127 .unwrap_or(CheckStatus::Pass);
128 Self { checks, overall }
129 }
130}
131
132#[derive(Debug, Clone)]
139pub struct Doctor {
140 config: SeerConfig,
141 config_path: Option<PathBuf>,
144 resolver: DnsResolver,
146 probe_domain: String,
148 whois_addr: String,
150 bootstrap_url: String,
152 probe_timeout: Duration,
154 #[cfg(test)]
158 allow_reserved_probe_hosts: bool,
159}
160
161impl Doctor {
162 pub fn from_config(config: &SeerConfig) -> Self {
165 Self {
166 config: config.clone(),
167 config_path: SeerConfig::config_path(),
168 resolver: DnsResolver::from_config(config),
169 probe_domain: DEFAULT_PROBE_DOMAIN.to_string(),
170 whois_addr: DEFAULT_WHOIS_ADDR.to_string(),
171 bootstrap_url: IANA_BOOTSTRAP_DNS.to_string(),
172 probe_timeout: DEFAULT_PROBE_TIMEOUT,
173 #[cfg(test)]
174 allow_reserved_probe_hosts: false,
175 }
176 }
177
178 #[cfg(test)]
184 pub(crate) fn with_config_path(mut self, path: PathBuf) -> Self {
185 self.config_path = Some(path);
186 self
187 }
188
189 #[cfg(test)]
192 pub(crate) fn with_resolver(mut self, resolver: DnsResolver) -> Self {
193 self.resolver = resolver;
194 self
195 }
196
197 #[cfg(test)]
199 pub(crate) fn with_probe_domain(mut self, domain: &str) -> Self {
200 self.probe_domain = domain.to_string();
201 self
202 }
203
204 #[cfg(test)]
206 pub(crate) fn with_whois_addr(mut self, addr: String) -> Self {
207 self.whois_addr = addr;
208 self
209 }
210
211 #[cfg(test)]
213 pub(crate) fn with_bootstrap_url(mut self, url: String) -> Self {
214 self.bootstrap_url = url;
215 self
216 }
217
218 #[cfg(test)]
220 pub(crate) fn with_probe_timeout(mut self, timeout: Duration) -> Self {
221 self.probe_timeout = timeout;
222 self
223 }
224
225 #[cfg(test)]
229 pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
230 self.allow_reserved_probe_hosts = true;
231 self
232 }
233
234 pub async fn run(&self) -> DoctorReport {
252 let (config, dns, whois, rdap) = tokio::join!(
253 self.check_config(),
254 self.check_dns(),
255 self.check_whois(),
256 self.check_rdap_bootstrap(),
257 );
258 DoctorReport::from_checks(vec![config, dns, whois, rdap])
259 }
260
261 async fn check_config(&self) -> DoctorCheck {
268 let Some(path) = self.config_path.as_ref() else {
269 return DoctorCheck::new(
270 CHECK_CONFIG,
271 CheckStatus::Pass,
272 "no home directory found; using built-in defaults",
273 None,
274 );
275 };
276 if !path.exists() {
277 return DoctorCheck::new(
278 CHECK_CONFIG,
279 CheckStatus::Pass,
280 format!("{} not present; using built-in defaults", path.display()),
281 None,
282 );
283 }
284 match std::fs::read_to_string(path) {
287 Ok(content) => match toml::from_str::<SeerConfig>(&content) {
288 Ok(_) => DoctorCheck::new(
289 CHECK_CONFIG,
290 CheckStatus::Pass,
291 format!("{} parsed OK", path.display()),
292 None,
293 ),
294 Err(e) => DoctorCheck::new(
295 CHECK_CONFIG,
296 CheckStatus::Warn,
297 format!(
298 "{} is malformed TOML (seer runs on defaults): {}",
299 path.display(),
300 one_line(&e.to_string())
301 ),
302 None,
303 ),
304 },
305 Err(e) => DoctorCheck::new(
306 CHECK_CONFIG,
307 CheckStatus::Warn,
308 format!(
309 "could not read {} (seer runs on defaults): {}",
310 path.display(),
311 e
312 ),
313 None,
314 ),
315 }
316 }
317
318 async fn check_dns(&self) -> DoctorCheck {
321 let nameserver = self.config.nameserver.as_deref();
322 let ns_label = nameserver.unwrap_or("8.8.8.8 (default)");
323 let start = Instant::now();
324 let outcome = timeout(
325 self.probe_timeout,
326 self.resolver
327 .resolve(&self.probe_domain, RecordType::A, nameserver),
328 )
329 .await;
330 let latency = Some(latency_since(start));
331 match outcome {
332 Ok(Ok(records)) if !records.is_empty() => DoctorCheck::new(
333 CHECK_DNS,
334 CheckStatus::Pass,
335 format!(
336 "resolved {} A via {} ({} record{})",
337 self.probe_domain,
338 ns_label,
339 records.len(),
340 plural(records.len())
341 ),
342 latency,
343 ),
344 Ok(Ok(_)) => DoctorCheck::new(
349 CHECK_DNS,
350 CheckStatus::Warn,
351 format!(
352 "{} returned no A records via {} — resolver reachable, but a well-known name came back empty (possible filtering or captive portal)",
353 self.probe_domain, ns_label
354 ),
355 latency,
356 ),
357 Ok(Err(e)) => DoctorCheck::new(
358 CHECK_DNS,
359 CheckStatus::Fail,
360 format!("resolving {} via {} failed: {}", self.probe_domain, ns_label, e),
361 latency,
362 ),
363 Err(_) => DoctorCheck::new(
364 CHECK_DNS,
365 CheckStatus::Fail,
366 format!(
367 "resolving {} via {} timed out after {:?}",
368 self.probe_domain, ns_label, self.probe_timeout
369 ),
370 latency,
371 ),
372 }
373 }
374
375 async fn check_whois(&self) -> DoctorCheck {
379 let start = Instant::now();
380 let addrs = match self.resolve_whois_target().await {
388 Ok(addrs) => addrs,
389 Err(detail) => {
390 return DoctorCheck::new(
391 CHECK_WHOIS,
392 CheckStatus::Fail,
393 detail,
394 Some(latency_since(start)),
395 )
396 }
397 };
398 let outcome = timeout(self.probe_timeout, self.whois_exchange(&addrs)).await;
401 let latency = Some(latency_since(start));
402 match outcome {
403 Ok(Ok(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Pass, detail, latency),
404 Ok(Err(detail)) => DoctorCheck::new(CHECK_WHOIS, CheckStatus::Fail, detail, latency),
405 Err(_) => DoctorCheck::new(
406 CHECK_WHOIS,
407 CheckStatus::Fail,
408 format!(
409 "WHOIS probe to {} timed out after {:?} (port 43 may be blocked)",
410 self.whois_addr, self.probe_timeout
411 ),
412 latency,
413 ),
414 }
415 }
416
417 async fn resolve_whois_target(&self) -> Result<Vec<SocketAddr>, String> {
423 let (host, port) = self
424 .whois_addr
425 .rsplit_once(':')
426 .and_then(|(host, port)| port.parse::<u16>().ok().map(|port| (host, port)))
427 .ok_or_else(|| format!("invalid WHOIS probe address {}", self.whois_addr))?;
428 #[cfg(test)]
429 if self.allow_reserved_probe_hosts {
430 return tokio::net::lookup_host((host, port))
431 .await
432 .map(|iter| iter.collect())
433 .map_err(|e| format!("resolving {} failed: {}", host, e));
434 }
435 crate::net::resolve_public_host(host, port)
436 .await
437 .map_err(|e| format!("resolving {} failed: {}", host, e))
438 }
439
440 async fn whois_exchange(&self, addrs: &[SocketAddr]) -> Result<String, String> {
444 let mut stream = TcpStream::connect(addrs)
445 .await
446 .map_err(|e| format!("connect to {} failed: {}", self.whois_addr, e))?;
447 stream
448 .write_all(format!("{}\r\n", self.probe_domain).as_bytes())
449 .await
450 .map_err(|e| format!("write to {} failed: {}", self.whois_addr, e))?;
451 let mut buf = [0u8; 256];
452 let n = stream
453 .read(&mut buf)
454 .await
455 .map_err(|e| format!("read from {} failed: {}", self.whois_addr, e))?;
456 if n == 0 {
457 return Err(format!(
458 "{} closed the connection without sending data",
459 self.whois_addr
460 ));
461 }
462 Ok(format!(
463 "queried {} via {} ({} byte{} received)",
464 self.probe_domain,
465 self.whois_addr,
466 n,
467 plural(n)
468 ))
469 }
470
471 async fn check_rdap_bootstrap(&self) -> DoctorCheck {
474 let start = Instant::now();
475 let outcome = timeout(self.probe_timeout, self.rdap_bootstrap_probe()).await;
476 let latency = Some(latency_since(start));
477 match outcome {
478 Ok(Ok(detail)) => {
479 DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Pass, detail, latency)
480 }
481 Ok(Err(detail)) => {
482 DoctorCheck::new(CHECK_RDAP_BOOTSTRAP, CheckStatus::Fail, detail, latency)
483 }
484 Err(_) => DoctorCheck::new(
485 CHECK_RDAP_BOOTSTRAP,
486 CheckStatus::Fail,
487 format!(
488 "GET {} timed out after {:?}",
489 self.bootstrap_url, self.probe_timeout
490 ),
491 latency,
492 ),
493 }
494 }
495
496 async fn rdap_bootstrap_probe(&self) -> Result<String, String> {
500 let client = reqwest::Client::builder()
503 .timeout(self.probe_timeout)
504 .build()
505 .map_err(|e| format!("could not build HTTP client: {}", e))?;
506 let response = client
507 .get(&self.bootstrap_url)
508 .send()
509 .await
510 .map_err(|e| format!("GET {} failed: {}", self.bootstrap_url, e))?;
511 let status = response.status();
512 if status != reqwest::StatusCode::OK {
513 return Err(format!(
514 "GET {} returned HTTP {}",
515 self.bootstrap_url, status
516 ));
517 }
518 let body = response
519 .bytes()
520 .await
521 .map_err(|e| format!("reading {} body failed: {}", self.bootstrap_url, e))?;
522 if body.is_empty() {
523 return Err(format!("GET {} returned an empty body", self.bootstrap_url));
524 }
525 Ok(format!(
526 "fetched {} ({} bytes)",
527 self.bootstrap_url,
528 body.len()
529 ))
530 }
531}
532
533fn latency_since(start: Instant) -> u64 {
535 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
536}
537
538fn plural(n: usize) -> &'static str {
540 if n == 1 {
541 ""
542 } else {
543 "s"
544 }
545}
546
547fn one_line(s: &str) -> String {
550 s.split_whitespace().collect::<Vec<_>>().join(" ")
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 use std::sync::atomic::{AtomicU32, Ordering};
558
559 use wiremock::matchers::method;
560 use wiremock::{Mock, MockServer, ResponseTemplate};
561
562 use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
563
564 struct TmpFile(PathBuf);
568
569 impl TmpFile {
570 fn unique(tag: &str) -> Self {
571 static COUNTER: AtomicU32 = AtomicU32::new(0);
572 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
573 Self(std::env::temp_dir().join(format!(
574 "seer-doctor-{}-{}-{}.toml",
575 tag,
576 std::process::id(),
577 n
578 )))
579 }
580
581 fn with_content(tag: &str, content: &str) -> Self {
582 let file = Self::unique(tag);
583 std::fs::write(&file.0, content).expect("write temp config");
584 file
585 }
586 }
587
588 impl Drop for TmpFile {
589 fn drop(&mut self) {
590 let _ = std::fs::remove_file(&self.0);
591 }
592 }
593
594 async fn spawn_whois_responder(response: &'static [u8]) -> u16 {
596 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
597 .await
598 .expect("bind mock whois");
599 let port = listener.local_addr().expect("local addr").port();
600 tokio::spawn(async move {
601 loop {
602 let Ok((mut sock, _)) = listener.accept().await else {
603 return;
604 };
605 let mut buf = [0u8; 128];
606 let _ = sock.read(&mut buf).await; let _ = sock.write_all(response).await;
608 }
609 });
610 port
611 }
612
613 async fn refused_port() -> u16 {
615 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
616 .await
617 .expect("bind for refused port");
618 let port = listener.local_addr().expect("local addr").port();
619 drop(listener);
620 port
621 }
622
623 fn base_doctor(config: &SeerConfig) -> Doctor {
624 Doctor::from_config(config)
630 .with_config_path(TmpFile::unique("unused").0.clone())
631 .with_probe_timeout(Duration::from_secs(2))
632 .allowing_reserved_for_tests()
633 }
634
635 async fn dns_doctor(mode: MockMode) -> Doctor {
637 let port = spawn_mock_dns(mode).await;
638 let config = SeerConfig {
639 nameserver: Some("127.0.0.1".to_string()),
640 ..SeerConfig::default()
641 };
642 base_doctor(&config)
643 .with_resolver(mock_dns_resolver(port))
644 .with_probe_domain("seer.test")
645 }
646
647 fn check(status: CheckStatus) -> DoctorCheck {
650 DoctorCheck::new("x", status, "detail", None)
651 }
652
653 #[test]
654 fn overall_is_worst_of_checks() {
655 use CheckStatus::{Fail, Pass, Warn};
656 let cases = [
657 (vec![], Pass),
658 (vec![check(Pass), check(Pass)], Pass),
659 (vec![check(Pass), check(Warn)], Warn),
660 (vec![check(Warn), check(Pass), check(Fail)], Fail),
661 (vec![check(Fail), check(Warn)], Fail),
662 ];
663 for (checks, expected) in cases {
664 assert_eq!(DoctorReport::from_checks(checks).overall, expected);
665 }
666 }
667
668 #[test]
669 fn report_serde_roundtrips_with_lowercase_statuses() {
670 let report = DoctorReport::from_checks(vec![DoctorCheck::new(
671 "dns",
672 CheckStatus::Fail,
673 "boom",
674 Some(12),
675 )]);
676 let json = serde_json::to_string(&report).expect("serialize report");
677 assert!(json.contains("\"fail\""), "got: {json}");
678 let back: DoctorReport = serde_json::from_str(&json).expect("deserialize report");
679 assert_eq!(back, report);
680 }
681
682 #[test]
683 fn doctor_defaults_target_real_endpoints() {
684 let doctor = Doctor::from_config(&SeerConfig::default());
687 assert_eq!(doctor.whois_addr, "whois.iana.org:43");
688 assert_eq!(doctor.bootstrap_url, "https://data.iana.org/rdap/dns.json");
689 assert_eq!(doctor.probe_domain, "example.com");
690 assert_eq!(doctor.probe_timeout, Duration::from_secs(5));
691 }
692
693 #[tokio::test]
696 async fn config_check_absent_file_passes() {
697 let missing = TmpFile::unique("absent");
698 let doctor =
699 Doctor::from_config(&SeerConfig::default()).with_config_path(missing.0.clone());
700 let check = doctor.check_config().await;
701 assert_eq!(check.status, CheckStatus::Pass);
702 assert!(check.detail.contains("defaults"), "got: {}", check.detail);
703 assert_eq!(check.latency_ms, None);
704 }
705
706 #[tokio::test]
707 async fn config_check_valid_file_passes() {
708 let file = TmpFile::with_content("valid", "output_format = \"json\"\n");
709 let doctor = Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
710 let check = doctor.check_config().await;
711 assert_eq!(check.status, CheckStatus::Pass);
712 assert!(check.detail.contains("parsed OK"), "got: {}", check.detail);
713 }
714
715 #[tokio::test]
716 async fn config_check_malformed_file_warns() {
717 for content in ["output_format = [not toml", "output_format = 42"] {
720 let file = TmpFile::with_content("malformed", content);
721 let doctor =
722 Doctor::from_config(&SeerConfig::default()).with_config_path(file.0.clone());
723 let check = doctor.check_config().await;
724 assert_eq!(check.status, CheckStatus::Warn, "content: {content}");
725 assert!(check.detail.contains("defaults"), "got: {}", check.detail);
726 assert!(!check.detail.contains('\n'), "got: {}", check.detail);
728 }
729 }
730
731 #[tokio::test]
734 async fn whois_check_passes_against_responding_listener() {
735 let port = spawn_whois_responder(b"% IANA WHOIS server\r\n").await;
736 let doctor =
737 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
738 let check = doctor.check_whois().await;
739 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
740 assert!(check.latency_ms.is_some());
741 }
742
743 #[tokio::test]
744 async fn whois_check_fails_on_connection_refused() {
745 let port = refused_port().await;
746 let doctor =
747 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
748 let check = doctor.check_whois().await;
749 assert_eq!(check.status, CheckStatus::Fail);
750 assert!(
755 check.detail.contains(&format!("127.0.0.1:{port}")),
756 "got: {}",
757 check.detail
758 );
759 }
760
761 #[tokio::test]
762 async fn whois_check_fails_on_eof_without_data() {
763 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
766 .await
767 .expect("bind mock whois");
768 let port = listener.local_addr().expect("local addr").port();
769 tokio::spawn(async move {
770 let Ok((mut sock, _)) = listener.accept().await else {
771 return;
772 };
773 let mut buf = [0u8; 128];
774 let _ = sock.read(&mut buf).await;
775 });
777 let doctor =
778 base_doctor(&SeerConfig::default()).with_whois_addr(format!("127.0.0.1:{port}"));
779 let check = doctor.check_whois().await;
780 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
781 }
782
783 #[tokio::test]
784 async fn whois_check_vets_probe_target_through_resolve_public_host() {
785 let unused = TmpFile::unique("reserved");
791 let doctor = Doctor::from_config(&SeerConfig::default())
792 .with_config_path(unused.0.clone())
793 .with_whois_addr("10.255.255.1:43".to_string())
794 .with_probe_timeout(Duration::from_millis(300));
795 let check = doctor.check_whois().await;
796 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
797 assert!(check.detail.contains("reserved"), "got: {}", check.detail);
798 assert!(
799 !check.detail.contains("port 43 may be blocked"),
800 "got: {}",
801 check.detail
802 );
803 }
804
805 #[tokio::test]
806 async fn whois_check_fails_on_timeout() {
807 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
809 .await
810 .expect("bind mock whois");
811 let port = listener.local_addr().expect("local addr").port();
812 tokio::spawn(async move {
813 let Ok((_sock, _)) = listener.accept().await else {
814 return;
815 };
816 tokio::time::sleep(Duration::from_secs(30)).await;
817 });
818 let doctor = base_doctor(&SeerConfig::default())
819 .with_whois_addr(format!("127.0.0.1:{port}"))
820 .with_probe_timeout(Duration::from_millis(200));
821 let check = doctor.check_whois().await;
822 assert_eq!(check.status, CheckStatus::Fail);
823 assert!(check.detail.contains("timed out"), "got: {}", check.detail);
824 }
825
826 #[tokio::test]
829 async fn rdap_check_passes_on_200_with_body() {
830 let server = MockServer::start().await;
831 Mock::given(method("GET"))
832 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
833 .mount(&server)
834 .await;
835 let doctor = base_doctor(&SeerConfig::default())
836 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
837 let check = doctor.check_rdap_bootstrap().await;
838 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
839 assert!(check.latency_ms.is_some());
840 }
841
842 #[tokio::test]
843 async fn rdap_check_fails_on_500() {
844 let server = MockServer::start().await;
845 Mock::given(method("GET"))
846 .respond_with(ResponseTemplate::new(500))
847 .mount(&server)
848 .await;
849 let doctor = base_doctor(&SeerConfig::default())
850 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
851 let check = doctor.check_rdap_bootstrap().await;
852 assert_eq!(check.status, CheckStatus::Fail);
853 assert!(check.detail.contains("500"), "got: {}", check.detail);
854 }
855
856 #[tokio::test]
857 async fn rdap_check_fails_on_empty_body() {
858 let server = MockServer::start().await;
859 Mock::given(method("GET"))
860 .respond_with(ResponseTemplate::new(200))
861 .mount(&server)
862 .await;
863 let doctor = base_doctor(&SeerConfig::default())
864 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()));
865 let check = doctor.check_rdap_bootstrap().await;
866 assert_eq!(check.status, CheckStatus::Fail);
867 assert!(check.detail.contains("empty body"), "got: {}", check.detail);
868 }
869
870 #[tokio::test]
871 async fn rdap_check_fails_on_timeout() {
872 let server = MockServer::start().await;
873 Mock::given(method("GET"))
874 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
875 .mount(&server)
876 .await;
877 let doctor = base_doctor(&SeerConfig::default())
878 .with_bootstrap_url(format!("{}/rdap/dns.json", server.uri()))
879 .with_probe_timeout(Duration::from_millis(200));
880 let check = doctor.check_rdap_bootstrap().await;
881 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
882 }
883
884 #[tokio::test]
887 async fn dns_check_passes_with_mock_zone() {
888 let doctor = dns_doctor(MockMode::Zone).await;
889 let check = doctor.check_dns().await;
890 assert_eq!(check.status, CheckStatus::Pass, "got: {}", check.detail);
891 assert!(check.detail.contains("127.0.0.1"), "got: {}", check.detail);
893 assert!(check.latency_ms.is_some());
894 }
895
896 #[tokio::test]
897 async fn dns_check_warns_on_nxdomain_for_probe_name() {
898 let doctor = dns_doctor(MockMode::Nxdomain).await;
899 let check = doctor.check_dns().await;
900 assert_eq!(check.status, CheckStatus::Warn, "got: {}", check.detail);
901 }
902
903 #[tokio::test]
904 async fn dns_check_fails_when_server_unresponsive() {
905 let doctor = dns_doctor(MockMode::Ignore)
908 .await
909 .with_probe_timeout(Duration::from_secs(1));
910 let check = doctor.check_dns().await;
911 assert_eq!(check.status, CheckStatus::Fail, "got: {}", check.detail);
912 }
913
914 #[tokio::test]
917 async fn run_reports_all_four_checks_in_order_and_passes() {
918 let config_file = TmpFile::with_content("run-valid", "output_format = \"yaml\"\n");
919 let whois_port = spawn_whois_responder(b"% ok\r\n").await;
920 let rdap = MockServer::start().await;
921 Mock::given(method("GET"))
922 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
923 .mount(&rdap)
924 .await;
925
926 let doctor = dns_doctor(MockMode::Zone)
927 .await
928 .with_config_path(config_file.0.clone())
929 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
930 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
931
932 let report = doctor.run().await;
933 let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
934 assert_eq!(names, ["config", "dns", "whois", "rdap-bootstrap"]);
935 for check in &report.checks {
936 assert_eq!(
937 check.status,
938 CheckStatus::Pass,
939 "{}: {}",
940 check.name,
941 check.detail
942 );
943 }
944 assert_eq!(report.overall, CheckStatus::Pass);
945 }
946
947 #[tokio::test]
948 async fn run_isolates_probe_failures() {
949 let missing_config = TmpFile::unique("run-absent");
952 let whois_port = refused_port().await;
953 let rdap = MockServer::start().await;
954 Mock::given(method("GET"))
955 .respond_with(ResponseTemplate::new(500))
956 .mount(&rdap)
957 .await;
958
959 let doctor = dns_doctor(MockMode::Zone)
960 .await
961 .with_config_path(missing_config.0.clone())
962 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
963 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
964
965 let report = doctor.run().await;
966 let by_name = |name: &str| {
967 report
968 .checks
969 .iter()
970 .find(|c| c.name == name)
971 .unwrap_or_else(|| panic!("missing check {name}"))
972 };
973 assert_eq!(by_name("config").status, CheckStatus::Pass);
974 assert_eq!(by_name("dns").status, CheckStatus::Pass);
975 assert_eq!(by_name("whois").status, CheckStatus::Fail);
976 assert_eq!(by_name("rdap-bootstrap").status, CheckStatus::Fail);
977 assert_eq!(report.overall, CheckStatus::Fail);
978 }
979
980 #[tokio::test]
981 async fn run_with_malformed_config_only_warns_overall() {
982 let config_file = TmpFile::with_content("run-malformed", "output_format = [broken");
985 let whois_port = spawn_whois_responder(b"% ok\r\n").await;
986 let rdap = MockServer::start().await;
987 Mock::given(method("GET"))
988 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"services":[]}"#))
989 .mount(&rdap)
990 .await;
991
992 let doctor = dns_doctor(MockMode::Zone)
993 .await
994 .with_config_path(config_file.0.clone())
995 .with_whois_addr(format!("127.0.0.1:{whois_port}"))
996 .with_bootstrap_url(format!("{}/rdap/dns.json", rdap.uri()));
997
998 let report = doctor.run().await;
999 assert_eq!(report.overall, CheckStatus::Warn);
1000 }
1001}