1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::dates::days_until;
5use crate::lookup::LookupResult;
6use crate::rdap::RdapResponse;
7use crate::whois::WhoisResponse;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum DomainInfoSource {
13 Both,
15 Rdap,
17 Whois,
19 Available,
25}
26
27impl std::fmt::Display for DomainInfoSource {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 let s = match self {
32 DomainInfoSource::Both => "both",
33 DomainInfoSource::Rdap => "rdap",
34 DomainInfoSource::Whois => "whois",
35 DomainInfoSource::Available => "available",
36 };
37 f.write_str(s)
38 }
39}
40
41const EXPIRING_SOON_DAYS: i64 = 30;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum ExpiryStatus {
52 Active,
54 ExpiringSoon,
56 Expired,
58 Redemption,
60 PendingDelete,
62}
63
64impl std::fmt::Display for ExpiryStatus {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 let s = match self {
67 ExpiryStatus::Active => "active",
68 ExpiryStatus::ExpiringSoon => "expiring-soon",
69 ExpiryStatus::Expired => "expired",
70 ExpiryStatus::Redemption => "redemption",
71 ExpiryStatus::PendingDelete => "pending-delete",
72 };
73 f.write_str(s)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct StatusDescription {
80 pub code: String,
82 pub description: String,
84}
85
86fn normalize_status_key(code: &str) -> String {
94 code.chars()
95 .take_while(|c| *c != '(')
96 .filter(|c| c.is_ascii_alphanumeric())
97 .map(|c| c.to_ascii_lowercase())
98 .collect()
99}
100
101pub(crate) fn has_status_key(statuses: &[String], key: &str) -> bool {
104 statuses.iter().any(|s| normalize_status_key(s) == key)
105}
106
107pub(crate) fn has_lock_vocabulary(statuses: &[String]) -> bool {
112 statuses
113 .iter()
114 .any(|s| normalize_status_key(s).ends_with("prohibited"))
115}
116
117pub fn describe_epp_status(code: &str) -> Option<&'static str> {
120 let desc = match normalize_status_key(code).as_str() {
121 "ok" | "active" => "no restrictions (standard registered state)",
122 "inactive" => "no nameservers delegated",
123 "addperiod" => "recently registered (add grace period)",
124 "autorenewperiod" => "recently auto-renewed (grace period)",
125 "renewperiod" => "recently renewed (grace period)",
126 "transferperiod" => "recently transferred (grace period)",
127 "redemptionperiod" => "recently expired, recoverable (redemption period)",
128 "pendingrestore" => "restore requested from redemption",
129 "pendingcreate" => "creation requested, pending",
130 "pendingdelete" => "scheduled for deletion",
131 "pendingrenew" => "renewal requested, pending",
132 "pendingtransfer" => "transfer requested, pending",
133 "pendingupdate" => "update requested, pending",
134 "clientdeleteprohibited" => "deletion locked (by registrar)",
135 "clienthold" => "resolution suspended (by registrar)",
136 "clientrenewprohibited" => "renewal locked (by registrar)",
137 "clienttransferprohibited" => "transfer locked (by registrar)",
138 "clientupdateprohibited" => "updates locked (by registrar)",
139 "serverdeleteprohibited" => "deletion locked (by registry)",
140 "serverhold" => "resolution suspended (by registry)",
141 "serverrenewprohibited" => "renewal locked (by registry)",
142 "servertransferprohibited" => "transfer locked (by registry)",
143 "serverupdateprohibited" => "updates locked (by registry)",
144 _ => return None,
145 };
146 Some(desc)
147}
148
149fn derive_expiry_status(
153 days_until_expiration: Option<i64>,
154 status: &[String],
155) -> Option<ExpiryStatus> {
156 let has = |needle: &str| status.iter().any(|s| normalize_status_key(s) == needle);
157 if has("pendingdelete") {
158 return Some(ExpiryStatus::PendingDelete);
159 }
160 if has("redemptionperiod") {
161 return Some(ExpiryStatus::Redemption);
162 }
163 let days = days_until_expiration?;
164 Some(if days < 0 {
165 ExpiryStatus::Expired
166 } else if days <= EXPIRING_SOON_DAYS {
167 ExpiryStatus::ExpiringSoon
168 } else {
169 ExpiryStatus::Active
170 })
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct DomainInfo {
178 pub domain: String,
179 pub source: DomainInfoSource,
180
181 pub registrar: Option<String>,
183 pub registrant: Option<String>,
184 pub organization: Option<String>,
185
186 pub creation_date: Option<DateTime<Utc>>,
188 pub expiration_date: Option<DateTime<Utc>>,
189 pub updated_date: Option<DateTime<Utc>>,
190
191 pub nameservers: Vec<String>,
193 pub status: Vec<String>,
194 pub dnssec: Option<String>,
195
196 pub registrant_email: Option<String>,
198 pub registrant_phone: Option<String>,
199 pub registrant_address: Option<String>,
200 pub registrant_country: Option<String>,
201
202 pub admin_name: Option<String>,
204 pub admin_organization: Option<String>,
205 pub admin_email: Option<String>,
206 pub admin_phone: Option<String>,
207
208 pub tech_name: Option<String>,
210 pub tech_organization: Option<String>,
211 pub tech_email: Option<String>,
212 pub tech_phone: Option<String>,
213
214 pub whois_server: Option<String>,
216 pub rdap_url: Option<String>,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub registrar_abuse_email: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub registrar_abuse_phone: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub registrar_iana_id: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub registrar_url: Option<String>,
231
232 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub availability_verdict: Option<String>,
238
239 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub days_until_expiration: Option<i64>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub domain_age_days: Option<i64>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub expiry_status: Option<ExpiryStatus>,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub status_descriptions: Vec<StatusDescription>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub dns_nameservers: Option<crate::DnsNameservers>,
258}
259
260impl DomainInfo {
261 pub fn has_status(&self, key: &str) -> bool {
264 has_status_key(&self.status, key)
265 }
266
267 pub fn from_sources(
272 domain: &str,
273 rdap: Option<&RdapResponse>,
274 whois: Option<&WhoisResponse>,
275 ) -> Self {
276 let source = match (rdap.is_some(), whois.is_some()) {
277 (true, true) => DomainInfoSource::Both,
278 (true, false) => DomainInfoSource::Rdap,
279 (false, true) => DomainInfoSource::Whois,
280 (false, false) => DomainInfoSource::Available,
281 };
282
283 macro_rules! rdap_or_whois {
285 ($rdap_expr:expr, $whois_field:ident) => {
286 $rdap_expr.or_else(|| whois.and_then(|w| w.$whois_field.clone()))
287 };
288 }
289
290 let registrar = rdap_or_whois!(rdap.and_then(|r| r.get_registrar()), registrar);
292 let registrant = rdap_or_whois!(rdap.and_then(|r| r.get_registrant()), registrant);
293 let organization = rdap_or_whois!(
294 rdap.and_then(|r| r.get_registrant_organization()),
295 organization
296 );
297
298 let creation_date = rdap
300 .and_then(|r| r.creation_date())
301 .or_else(|| whois.and_then(|w| w.creation_date));
302 let expiration_date = rdap
303 .and_then(|r| r.expiration_date())
304 .or_else(|| whois.and_then(|w| w.expiration_date));
305 let updated_date = rdap
306 .and_then(|r| r.last_updated())
307 .or_else(|| whois.and_then(|w| w.updated_date));
308
309 let rdap_ns = rdap.map(|r| r.nameserver_names()).unwrap_or_default();
311 let nameservers = if !rdap_ns.is_empty() {
312 rdap_ns
313 } else {
314 whois.map(|w| w.nameservers.clone()).unwrap_or_default()
315 };
316
317 let rdap_status = rdap.map(|r| r.status.clone()).unwrap_or_default();
318 let status = if !rdap_status.is_empty() {
319 rdap_status
320 } else {
321 whois.map(|w| w.status.clone()).unwrap_or_default()
322 };
323
324 let dnssec = rdap
326 .and_then(|r| r.secure_dns.as_ref())
327 .and_then(|sd| sd.delegation_signed)
328 .map(|signed| {
329 if signed {
330 "signed".to_string()
331 } else {
332 "unsigned".to_string()
333 }
334 })
335 .or_else(|| whois.and_then(|w| w.dnssec.clone()));
336
337 let rdap_registrant = rdap.and_then(|r| r.get_registrant_contact());
339 let rdap_admin = rdap.and_then(|r| r.get_admin_contact());
340 let rdap_tech = rdap.and_then(|r| r.get_tech_contact());
341 macro_rules! contact_or_whois {
342 ($contact:ident.$field:ident, $whois_field:ident) => {
343 rdap_or_whois!(
344 $contact.as_ref().and_then(|c| c.$field.clone()),
345 $whois_field
346 )
347 };
348 }
349 let registrant_email = contact_or_whois!(rdap_registrant.email, registrant_email);
350 let registrant_phone = contact_or_whois!(rdap_registrant.phone, registrant_phone);
351 let registrant_address = contact_or_whois!(rdap_registrant.address, registrant_address);
352 let registrant_country = contact_or_whois!(rdap_registrant.country, registrant_country);
353 let admin_name = contact_or_whois!(rdap_admin.name, admin_name);
354 let admin_organization = contact_or_whois!(rdap_admin.organization, admin_organization);
355 let admin_email = contact_or_whois!(rdap_admin.email, admin_email);
356 let admin_phone = contact_or_whois!(rdap_admin.phone, admin_phone);
357 let tech_name = contact_or_whois!(rdap_tech.name, tech_name);
358 let tech_organization = contact_or_whois!(rdap_tech.organization, tech_organization);
359 let tech_email = contact_or_whois!(rdap_tech.email, tech_email);
360 let tech_phone = contact_or_whois!(rdap_tech.phone, tech_phone);
361
362 let rdap_url = rdap.and_then(|r| {
364 r.links
365 .iter()
366 .find(|l| l.rel.as_deref() == Some("self"))
367 .and_then(|l| l.href.clone())
368 });
369
370 let whois_server = whois
371 .map(|w| w.whois_server.clone())
372 .filter(|s| !s.is_empty());
373
374 let registrar_detail = rdap.and_then(|r| r.get_registrar_detail());
377 let registrar_abuse_email = registrar_detail
378 .as_ref()
379 .and_then(|d| d.abuse_email.clone());
380 let registrar_abuse_phone = registrar_detail
381 .as_ref()
382 .and_then(|d| d.abuse_phone.clone());
383 let registrar_iana_id = registrar_detail.as_ref().and_then(|d| d.iana_id.clone());
384 let registrar_url = registrar_detail.as_ref().and_then(|d| d.url.clone());
385
386 let mut info = DomainInfo {
387 domain: domain.to_string(),
388 source,
389 registrar,
390 registrant,
391 organization,
392 creation_date,
393 expiration_date,
394 updated_date,
395 nameservers,
396 status,
397 dnssec,
398 registrant_email,
399 registrant_phone,
400 registrant_address,
401 registrant_country,
402 admin_name,
403 admin_organization,
404 admin_email,
405 admin_phone,
406 tech_name,
407 tech_organization,
408 tech_email,
409 tech_phone,
410 whois_server,
411 rdap_url,
412 registrar_abuse_email,
413 registrar_abuse_phone,
414 registrar_iana_id,
415 registrar_url,
416 availability_verdict: None,
417 days_until_expiration: None,
418 domain_age_days: None,
419 expiry_status: None,
420 status_descriptions: Vec::new(),
421 dns_nameservers: None,
422 };
423 info.compute_lifecycle(Utc::now());
424 info
425 }
426
427 fn compute_lifecycle(&mut self, now: DateTime<Utc>) {
431 self.days_until_expiration = self.expiration_date.map(|e| days_until(e, now));
432 self.domain_age_days = self.creation_date.map(|c| (now - c).num_days());
433 self.expiry_status = derive_expiry_status(self.days_until_expiration, &self.status);
434 self.status_descriptions = self
435 .status
436 .iter()
437 .filter_map(|code| {
438 describe_epp_status(code).map(|d| StatusDescription {
439 code: code.clone(),
440 description: d.to_string(),
441 })
442 })
443 .collect();
444 }
445
446 pub fn from_lookup_result(result: &LookupResult) -> Self {
449 match result {
450 LookupResult::Rdap {
451 data,
452 whois_fallback,
453 } => Self::from_sources(
454 &result
457 .domain_name()
458 .unwrap_or_else(|| "unknown".to_string()),
459 Some(data),
460 whois_fallback.as_ref(),
461 ),
462 LookupResult::Whois {
463 data,
464 rdap_fallback,
465 dns_nameservers,
466 ..
467 } => Self {
468 dns_nameservers: dns_nameservers.clone(),
469 ..Self::from_sources(
470 &data.domain,
471 rdap_fallback.as_ref().map(|b| b.as_ref()),
472 Some(data),
473 )
474 },
475 LookupResult::Available {
476 data, whois_data, ..
477 } => {
478 let whois = whois_data.as_ref().filter(|_| !data.available);
484 let mut info = Self::from_sources(&data.domain, None, whois);
485 info.source = DomainInfoSource::Available;
486 info.availability_verdict = Some(data.verdict().to_string());
487 info
488 }
489 }
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn has_status_key_matches_either_protocol_form() {
499 let statuses = vec![
500 "client transfer prohibited (https://icann.org/epp)".to_string(),
501 "serverDeleteProhibited".to_string(),
502 ];
503 assert!(has_status_key(&statuses, "clienttransferprohibited"));
504 assert!(has_status_key(&statuses, "serverdeleteprohibited"));
505 assert!(!has_status_key(&statuses, "clientupdateprohibited"));
506 }
507
508 fn make_test_rdap() -> RdapResponse {
511 serde_json::from_value(serde_json::json!({
512 "objectClassName": "domain",
513 "ldhName": "example.com",
514 "status": ["active"],
515 "events": [
516 {
517 "eventAction": "registration",
518 "eventDate": "2020-01-15T00:00:00Z"
519 },
520 {
521 "eventAction": "expiration",
522 "eventDate": "2025-01-15T00:00:00Z"
523 },
524 {
525 "eventAction": "last changed",
526 "eventDate": "2024-06-01T12:00:00Z"
527 }
528 ],
529 "entities": [
530 {
531 "objectClassName": "entity",
532 "roles": ["registrar"],
533 "vcardArray": [
534 "vcard",
535 [
536 ["version", {}, "text", "4.0"],
537 ["fn", {}, "text", "RDAP Registrar LLC"]
538 ]
539 ]
540 }
541 ],
542 "nameservers": [
543 {
544 "objectClassName": "nameserver",
545 "ldhName": "ns1.rdap-example.com"
546 }
547 ],
548 "secureDNS": {
549 "delegationSigned": true,
550 "dsData": [],
551 "keyData": []
552 },
553 "links": [
554 {
555 "rel": "self",
556 "href": "https://rdap.example.com/domain/example.com"
557 }
558 ]
559 }))
560 .expect("test RDAP JSON should deserialize")
561 }
562
563 fn make_test_whois() -> WhoisResponse {
565 use chrono::TimeZone;
566 WhoisResponse {
567 domain: "example.com".to_string(),
568 registrar: Some("WHOIS Registrar Inc.".to_string()),
569 registrant: Some("John Doe".to_string()),
570 organization: Some("Example Corp".to_string()),
571 registrant_email: Some("john@example.com".to_string()),
572 registrant_phone: Some("+1.5551234567".to_string()),
573 registrant_address: Some("123 Main St".to_string()),
574 registrant_country: Some("US".to_string()),
575 admin_name: Some("Admin Person".to_string()),
576 admin_organization: Some("Admin Org".to_string()),
577 admin_email: Some("admin@example.com".to_string()),
578 admin_phone: Some("+1.5559876543".to_string()),
579 tech_name: Some("Tech Person".to_string()),
580 tech_organization: Some("Tech Org".to_string()),
581 tech_email: Some("tech@example.com".to_string()),
582 tech_phone: Some("+1.5555555555".to_string()),
583 creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
584 expiration_date: Some(Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap()),
585 updated_date: Some(Utc.with_ymd_and_hms(2023, 12, 15, 0, 0, 0).unwrap()),
586 nameservers: vec![
587 "ns1.whois-example.com".to_string(),
588 "ns2.whois-example.com".to_string(),
589 ],
590 status: vec!["clientTransferProhibited".to_string()],
591 dnssec: Some("signedDelegation".to_string()),
592 whois_server: "whois.example.com".to_string(),
593 raw_response: "raw whois data".to_string(),
594 }
595 }
596
597 #[test]
598 fn test_from_sources_both() {
599 let rdap = make_test_rdap();
600 let whois = make_test_whois();
601 let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
602
603 assert_eq!(info.source, DomainInfoSource::Both);
604 assert_eq!(info.domain, "example.com");
605
606 assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
608
609 assert_eq!(info.registrant.as_deref(), Some("John Doe"));
611 assert_eq!(info.organization.as_deref(), Some("Example Corp"));
612
613 assert!(info.creation_date.is_some());
615 assert_eq!(
616 info.creation_date.unwrap().to_rfc3339(),
617 "2020-01-15T00:00:00+00:00"
618 );
619 assert_eq!(
620 info.expiration_date.unwrap().to_rfc3339(),
621 "2025-01-15T00:00:00+00:00"
622 );
623 assert_eq!(
624 info.updated_date.unwrap().to_rfc3339(),
625 "2024-06-01T12:00:00+00:00"
626 );
627
628 assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
630
631 assert_eq!(info.status, vec!["active"]);
633
634 assert_eq!(info.dnssec.as_deref(), Some("signed"));
636
637 assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
639 assert_eq!(info.registrant_phone.as_deref(), Some("+1.5551234567"));
640 assert_eq!(info.registrant_address.as_deref(), Some("123 Main St"));
641 assert_eq!(info.registrant_country.as_deref(), Some("US"));
642 assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
643 assert_eq!(info.admin_organization.as_deref(), Some("Admin Org"));
644 assert_eq!(info.admin_email.as_deref(), Some("admin@example.com"));
645 assert_eq!(info.admin_phone.as_deref(), Some("+1.5559876543"));
646 assert_eq!(info.tech_name.as_deref(), Some("Tech Person"));
647 assert_eq!(info.tech_organization.as_deref(), Some("Tech Org"));
648 assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
649 assert_eq!(info.tech_phone.as_deref(), Some("+1.5555555555"));
650
651 assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
653 assert_eq!(
654 info.rdap_url.as_deref(),
655 Some("https://rdap.example.com/domain/example.com")
656 );
657 }
658
659 #[test]
660 fn test_from_sources_whois_only() {
661 let whois = make_test_whois();
662 let info = DomainInfo::from_sources("example.com", None, Some(&whois));
663
664 assert_eq!(info.source, DomainInfoSource::Whois);
665 assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
666 assert_eq!(info.registrant.as_deref(), Some("John Doe"));
667 assert_eq!(info.organization.as_deref(), Some("Example Corp"));
668 assert_eq!(
669 info.creation_date.unwrap().to_rfc3339(),
670 "2019-06-01T00:00:00+00:00"
671 );
672 assert_eq!(
673 info.expiration_date.unwrap().to_rfc3339(),
674 "2024-06-01T00:00:00+00:00"
675 );
676 assert_eq!(
677 info.updated_date.unwrap().to_rfc3339(),
678 "2023-12-15T00:00:00+00:00"
679 );
680 assert_eq!(
681 info.nameservers,
682 vec!["ns1.whois-example.com", "ns2.whois-example.com"]
683 );
684 assert_eq!(info.status, vec!["clientTransferProhibited"]);
685 assert_eq!(info.dnssec.as_deref(), Some("signedDelegation"));
686 assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
687 assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
688 assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
689 assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
690 assert!(info.rdap_url.is_none());
691 }
692
693 #[test]
694 fn test_from_sources_rdap_only() {
695 let rdap = make_test_rdap();
696 let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
697
698 assert_eq!(info.source, DomainInfoSource::Rdap);
699 assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
700 assert!(info.registrant.is_none());
702 assert!(info.organization.is_none());
703 assert!(info.creation_date.is_some());
704 assert!(info.expiration_date.is_some());
705 assert!(info.updated_date.is_some());
706 assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
707 assert_eq!(info.status, vec!["active"]);
708 assert_eq!(info.dnssec.as_deref(), Some("signed"));
709 assert!(info.registrant_email.is_none());
711 assert!(info.admin_name.is_none());
712 assert!(info.tech_email.is_none());
713 assert!(info.whois_server.is_none());
714 assert_eq!(
715 info.rdap_url.as_deref(),
716 Some("https://rdap.example.com/domain/example.com")
717 );
718 }
719
720 #[test]
721 fn test_from_sources_neither() {
722 let info = DomainInfo::from_sources("available.com", None, None);
723
724 assert_eq!(info.source, DomainInfoSource::Available);
725 assert_eq!(info.domain, "available.com");
726 assert!(info.registrar.is_none());
727 assert!(info.registrant.is_none());
728 assert!(info.organization.is_none());
729 assert!(info.creation_date.is_none());
730 assert!(info.expiration_date.is_none());
731 assert!(info.updated_date.is_none());
732 assert!(info.nameservers.is_empty());
733 assert!(info.status.is_empty());
734 assert!(info.dnssec.is_none());
735 assert!(info.registrant_email.is_none());
736 assert!(info.admin_name.is_none());
737 assert!(info.tech_email.is_none());
738 assert!(info.whois_server.is_none());
739 assert!(info.rdap_url.is_none());
740 }
741
742 #[test]
743 fn test_from_lookup_result_rdap_variant() {
744 let rdap = make_test_rdap();
745 let whois = make_test_whois();
746 let result = LookupResult::Rdap {
747 data: Box::new(rdap),
748 whois_fallback: Some(whois),
749 };
750
751 let info = DomainInfo::from_lookup_result(&result);
752 assert_eq!(info.source, DomainInfoSource::Both);
753 assert_eq!(info.domain, "example.com");
754 assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
755 assert_eq!(info.registrant.as_deref(), Some("John Doe"));
757 }
758
759 #[test]
760 fn from_lookup_result_rdap_domain_is_lowercase() {
761 let mut rdap = make_test_rdap();
762 rdap.ldh_name = Some("EXAMPLE.COM".to_string());
763 let result = LookupResult::Rdap {
764 data: Box::new(rdap),
765 whois_fallback: None,
766 };
767 assert_eq!(
768 DomainInfo::from_lookup_result(&result).domain,
769 "example.com"
770 );
771 }
772
773 #[test]
774 fn test_from_lookup_result_whois_variant() {
775 let whois = make_test_whois();
776 let result = LookupResult::Whois {
777 data: whois,
778 rdap_error: Some("RDAP failed".to_string()),
779 rdap_fallback: None,
780 dns_nameservers: None,
781 };
782
783 let info = DomainInfo::from_lookup_result(&result);
784 assert_eq!(info.source, DomainInfoSource::Whois);
785 assert_eq!(info.domain, "example.com");
786 assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
787 assert!(info.rdap_url.is_none());
788 }
789
790 #[test]
791 fn from_sources_populates_registrar_detail() {
792 let rdap: RdapResponse = serde_json::from_value(serde_json::json!({
793 "objectClassName": "domain",
794 "ldhName": "example.com",
795 "entities": [{
796 "objectClassName": "entity",
797 "roles": ["registrar"],
798 "publicIds": [{"type": "IANA Registrar ID", "identifier": "292"}],
799 "links": [{"rel": "about", "href": "https://registrar.example"}],
800 "vcardArray": ["vcard", [["fn", {}, "text", "Example Registrar"]]],
801 "entities": [{
802 "objectClassName": "entity",
803 "roles": ["abuse"],
804 "vcardArray": ["vcard", [
805 ["email", {}, "text", "abuse@registrar.example"],
806 ["tel", {}, "text", "+1.5551230000"]
807 ]]
808 }]
809 }]
810 }))
811 .unwrap();
812 let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
813 assert_eq!(info.registrar_iana_id.as_deref(), Some("292"));
814 assert_eq!(
815 info.registrar_url.as_deref(),
816 Some("https://registrar.example")
817 );
818 assert_eq!(
819 info.registrar_abuse_email.as_deref(),
820 Some("abuse@registrar.example")
821 );
822 assert_eq!(info.registrar_abuse_phone.as_deref(), Some("+1.5551230000"));
823 }
824
825 #[test]
826 fn describe_epp_status_decodes_known_codes() {
827 assert_eq!(
828 describe_epp_status("clientTransferProhibited"),
829 Some("transfer locked (by registrar)")
830 );
831 assert_eq!(
833 describe_epp_status("client transfer prohibited"),
834 Some("transfer locked (by registrar)")
835 );
836 assert_eq!(
837 describe_epp_status("serverHold"),
838 Some("resolution suspended (by registry)")
839 );
840 assert_eq!(
841 describe_epp_status("redemptionPeriod"),
842 Some("recently expired, recoverable (redemption period)")
843 );
844 assert_eq!(
846 describe_epp_status("client hold (https://example.com/info)"),
847 Some("resolution suspended (by registrar)")
848 );
849 assert_eq!(describe_epp_status("totally unknown code"), None);
850 }
851
852 #[test]
853 fn derive_expiry_status_bands_by_days_and_codes() {
854 assert_eq!(
856 derive_expiry_status(Some(200), &["pendingDelete".into()]),
857 Some(ExpiryStatus::PendingDelete)
858 );
859 assert_eq!(
860 derive_expiry_status(Some(200), &["redemptionPeriod".into()]),
861 Some(ExpiryStatus::Redemption)
862 );
863 assert_eq!(
865 derive_expiry_status(Some(365), &[]),
866 Some(ExpiryStatus::Active)
867 );
868 assert_eq!(
869 derive_expiry_status(Some(30), &[]),
870 Some(ExpiryStatus::ExpiringSoon)
871 );
872 assert_eq!(
873 derive_expiry_status(Some(0), &[]),
874 Some(ExpiryStatus::ExpiringSoon)
875 );
876 assert_eq!(
877 derive_expiry_status(Some(-5), &[]),
878 Some(ExpiryStatus::Expired)
879 );
880 assert_eq!(derive_expiry_status(None, &["ok".into()]), None);
882 }
883
884 #[test]
885 fn compute_lifecycle_uses_fixed_clock() {
886 use chrono::TimeZone;
887 let whois = make_test_whois();
890 let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
891 let now = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
892 info.compute_lifecycle(now);
893
894 let expected_days = (Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap() - now).num_days();
895 assert_eq!(info.days_until_expiration, Some(expected_days));
896 assert!(info.days_until_expiration.unwrap() > 0);
897 assert!(info.domain_age_days.unwrap() > 0);
898 assert_eq!(info.expiry_status, Some(ExpiryStatus::Active));
900 assert_eq!(info.status_descriptions.len(), 1);
902 assert_eq!(info.status_descriptions[0].code, "clientTransferProhibited");
903 assert_eq!(
904 info.status_descriptions[0].description,
905 "transfer locked (by registrar)"
906 );
907 }
908
909 #[test]
910 fn test_serialization_round_trip() {
911 let rdap = make_test_rdap();
912 let whois = make_test_whois();
913 let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
914
915 let json = serde_json::to_string_pretty(&info).expect("serialize");
916 let deserialized: DomainInfo = serde_json::from_str(&json).expect("deserialize");
917
918 assert_eq!(deserialized.domain, info.domain);
919 assert_eq!(deserialized.source, info.source);
920 assert_eq!(deserialized.registrar, info.registrar);
921 assert_eq!(deserialized.registrant, info.registrant);
922 assert_eq!(deserialized.organization, info.organization);
923 assert_eq!(deserialized.creation_date, info.creation_date);
924 assert_eq!(deserialized.expiration_date, info.expiration_date);
925 assert_eq!(deserialized.updated_date, info.updated_date);
926 assert_eq!(deserialized.nameservers, info.nameservers);
927 assert_eq!(deserialized.status, info.status);
928 assert_eq!(deserialized.dnssec, info.dnssec);
929 assert_eq!(deserialized.registrant_email, info.registrant_email);
930 assert_eq!(deserialized.admin_name, info.admin_name);
931 assert_eq!(deserialized.tech_email, info.tech_email);
932 assert_eq!(deserialized.whois_server, info.whois_server);
933 assert_eq!(deserialized.rdap_url, info.rdap_url);
934
935 assert!(json.contains("\"source\": \"both\""));
937 }
938
939 #[test]
940 fn days_until_is_negative_for_a_just_expired_domain() {
941 use chrono::TimeZone;
942 let now = Utc.with_ymd_and_hms(2026, 1, 10, 12, 0, 0).unwrap();
943 let hours = |h: i64| now + chrono::TimeDelta::hours(h);
944 assert_eq!(days_until(hours(-10), now), -1);
945 assert_eq!(days_until(hours(10), now), 0);
946 assert_eq!(days_until(hours(-48), now), -2);
947 assert_eq!(days_until(hours(-49), now), -2);
948 assert_eq!(days_until(hours(49), now), 2);
949
950 let mut whois = make_test_whois();
953 whois.expiration_date = Some(hours(-10));
954 let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
955 info.compute_lifecycle(now);
956 assert_eq!(info.days_until_expiration, Some(-1));
957 assert_eq!(info.expiry_status, Some(ExpiryStatus::Expired));
958
959 whois.expiration_date = Some(hours(10));
961 let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
962 info.compute_lifecycle(now);
963 assert_eq!(info.days_until_expiration, Some(0));
964 assert_eq!(info.expiry_status, Some(ExpiryStatus::ExpiringSoon));
965 }
966
967 fn available_lookup(available: bool, confidence: &str, method: &str) -> LookupResult {
968 LookupResult::Available {
969 data: Box::new(crate::availability::AvailabilityResult::new(
970 "example.com",
971 available,
972 confidence,
973 method,
974 )),
975 rdap_error: String::new(),
976 whois_error: String::new(),
977 whois_data: Some(make_test_whois()),
978 }
979 }
980
981 #[test]
982 fn from_lookup_result_merges_whois_data_for_unavailable_verdicts() {
983 let info = DomainInfo::from_lookup_result(&available_lookup(false, "high", "dns_present"));
986 assert_eq!(info.source, DomainInfoSource::Available);
987 assert_eq!(info.availability_verdict.as_deref(), Some("registered"));
988 assert_eq!(
989 info.nameservers,
990 vec!["ns1.whois-example.com", "ns2.whois-example.com"]
991 );
992 assert_eq!(info.status, vec!["clientTransferProhibited"]);
993 assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
994
995 let info = DomainInfo::from_lookup_result(&available_lookup(true, "high", "whois"));
997 assert_eq!(info.availability_verdict.as_deref(), Some("available"));
998 assert!(info.nameservers.is_empty());
999 assert!(info.registrar.is_none());
1000 assert!(info.whois_server.is_none());
1001 }
1002}