1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::lookup::LookupResult;
5use crate::rdap::RdapResponse;
6use crate::whois::WhoisResponse;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum DomainInfoSource {
12 Both,
14 Rdap,
16 Whois,
18 Available,
20}
21
22impl std::fmt::Display for DomainInfoSource {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 let s = match self {
27 DomainInfoSource::Both => "both",
28 DomainInfoSource::Rdap => "rdap",
29 DomainInfoSource::Whois => "whois",
30 DomainInfoSource::Available => "available",
31 };
32 f.write_str(s)
33 }
34}
35
36const EXPIRING_SOON_DAYS: i64 = 30;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum ExpiryStatus {
46 Active,
48 ExpiringSoon,
50 Expired,
52 Redemption,
54 PendingDelete,
56}
57
58impl std::fmt::Display for ExpiryStatus {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 let s = match self {
61 ExpiryStatus::Active => "active",
62 ExpiryStatus::ExpiringSoon => "expiring-soon",
63 ExpiryStatus::Expired => "expired",
64 ExpiryStatus::Redemption => "redemption",
65 ExpiryStatus::PendingDelete => "pending-delete",
66 };
67 f.write_str(s)
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct StatusDescription {
74 pub code: String,
76 pub description: String,
78}
79
80fn normalize_status_key(code: &str) -> String {
88 code.chars()
89 .take_while(|c| *c != '(')
90 .filter(|c| c.is_ascii_alphanumeric())
91 .map(|c| c.to_ascii_lowercase())
92 .collect()
93}
94
95pub fn describe_epp_status(code: &str) -> Option<&'static str> {
98 let desc = match normalize_status_key(code).as_str() {
99 "ok" | "active" => "no restrictions (standard registered state)",
100 "inactive" => "no nameservers delegated",
101 "addperiod" => "recently registered (add grace period)",
102 "autorenewperiod" => "recently auto-renewed (grace period)",
103 "renewperiod" => "recently renewed (grace period)",
104 "transferperiod" => "recently transferred (grace period)",
105 "redemptionperiod" => "recently expired, recoverable (redemption period)",
106 "pendingrestore" => "restore requested from redemption",
107 "pendingcreate" => "creation requested, pending",
108 "pendingdelete" => "scheduled for deletion",
109 "pendingrenew" => "renewal requested, pending",
110 "pendingtransfer" => "transfer requested, pending",
111 "pendingupdate" => "update requested, pending",
112 "clientdeleteprohibited" => "deletion locked (by registrar)",
113 "clienthold" => "resolution suspended (by registrar)",
114 "clientrenewprohibited" => "renewal locked (by registrar)",
115 "clienttransferprohibited" => "transfer locked (by registrar)",
116 "clientupdateprohibited" => "updates locked (by registrar)",
117 "serverdeleteprohibited" => "deletion locked (by registry)",
118 "serverhold" => "resolution suspended (by registry)",
119 "serverrenewprohibited" => "renewal locked (by registry)",
120 "servertransferprohibited" => "transfer locked (by registry)",
121 "serverupdateprohibited" => "updates locked (by registry)",
122 _ => return None,
123 };
124 Some(desc)
125}
126
127fn derive_expiry_status(
131 days_until_expiration: Option<i64>,
132 status: &[String],
133) -> Option<ExpiryStatus> {
134 let has = |needle: &str| status.iter().any(|s| normalize_status_key(s) == needle);
135 if has("pendingdelete") {
136 return Some(ExpiryStatus::PendingDelete);
137 }
138 if has("redemptionperiod") {
139 return Some(ExpiryStatus::Redemption);
140 }
141 let days = days_until_expiration?;
142 Some(if days < 0 {
143 ExpiryStatus::Expired
144 } else if days <= EXPIRING_SOON_DAYS {
145 ExpiryStatus::ExpiringSoon
146 } else {
147 ExpiryStatus::Active
148 })
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct DomainInfo {
156 pub domain: String,
157 pub source: DomainInfoSource,
158
159 pub registrar: Option<String>,
161 pub registrant: Option<String>,
162 pub organization: Option<String>,
163
164 pub creation_date: Option<DateTime<Utc>>,
166 pub expiration_date: Option<DateTime<Utc>>,
167 pub updated_date: Option<DateTime<Utc>>,
168
169 pub nameservers: Vec<String>,
171 pub status: Vec<String>,
172 pub dnssec: Option<String>,
173
174 pub registrant_email: Option<String>,
176 pub registrant_phone: Option<String>,
177 pub registrant_address: Option<String>,
178 pub registrant_country: Option<String>,
179
180 pub admin_name: Option<String>,
182 pub admin_organization: Option<String>,
183 pub admin_email: Option<String>,
184 pub admin_phone: Option<String>,
185
186 pub tech_name: Option<String>,
188 pub tech_organization: Option<String>,
189 pub tech_email: Option<String>,
190 pub tech_phone: Option<String>,
191
192 pub whois_server: Option<String>,
194 pub rdap_url: Option<String>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub registrar_abuse_email: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub registrar_abuse_phone: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub registrar_iana_id: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub registrar_url: Option<String>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub availability_verdict: Option<String>,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub days_until_expiration: Option<i64>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub domain_age_days: Option<i64>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub expiry_status: Option<ExpiryStatus>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub status_descriptions: Vec<StatusDescription>,
230}
231
232impl DomainInfo {
233 pub fn from_sources(
238 domain: &str,
239 rdap: Option<&RdapResponse>,
240 whois: Option<&WhoisResponse>,
241 ) -> Self {
242 let source = match (rdap.is_some(), whois.is_some()) {
243 (true, true) => DomainInfoSource::Both,
244 (true, false) => DomainInfoSource::Rdap,
245 (false, true) => DomainInfoSource::Whois,
246 (false, false) => DomainInfoSource::Available,
247 };
248
249 macro_rules! rdap_or_whois {
251 ($rdap_expr:expr, $whois_field:ident) => {
252 $rdap_expr.or_else(|| whois.and_then(|w| w.$whois_field.clone()))
253 };
254 }
255
256 let registrar = rdap_or_whois!(rdap.and_then(|r| r.get_registrar()), registrar);
258 let registrant = rdap_or_whois!(rdap.and_then(|r| r.get_registrant()), registrant);
259 let organization = rdap_or_whois!(
260 rdap.and_then(|r| r.get_registrant_organization()),
261 organization
262 );
263
264 let creation_date = rdap
266 .and_then(|r| r.creation_date())
267 .or_else(|| whois.and_then(|w| w.creation_date));
268 let expiration_date = rdap
269 .and_then(|r| r.expiration_date())
270 .or_else(|| whois.and_then(|w| w.expiration_date));
271 let updated_date = rdap
272 .and_then(|r| r.last_updated())
273 .or_else(|| whois.and_then(|w| w.updated_date));
274
275 let rdap_ns = rdap.map(|r| r.nameserver_names()).unwrap_or_default();
277 let nameservers = if !rdap_ns.is_empty() {
278 rdap_ns
279 } else {
280 whois.map(|w| w.nameservers.clone()).unwrap_or_default()
281 };
282
283 let rdap_status = rdap.map(|r| r.status.clone()).unwrap_or_default();
284 let status = if !rdap_status.is_empty() {
285 rdap_status
286 } else {
287 whois.map(|w| w.status.clone()).unwrap_or_default()
288 };
289
290 let dnssec = rdap
292 .and_then(|r| r.secure_dns.as_ref())
293 .and_then(|sd| sd.delegation_signed)
294 .map(|signed| {
295 if signed {
296 "signed".to_string()
297 } else {
298 "unsigned".to_string()
299 }
300 })
301 .or_else(|| whois.and_then(|w| w.dnssec.clone()));
302
303 let rdap_registrant_contact = rdap.and_then(|r| r.get_registrant_contact());
305 let registrant_email = rdap_registrant_contact
306 .as_ref()
307 .and_then(|c| c.email.clone())
308 .or_else(|| whois.and_then(|w| w.registrant_email.clone()));
309 let registrant_phone = rdap_registrant_contact
310 .as_ref()
311 .and_then(|c| c.phone.clone())
312 .or_else(|| whois.and_then(|w| w.registrant_phone.clone()));
313 let registrant_address = rdap_registrant_contact
314 .as_ref()
315 .and_then(|c| c.address.clone())
316 .or_else(|| whois.and_then(|w| w.registrant_address.clone()));
317 let registrant_country = rdap_registrant_contact
318 .as_ref()
319 .and_then(|c| c.country.clone())
320 .or_else(|| whois.and_then(|w| w.registrant_country.clone()));
321
322 let rdap_admin_contact = rdap.and_then(|r| r.get_admin_contact());
324 let admin_name = rdap_admin_contact
325 .as_ref()
326 .and_then(|c| c.name.clone())
327 .or_else(|| whois.and_then(|w| w.admin_name.clone()));
328 let admin_organization = rdap_admin_contact
329 .as_ref()
330 .and_then(|c| c.organization.clone())
331 .or_else(|| whois.and_then(|w| w.admin_organization.clone()));
332 let admin_email = rdap_admin_contact
333 .as_ref()
334 .and_then(|c| c.email.clone())
335 .or_else(|| whois.and_then(|w| w.admin_email.clone()));
336 let admin_phone = rdap_admin_contact
337 .as_ref()
338 .and_then(|c| c.phone.clone())
339 .or_else(|| whois.and_then(|w| w.admin_phone.clone()));
340
341 let rdap_tech_contact = rdap.and_then(|r| r.get_tech_contact());
343 let tech_name = rdap_tech_contact
344 .as_ref()
345 .and_then(|c| c.name.clone())
346 .or_else(|| whois.and_then(|w| w.tech_name.clone()));
347 let tech_organization = rdap_tech_contact
348 .as_ref()
349 .and_then(|c| c.organization.clone())
350 .or_else(|| whois.and_then(|w| w.tech_organization.clone()));
351 let tech_email = rdap_tech_contact
352 .as_ref()
353 .and_then(|c| c.email.clone())
354 .or_else(|| whois.and_then(|w| w.tech_email.clone()));
355 let tech_phone = rdap_tech_contact
356 .as_ref()
357 .and_then(|c| c.phone.clone())
358 .or_else(|| whois.and_then(|w| w.tech_phone.clone()));
359
360 let rdap_url = rdap.and_then(|r| {
362 r.links
363 .iter()
364 .find(|l| l.rel.as_deref() == Some("self"))
365 .and_then(|l| l.href.clone())
366 });
367
368 let whois_server = whois
369 .map(|w| w.whois_server.clone())
370 .filter(|s| !s.is_empty());
371
372 let registrar_detail = rdap.and_then(|r| r.get_registrar_detail());
375 let registrar_abuse_email = registrar_detail
376 .as_ref()
377 .and_then(|d| d.abuse_email.clone());
378 let registrar_abuse_phone = registrar_detail
379 .as_ref()
380 .and_then(|d| d.abuse_phone.clone());
381 let registrar_iana_id = registrar_detail.as_ref().and_then(|d| d.iana_id.clone());
382 let registrar_url = registrar_detail.as_ref().and_then(|d| d.url.clone());
383
384 let mut info = DomainInfo {
385 domain: domain.to_string(),
386 source,
387 registrar,
388 registrant,
389 organization,
390 creation_date,
391 expiration_date,
392 updated_date,
393 nameservers,
394 status,
395 dnssec,
396 registrant_email,
397 registrant_phone,
398 registrant_address,
399 registrant_country,
400 admin_name,
401 admin_organization,
402 admin_email,
403 admin_phone,
404 tech_name,
405 tech_organization,
406 tech_email,
407 tech_phone,
408 whois_server,
409 rdap_url,
410 registrar_abuse_email,
411 registrar_abuse_phone,
412 registrar_iana_id,
413 registrar_url,
414 availability_verdict: None,
415 days_until_expiration: None,
416 domain_age_days: None,
417 expiry_status: None,
418 status_descriptions: Vec::new(),
419 };
420 info.compute_lifecycle(Utc::now());
421 info
422 }
423
424 fn compute_lifecycle(&mut self, now: DateTime<Utc>) {
428 self.days_until_expiration = self.expiration_date.map(|e| (e - now).num_days());
429 self.domain_age_days = self.creation_date.map(|c| (now - c).num_days());
430 self.expiry_status = derive_expiry_status(self.days_until_expiration, &self.status);
431 self.status_descriptions = self
432 .status
433 .iter()
434 .filter_map(|code| {
435 describe_epp_status(code).map(|d| StatusDescription {
436 code: code.clone(),
437 description: d.to_string(),
438 })
439 })
440 .collect();
441 }
442
443 pub fn from_lookup_result(result: &LookupResult) -> Self {
446 match result {
447 LookupResult::Rdap {
448 data,
449 whois_fallback,
450 } => Self::from_sources(
451 data.domain_name().unwrap_or("unknown"),
452 Some(data),
453 whois_fallback.as_ref(),
454 ),
455 LookupResult::Whois {
456 data,
457 rdap_fallback,
458 ..
459 } => Self::from_sources(
460 &data.domain,
461 rdap_fallback.as_ref().map(|b| b.as_ref()),
462 Some(data),
463 ),
464 LookupResult::Available { data, .. } => {
465 let mut info = Self::from_sources(&data.domain, None, None);
466 info.source = DomainInfoSource::Available;
467 info.availability_verdict = Some(data.verdict().to_string());
468 info
469 }
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn make_test_rdap() -> RdapResponse {
481 serde_json::from_value(serde_json::json!({
482 "objectClassName": "domain",
483 "ldhName": "example.com",
484 "status": ["active"],
485 "events": [
486 {
487 "eventAction": "registration",
488 "eventDate": "2020-01-15T00:00:00Z"
489 },
490 {
491 "eventAction": "expiration",
492 "eventDate": "2025-01-15T00:00:00Z"
493 },
494 {
495 "eventAction": "last changed",
496 "eventDate": "2024-06-01T12:00:00Z"
497 }
498 ],
499 "entities": [
500 {
501 "objectClassName": "entity",
502 "roles": ["registrar"],
503 "vcardArray": [
504 "vcard",
505 [
506 ["version", {}, "text", "4.0"],
507 ["fn", {}, "text", "RDAP Registrar LLC"]
508 ]
509 ]
510 }
511 ],
512 "nameservers": [
513 {
514 "objectClassName": "nameserver",
515 "ldhName": "ns1.rdap-example.com"
516 }
517 ],
518 "secureDns": {
519 "delegationSigned": true,
520 "dsData": [],
521 "keyData": []
522 },
523 "links": [
524 {
525 "rel": "self",
526 "href": "https://rdap.example.com/domain/example.com"
527 }
528 ]
529 }))
530 .expect("test RDAP JSON should deserialize")
531 }
532
533 fn make_test_whois() -> WhoisResponse {
535 use chrono::TimeZone;
536 WhoisResponse {
537 domain: "example.com".to_string(),
538 registrar: Some("WHOIS Registrar Inc.".to_string()),
539 registrant: Some("John Doe".to_string()),
540 organization: Some("Example Corp".to_string()),
541 registrant_email: Some("john@example.com".to_string()),
542 registrant_phone: Some("+1.5551234567".to_string()),
543 registrant_address: Some("123 Main St".to_string()),
544 registrant_country: Some("US".to_string()),
545 admin_name: Some("Admin Person".to_string()),
546 admin_organization: Some("Admin Org".to_string()),
547 admin_email: Some("admin@example.com".to_string()),
548 admin_phone: Some("+1.5559876543".to_string()),
549 tech_name: Some("Tech Person".to_string()),
550 tech_organization: Some("Tech Org".to_string()),
551 tech_email: Some("tech@example.com".to_string()),
552 tech_phone: Some("+1.5555555555".to_string()),
553 creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
554 expiration_date: Some(Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap()),
555 updated_date: Some(Utc.with_ymd_and_hms(2023, 12, 15, 0, 0, 0).unwrap()),
556 nameservers: vec![
557 "ns1.whois-example.com".to_string(),
558 "ns2.whois-example.com".to_string(),
559 ],
560 status: vec!["clientTransferProhibited".to_string()],
561 dnssec: Some("signedDelegation".to_string()),
562 whois_server: "whois.example.com".to_string(),
563 raw_response: "raw whois data".to_string(),
564 }
565 }
566
567 #[test]
568 fn test_from_sources_both() {
569 let rdap = make_test_rdap();
570 let whois = make_test_whois();
571 let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
572
573 assert_eq!(info.source, DomainInfoSource::Both);
574 assert_eq!(info.domain, "example.com");
575
576 assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
578
579 assert_eq!(info.registrant.as_deref(), Some("John Doe"));
581 assert_eq!(info.organization.as_deref(), Some("Example Corp"));
582
583 assert!(info.creation_date.is_some());
585 assert_eq!(
586 info.creation_date.unwrap().to_rfc3339(),
587 "2020-01-15T00:00:00+00:00"
588 );
589 assert_eq!(
590 info.expiration_date.unwrap().to_rfc3339(),
591 "2025-01-15T00:00:00+00:00"
592 );
593 assert_eq!(
594 info.updated_date.unwrap().to_rfc3339(),
595 "2024-06-01T12:00:00+00:00"
596 );
597
598 assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
600
601 assert_eq!(info.status, vec!["active"]);
603
604 assert_eq!(info.dnssec.as_deref(), Some("signed"));
606
607 assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
609 assert_eq!(info.registrant_phone.as_deref(), Some("+1.5551234567"));
610 assert_eq!(info.registrant_address.as_deref(), Some("123 Main St"));
611 assert_eq!(info.registrant_country.as_deref(), Some("US"));
612 assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
613 assert_eq!(info.admin_organization.as_deref(), Some("Admin Org"));
614 assert_eq!(info.admin_email.as_deref(), Some("admin@example.com"));
615 assert_eq!(info.admin_phone.as_deref(), Some("+1.5559876543"));
616 assert_eq!(info.tech_name.as_deref(), Some("Tech Person"));
617 assert_eq!(info.tech_organization.as_deref(), Some("Tech Org"));
618 assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
619 assert_eq!(info.tech_phone.as_deref(), Some("+1.5555555555"));
620
621 assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
623 assert_eq!(
624 info.rdap_url.as_deref(),
625 Some("https://rdap.example.com/domain/example.com")
626 );
627 }
628
629 #[test]
630 fn test_from_sources_whois_only() {
631 let whois = make_test_whois();
632 let info = DomainInfo::from_sources("example.com", None, Some(&whois));
633
634 assert_eq!(info.source, DomainInfoSource::Whois);
635 assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
636 assert_eq!(info.registrant.as_deref(), Some("John Doe"));
637 assert_eq!(info.organization.as_deref(), Some("Example Corp"));
638 assert_eq!(
639 info.creation_date.unwrap().to_rfc3339(),
640 "2019-06-01T00:00:00+00:00"
641 );
642 assert_eq!(
643 info.expiration_date.unwrap().to_rfc3339(),
644 "2024-06-01T00:00:00+00:00"
645 );
646 assert_eq!(
647 info.updated_date.unwrap().to_rfc3339(),
648 "2023-12-15T00:00:00+00:00"
649 );
650 assert_eq!(
651 info.nameservers,
652 vec!["ns1.whois-example.com", "ns2.whois-example.com"]
653 );
654 assert_eq!(info.status, vec!["clientTransferProhibited"]);
655 assert_eq!(info.dnssec.as_deref(), Some("signedDelegation"));
656 assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
657 assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
658 assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
659 assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
660 assert!(info.rdap_url.is_none());
661 }
662
663 #[test]
664 fn test_from_sources_rdap_only() {
665 let rdap = make_test_rdap();
666 let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
667
668 assert_eq!(info.source, DomainInfoSource::Rdap);
669 assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
670 assert!(info.registrant.is_none());
672 assert!(info.organization.is_none());
673 assert!(info.creation_date.is_some());
674 assert!(info.expiration_date.is_some());
675 assert!(info.updated_date.is_some());
676 assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
677 assert_eq!(info.status, vec!["active"]);
678 assert_eq!(info.dnssec.as_deref(), Some("signed"));
679 assert!(info.registrant_email.is_none());
681 assert!(info.admin_name.is_none());
682 assert!(info.tech_email.is_none());
683 assert!(info.whois_server.is_none());
684 assert_eq!(
685 info.rdap_url.as_deref(),
686 Some("https://rdap.example.com/domain/example.com")
687 );
688 }
689
690 #[test]
691 fn test_from_sources_neither() {
692 let info = DomainInfo::from_sources("available.com", None, None);
693
694 assert_eq!(info.source, DomainInfoSource::Available);
695 assert_eq!(info.domain, "available.com");
696 assert!(info.registrar.is_none());
697 assert!(info.registrant.is_none());
698 assert!(info.organization.is_none());
699 assert!(info.creation_date.is_none());
700 assert!(info.expiration_date.is_none());
701 assert!(info.updated_date.is_none());
702 assert!(info.nameservers.is_empty());
703 assert!(info.status.is_empty());
704 assert!(info.dnssec.is_none());
705 assert!(info.registrant_email.is_none());
706 assert!(info.admin_name.is_none());
707 assert!(info.tech_email.is_none());
708 assert!(info.whois_server.is_none());
709 assert!(info.rdap_url.is_none());
710 }
711
712 #[test]
713 fn test_from_lookup_result_rdap_variant() {
714 let rdap = make_test_rdap();
715 let whois = make_test_whois();
716 let result = LookupResult::Rdap {
717 data: Box::new(rdap),
718 whois_fallback: Some(whois),
719 };
720
721 let info = DomainInfo::from_lookup_result(&result);
722 assert_eq!(info.source, DomainInfoSource::Both);
723 assert_eq!(info.domain, "example.com");
724 assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
725 assert_eq!(info.registrant.as_deref(), Some("John Doe"));
727 }
728
729 #[test]
730 fn test_from_lookup_result_whois_variant() {
731 let whois = make_test_whois();
732 let result = LookupResult::Whois {
733 data: whois,
734 rdap_error: Some("RDAP failed".to_string()),
735 rdap_fallback: None,
736 };
737
738 let info = DomainInfo::from_lookup_result(&result);
739 assert_eq!(info.source, DomainInfoSource::Whois);
740 assert_eq!(info.domain, "example.com");
741 assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
742 assert!(info.rdap_url.is_none());
743 }
744
745 #[test]
746 fn from_sources_populates_registrar_detail() {
747 let rdap: RdapResponse = serde_json::from_value(serde_json::json!({
748 "objectClassName": "domain",
749 "ldhName": "example.com",
750 "entities": [{
751 "objectClassName": "entity",
752 "roles": ["registrar"],
753 "publicIds": [{"type": "IANA Registrar ID", "identifier": "292"}],
754 "links": [{"rel": "about", "href": "https://registrar.example"}],
755 "vcardArray": ["vcard", [["fn", {}, "text", "Example Registrar"]]],
756 "entities": [{
757 "objectClassName": "entity",
758 "roles": ["abuse"],
759 "vcardArray": ["vcard", [
760 ["email", {}, "text", "abuse@registrar.example"],
761 ["tel", {}, "text", "+1.5551230000"]
762 ]]
763 }]
764 }]
765 }))
766 .unwrap();
767 let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
768 assert_eq!(info.registrar_iana_id.as_deref(), Some("292"));
769 assert_eq!(
770 info.registrar_url.as_deref(),
771 Some("https://registrar.example")
772 );
773 assert_eq!(
774 info.registrar_abuse_email.as_deref(),
775 Some("abuse@registrar.example")
776 );
777 assert_eq!(info.registrar_abuse_phone.as_deref(), Some("+1.5551230000"));
778 }
779
780 #[test]
781 fn describe_epp_status_decodes_known_codes() {
782 assert_eq!(
783 describe_epp_status("clientTransferProhibited"),
784 Some("transfer locked (by registrar)")
785 );
786 assert_eq!(
788 describe_epp_status("client transfer prohibited"),
789 Some("transfer locked (by registrar)")
790 );
791 assert_eq!(
792 describe_epp_status("serverHold"),
793 Some("resolution suspended (by registry)")
794 );
795 assert_eq!(
796 describe_epp_status("redemptionPeriod"),
797 Some("recently expired, recoverable (redemption period)")
798 );
799 assert_eq!(
801 describe_epp_status("client hold (https://example.com/info)"),
802 Some("resolution suspended (by registrar)")
803 );
804 assert_eq!(describe_epp_status("totally unknown code"), None);
805 }
806
807 #[test]
808 fn derive_expiry_status_bands_by_days_and_codes() {
809 assert_eq!(
811 derive_expiry_status(Some(200), &["pendingDelete".into()]),
812 Some(ExpiryStatus::PendingDelete)
813 );
814 assert_eq!(
815 derive_expiry_status(Some(200), &["redemptionPeriod".into()]),
816 Some(ExpiryStatus::Redemption)
817 );
818 assert_eq!(
820 derive_expiry_status(Some(365), &[]),
821 Some(ExpiryStatus::Active)
822 );
823 assert_eq!(
824 derive_expiry_status(Some(30), &[]),
825 Some(ExpiryStatus::ExpiringSoon)
826 );
827 assert_eq!(
828 derive_expiry_status(Some(0), &[]),
829 Some(ExpiryStatus::ExpiringSoon)
830 );
831 assert_eq!(
832 derive_expiry_status(Some(-5), &[]),
833 Some(ExpiryStatus::Expired)
834 );
835 assert_eq!(derive_expiry_status(None, &["ok".into()]), None);
837 }
838
839 #[test]
840 fn compute_lifecycle_uses_fixed_clock() {
841 use chrono::TimeZone;
842 let whois = make_test_whois();
845 let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
846 let now = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
847 info.compute_lifecycle(now);
848
849 let expected_days = (Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap() - now).num_days();
850 assert_eq!(info.days_until_expiration, Some(expected_days));
851 assert!(info.days_until_expiration.unwrap() > 0);
852 assert!(info.domain_age_days.unwrap() > 0);
853 assert_eq!(info.expiry_status, Some(ExpiryStatus::Active));
855 assert_eq!(info.status_descriptions.len(), 1);
857 assert_eq!(info.status_descriptions[0].code, "clientTransferProhibited");
858 assert_eq!(
859 info.status_descriptions[0].description,
860 "transfer locked (by registrar)"
861 );
862 }
863
864 #[test]
865 fn test_serialization_round_trip() {
866 let rdap = make_test_rdap();
867 let whois = make_test_whois();
868 let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
869
870 let json = serde_json::to_string_pretty(&info).expect("serialize");
871 let deserialized: DomainInfo = serde_json::from_str(&json).expect("deserialize");
872
873 assert_eq!(deserialized.domain, info.domain);
874 assert_eq!(deserialized.source, info.source);
875 assert_eq!(deserialized.registrar, info.registrar);
876 assert_eq!(deserialized.registrant, info.registrant);
877 assert_eq!(deserialized.organization, info.organization);
878 assert_eq!(deserialized.creation_date, info.creation_date);
879 assert_eq!(deserialized.expiration_date, info.expiration_date);
880 assert_eq!(deserialized.updated_date, info.updated_date);
881 assert_eq!(deserialized.nameservers, info.nameservers);
882 assert_eq!(deserialized.status, info.status);
883 assert_eq!(deserialized.dnssec, info.dnssec);
884 assert_eq!(deserialized.registrant_email, info.registrant_email);
885 assert_eq!(deserialized.admin_name, info.admin_name);
886 assert_eq!(deserialized.tech_email, info.tech_email);
887 assert_eq!(deserialized.whois_server, info.whois_server);
888 assert_eq!(deserialized.rdap_url, info.rdap_url);
889
890 assert!(json.contains("\"source\": \"both\""));
892 }
893}