icann_rdap_cli/rt/
results.rs

1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
2
3/// Contains the results of test execution.
4use chrono::{DateTime, Utc};
5use icann_rdap_client::{
6    md::{string::StringUtil, table::MultiPartTable, MdOptions},
7    rdap::ResponseData,
8    RdapClientError,
9};
10use icann_rdap_common::{
11    check::{traverse_checks, Check, CheckClass, CheckItem, CheckParams, Checks, GetChecks},
12    response::{types::ExtensionId, RdapResponse},
13};
14use reqwest::StatusCode;
15use serde::Serialize;
16use strum_macros::Display;
17
18use super::exec::TestOptions;
19
20#[derive(Debug, Serialize)]
21pub struct TestResults {
22    pub query_url: String,
23    pub dns_data: DnsData,
24    pub start_time: DateTime<Utc>,
25    pub end_time: Option<DateTime<Utc>>,
26    pub service_checks: Vec<CheckItem>,
27    pub test_runs: Vec<TestRun>,
28}
29
30impl TestResults {
31    pub fn new(query_url: String, dns_data: DnsData) -> Self {
32        TestResults {
33            query_url,
34            dns_data,
35            start_time: Utc::now(),
36            end_time: None,
37            service_checks: vec![],
38            test_runs: vec![],
39        }
40    }
41
42    pub fn end(&mut self, options: &TestOptions) {
43        self.end_time = Some(Utc::now());
44
45        //service checks
46        if self.dns_data.v4_cname.is_some() && self.dns_data.v4_addrs.is_empty() {
47            self.service_checks
48                .push(Check::CnameWithoutARecords.check_item());
49        }
50        if self.dns_data.v6_cname.is_some() && self.dns_data.v6_addrs.is_empty() {
51            self.service_checks
52                .push(Check::CnameWithoutAAAARecords.check_item());
53        }
54        if self.dns_data.v4_addrs.is_empty() {
55            self.service_checks.push(Check::NoARecords.check_item());
56        }
57        if self.dns_data.v6_addrs.is_empty() {
58            self.service_checks.push(Check::NoAAAARecords.check_item());
59
60            // see if required by ICANN
61            let tig0 = ExtensionId::IcannRdapTechnicalImplementationGuide0.to_string();
62            let tig1 = ExtensionId::IcannRdapTechnicalImplementationGuide1.to_string();
63            let both_tigs = format!("{tig0}|{tig1}");
64            if options.expect_extensions.contains(&tig0)
65                || options.expect_extensions.contains(&tig1)
66                || options.expect_extensions.contains(&both_tigs)
67            {
68                self.service_checks
69                    .push(Check::Ipv6SupportRequiredByIcann.check_item())
70            }
71        }
72    }
73
74    pub fn add_test_run(&mut self, test_run: TestRun) {
75        self.test_runs.push(test_run);
76    }
77
78    pub fn to_md(&self, options: &MdOptions, check_classes: &[CheckClass]) -> String {
79        let mut md = String::new();
80
81        // h1
82        md.push_str(&format!(
83            "\n{}\n",
84            self.query_url.to_owned().to_header(1, options)
85        ));
86
87        // table
88        let mut table = MultiPartTable::new();
89
90        // test results summary
91        table = table.multi(vec![
92            "Start Time".to_inline(options),
93            "End Time".to_inline(options),
94            "Duration".to_inline(options),
95            "Tested".to_inline(options),
96        ]);
97        let (end_time_s, duration_s) = if let Some(end_time) = self.end_time {
98            (
99                format_date_time(end_time),
100                format!("{} s", (end_time - self.start_time).num_seconds()),
101            )
102        } else {
103            ("FATAL".to_em(options), "N/A".to_string())
104        };
105        let tested = self
106            .test_runs
107            .iter()
108            .filter(|r| matches!(r.outcome, RunOutcome::Tested))
109            .count();
110        table = table.multi(vec![
111            format_date_time(self.start_time),
112            end_time_s,
113            duration_s,
114            format!("{tested} of {}", self.test_runs.len()),
115        ]);
116
117        // dns data
118        table = table.multi(vec![
119            "DNS Query".to_inline(options),
120            "DNS Answer".to_inline(options),
121        ]);
122        let v4_cname = if let Some(ref cname) = self.dns_data.v4_cname {
123            cname.to_owned()
124        } else {
125            format!("{} A records", self.dns_data.v4_addrs.len())
126        };
127        table = table.multi(vec!["A (v4)".to_string(), v4_cname]);
128        let v6_cname = if let Some(ref cname) = self.dns_data.v6_cname {
129            cname.to_owned()
130        } else {
131            format!("{} AAAA records", self.dns_data.v6_addrs.len())
132        };
133        table = table.multi(vec!["AAAA (v6)".to_string(), v6_cname]);
134
135        // summary of each run
136        table = table.multi(vec![
137            "Address".to_inline(options),
138            "Attributes".to_inline(options),
139            "Duration".to_inline(options),
140            "Outcome".to_inline(options),
141        ]);
142        for test_run in &self.test_runs {
143            table = test_run.add_summary(table, options);
144        }
145        md.push_str(&table.to_md_table(options));
146
147        md.push('\n');
148
149        // checks that are about the service and not a particular test run
150        if !self.service_checks.is_empty() {
151            md.push_str(&"Service Checks".to_string().to_header(1, options));
152            let mut table = MultiPartTable::new();
153
154            table = table.multi(vec!["Message".to_inline(options)]);
155            for c in &self.service_checks {
156                let message = check_item_md(c, options);
157                table = table.multi(vec![message]);
158            }
159            md.push_str(&table.to_md_table(options));
160            md.push('\n');
161        }
162
163        // each run in detail
164        for run in &self.test_runs {
165            md.push_str(&run.to_md(options, check_classes));
166        }
167        md
168    }
169}
170
171#[derive(Debug, Serialize, Clone, Default)]
172pub struct DnsData {
173    pub v4_cname: Option<String>,
174    pub v6_cname: Option<String>,
175    pub v4_addrs: Vec<Ipv4Addr>,
176    pub v6_addrs: Vec<Ipv6Addr>,
177}
178
179#[derive(Debug, Serialize, Display)]
180#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
181pub enum RunOutcome {
182    Tested,
183    NetworkError,
184    HttpProtocolError,
185    HttpConnectError,
186    HttpRedirectError,
187    HttpTimeoutError,
188    HttpNon200Error,
189    HttpTooManyRequestsError,
190    HttpNotFoundError,
191    HttpBadRequestError,
192    HttpUnauthorizedError,
193    HttpForbiddenError,
194    JsonError,
195    RdapDataError,
196    InternalError,
197    Skipped,
198}
199
200#[derive(Debug, Serialize, Display)]
201#[strum(serialize_all = "snake_case")]
202pub enum RunFeature {
203    OriginHeader,
204}
205
206impl RunOutcome {
207    pub fn to_md(&self, options: &MdOptions) -> String {
208        match self {
209            RunOutcome::Tested => self.to_bold(options),
210            RunOutcome::Skipped => self.to_string(),
211            _ => self.to_em(options),
212        }
213    }
214}
215
216#[derive(Debug, Serialize)]
217pub struct TestRun {
218    pub features: Vec<RunFeature>,
219    pub socket_addr: SocketAddr,
220    pub start_time: DateTime<Utc>,
221    pub end_time: Option<DateTime<Utc>>,
222    pub response_data: Option<ResponseData>,
223    pub outcome: RunOutcome,
224    pub checks: Option<Checks>,
225}
226
227impl TestRun {
228    pub fn new_v4(features: Vec<RunFeature>, ipv4: Ipv4Addr, port: u16) -> Self {
229        TestRun {
230            features,
231            start_time: Utc::now(),
232            socket_addr: SocketAddr::new(IpAddr::V4(ipv4), port),
233            end_time: None,
234            response_data: None,
235            outcome: RunOutcome::Skipped,
236            checks: None,
237        }
238    }
239
240    pub fn new_v6(features: Vec<RunFeature>, ipv6: Ipv6Addr, port: u16) -> Self {
241        TestRun {
242            features,
243            start_time: Utc::now(),
244            socket_addr: SocketAddr::new(IpAddr::V6(ipv6), port),
245            end_time: None,
246            response_data: None,
247            outcome: RunOutcome::Skipped,
248            checks: None,
249        }
250    }
251
252    pub fn end(
253        mut self,
254        rdap_response: Result<ResponseData, RdapClientError>,
255        options: &TestOptions,
256    ) -> Self {
257        if let Ok(response_data) = rdap_response {
258            self.end_time = Some(Utc::now());
259            self.outcome = RunOutcome::Tested;
260            self.checks = Some(do_checks(&response_data, options));
261            self.response_data = Some(response_data);
262        } else {
263            self.outcome = match rdap_response.err().unwrap() {
264                RdapClientError::InvalidQueryValue
265                | RdapClientError::AmbiquousQueryType
266                | RdapClientError::Poison
267                | RdapClientError::DomainNameError(_)
268                | RdapClientError::BootstrapUnavailable
269                | RdapClientError::BootstrapError(_)
270                | RdapClientError::IanaResponse(_) => RunOutcome::InternalError,
271                RdapClientError::Response(_) => RunOutcome::RdapDataError,
272                RdapClientError::Json(_) | RdapClientError::ParsingError(_) => {
273                    RunOutcome::JsonError
274                }
275                RdapClientError::IoError(_) => RunOutcome::NetworkError,
276                RdapClientError::Client(e) => {
277                    if e.is_redirect() {
278                        RunOutcome::HttpRedirectError
279                    } else if e.is_connect() {
280                        RunOutcome::HttpConnectError
281                    } else if e.is_timeout() {
282                        RunOutcome::HttpTimeoutError
283                    } else if e.is_status() {
284                        match e.status().unwrap() {
285                            StatusCode::TOO_MANY_REQUESTS => RunOutcome::HttpTooManyRequestsError,
286                            StatusCode::NOT_FOUND => RunOutcome::HttpNotFoundError,
287                            StatusCode::BAD_REQUEST => RunOutcome::HttpBadRequestError,
288                            StatusCode::UNAUTHORIZED => RunOutcome::HttpUnauthorizedError,
289                            StatusCode::FORBIDDEN => RunOutcome::HttpForbiddenError,
290                            _ => RunOutcome::HttpNon200Error,
291                        }
292                    } else {
293                        RunOutcome::HttpProtocolError
294                    }
295                }
296            };
297            self.end_time = Some(Utc::now());
298        };
299        self
300    }
301
302    fn add_summary(&self, mut table: MultiPartTable, options: &MdOptions) -> MultiPartTable {
303        let duration_s = if let Some(end_time) = self.end_time {
304            format!("{} ms", (end_time - self.start_time).num_milliseconds())
305        } else {
306            "n/a".to_string()
307        };
308        table = table.multi(vec![
309            self.socket_addr.to_string(),
310            self.attribute_set(),
311            duration_s,
312            self.outcome.to_md(options),
313        ]);
314        table
315    }
316
317    fn to_md(&self, options: &MdOptions, check_classes: &[CheckClass]) -> String {
318        let mut md = String::new();
319
320        // h1
321        let header_value = format!("{} - {}", self.socket_addr, self.attribute_set());
322        md.push_str(&format!("\n{}\n", header_value.to_header(1, options)));
323
324        // if outcome is tested
325        if matches!(self.outcome, RunOutcome::Tested) {
326            // get check items according to class
327            let mut check_v: Vec<(String, String)> = Vec::new();
328            if let Some(ref checks) = self.checks {
329                traverse_checks(checks, check_classes, None, &mut |struct_name, item| {
330                    let message = check_item_md(item, options);
331                    check_v.push((struct_name.to_string(), message))
332                });
333            };
334
335            // table
336            let mut table = MultiPartTable::new();
337
338            if check_v.is_empty() {
339                table = table.header_ref(&"No issues or errors.");
340            } else {
341                table = table.multi(vec![
342                    "RDAP Structure".to_inline(options),
343                    "Message".to_inline(options),
344                ]);
345                for c in check_v {
346                    table = table.nv(&c.0, c.1);
347                }
348            }
349            md.push_str(&table.to_md_table(options));
350        } else {
351            let mut table = MultiPartTable::new();
352            table = table.multi(vec![self.outcome.to_md(options)]);
353            md.push_str(&table.to_md_table(options));
354        }
355
356        md
357    }
358
359    fn attribute_set(&self) -> String {
360        let socket_type = if self.socket_addr.is_ipv4() {
361            "v4"
362        } else {
363            "v6"
364        };
365        if !self.features.is_empty() {
366            format!(
367                "{socket_type}, {}",
368                self.features
369                    .iter()
370                    .map(|f| f.to_string())
371                    .collect::<Vec<_>>()
372                    .join(", ")
373            )
374        } else {
375            socket_type.to_string()
376        }
377    }
378}
379
380fn check_item_md(item: &CheckItem, options: &MdOptions) -> String {
381    if !matches!(item.check_class, CheckClass::Informational)
382        && !matches!(item.check_class, CheckClass::SpecificationNote)
383    {
384        item.to_string().to_em(options)
385    } else {
386        item.to_string()
387    }
388}
389
390fn format_date_time(date: DateTime<Utc>) -> String {
391    date.format("%a, %v %X %Z").to_string()
392}
393
394fn do_checks(response: &ResponseData, options: &TestOptions) -> Checks {
395    let check_params = CheckParams {
396        do_subchecks: true,
397        root: &response.rdap,
398        parent_type: response.rdap.get_type(),
399        allow_unreg_ext: options.allow_unregistered_extensions,
400    };
401    let mut checks = response.rdap.get_checks(check_params);
402
403    // httpdata checks
404    checks
405        .items
406        .append(&mut response.http_data.get_checks(check_params).items);
407
408    // add expected extension checks
409    for ext in &options.expect_extensions {
410        if !rdap_has_expected_extension(&response.rdap, ext) {
411            checks
412                .items
413                .push(Check::ExpectedExtensionNotFound.check_item());
414        }
415    }
416
417    //return
418    checks
419}
420
421fn rdap_has_expected_extension(rdap: &RdapResponse, ext: &str) -> bool {
422    let count = ext.split('|').filter(|s| rdap.has_extension(s)).count();
423    count > 0
424}
425
426#[cfg(test)]
427#[allow(non_snake_case)]
428mod tests {
429    use icann_rdap_common::response::{
430        domain::Domain,
431        types::{Common, Extension},
432        RdapResponse,
433    };
434
435    use super::rdap_has_expected_extension;
436
437    #[test]
438    fn GIVEN_expected_extension_WHEN_rdap_has_THEN_true() {
439        // GIVEN
440        let domain = Domain::basic().ldh_name("foo.example.com").build();
441        let domain = Domain {
442            common: Common::level0_with_options()
443                .extension(Extension::from("foo0"))
444                .build(),
445            ..domain
446        };
447        let rdap = RdapResponse::Domain(domain);
448
449        // WHEN
450        let actual = rdap_has_expected_extension(&rdap, "foo0");
451
452        // THEN
453        assert!(actual);
454    }
455
456    #[test]
457    fn GIVEN_expected_extension_WHEN_rdap_does_not_have_THEN_false() {
458        // GIVEN
459        let domain = Domain::basic().ldh_name("foo.example.com").build();
460        let domain = Domain {
461            common: Common::level0_with_options()
462                .extension(Extension::from("foo0"))
463                .build(),
464            ..domain
465        };
466        let rdap = RdapResponse::Domain(domain);
467
468        // WHEN
469        let actual = rdap_has_expected_extension(&rdap, "foo1");
470
471        // THEN
472        assert!(!actual);
473    }
474
475    #[test]
476    fn GIVEN_compound_expected_extension_WHEN_rdap_has_THEN_true() {
477        // GIVEN
478        let domain = Domain::basic().ldh_name("foo.example.com").build();
479        let domain = Domain {
480            common: Common::level0_with_options()
481                .extension(Extension::from("foo0"))
482                .build(),
483            ..domain
484        };
485        let rdap = RdapResponse::Domain(domain);
486
487        // WHEN
488        let actual = rdap_has_expected_extension(&rdap, "foo0|foo1");
489
490        // THEN
491        assert!(actual);
492    }
493}