Skip to main content

seer_core/
availability.rs

1//! Domain availability checking.
2//!
3//! Determines if a domain is available for registration by interpreting
4//! WHOIS/RDAP "not found" responses.
5
6use 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/// Result of a domain availability check.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AvailabilityResult {
17    /// The domain that was checked.
18    pub domain: String,
19    /// Whether the domain appears to be available for registration.
20    pub available: bool,
21    /// Confidence level of the result ("high", "medium", "low").
22    pub confidence: String,
23    /// How availability was determined.
24    pub method: String,
25    /// Additional details about the check.
26    pub details: Option<String>,
27}
28
29impl AvailabilityResult {
30    /// Stable verdict string derived from `(available, confidence)`. Use this
31    /// instead of branching on `confidence` alone — a `confidence: "high"`
32    /// result can still mean "registered" when `available == false`.
33    pub fn verdict(&self) -> &'static str {
34        match (self.available, self.confidence.as_str()) {
35            (true, "high") => "available",
36            (true, "medium") => "likely_available",
37            (false, "high") => "registered",
38            (false, "medium") => "likely_registered",
39            _ => "unknown",
40        }
41    }
42}
43
44/// A prior RDAP attempt handed to [`AvailabilityChecker::check_with_prior`], so
45/// the checker can reuse the smart-lookup race's result instead of re-issuing
46/// the identical query that just ran (and re-hammering a throttling registry).
47pub(crate) enum PriorRdap {
48    /// RDAP returned a response (HTTP 200); reuse it rather than re-querying.
49    /// Boxed because an `RdapResponse` dwarfs the other variants.
50    Response(Box<crate::rdap::RdapResponse>),
51    /// RDAP failed with this error; reuse it rather than re-querying.
52    Failed(crate::error::SeerError),
53    /// No usable RDAP result (e.g. grace-truncated); query fresh.
54    Missing,
55}
56
57/// A prior WHOIS attempt handed to [`AvailabilityChecker::check_with_prior`].
58///
59/// There is no `Response` variant: a successful WHOIS is consumed before the
60/// smart-lookup ever reaches the availability fallback, so the checker only
61/// ever reuses a failed leg or re-queries a genuinely missing one.
62pub(crate) enum PriorWhois {
63    /// WHOIS failed with this error; reuse it rather than re-querying.
64    Failed(crate::error::SeerError),
65    /// No usable WHOIS result (e.g. grace-truncated); query fresh.
66    Missing,
67}
68
69/// Checks domain availability by attempting lookups and interpreting failures.
70#[derive(Debug, Clone)]
71pub struct AvailabilityChecker {
72    rdap_client: RdapClient,
73    whois_client: WhoisClient,
74    dns_resolver: DnsResolver,
75}
76
77impl Default for AvailabilityChecker {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl AvailabilityChecker {
84    pub fn new() -> Self {
85        Self {
86            rdap_client: RdapClient::new(),
87            whois_client: WhoisClient::new(),
88            dns_resolver: DnsResolver::new(),
89        }
90    }
91
92    /// Builds a checker whose sub-clients honor the timeouts in `config`.
93    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
94        Self {
95            rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
96            whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
97            dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
98        }
99    }
100
101    /// Check if a domain is available for registration.
102    #[instrument(skip(self), fields(domain = %domain))]
103    pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
104        let domain = crate::validation::normalize_domain(domain)?;
105        debug!(domain = %domain, "Checking domain availability");
106
107        // Try RDAP first - it gives structured error responses.
108        match self.rdap_client.lookup_domain(&domain).await {
109            Ok(response) => Ok(decide_from_rdap(&domain, response)),
110            Err(rdap_err) => {
111                debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
112                // Probe WHOIS and the apex DNS presence concurrently. DNS is
113                // only the tie-breaker when WHOIS is thin/blocked and the RDAP
114                // failure was not an authoritative 404, so running it alongside
115                // WHOIS (rather than on demand) adds no extra wall-clock time.
116                let (whois_result, dns_presence) = tokio::join!(
117                    self.whois_client.lookup(&domain),
118                    self.dns_resolver.presence(&domain),
119                );
120                Ok(decide_fallback(
121                    &domain,
122                    &rdap_err,
123                    whois_result,
124                    dns_presence,
125                ))
126            }
127        }
128    }
129
130    /// Like [`check`](Self::check), but reuses protocol outcomes already
131    /// gathered by the caller (the smart-lookup RDAP+WHOIS race), issuing only
132    /// the queries that are genuinely missing or were grace-truncated. The DNS
133    /// presence probe runs exactly as in `check`.
134    ///
135    /// Routing through the same pure deciders (`decide_from_rdap` /
136    /// `decide_fallback`) guarantees an identical verdict to `check` for the
137    /// same protocol outcomes — this path only removes redundant network calls,
138    /// so the two availability ladders stay in lockstep.
139    #[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
140    pub(crate) async fn check_with_prior(
141        &self,
142        domain: &str,
143        prior_rdap: PriorRdap,
144        prior_whois: PriorWhois,
145    ) -> Result<AvailabilityResult> {
146        let domain = crate::validation::normalize_domain(domain)?;
147        debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
148
149        // Reuse the prior RDAP result; only re-query when it is truly absent
150        // (grace-truncated), never when it already succeeded or errored.
151        let rdap = match prior_rdap {
152            PriorRdap::Response(r) => Ok(*r),
153            PriorRdap::Failed(e) => Err(e),
154            PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
155        };
156
157        match rdap {
158            Ok(response) => Ok(decide_from_rdap(&domain, response)),
159            Err(rdap_err) => {
160                // Reuse the prior WHOIS result; only re-query when absent. The
161                // DNS presence probe always runs (it is the tie-breaker
162                // `decide_fallback` consults), concurrently with a fresh WHOIS
163                // query when one is needed.
164                let (whois_result, dns_presence) = match prior_whois {
165                    PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
166                    PriorWhois::Missing => tokio::join!(
167                        self.whois_client.lookup(&domain),
168                        self.dns_resolver.presence(&domain),
169                    ),
170                };
171                Ok(decide_fallback(
172                    &domain,
173                    &rdap_err,
174                    whois_result,
175                    dns_presence,
176                ))
177            }
178        }
179    }
180}
181
182/// Pure decision function: build an `AvailabilityResult` from a successful
183/// RDAP lookup. Extracted from `check()` so the decision matrix can be
184/// table-tested without a network stack.
185fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
186    let statuses: Vec<String> = response.status.clone();
187    let is_redemption = statuses.iter().any(|s| {
188        // RDAP/EPP status tokens are a controlled vocabulary; match the
189        // standard redemption / pending-delete tokens exactly (case- and
190        // whitespace-insensitive) rather than by substring, so a verbose
191        // status such as "clientHold (no redemption requested)" is not
192        // misread as the redemption state, and a capitalized "Redemption
193        // Period" is still detected.
194        let norm: String = s
195            .chars()
196            .filter(|c| !c.is_whitespace())
197            .collect::<String>()
198            .to_lowercase();
199        matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
200    });
201
202    if is_redemption {
203        return AvailabilityResult {
204            domain: domain.to_string(),
205            available: false,
206            confidence: "medium".to_string(),
207            method: "rdap".to_string(),
208            details: Some("Domain is in redemption/pending delete period".to_string()),
209        };
210    }
211
212    AvailabilityResult {
213        domain: domain.to_string(),
214        available: false,
215        confidence: "high".to_string(),
216        method: "rdap".to_string(),
217        details: Some(format!(
218            "Domain is registered (status: {})",
219            statuses.join(", ")
220        )),
221    }
222}
223
224/// Pure decision function: build an `AvailabilityResult` when RDAP failed
225/// and WHOIS (plus a DNS presence probe) is the fallback. Extracted from
226/// `check()` for table-testing. `dns_presence` is only consulted when the
227/// registry signals are inconclusive — a thin/blocked WHOIS body and a
228/// non-404 RDAP failure; an apex with no DNS presence (NXDOMAIN) then reads
229/// as likely-available at medium confidence.
230fn decide_fallback(
231    domain: &str,
232    rdap_err: &crate::error::SeerError,
233    whois_result: Result<crate::whois::WhoisResponse>,
234    dns_presence: DnsPresence,
235) -> AvailabilityResult {
236    match whois_result {
237        Ok(whois_response) => {
238            // "Thin" = no positive registration signal at all (no registrar,
239            // no creation/expiry dates). A thin body is what blocked or
240            // RDAP-first registries return for an unregistered domain.
241            let thin = whois_response.registrar.is_none()
242                && whois_response.creation_date.is_none()
243                && whois_response.expiration_date.is_none();
244
245            if whois_response.is_available() {
246                AvailabilityResult {
247                    domain: domain.to_string(),
248                    available: true,
249                    confidence: "high".to_string(),
250                    method: "whois".to_string(),
251                    details: Some("WHOIS indicates domain is not registered".to_string()),
252                }
253            } else if !thin {
254                // A concrete registration signal (registrar / dates) is
255                // present → the domain is registered.
256                AvailabilityResult {
257                    domain: domain.to_string(),
258                    available: false,
259                    confidence: "high".to_string(),
260                    method: "whois".to_string(),
261                    details: whois_response
262                        .registrar
263                        .map(|r| format!("Registered with {}", r)),
264                }
265            } else if rdap_error_is_404(rdap_err) {
266                // Thin WHOIS — often an access-blocked refusal like SWITCH's
267                // ".ch" — but the registry's own RDAP authoritatively 404'd.
268                AvailabilityResult {
269                    domain: domain.to_string(),
270                    available: true,
271                    confidence: "high".to_string(),
272                    method: "rdap".to_string(),
273                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
274                }
275            } else if whois_response.indicates_registry_refusal() {
276                // Thin WHOIS that explicitly refused / throttled / negated the
277                // query (rate limit, access denied, reserved, "not available
278                // for registration") while RDAP did not authoritatively 404.
279                // There is no usable registration signal — report inconclusive
280                // rather than inverting the refusal into "available" or guessing
281                // from DNS presence / fail-safing to "registered" (issue #45).
282                AvailabilityResult {
283                    domain: domain.to_string(),
284                    available: false,
285                    confidence: "none".to_string(),
286                    method: "inconclusive".to_string(),
287                    details: Some(
288                        "Registry refused or throttled the query; availability is inconclusive"
289                            .to_string(),
290                    ),
291                }
292            } else if dns_presence == DnsPresence::Absent {
293                // Thin WHOIS, RDAP did not 404, and the apex is NXDOMAIN —
294                // corroborating evidence the domain is unregistered.
295                AvailabilityResult {
296                    domain: domain.to_string(),
297                    available: true,
298                    confidence: "medium".to_string(),
299                    method: "dns_nxdomain".to_string(),
300                    details: Some(
301                        "No registry data available; domain has no DNS presence (NXDOMAIN)"
302                            .to_string(),
303                    ),
304                }
305            } else {
306                // Thin WHOIS we could not interpret and no corroborating
307                // NXDOMAIN — fail safe toward "registered".
308                AvailabilityResult {
309                    domain: domain.to_string(),
310                    available: false,
311                    confidence: "high".to_string(),
312                    method: "whois".to_string(),
313                    details: None,
314                }
315            }
316        }
317        Err(whois_err) => {
318            // RDAP 404 is authoritative even when the WHOIS leg errored: the
319            // registry's RDAP server reports no such object, so the domain is
320            // unregistered regardless of why WHOIS failed.
321            if rdap_error_is_404(rdap_err) {
322                return AvailabilityResult {
323                    domain: domain.to_string(),
324                    available: true,
325                    confidence: "high".to_string(),
326                    method: "rdap".to_string(),
327                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
328                };
329            }
330            // Both registry legs failed. Only a WHOIS-*protocol* error can
331            // carry a registry "no match" signal; a transport failure
332            // (timeout, connection reset, DNS, SSRF refusal) tells us nothing
333            // about registration, so we must not infer availability from its
334            // text even if it incidentally contains a phrase like "no match".
335            let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
336                let whois_msg = whois_err.to_string().to_lowercase();
337                whois_msg.contains("no match")
338                    || whois_msg.contains("not found")
339                    || whois_msg.contains("no data found")
340                    || whois_msg.contains("no entries found")
341            };
342
343            if likely_available {
344                AvailabilityResult {
345                    domain: domain.to_string(),
346                    available: true,
347                    confidence: "medium".to_string(),
348                    method: "whois_error".to_string(),
349                    details: Some("WHOIS server indicates no matching records".to_string()),
350                }
351            } else if dns_presence == DnsPresence::Absent {
352                // Both registry legs failed, but the apex is NXDOMAIN — the
353                // domain has no DNS presence, so it is likely unregistered.
354                AvailabilityResult {
355                    domain: domain.to_string(),
356                    available: true,
357                    confidence: "medium".to_string(),
358                    method: "dns_nxdomain".to_string(),
359                    details: Some(
360                        "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
361                            .to_string(),
362                    ),
363                }
364            } else {
365                // Both queries failed with non-"not found" errors and the
366                // domain still resolves (or DNS was unknown). We genuinely
367                // don't know — could be registered, blocked, or servers down.
368                // Default to available=false so we never tell the user a taken
369                // domain is free.
370                AvailabilityResult {
371                    domain: domain.to_string(),
372                    available: false,
373                    confidence: "none".to_string(),
374                    method: "inconclusive".to_string(),
375                    // Use the sanitized error projection so this string —
376                    // which flows into JSON / CSV / MCP output paths —
377                    // never carries raw ANSI escapes or internal IPs from
378                    // a third-party WHOIS/RDAP server's error message.
379                    details: Some(format!(
380                        "Could not determine availability. RDAP: {}. WHOIS: {}",
381                        rdap_err.sanitized_message(),
382                        whois_err.sanitized_message()
383                    )),
384                }
385            }
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::error::SeerError;
394    use crate::rdap::RdapResponse;
395    use crate::whois::WhoisResponse;
396
397    #[test]
398    fn verdict_matrix() {
399        let make = |available, confidence: &str| AvailabilityResult {
400            domain: "example.test".to_string(),
401            available,
402            confidence: confidence.to_string(),
403            method: "whois".to_string(),
404            details: None,
405        };
406        assert_eq!(make(true, "high").verdict(), "available");
407        assert_eq!(make(true, "medium").verdict(), "likely_available");
408        assert_eq!(make(false, "high").verdict(), "registered");
409        assert_eq!(make(false, "medium").verdict(), "likely_registered");
410        assert_eq!(make(false, "none").verdict(), "unknown");
411        assert_eq!(make(true, "low").verdict(), "unknown");
412    }
413
414    #[test]
415    fn test_availability_result_serialization() {
416        let result = AvailabilityResult {
417            domain: "example.com".to_string(),
418            available: false,
419            confidence: "high".to_string(),
420            method: "rdap".to_string(),
421            details: Some("Domain is registered".to_string()),
422        };
423        let json = serde_json::to_string(&result).unwrap();
424        assert!(json.contains("\"available\":false"));
425        assert!(json.contains("\"confidence\":\"high\""));
426    }
427
428    // ------------------------------------------------------------------
429    // M11: Decision matrix coverage for `check()`.
430    //
431    // Tests the pure decision helpers — `decide_from_rdap` and
432    // `decide_fallback` — that were extracted from `check()` for
433    // hermetic testing. Each case asserts (available, confidence, method)
434    // against a realistic input shape.
435    // ------------------------------------------------------------------
436
437    /// Small helper to build an empty WhoisResponse with the given fields
438    /// populated; used to keep the test table concise.
439    fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
440        WhoisResponse {
441            domain: "example.test".to_string(),
442            registrar: registrar.map(str::to_string),
443            registrant: None,
444            organization: None,
445            registrant_email: None,
446            registrant_phone: None,
447            registrant_address: None,
448            registrant_country: None,
449            admin_name: None,
450            admin_organization: None,
451            admin_email: None,
452            admin_phone: None,
453            tech_name: None,
454            tech_organization: None,
455            tech_email: None,
456            tech_phone: None,
457            creation_date: None,
458            expiration_date: None,
459            updated_date: None,
460            nameservers: vec![],
461            status: vec![],
462            dnssec: None,
463            whois_server: "whois.test".to_string(),
464            raw_response: raw.to_string(),
465        }
466    }
467
468    fn rdap_with(statuses: &[&str]) -> RdapResponse {
469        RdapResponse {
470            status: statuses.iter().map(|s| s.to_string()).collect(),
471            ldh_name: Some("example.test".to_string()),
472            ..Default::default()
473        }
474    }
475
476    // --- RDAP success branches ---------------------------------------
477
478    #[test]
479    fn rdap_success_registered_marks_taken_high_confidence() {
480        let rdap = rdap_with(&["active"]);
481        let r = decide_from_rdap("example.test", rdap);
482        assert!(!r.available, "registered domain must be marked taken");
483        assert_eq!(r.confidence, "high");
484        assert_eq!(r.method, "rdap");
485        assert!(
486            r.details.as_deref().unwrap().contains("active"),
487            "details should include status list"
488        );
489    }
490
491    #[test]
492    fn rdap_success_empty_status_marks_taken_high_confidence() {
493        // Some RDAP servers return 200 with no status array populated; the
494        // existence of the object still means the domain is registered.
495        let rdap = rdap_with(&[]);
496        let r = decide_from_rdap("example.test", rdap);
497        assert!(!r.available);
498        assert_eq!(r.confidence, "high");
499        assert_eq!(r.method, "rdap");
500    }
501
502    #[test]
503    fn rdap_success_redemption_period_marks_taken_medium_confidence() {
504        let rdap = rdap_with(&["redemption period"]);
505        let r = decide_from_rdap("example.test", rdap);
506        assert!(!r.available, "redemption period still means taken");
507        assert_eq!(r.confidence, "medium", "redemption drops confidence");
508        assert_eq!(r.method, "rdap");
509        assert!(r.details.as_deref().unwrap().contains("redemption"));
510    }
511
512    #[test]
513    fn rdap_success_pending_delete_marks_taken_medium_confidence() {
514        let rdap = rdap_with(&["pending delete"]);
515        let r = decide_from_rdap("example.test", rdap);
516        assert!(!r.available);
517        assert_eq!(r.confidence, "medium");
518        assert!(r.details.as_deref().unwrap().contains("redemption"));
519    }
520
521    #[test]
522    fn rdap_status_substring_redemption_not_misclassified() {
523        // A non-standard verbose status that merely CONTAINS the word
524        // "redemption" must not be misread as the redemption-period state
525        // (which would wrongly drop confidence to medium).
526        let rdap = rdap_with(&["clientHold (no redemption requested)"]);
527        let r = decide_from_rdap("example.test", rdap);
528        assert!(!r.available, "still registered");
529        assert_eq!(
530            r.confidence, "high",
531            "verbose status must not be downgraded to redemption/medium"
532        );
533    }
534
535    #[test]
536    fn rdap_status_redemption_detected_case_insensitively() {
537        // A capitalized standard token must still be detected (the old
538        // case-sensitive `contains` missed "Redemption Period").
539        let rdap = rdap_with(&["Redemption Period"]);
540        let r = decide_from_rdap("example.test", rdap);
541        assert_eq!(
542            r.confidence, "medium",
543            "standard token detected regardless of case"
544        );
545    }
546
547    // --- WHOIS fallback branches -------------------------------------
548
549    #[test]
550    fn rdap_fail_whois_says_available_high_confidence() {
551        // is_available() reads raw_response and looks for the patterns
552        // that every TLD uses to signal unregistered.
553        let whois = whois_with("No match for \"example.test\".\n", None);
554        let rdap_err = SeerError::RdapError("404 not found".to_string());
555        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
556        assert!(r.available, "WHOIS 'no match' must mark available");
557        assert_eq!(r.confidence, "high");
558        assert_eq!(r.method, "whois");
559    }
560
561    #[test]
562    fn rdap_fail_whois_says_registered_high_confidence() {
563        let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
564        let rdap_err = SeerError::RdapError("404 not found".to_string());
565        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
566        assert!(!r.available);
567        assert_eq!(r.confidence, "high");
568        assert_eq!(r.method, "whois");
569        assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
570    }
571
572    #[test]
573    fn rdap_fail_whois_registered_without_registrar_no_detail() {
574        // Corner case: has_core_data is false but not-available, so the
575        // details string is None (registrar field is None).
576        let whois = whois_with("Domain Name: example.test\n", None);
577        let rdap_err = SeerError::RdapError("404".to_string());
578        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
579        assert!(!r.available);
580        assert_eq!(r.confidence, "high");
581        assert!(
582            r.details.is_none(),
583            "no registrar means no details string, got: {:?}",
584            r.details
585        );
586    }
587
588    // --- Both-fail branches ------------------------------------------
589
590    #[test]
591    fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
592        let rdap_err = SeerError::RdapError("500".to_string());
593        let whois_err =
594            SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
595        let r = decide_fallback(
596            "example.test",
597            &rdap_err,
598            Err(whois_err),
599            DnsPresence::Unknown,
600        );
601        assert!(
602            r.available,
603            "whois error containing 'no match' is available"
604        );
605        assert_eq!(r.confidence, "medium");
606        assert_eq!(r.method, "whois_error");
607    }
608
609    #[test]
610    fn rdap_fail_whois_error_not_found_marks_available_medium() {
611        let rdap_err = SeerError::RdapError("500".to_string());
612        let whois_err = SeerError::WhoisError("Domain not found".to_string());
613        let r = decide_fallback(
614            "example.test",
615            &rdap_err,
616            Err(whois_err),
617            DnsPresence::Unknown,
618        );
619        assert!(r.available);
620        assert_eq!(r.confidence, "medium");
621        assert_eq!(r.method, "whois_error");
622    }
623
624    #[test]
625    fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
626        let rdap_err = SeerError::RdapError("no".to_string());
627        let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
628        let r = decide_fallback(
629            "example.test",
630            &rdap_err,
631            Err(whois_err),
632            DnsPresence::Unknown,
633        );
634        assert!(r.available);
635        assert_eq!(r.confidence, "medium");
636    }
637
638    #[test]
639    fn rdap_fail_whois_error_no_entries_marks_available_medium() {
640        let rdap_err = SeerError::RdapError("no".to_string());
641        let whois_err =
642            SeerError::WhoisError("No entries found for the selected source".to_string());
643        let r = decide_fallback(
644            "example.test",
645            &rdap_err,
646            Err(whois_err),
647            DnsPresence::Unknown,
648        );
649        assert!(r.available);
650        assert_eq!(r.confidence, "medium");
651    }
652
653    #[test]
654    fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
655        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
656        let whois_err = SeerError::Timeout("whois timed out".to_string());
657        let r = decide_fallback(
658            "example.test",
659            &rdap_err,
660            Err(whois_err),
661            DnsPresence::Unknown,
662        );
663        assert!(
664            !r.available,
665            "inconclusive means NOT available (fail-safe default)"
666        );
667        assert_eq!(r.confidence, "none");
668        assert_eq!(r.method, "inconclusive");
669        assert!(r.details.as_deref().unwrap().contains("RDAP:"));
670        assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
671    }
672
673    #[test]
674    fn rdap_fail_whois_transport_error_with_phrase_not_available() {
675        // A transport-level WHOIS failure (here a timeout) whose message
676        // merely contains "no match" must NOT be read as available — only a
677        // WHOIS-*protocol* error (WhoisError) can carry a registry no-match
678        // signal. Otherwise an error string that incidentally quotes the
679        // phrase flips a possibly-registered domain to "available".
680        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
681        let whois_err =
682            SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
683        let r = decide_fallback(
684            "example.test",
685            &rdap_err,
686            Err(whois_err),
687            DnsPresence::Present,
688        );
689        assert!(
690            !r.available,
691            "transport error text must not infer availability"
692        );
693    }
694
695    #[test]
696    fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
697        let rdap_err = SeerError::RdapError("connection refused".to_string());
698        let whois_err = SeerError::WhoisError(
699            "failed to connect to whois.example: connection refused".to_string(),
700        );
701        let r = decide_fallback(
702            "example.test",
703            &rdap_err,
704            Err(whois_err),
705            DnsPresence::Unknown,
706        );
707        assert!(!r.available);
708        assert_eq!(r.confidence, "none");
709        assert_eq!(r.method, "inconclusive");
710    }
711
712    #[test]
713    fn rdap_fail_whois_error_case_insensitive_not_found() {
714        // The real code lowercases before matching; verify the Uppercase
715        // form still classifies correctly.
716        let rdap_err = SeerError::RdapError("500".to_string());
717        let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
718        let r = decide_fallback(
719            "example.test",
720            &rdap_err,
721            Err(whois_err),
722            DnsPresence::Unknown,
723        );
724        assert!(r.available, "'NOT FOUND' should classify as available");
725        assert_eq!(r.confidence, "medium");
726    }
727
728    // --- RDAP-404-is-authoritative branches (Fix #4) -----------------
729
730    #[test]
731    fn rdap_404_with_blocked_whois_marks_available() {
732        // SWITCH (.ch) blocks port-43 WHOIS with a refusal carrying no
733        // registration data and no availability phrase. The registry's own
734        // RDAP authoritatively 404s for an unregistered domain — that 404 is
735        // the signal and must win over the unhelpful WHOIS body.
736        let whois = whois_with("Requests of this client are not permitted.\n", None);
737        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
738        let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
739        assert!(
740            r.available,
741            "RDAP 404 must mark available even with blocked WHOIS"
742        );
743        assert_eq!(r.confidence, "high");
744        assert_eq!(r.method, "rdap");
745    }
746
747    #[test]
748    fn rdap_404_with_whois_error_marks_available() {
749        // RDAP 404 is authoritative even when WHOIS itself errored out.
750        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
751        let whois_err = SeerError::WhoisError("connection refused".to_string());
752        let r = decide_fallback(
753            "example.test",
754            &rdap_err,
755            Err(whois_err),
756            DnsPresence::Unknown,
757        );
758        assert!(r.available);
759        assert_eq!(r.confidence, "high");
760        assert_eq!(r.method, "rdap");
761    }
762
763    #[test]
764    fn rdap_404_but_whois_has_full_registration_marks_registered() {
765        // Conflict case: RDAP 404 but WHOIS returns real registration data
766        // (registrar + dates + nameservers). Prefer the concrete registration
767        // so we never tell the user a registered domain is free.
768        let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
769        whois.creation_date = Some(chrono::Utc::now());
770        whois.nameservers = vec!["ns1.example.net".to_string()];
771        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
772        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
773        assert!(
774            !r.available,
775            "concrete WHOIS registration must win over RDAP 404"
776        );
777        assert_eq!(r.confidence, "high");
778        assert_eq!(r.method, "whois");
779    }
780
781    // --- DNS-NXDOMAIN safety net (Fix #2) ----------------------------
782
783    #[test]
784    fn thin_whois_non404_dns_absent_marks_likely_available() {
785        // Red.es (.es) returns a port-43 "Conditions of use" banner that
786        // parses to nothing, and .es has no RDAP server (a non-404 failure).
787        // The apex is NXDOMAIN, so the domain is likely available.
788        let whois = whois_with(
789            "Conditions of use for the whois service via port 43\n",
790            None,
791        );
792        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
793        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
794        assert!(r.available);
795        assert_eq!(r.confidence, "medium");
796        assert_eq!(r.method, "dns_nxdomain");
797    }
798
799    #[test]
800    fn thin_whois_non404_dns_present_stays_unavailable() {
801        // Same thin WHOIS + non-404 RDAP failure, but the apex resolves — we
802        // must not claim availability.
803        let whois = whois_with(
804            "Conditions of use for the whois service via port 43\n",
805            None,
806        );
807        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
808        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
809        assert!(!r.available);
810        assert_ne!(r.method, "dns_nxdomain");
811    }
812
813    #[test]
814    fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
815        // Thin WHOIS, non-404 RDAP failure, DNS itself failed → genuinely
816        // unknown; fail safe to not-available so we never call a taken domain
817        // free on a transient DNS blip.
818        let whois = whois_with(
819            "Conditions of use for the whois service via port 43\n",
820            None,
821        );
822        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
823        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
824        assert!(!r.available);
825    }
826
827    #[test]
828    fn both_legs_failed_dns_absent_marks_likely_available() {
829        // RDAP errored (non-404), WHOIS errored (not a "not found" message),
830        // but the apex is NXDOMAIN.
831        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
832        let whois_err = SeerError::WhoisError("connection refused".to_string());
833        let r = decide_fallback(
834            "example.test",
835            &rdap_err,
836            Err(whois_err),
837            DnsPresence::Absent,
838        );
839        assert!(r.available);
840        assert_eq!(r.confidence, "medium");
841        assert_eq!(r.method, "dns_nxdomain");
842    }
843
844    // --- #45: refusal/throttle bodies route to inconclusive --------------
845
846    #[test]
847    fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
848        // A throttled thin WHOIS body (no registration data) with a non-404
849        // RDAP failure must route to an inconclusive verdict — even with DNS
850        // absent, which would otherwise read as likely-available — instead of
851        // inverting a rate-limit banner ("no data found") into "available".
852        let whois = whois_with(
853            "Access rate limited; no data found for unauthenticated clients\n",
854            None,
855        );
856        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
857        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
858        assert!(
859            !r.available,
860            "refusal/throttle must not be called available"
861        );
862        assert_eq!(r.confidence, "none");
863        assert_eq!(r.method, "inconclusive");
864    }
865
866    #[test]
867    fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
868        // "not available for registration" (reserved/premium) previously
869        // inverted to available; with RDAP also failing non-404 it must be
870        // inconclusive, not a DNS-presence guess.
871        let whois = whois_with(
872            "This domain name is not available for registration.\n",
873            None,
874        );
875        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
876        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
877        assert!(!r.available);
878        assert_eq!(r.confidence, "none");
879        assert_eq!(r.method, "inconclusive");
880    }
881
882    #[test]
883    fn rdap_404_with_refusal_whois_still_marks_available() {
884        // The refusal routing must NOT override an authoritative RDAP 404: a
885        // refused/throttled port-43 body with an RDAP 404 is still available.
886        let whois = whois_with("Access rate limited; please try again later.\n", None);
887        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
888        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
889        assert!(r.available, "RDAP 404 is authoritative over a refusal body");
890        assert_eq!(r.confidence, "high");
891        assert_eq!(r.method, "rdap");
892    }
893
894    // --- check_with_prior: reuse without re-querying ------------------
895
896    #[tokio::test]
897    async fn check_with_prior_reuses_rdap_response_without_network() {
898        // A prior successful RDAP response short-circuits to `decide_from_rdap`
899        // with no WHOIS or DNS I/O — proving the in-hand outcome is reused
900        // rather than re-queried, and yielding the same verdict `check` would
901        // for that response.
902        let checker = AvailabilityChecker::new();
903        let rdap = rdap_with(&["active"]);
904        let r = checker
905            .check_with_prior(
906                "example.test",
907                PriorRdap::Response(Box::new(rdap)),
908                PriorWhois::Missing,
909            )
910            .await
911            .expect("prior-response path must not error");
912        assert!(!r.available, "an existing RDAP object means registered");
913        assert_eq!(r.confidence, "high");
914        assert_eq!(r.method, "rdap");
915    }
916
917    #[tokio::test]
918    async fn check_with_prior_reuses_redemption_status_verdict() {
919        // The reused response flows through the identical redemption decision as
920        // `decide_from_rdap`, so a redemption status still drops to medium.
921        let checker = AvailabilityChecker::new();
922        let rdap = rdap_with(&["redemption period"]);
923        let r = checker
924            .check_with_prior(
925                "example.test",
926                PriorRdap::Response(Box::new(rdap)),
927                PriorWhois::Missing,
928            )
929            .await
930            .expect("prior-response path must not error");
931        assert!(!r.available);
932        assert_eq!(r.confidence, "medium");
933        assert_eq!(r.method, "rdap");
934    }
935}