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