1use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::dns::{DnsPresence, DnsResolver};
10use crate::error::Result;
11use crate::rdap::{rdap_error_is_404, RdapClient};
12use crate::whois::WhoisClient;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AvailabilityResult {
17 pub domain: String,
19 pub available: bool,
21 pub confidence: String,
23 pub method: String,
25 pub details: Option<String>,
27}
28
29impl AvailabilityResult {
30 pub(crate) fn new(domain: &str, available: bool, confidence: &str, method: &str) -> Self {
33 Self {
34 domain: domain.to_string(),
35 available,
36 confidence: confidence.to_string(),
37 method: method.to_string(),
38 details: None,
39 }
40 }
41
42 pub(crate) fn with_details(mut self, details: impl Into<String>) -> Self {
43 self.details = Some(details.into());
44 self
45 }
46
47 pub fn verdict(&self) -> &'static str {
51 match (self.available, self.confidence.as_str()) {
52 (true, "high") => "available",
53 (true, "medium") => "likely_available",
54 (false, "high") => "registered",
55 (false, "medium") => "likely_registered",
56 _ => "unknown",
57 }
58 }
59}
60
61pub(crate) enum PriorRdap {
65 Response(Box<crate::rdap::RdapResponse>),
68 Failed(crate::error::SeerError),
70 Missing,
72}
73
74pub(crate) enum PriorWhois {
80 Failed(crate::error::SeerError),
82 Missing,
84}
85
86#[derive(Debug, Clone, Default)]
88pub struct AvailabilityChecker {
89 rdap_client: RdapClient,
90 whois_client: WhoisClient,
91 dns_resolver: DnsResolver,
92}
93
94impl AvailabilityChecker {
95 pub fn new() -> Self {
96 Self::default()
97 }
98
99 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
101 Self {
102 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
103 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
104 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
105 }
106 }
107
108 #[instrument(skip(self), fields(domain = %domain))]
115 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
116 let domain = crate::validation::normalize_domain(domain)?;
117 debug!(domain = %domain, "Checking domain availability");
118 let result = self.check_registry(&domain).await;
119 Ok(self.guard_subdomain_claim(result).await)
120 }
121
122 async fn check_registry(&self, domain: &str) -> AvailabilityResult {
125 match self.rdap_client.lookup_domain(domain).await {
127 Ok(response) => decide_from_rdap(domain, response),
128 Err(rdap_err) => {
129 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
130 let (whois_result, dns_presence) = tokio::join!(
136 self.whois_client.lookup(domain),
137 self.dns_resolver.presence(domain),
138 );
139 decide_fallback(domain, &rdap_err, whois_result, dns_presence)
140 }
141 }
142 }
143
144 pub(crate) async fn guard_subdomain_claim(
155 &self,
156 result: AvailabilityResult,
157 ) -> AvailabilityResult {
158 if !result.available {
159 return result;
160 }
161 let Some(parent) = crate::psl::registrable_parent(&result.domain) else {
162 return result;
163 };
164 debug!(
165 domain = %result.domain,
166 parent = %parent,
167 "No registry object for a subdomain; checking its registrable parent"
168 );
169 let parent_result = self.check_registry(parent).await;
170 subdomain_verdict(&result.domain, &parent_result)
171 }
172
173 #[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
183 pub(crate) async fn check_with_prior(
184 &self,
185 domain: &str,
186 prior_rdap: PriorRdap,
187 prior_whois: PriorWhois,
188 ) -> Result<AvailabilityResult> {
189 let domain = crate::validation::normalize_domain(domain)?;
190 debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
191
192 let rdap = match prior_rdap {
195 PriorRdap::Response(r) => Ok(*r),
196 PriorRdap::Failed(e) => Err(e),
197 PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
198 };
199
200 let result = match rdap {
201 Ok(response) => decide_from_rdap(&domain, response),
202 Err(rdap_err) => {
203 let (whois_result, dns_presence) = match prior_whois {
208 PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
209 PriorWhois::Missing => tokio::join!(
210 self.whois_client.lookup(&domain),
211 self.dns_resolver.presence(&domain),
212 ),
213 };
214 decide_fallback(&domain, &rdap_err, whois_result, dns_presence)
215 }
216 };
217 Ok(self.guard_subdomain_claim(result).await)
218 }
219}
220
221pub(crate) const REFUSED_DETAILS: &str =
224 "Registry refused or throttled the query; availability is inconclusive";
225
226pub(crate) const THIN_NXDOMAIN_DETAILS: &str =
228 "No registry data available; domain has no DNS presence (NXDOMAIN)";
229
230fn subdomain_verdict(domain: &str, parent: &AvailabilityResult) -> AvailabilityResult {
240 let p = &parent.domain;
241 let (confidence, status) = match parent.verdict() {
242 "registered" | "likely_registered" => {
243 (parent.confidence.clone(), format!("{p} is registered"))
244 }
245 "available" | "likely_available" => (
246 "none".to_string(),
247 format!("{p} appears available; register {p} to obtain {domain}"),
248 ),
249 _ => (
250 "none".to_string(),
251 format!("the registration status of {p} could not be determined"),
252 ),
253 };
254 AvailabilityResult::new(domain, false, &confidence, "registrable_parent").with_details(format!(
255 "{domain} is not itself registrable: it is a name under the registrable domain {p}, \
256 and registries hold no object for subdomains. {status}."
257 ))
258}
259
260fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
264 let statuses: Vec<String> = response.status.clone();
265 let is_redemption = statuses.iter().any(|s| {
266 let norm: String = s
273 .chars()
274 .filter(|c| !c.is_whitespace())
275 .collect::<String>()
276 .to_lowercase();
277 matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
278 });
279
280 if is_redemption {
281 return AvailabilityResult::new(domain, false, "medium", "rdap")
282 .with_details("Domain is in redemption/pending delete period");
283 }
284
285 AvailabilityResult::new(domain, false, "high", "rdap").with_details(format!(
286 "Domain is registered (status: {})",
287 statuses.join(", ")
288 ))
289}
290
291fn decide_fallback(
300 domain: &str,
301 rdap_err: &crate::error::SeerError,
302 whois_result: Result<crate::whois::WhoisResponse>,
303 dns_presence: DnsPresence,
304) -> AvailabilityResult {
305 match whois_result {
306 Ok(whois_response) => {
307 let thin = whois_response.is_thin();
310
311 if whois_response.is_available() {
312 AvailabilityResult::new(domain, true, "high", "whois")
313 .with_details("WHOIS indicates domain is not registered")
314 } else if !thin {
315 AvailabilityResult {
318 details: whois_response
319 .registrar
320 .map(|r| format!("Registered with {}", r)),
321 ..AvailabilityResult::new(domain, false, "high", "whois")
322 }
323 } else if rdap_error_is_404(rdap_err) {
324 AvailabilityResult::new(domain, true, "high", "rdap")
327 .with_details("Registry RDAP reports no such domain (HTTP 404)")
328 } else if whois_response.indicates_registry_refusal() {
329 AvailabilityResult::new(domain, false, "none", "inconclusive")
336 .with_details(REFUSED_DETAILS)
337 } else if dns_presence == DnsPresence::Absent {
338 AvailabilityResult::new(domain, true, "medium", "dns_nxdomain")
341 .with_details(THIN_NXDOMAIN_DETAILS)
342 } else {
343 AvailabilityResult::new(domain, false, "high", "whois")
346 }
347 }
348 Err(whois_err) => {
349 if rdap_error_is_404(rdap_err) {
353 return AvailabilityResult::new(domain, true, "high", "rdap")
354 .with_details("Registry RDAP reports no such domain (HTTP 404)");
355 }
356 let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
362 let whois_msg = whois_err.to_string().to_lowercase();
363 whois_msg.contains("no match")
364 || whois_msg.contains("not found")
365 || whois_msg.contains("no data found")
366 || whois_msg.contains("no entries found")
367 };
368
369 if likely_available {
370 AvailabilityResult::new(domain, true, "medium", "whois_error")
371 .with_details("WHOIS server indicates no matching records")
372 } else if dns_presence == DnsPresence::Absent {
373 AvailabilityResult::new(domain, true, "medium", "dns_nxdomain")
376 .with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)")
377 } else if dns_presence == DnsPresence::Present {
378 AvailabilityResult::new(domain, false, "medium", "dns_present").with_details(
388 "Registry lookups failed, but the apex is delegated in DNS \
389 (NS records present) — the domain is almost certainly registered",
390 )
391 } else {
392 AvailabilityResult::new(domain, false, "none", "inconclusive").with_details(
401 format!(
402 "Could not determine availability. RDAP: {}. WHOIS: {}",
403 rdap_err.sanitized_message(),
404 whois_err.sanitized_message()
405 ),
406 )
407 }
408 }
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::error::SeerError;
416 use crate::rdap::RdapResponse;
417 use crate::whois::WhoisResponse;
418
419 #[test]
420 fn verdict_matrix() {
421 let make = |available, confidence: &str| {
422 AvailabilityResult::new("example.test", available, confidence, "whois")
423 };
424 assert_eq!(make(true, "high").verdict(), "available");
425 assert_eq!(make(true, "medium").verdict(), "likely_available");
426 assert_eq!(make(false, "high").verdict(), "registered");
427 assert_eq!(make(false, "medium").verdict(), "likely_registered");
428 assert_eq!(make(false, "none").verdict(), "unknown");
429 assert_eq!(make(true, "low").verdict(), "unknown");
430 }
431
432 #[test]
433 fn test_availability_result_serialization() {
434 let result = AvailabilityResult::new("example.com", false, "high", "rdap")
435 .with_details("Domain is registered");
436 let json = serde_json::to_string(&result).unwrap();
437 assert!(json.contains("\"available\":false"));
438 assert!(json.contains("\"confidence\":\"high\""));
439 }
440
441 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
453 WhoisResponse {
454 domain: "example.test".to_string(),
455 registrar: registrar.map(str::to_string),
456 whois_server: "whois.test".to_string(),
457 raw_response: raw.to_string(),
458 ..Default::default()
459 }
460 }
461
462 fn rdap_with(statuses: &[&str]) -> RdapResponse {
463 RdapResponse {
464 status: statuses.iter().map(|s| s.to_string()).collect(),
465 ldh_name: Some("example.test".to_string()),
466 ..Default::default()
467 }
468 }
469
470 #[test]
473 fn rdap_success_registered_marks_taken_high_confidence() {
474 let rdap = rdap_with(&["active"]);
475 let r = decide_from_rdap("example.test", rdap);
476 assert!(!r.available, "registered domain must be marked taken");
477 assert_eq!(r.confidence, "high");
478 assert_eq!(r.method, "rdap");
479 assert!(
480 r.details.as_deref().unwrap().contains("active"),
481 "details should include status list"
482 );
483 }
484
485 #[test]
486 fn rdap_success_empty_status_marks_taken_high_confidence() {
487 let rdap = rdap_with(&[]);
490 let r = decide_from_rdap("example.test", rdap);
491 assert!(!r.available);
492 assert_eq!(r.confidence, "high");
493 assert_eq!(r.method, "rdap");
494 }
495
496 #[test]
497 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
498 let rdap = rdap_with(&["redemption period"]);
499 let r = decide_from_rdap("example.test", rdap);
500 assert!(!r.available, "redemption period still means taken");
501 assert_eq!(r.confidence, "medium", "redemption drops confidence");
502 assert_eq!(r.method, "rdap");
503 assert!(r.details.as_deref().unwrap().contains("redemption"));
504 }
505
506 #[test]
507 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
508 let rdap = rdap_with(&["pending delete"]);
509 let r = decide_from_rdap("example.test", rdap);
510 assert!(!r.available);
511 assert_eq!(r.confidence, "medium");
512 assert!(r.details.as_deref().unwrap().contains("redemption"));
513 }
514
515 #[test]
516 fn rdap_status_substring_redemption_not_misclassified() {
517 let rdap = rdap_with(&["clientHold (no redemption requested)"]);
521 let r = decide_from_rdap("example.test", rdap);
522 assert!(!r.available, "still registered");
523 assert_eq!(
524 r.confidence, "high",
525 "verbose status must not be downgraded to redemption/medium"
526 );
527 }
528
529 #[test]
530 fn rdap_status_redemption_detected_case_insensitively() {
531 let rdap = rdap_with(&["Redemption Period"]);
534 let r = decide_from_rdap("example.test", rdap);
535 assert_eq!(
536 r.confidence, "medium",
537 "standard token detected regardless of case"
538 );
539 }
540
541 #[test]
544 fn rdap_fail_whois_says_available_high_confidence() {
545 let whois = whois_with("No match for \"example.test\".\n", None);
548 let rdap_err = SeerError::RdapError("404 not found".to_string());
549 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
550 assert!(r.available, "WHOIS 'no match' must mark available");
551 assert_eq!(r.confidence, "high");
552 assert_eq!(r.method, "whois");
553 }
554
555 #[test]
556 fn rdap_fail_whois_says_registered_high_confidence() {
557 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
558 let rdap_err = SeerError::RdapError("404 not found".to_string());
559 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
560 assert!(!r.available);
561 assert_eq!(r.confidence, "high");
562 assert_eq!(r.method, "whois");
563 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
564 }
565
566 #[test]
567 fn rdap_fail_whois_registered_without_registrar_no_detail() {
568 let whois = whois_with("Domain Name: example.test\n", None);
571 let rdap_err = SeerError::RdapError("404".to_string());
572 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
573 assert!(!r.available);
574 assert_eq!(r.confidence, "high");
575 assert!(
576 r.details.is_none(),
577 "no registrar means no details string, got: {:?}",
578 r.details
579 );
580 }
581
582 #[test]
585 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
586 let rdap_err = SeerError::RdapError("500".to_string());
587 let whois_err =
588 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
589 let r = decide_fallback(
590 "example.test",
591 &rdap_err,
592 Err(whois_err),
593 DnsPresence::Unknown,
594 );
595 assert!(
596 r.available,
597 "whois error containing 'no match' is available"
598 );
599 assert_eq!(r.confidence, "medium");
600 assert_eq!(r.method, "whois_error");
601 }
602
603 #[test]
604 fn rdap_fail_whois_error_not_found_marks_available_medium() {
605 let rdap_err = SeerError::RdapError("500".to_string());
606 let whois_err = SeerError::WhoisError("Domain not found".to_string());
607 let r = decide_fallback(
608 "example.test",
609 &rdap_err,
610 Err(whois_err),
611 DnsPresence::Unknown,
612 );
613 assert!(r.available);
614 assert_eq!(r.confidence, "medium");
615 assert_eq!(r.method, "whois_error");
616 }
617
618 #[test]
619 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
620 let rdap_err = SeerError::RdapError("no".to_string());
621 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
622 let r = decide_fallback(
623 "example.test",
624 &rdap_err,
625 Err(whois_err),
626 DnsPresence::Unknown,
627 );
628 assert!(r.available);
629 assert_eq!(r.confidence, "medium");
630 }
631
632 #[test]
633 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
634 let rdap_err = SeerError::RdapError("no".to_string());
635 let whois_err =
636 SeerError::WhoisError("No entries found for the selected source".to_string());
637 let r = decide_fallback(
638 "example.test",
639 &rdap_err,
640 Err(whois_err),
641 DnsPresence::Unknown,
642 );
643 assert!(r.available);
644 assert_eq!(r.confidence, "medium");
645 }
646
647 #[test]
648 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
649 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
650 let whois_err = SeerError::Timeout("whois timed out".to_string());
651 let r = decide_fallback(
652 "example.test",
653 &rdap_err,
654 Err(whois_err),
655 DnsPresence::Unknown,
656 );
657 assert!(
658 !r.available,
659 "inconclusive means NOT available (fail-safe default)"
660 );
661 assert_eq!(r.confidence, "none");
662 assert_eq!(r.method, "inconclusive");
663 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
664 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
665 }
666
667 #[test]
668 fn rdap_fail_whois_transport_error_with_phrase_not_available() {
669 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
675 let whois_err =
676 SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
677 let r = decide_fallback(
678 "example.test",
679 &rdap_err,
680 Err(whois_err),
681 DnsPresence::Present,
682 );
683 assert!(
684 !r.available,
685 "transport error text must not infer availability"
686 );
687 }
688
689 #[test]
690 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
691 let rdap_err = SeerError::RdapError("connection refused".to_string());
692 let whois_err = SeerError::WhoisError(
693 "failed to connect to whois.example: connection refused".to_string(),
694 );
695 let r = decide_fallback(
696 "example.test",
697 &rdap_err,
698 Err(whois_err),
699 DnsPresence::Unknown,
700 );
701 assert!(!r.available);
702 assert_eq!(r.confidence, "none");
703 assert_eq!(r.method, "inconclusive");
704 }
705
706 #[test]
707 fn rdap_fail_whois_error_case_insensitive_not_found() {
708 let rdap_err = SeerError::RdapError("500".to_string());
711 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
712 let r = decide_fallback(
713 "example.test",
714 &rdap_err,
715 Err(whois_err),
716 DnsPresence::Unknown,
717 );
718 assert!(r.available, "'NOT FOUND' should classify as available");
719 assert_eq!(r.confidence, "medium");
720 }
721
722 #[test]
725 fn rdap_404_with_blocked_whois_marks_available() {
726 let whois = whois_with("Requests of this client are not permitted.\n", None);
731 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
732 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
733 assert!(
734 r.available,
735 "RDAP 404 must mark available even with blocked WHOIS"
736 );
737 assert_eq!(r.confidence, "high");
738 assert_eq!(r.method, "rdap");
739 }
740
741 #[test]
742 fn rdap_404_with_whois_error_marks_available() {
743 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
745 let whois_err = SeerError::WhoisError("connection refused".to_string());
746 let r = decide_fallback(
747 "example.test",
748 &rdap_err,
749 Err(whois_err),
750 DnsPresence::Unknown,
751 );
752 assert!(r.available);
753 assert_eq!(r.confidence, "high");
754 assert_eq!(r.method, "rdap");
755 }
756
757 #[test]
758 fn rdap_404_but_whois_has_full_registration_marks_registered() {
759 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
763 whois.creation_date = Some(chrono::Utc::now());
764 whois.nameservers = vec!["ns1.example.net".to_string()];
765 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
766 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
767 assert!(
768 !r.available,
769 "concrete WHOIS registration must win over RDAP 404"
770 );
771 assert_eq!(r.confidence, "high");
772 assert_eq!(r.method, "whois");
773 }
774
775 #[test]
778 fn thin_whois_non404_dns_absent_marks_likely_available() {
779 let whois = whois_with(
783 "Conditions of use for the whois service via port 43\n",
784 None,
785 );
786 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
787 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
788 assert!(r.available);
789 assert_eq!(r.confidence, "medium");
790 assert_eq!(r.method, "dns_nxdomain");
791 }
792
793 #[test]
794 fn thin_whois_non404_dns_present_stays_unavailable() {
795 let whois = whois_with(
798 "Conditions of use for the whois service via port 43\n",
799 None,
800 );
801 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
802 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
803 assert!(!r.available);
804 assert_ne!(r.method, "dns_nxdomain");
805 }
806
807 #[test]
808 fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
809 let whois = whois_with(
813 "Conditions of use for the whois service via port 43\n",
814 None,
815 );
816 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
817 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
818 assert!(!r.available);
819 }
820
821 #[test]
822 fn both_legs_failed_dns_absent_marks_likely_available() {
823 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
826 let whois_err = SeerError::WhoisError("connection refused".to_string());
827 let r = decide_fallback(
828 "example.test",
829 &rdap_err,
830 Err(whois_err),
831 DnsPresence::Absent,
832 );
833 assert!(r.available);
834 assert_eq!(r.confidence, "medium");
835 assert_eq!(r.method, "dns_nxdomain");
836 }
837
838 #[test]
839 fn both_legs_failed_dns_present_marks_likely_registered() {
840 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
846 let whois_err = SeerError::Timeout(
847 "Operation failed after 3 attempts: Operation timed out".to_string(),
848 );
849 let r = decide_fallback(
850 "example.ru",
851 &rdap_err,
852 Err(whois_err),
853 DnsPresence::Present,
854 );
855 assert!(
856 !r.available,
857 "a delegated apex must never read as available"
858 );
859 assert_eq!(r.confidence, "medium");
860 assert_eq!(r.method, "dns_present");
861 assert_eq!(r.verdict(), "likely_registered");
862 assert!(
863 r.details.as_deref().unwrap().contains("delegated"),
864 "details should explain the DNS-delegation evidence"
865 );
866 }
867
868 #[test]
871 fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
872 let whois = whois_with(
877 "Access rate limited; no data found for unauthenticated clients\n",
878 None,
879 );
880 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
881 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
882 assert!(
883 !r.available,
884 "refusal/throttle must not be called available"
885 );
886 assert_eq!(r.confidence, "none");
887 assert_eq!(r.method, "inconclusive");
888 }
889
890 #[test]
891 fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
892 let whois = whois_with(
896 "This domain name is not available for registration.\n",
897 None,
898 );
899 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
900 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
901 assert!(!r.available);
902 assert_eq!(r.confidence, "none");
903 assert_eq!(r.method, "inconclusive");
904 }
905
906 #[test]
907 fn rdap_404_with_refusal_whois_still_marks_available() {
908 let whois = whois_with("Access rate limited; please try again later.\n", None);
911 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
912 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
913 assert!(r.available, "RDAP 404 is authoritative over a refusal body");
914 assert_eq!(r.confidence, "high");
915 assert_eq!(r.method, "rdap");
916 }
917
918 #[tokio::test]
921 async fn check_with_prior_reuses_rdap_response_without_network() {
922 let checker = AvailabilityChecker::new();
927 let rdap = rdap_with(&["active"]);
928 let r = checker
929 .check_with_prior(
930 "example.test",
931 PriorRdap::Response(Box::new(rdap)),
932 PriorWhois::Missing,
933 )
934 .await
935 .expect("prior-response path must not error");
936 assert!(!r.available, "an existing RDAP object means registered");
937 assert_eq!(r.confidence, "high");
938 assert_eq!(r.method, "rdap");
939 }
940
941 #[tokio::test]
942 async fn check_with_prior_reuses_redemption_status_verdict() {
943 let checker = AvailabilityChecker::new();
946 let rdap = rdap_with(&["redemption period"]);
947 let r = checker
948 .check_with_prior(
949 "example.test",
950 PriorRdap::Response(Box::new(rdap)),
951 PriorWhois::Missing,
952 )
953 .await
954 .expect("prior-response path must not error");
955 assert!(!r.available);
956 assert_eq!(r.confidence, "medium");
957 assert_eq!(r.method, "rdap");
958 }
959
960 fn denic_whois() -> WhoisResponse {
965 let mut w = whois_with(
966 "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n",
967 None,
968 );
969 w.nameservers = vec!["ns1.example.net".to_string()];
970 w.status = vec!["active".to_string()];
971 w
972 }
973
974 #[test]
975 fn whois_with_nameservers_is_not_thin() {
976 assert!(whois_with("", None).is_thin());
977 assert!(!denic_whois().is_thin());
978 }
979
980 #[test]
981 fn denic_style_whois_without_registrar_or_dates_is_registered() {
982 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
986 let r = decide_fallback(
987 "example.de",
988 &rdap_err,
989 Ok(denic_whois()),
990 DnsPresence::Absent,
991 );
992 assert!(!r.available, "a delegated DENIC record is registered");
993 assert_eq!(r.confidence, "high");
994 assert_eq!(r.method, "whois");
995 }
996
997 fn avail(domain: &str, available: bool, confidence: &str) -> AvailabilityResult {
1000 AvailabilityResult::new(domain, available, confidence, "rdap")
1001 }
1002
1003 #[test]
1004 fn subdomain_under_registered_parent_is_registered_not_available() {
1005 let r = subdomain_verdict("mail.google.com", &avail("google.com", false, "high"));
1006 assert!(!r.available);
1007 assert_eq!(r.domain, "mail.google.com");
1008 assert_eq!(r.verdict(), "registered");
1009 assert_eq!(r.method, "registrable_parent");
1010 let details = r.details.unwrap();
1011 assert!(details.contains("google.com is registered"), "{details}");
1012 }
1013
1014 #[test]
1015 fn subdomain_under_available_parent_is_never_claimed_available() {
1016 let r = subdomain_verdict("mail.freebrand.com", &avail("freebrand.com", true, "high"));
1017 assert!(!r.available, "a subdomain itself is never registrable");
1018 assert_eq!(r.verdict(), "unknown");
1019 assert!(r.details.unwrap().contains("register freebrand.com"));
1020
1021 let r = subdomain_verdict("a.b.co.uk", &avail("b.co.uk", false, "none"));
1022 assert!(!r.available);
1023 assert_eq!(r.verdict(), "unknown");
1024 assert!(r.details.unwrap().contains("could not be determined"));
1025 }
1026
1027 #[tokio::test]
1028 async fn subdomain_guard_leaves_registrable_names_and_negative_results_alone() {
1029 let checker = AvailabilityChecker::new();
1032 let r = checker
1033 .guard_subdomain_claim(avail("example.co.uk", true, "high"))
1034 .await;
1035 assert!(r.available);
1036 assert_eq!(r.method, "rdap");
1037
1038 let r = checker
1039 .guard_subdomain_claim(avail("mail.example.com", false, "high"))
1040 .await;
1041 assert!(!r.available);
1042 assert_eq!(r.method, "rdap");
1043 }
1044}