Skip to main content

ocpi_kit/client/
conformance.rs

1//! A read-only conformance runner: drive a live peer and report what it does wrong.
2//!
3//! Every party in a roaming network has the same problem — the partner's implementation is not
4//! quite the specification, and finding out *where* means reading their JSON by hand. This module
5//! does it mechanically, and produces a report either side can act on.
6//!
7//! # It never changes anything
8//!
9//! The runner issues `GET` requests and exactly one deliberately-unauthenticated `GET`. It does
10//! **not** register, does not POST credentials, does not write an object, and does not delete
11//! one. A conformance check that mutates the peer it is checking is not a conformance check, and
12//! running this against a production partner is safe.
13//!
14//! # What it checks
15//!
16//! Discovery and the transport rules first, because a peer that gets those wrong will fail
17//! everything else for reasons that are hard to read:
18//!
19//! * `/versions` answers with a `1000` envelope carrying a plausible timestamp
20//! * the versions offered are unique, and at least one is one this build speaks
21//! * every advertised URL is absolute and passes the [`UrlPolicy`](crate::types::UrlPolicy)
22//! * the version details list `credentials`, list no module that does not exist in that version,
23//!   and list no `(module, role)` pair twice
24//! * `X-Request-ID` and `X-Correlation-ID` come back on the response
25//! * a request with no token, and one with a wrong token, are refused with `401`
26//!
27//! Then, for each Sender interface it can reach, that a page decodes, that its pagination headers
28//! agree with each other, that a `limit` is never exceeded, and that the objects conform.
29//!
30//! ```no_run
31//! use ocpi_kit::client::{Conformance, OcpiClient};
32//! use ocpi_kit::transport::CredentialsToken;
33//! use ocpi_kit::types::Url;
34//!
35//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
36//! let client = OcpiClient::new()?;
37//! let report = Conformance::new(
38//!         Url::new("https://cpo.example.com/ocpi/versions")?,
39//!         CredentialsToken::new("our-token-c")?,
40//!     )
41//!     .run(client.transport())
42//!     .await;
43//!
44//! println!("{report}");
45//! if report.has_failures() {
46//!     // …open a ticket with the partner, quoting `report`
47//! }
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! Spec: 2.3.0 §version_information_endpoint, §transport_and_format_transport_and_format,
53//! §status_codes_status_codes
54
55use core::fmt;
56
57use crate::transport::{
58    CredentialsToken, OcpiError, OcpiRequest, Page, PageQuery, Quirks, RequestIds, StatusCode,
59};
60use crate::types::{DateTime, Url, Validate};
61use crate::v2_3_0::versions::{Version, VersionDetails};
62use crate::{InterfaceRole, ModuleId, VersionNumber};
63
64use super::http::Transport;
65
66/// How one check came out.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
68#[non_exhaustive]
69pub enum Outcome {
70    /// The peer did what the specification says.
71    Pass,
72    /// The peer did something the specification does not require but that will cause trouble.
73    Warn,
74    /// The peer contradicts the specification.
75    Fail,
76    /// The check could not run — usually because the peer does not implement that module.
77    Skipped,
78}
79
80impl Outcome {
81    /// A single character for a compact report line.
82    #[must_use]
83    pub const fn glyph(self) -> char {
84        match self {
85            Self::Pass => '+',
86            Self::Warn => '!',
87            Self::Fail => 'x',
88            Self::Skipped => '-',
89        }
90    }
91}
92
93impl fmt::Display for Outcome {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(match self {
96            Self::Pass => "PASS",
97            Self::Warn => "WARN",
98            Self::Fail => "FAIL",
99            Self::Skipped => "SKIP",
100        })
101    }
102}
103
104/// One thing that was checked, and what the peer did.
105#[derive(Clone, Debug)]
106pub struct Check {
107    /// Stable identifier, so a report can be diffed between runs.
108    pub id: &'static str,
109    /// What was checked, in one line.
110    pub title: String,
111    /// How it came out.
112    pub outcome: Outcome,
113    /// What the peer actually did.
114    pub detail: String,
115    /// The specification anchor this check comes from.
116    pub spec: &'static str,
117}
118
119impl Check {
120    fn new(
121        id: &'static str,
122        title: impl Into<String>,
123        outcome: Outcome,
124        detail: impl Into<String>,
125        spec: &'static str,
126    ) -> Self {
127        Self { id, title: title.into(), outcome, detail: detail.into(), spec }
128    }
129}
130
131/// Everything the runner found.
132#[derive(Clone, Debug, Default)]
133pub struct Report {
134    /// The checks, in the order they ran.
135    pub checks: Vec<Check>,
136    /// The version that was selected, once discovery got that far.
137    pub version: Option<VersionNumber>,
138}
139
140impl Report {
141    /// How many checks came out a given way.
142    #[must_use]
143    pub fn count(&self, outcome: Outcome) -> usize {
144        self.checks.iter().filter(|c| c.outcome == outcome).count()
145    }
146
147    /// Whether anything contradicted the specification.
148    #[must_use]
149    pub fn has_failures(&self) -> bool {
150        self.count(Outcome::Fail) > 0
151    }
152
153    /// The failing checks, for a caller that wants to act on them rather than print them.
154    pub fn failures(&self) -> impl Iterator<Item = &Check> {
155        self.checks.iter().filter(|c| c.outcome == Outcome::Fail)
156    }
157
158    fn push(&mut self, check: Check) {
159        self.checks.push(check);
160    }
161
162    fn pass(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
163        self.push(Check::new(id, title, Outcome::Pass, detail, spec));
164    }
165
166    fn fail(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
167        self.push(Check::new(id, title, Outcome::Fail, detail, spec));
168    }
169
170    fn warn(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
171        self.push(Check::new(id, title, Outcome::Warn, detail, spec));
172    }
173
174    fn skip(&mut self, id: &'static str, title: &str, detail: impl Into<String>, spec: &'static str) {
175        self.push(Check::new(id, title, Outcome::Skipped, detail, spec));
176    }
177
178    /// Records `Pass` or `Fail` from a boolean, with the reason for each.
179    fn assert(
180        &mut self,
181        id: &'static str,
182        title: &str,
183        ok: bool,
184        detail: impl Into<String>,
185        spec: &'static str,
186    ) {
187        let outcome = if ok { Outcome::Pass } else { Outcome::Fail };
188        self.push(Check::new(id, title, outcome, detail, spec));
189    }
190}
191
192impl fmt::Display for Report {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        for check in &self.checks {
195            writeln!(f, "[{}] {:<10} {}", check.outcome.glyph(), check.id, check.title)?;
196            if !check.detail.is_empty() {
197                writeln!(f, "               {}", check.detail)?;
198            }
199            if check.outcome == Outcome::Fail || check.outcome == Outcome::Warn {
200                writeln!(f, "               spec: {}", check.spec)?;
201            }
202        }
203        writeln!(
204            f,
205            "\n{} passed, {} failed, {} warnings, {} skipped",
206            self.count(Outcome::Pass),
207            self.count(Outcome::Fail),
208            self.count(Outcome::Warn),
209            self.count(Outcome::Skipped),
210        )
211    }
212}
213
214/// The modules whose Sender interface the runner will pull one page from.
215///
216/// All of them are list endpoints whose objects this crate models, so a page can be decoded and
217/// validated. Nothing here writes.
218const PULLABLE: &[ModuleId] =
219    &[ModuleId::Locations, ModuleId::Sessions, ModuleId::Cdrs, ModuleId::Tariffs, ModuleId::Tokens];
220
221/// Drives a peer through the checks.
222#[derive(Clone, Debug)]
223pub struct Conformance {
224    versions_url: Url,
225    token: CredentialsToken,
226    quirks: Quirks,
227    page_limit: u64,
228    max_clock_skew: core::time::Duration,
229    check_auth: bool,
230}
231
232impl Conformance {
233    /// A run against `versions_url`, authenticating with `token`.
234    ///
235    /// `token` should be the `CREDENTIALS_TOKEN_C` of an existing registration — the runner reads
236    /// the peer's data, which requires being registered with it.
237    #[must_use]
238    pub fn new(versions_url: Url, token: CredentialsToken) -> Self {
239        Self {
240            versions_url,
241            token,
242            quirks: Quirks::default(),
243            page_limit: 10,
244            max_clock_skew: core::time::Duration::from_secs(300),
245            check_auth: true,
246        }
247    }
248
249    /// Applies the peer's known quirks, so the run reports real problems rather than known ones.
250    #[must_use]
251    pub fn with_quirks(mut self, quirks: Quirks) -> Self {
252        self.quirks = quirks;
253        self
254    }
255
256    /// How many objects to ask for per page. Small by default: this is a check, not a crawl.
257    #[must_use]
258    pub const fn with_page_limit(mut self, limit: u64) -> Self {
259        self.page_limit = limit;
260        self
261    }
262
263    /// How far the peer's clock may be from ours before the timestamp check complains.
264    #[must_use]
265    pub const fn with_max_clock_skew(mut self, skew: core::time::Duration) -> Self {
266        self.max_clock_skew = skew;
267        self
268    }
269
270    /// Whether to send the two deliberately-rejected requests that check authentication.
271    ///
272    /// On by default. Turn it off against a peer whose intrusion detection would rather not see
273    /// a failed authentication from a partner.
274    #[must_use]
275    pub const fn with_auth_checks(mut self, check: bool) -> Self {
276        self.check_auth = check;
277        self
278    }
279
280    /// Runs every check and returns the report.
281    ///
282    /// Never returns an error: a peer that cannot be reached at all is itself a finding, and it
283    /// is recorded as one.
284    pub async fn run(&self, transport: &Transport) -> Report {
285        let mut report = Report::default();
286
287        let Some(versions) = self.check_versions(transport, &mut report).await else {
288            return report;
289        };
290        let Some((version, details)) = self.check_details(transport, &mut report, &versions).await else {
291            return report;
292        };
293        report.version = Some(version.clone());
294
295        Self::check_endpoints(&mut report, &version, &details);
296        if self.check_auth {
297            self.check_authentication(transport, &mut report, &details).await;
298        }
299        self.check_modules(transport, &mut report, &details).await;
300
301        report
302    }
303
304    /// `GET /versions` and everything that can be said about the answer.
305    async fn check_versions(&self, transport: &Transport, report: &mut Report) -> Option<Vec<Version>> {
306        const SPEC: &str = "2.3.0 §version_information_endpoint";
307        let request = OcpiRequest::new(http::Method::GET, self.versions_url.clone(), ModuleId::Versions)
308            .with_ids(RequestIds::generate());
309
310        let (envelope, headers) =
311            match transport.send_with_headers::<Vec<Version>>(&request, &self.token, &self.quirks).await {
312                Ok(pair) => pair,
313                Err(e) => {
314                    report.fail("versions.get", "GET /versions answers", e.to_string(), SPEC);
315                    return None;
316                }
317            };
318
319        report.assert(
320            "versions.status",
321            "GET /versions returns status_code 1000",
322            envelope.status_code == StatusCode::SUCCESS,
323            format!("got {}", envelope.status_code),
324            "2.3.0 §status_codes_1xxx_success",
325        );
326
327        Self::check_echoed_ids(report, &request.ids, &headers);
328        self.check_timestamp(report, envelope.timestamp);
329
330        let Some(versions) = envelope.data else {
331            report.fail(
332                "versions.data",
333                "GET /versions carries a data field",
334                "the envelope has no `data`, so no version could be read",
335                SPEC,
336            );
337            return None;
338        };
339
340        report.assert(
341            "versions.nonempty",
342            "at least one version is offered",
343            !versions.is_empty(),
344            format!("{} offered", versions.len()),
345            SPEC,
346        );
347
348        let mut numbers: Vec<String> = versions.iter().map(|v| v.version.to_string()).collect();
349        numbers.sort();
350        let unique = {
351            let mut d = numbers.clone();
352            d.dedup();
353            d.len() == numbers.len()
354        };
355        report.assert("versions.unique", "each version is listed once", unique, numbers.join(", "), SPEC);
356
357        for version in &versions {
358            if let Err(e) = transport.url_policy().check(&version.url) {
359                report.fail(
360                    "versions.url",
361                    "every advertised version URL is usable",
362                    format!("{}: {e}", version.version),
363                    "2.3.0 §types_url_type",
364                );
365            }
366        }
367
368        let common: Vec<&Version> = versions.iter().filter(|v| v.version.is_supported()).collect();
369        if common.is_empty() {
370            report.fail(
371                "versions.common",
372                "the peer offers a version this build speaks",
373                format!(
374                    "peer offers {}; this build speaks {}",
375                    numbers.join(", "),
376                    VersionNumber::supported().iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
377                ),
378                SPEC,
379            );
380            return None;
381        }
382        report.pass(
383            "versions.common",
384            "the peer offers a version this build speaks",
385            common.iter().map(|v| v.version.to_string()).collect::<Vec<_>>().join(", "),
386            SPEC,
387        );
388
389        Some(versions)
390    }
391
392    /// `GET` the version-details URL of the newest common version.
393    async fn check_details(
394        &self,
395        transport: &Transport,
396        report: &mut Report,
397        versions: &[Version],
398    ) -> Option<(VersionNumber, VersionDetails)> {
399        const SPEC: &str = "2.3.0 §version_information_endpoint_version_details";
400
401        let best = versions
402            .iter()
403            .filter(|v| v.version.is_supported())
404            .max_by(|a, b| a.version.cmp_by_release(&b.version))?;
405
406        let request = OcpiRequest::new(http::Method::GET, best.url.clone(), ModuleId::Versions)
407            .with_ids(RequestIds::generate());
408        let (envelope, headers) =
409            match transport.send_with_headers::<VersionDetails>(&request, &self.token, &self.quirks).await {
410                Ok(pair) => pair,
411                Err(e) => {
412                    report.fail(
413                        "details.get",
414                        "the version-details endpoint answers",
415                        format!("{}: {e}", best.url.as_str()),
416                        SPEC,
417                    );
418                    return None;
419                }
420            };
421
422        Self::check_echoed_ids(report, &request.ids, &headers);
423
424        let Some(details) = envelope.data else {
425            report.fail("details.data", "version details carry a data field", "no `data`", SPEC);
426            return None;
427        };
428
429        report.assert(
430            "details.version",
431            "the details name the version they were fetched for",
432            details.version == best.version,
433            format!("asked for {}, got {}", best.version, details.version),
434            SPEC,
435        );
436
437        Some((best.version.clone(), details))
438    }
439
440    /// Everything that can be checked about the endpoint list without calling any of it.
441    fn check_endpoints(report: &mut Report, version: &VersionNumber, details: &VersionDetails) {
442        const SPEC: &str = "2.3.0 §version_information_endpoint_endpoint_class";
443
444        report.assert(
445            "endpoints.nonempty",
446            "the version details list at least one endpoint",
447            !details.endpoints.is_empty(),
448            format!("{} listed", details.endpoints.len()),
449            SPEC,
450        );
451
452        report.assert(
453            "endpoints.credentials",
454            "the credentials module is offered",
455            details.credentials_url().is_some(),
456            "every implementation must have a credentials endpoint",
457            "2.3.0 §credentials_credentials_module",
458        );
459
460        let mut pairs: Vec<(String, InterfaceRole)> =
461            details.endpoints.iter().map(|e| (e.identifier.to_string(), e.role)).collect();
462        pairs.sort();
463        let duplicate = pairs.windows(2).find(|w| w[0] == w[1]).map(|w| w[0].clone());
464        report.assert(
465            "endpoints.unique",
466            "no module and role pair is listed twice",
467            duplicate.is_none(),
468            duplicate.map_or_else(String::new, |(m, r)| format!("{m}/{r} appears more than once")),
469            SPEC,
470        );
471
472        for endpoint in &details.endpoints {
473            if !endpoint.identifier.exists_in(version) {
474                report.warn(
475                    "endpoints.known",
476                    "every advertised module exists in this version",
477                    format!("`{}` is not a module of OCPI {version}", endpoint.identifier),
478                    "2.3.0 §version_information_endpoint_moduleid_enum",
479                );
480            }
481            if let Err(e) = endpoint.url.parse() {
482                report.fail(
483                    "endpoints.absolute",
484                    "every endpoint URL is absolute",
485                    format!("{}/{}: {e}", endpoint.identifier, endpoint.role),
486                    "2.3.0 §types_url_type",
487                );
488            }
489        }
490
491        if let Err(violations) = details.validate() {
492            for v in &violations {
493                report.warn(
494                    "details.conform",
495                    "the version details conform",
496                    format!("{}: {}", v.pointer, v.message),
497                    SPEC,
498                );
499            }
500        }
501    }
502
503    /// Two requests that must be refused: no token at all, and a token that is not ours.
504    async fn check_authentication(
505        &self,
506        transport: &Transport,
507        report: &mut Report,
508        details: &VersionDetails,
509    ) {
510        const SPEC: &str = "2.3.0 §transport_and_format_authorization_header";
511
512        let Some((module, url)) = PULLABLE
513            .iter()
514            .find_map(|m| details.url(m, InterfaceRole::Sender).map(|u| (m.clone(), u.clone())))
515        else {
516            report.skip(
517                "auth.unauthenticated",
518                "an unauthenticated request is refused",
519                "the peer offers no Sender interface to try it on",
520                SPEC,
521            );
522            return;
523        };
524
525        for (id, title, token) in [
526            (
527                "auth.empty",
528                "a request with an empty token is refused with 401",
529                CredentialsToken::new_lenient(String::new()),
530            ),
531            (
532                "auth.wrong",
533                "a request with a token that is not ours is refused with 401",
534                CredentialsToken::new_lenient("ocpi-kit-conformance-not-a-real-token"),
535            ),
536        ] {
537            let request = OcpiRequest::new(http::Method::GET, url.clone(), module.clone())
538                .with_ids(RequestIds::generate());
539            let outcome = transport.send::<serde_json::Value>(&request, &token, &self.quirks).await;
540            match outcome {
541                Err(OcpiError::Unauthorized(_)) => {
542                    report.pass(id, title, "401, as required", SPEC);
543                }
544                Err(OcpiError::NotFound(_)) => {
545                    // Also defensible: the peer refuses to say the endpoint exists at all.
546                    report.pass(id, title, "404 — the peer will not confirm the endpoint exists", SPEC);
547                }
548                Ok(_) => report.fail(
549                    id,
550                    title,
551                    "the peer answered 200 to an unauthenticated request, exposing its data",
552                    SPEC,
553                ),
554                Err(other) => report.warn(id, title, format!("expected 401, got {other}"), SPEC),
555            }
556        }
557    }
558
559    /// One page from every Sender interface the peer offers.
560    async fn check_modules(&self, transport: &Transport, report: &mut Report, details: &VersionDetails) {
561        for module in PULLABLE {
562            let Some(url) = details.url(module, InterfaceRole::Sender) else {
563                report.skip(
564                    "module.page",
565                    &format!("{module} Sender returns a decodable page"),
566                    "not offered by this peer",
567                    "2.3.0 §transport_and_format_pagination",
568                );
569                continue;
570            };
571            self.check_one_module(transport, report, module, url).await;
572        }
573    }
574
575    async fn check_one_module(
576        &self,
577        transport: &Transport,
578        report: &mut Report,
579        module: &ModuleId,
580        url: &Url,
581    ) {
582        const SPEC: &str = "2.3.0 §transport_and_format_pagination";
583        let query = PageQuery::new().with_limit(self.page_limit);
584        let request = OcpiRequest::new(http::Method::GET, query.apply_to(url), module.clone())
585            .with_ids(RequestIds::generate());
586
587        // Decoded as `Value` first: a page that fails to decode into the typed object is a
588        // finding about the *objects*, not about pagination, and both are worth separating.
589        let page = match transport.send_page::<serde_json::Value>(&request, &self.token, &self.quirks).await {
590            Ok(page) => page,
591            Err(e) => {
592                report.fail(
593                    "module.page",
594                    &format!("{module} Sender returns a decodable page"),
595                    e.to_string(),
596                    SPEC,
597                );
598                return;
599            }
600        };
601
602        report.pass(
603            "module.page",
604            &format!("{module} Sender returns a decodable page"),
605            format!("{} object(s)", page.items.len()),
606            SPEC,
607        );
608
609        let count = page.items.len() as u64;
610        report.assert(
611            "module.limit",
612            &format!("{module} honours the requested limit"),
613            count <= self.page_limit,
614            format!("asked for at most {}, got {count}", self.page_limit),
615            SPEC,
616        );
617
618        match page.meta.limit {
619            Some(limit) => {
620                report.assert(
621                    "module.xlimit",
622                    &format!("{module} reports X-Limit consistently"),
623                    count <= limit,
624                    format!("X-Limit: {limit}, body carried {count}"),
625                    SPEC,
626                );
627            }
628            None => report.warn(
629                "module.xlimit",
630                &format!("{module} sends an X-Limit header"),
631                "absent, so a client cannot tell whether its limit was reduced",
632                SPEC,
633            ),
634        }
635
636        match page.meta.total_count {
637            Some(total) => {
638                let expects_next = total > count;
639                report.assert(
640                    "module.link",
641                    &format!("{module} sends Link: rel=\"next\" exactly when there is more"),
642                    expects_next == page.meta.next.is_some(),
643                    format!(
644                        "X-Total-Count: {total}, this page: {count}, next link: {}",
645                        page.meta.next.as_ref().map_or("absent", |_| "present")
646                    ),
647                    SPEC,
648                );
649            }
650            None => report.warn(
651                "module.total",
652                &format!("{module} sends an X-Total-Count header"),
653                "absent, so a client cannot size the crawl",
654                SPEC,
655            ),
656        }
657
658        Self::check_objects(report, module, &page.items);
659        self.check_offset(transport, report, module, url, &page).await;
660        self.check_date_from(transport, report, module, url, &page).await;
661    }
662
663    /// Whether the peer actually applies `offset`.
664    ///
665    /// A peer that ignores it answers every page with the same objects, which is not a wrong
666    /// answer so much as an endless one: a client following `Link: rel="next"` never terminates,
667    /// and `DEFAULT_MAX_PAGES` is the only thing between it and a loop. Nothing in a single page
668    /// reveals this, which is why it belongs in a conformance run rather than in a client.
669    ///
670    /// > *Example: With offset=0 and limit=10 the server shall return the first 10 records (if 10
671    /// > objects match the request). Then the next page starts with offset=10.*
672    async fn check_offset(
673        &self,
674        transport: &Transport,
675        report: &mut Report,
676        module: &ModuleId,
677        url: &Url,
678        first: &Page<serde_json::Value>,
679    ) {
680        const SPEC: &str = "2.3.0 §transport_and_format_pagination";
681        let title = format!("{module} applies the offset parameter");
682        if first.items.len() < 2 {
683            report.skip("module.offset", &title, "fewer than two objects to distinguish", SPEC);
684            return;
685        }
686        let query = PageQuery::new().with_offset(1).with_limit(1);
687        let request = OcpiRequest::new(http::Method::GET, query.apply_to(url), module.clone())
688            .with_ids(RequestIds::generate());
689        match transport.send_page::<serde_json::Value>(&request, &self.token, &self.quirks).await {
690            Err(e) => report.fail("module.offset", &title, e.to_string(), SPEC),
691            Ok(second) => match second.items.first() {
692                None => report.warn(
693                    "module.offset",
694                    &title,
695                    "offset=1&limit=1 returned nothing, although the unfiltered page had at least two                      objects",
696                    SPEC,
697                ),
698                Some(item) => report.assert(
699                    "module.offset",
700                    &title,
701                    *item == first.items[1],
702                    if *item == first.items[0] {
703                        "offset=1 returned the object at offset 0; a crawl over this endpoint would                          never terminate"
704                            .to_owned()
705                    } else {
706                        "offset=1 returned an object, though not the second of the first page —                          acceptable if the set changed between the two requests"
707                            .to_owned()
708                    },
709                    SPEC,
710                ),
711            },
712        }
713    }
714
715    /// Whether the peer actually applies `date_from`.
716    ///
717    /// > *`date_from`: Only return objects that have `last_updated` after or equal to this
718    /// > Date/Time (inclusive).*
719    ///
720    /// A peer that ignores it turns every incremental pull into a full one. The cost is invisible
721    /// — the data is correct — until a partner with a million CDRs wonders why a nightly sync
722    /// takes six hours.
723    async fn check_date_from(
724        &self,
725        transport: &Transport,
726        report: &mut Report,
727        module: &ModuleId,
728        url: &Url,
729        first: &Page<serde_json::Value>,
730    ) {
731        const SPEC: &str = "2.3.0 §transport_and_format_pagination";
732        let title = format!("{module} applies the date_from filter");
733        let Some(newest) = first.items.iter().filter_map(last_updated).max() else {
734            report.skip("module.date_from", &title, "no object carried a usable last_updated", SPEC);
735            return;
736        };
737        // One second *past* the newest object this peer just showed us. A peer that applies the
738        // filter answers with nothing, or with whatever changed in between; a peer that ignores it
739        // hands back the same page, every object of which is now demonstrably too old. Filtering
740        // at `newest` itself would prove nothing when a page's objects share a timestamp, which
741        // is the normal case for a bulk import.
742        let Ok(after) = DateTime::from_unix_timestamp(newest.unix_timestamp() + 1) else {
743            report.skip("module.date_from", &title, "the newest timestamp is at the end of time", SPEC);
744            return;
745        };
746        let query = PageQuery::since(after).with_limit(self.page_limit);
747        let request = OcpiRequest::new(http::Method::GET, query.apply_to(url), module.clone())
748            .with_ids(RequestIds::generate());
749        match transport.send_page::<serde_json::Value>(&request, &self.token, &self.quirks).await {
750            Err(e) => report.fail("module.date_from", &title, e.to_string(), SPEC),
751            Ok(filtered) => {
752                let stale = filtered.items.iter().filter_map(last_updated).filter(|t| *t < after).count();
753                let total = filtered.items.len();
754                report.assert(
755                    "module.date_from",
756                    &title,
757                    stale == 0,
758                    if stale == 0 {
759                        format!("asked for last_updated >= {after}, got {total} object(s), none older")
760                    } else {
761                        format!(
762                            "asked for last_updated >= {after}, got {stale} of {total} object(s) older \
763                             than that; a peer that ignores date_from turns every incremental pull \
764                             into a full one"
765                        )
766                    },
767                    SPEC,
768                );
769            }
770        }
771    }
772
773    /// Re-decodes the page as the typed object and validates each one.
774    fn check_objects(report: &mut Report, module: &ModuleId, items: &[serde_json::Value]) {
775        macro_rules! typed {
776            ($ty:ty) => {{
777                let mut decoded = 0usize;
778                let mut problems: Vec<String> = Vec::new();
779                for (i, raw) in items.iter().enumerate() {
780                    match serde_json::from_value::<$ty>(raw.clone()) {
781                        Ok(object) => {
782                            decoded += 1;
783                            if let Err(violations) = object.validate() {
784                                for v in violations.iter() {
785                                    problems.push(format!("[{i}]{}: {}", v.pointer, v.message));
786                                }
787                            }
788                        }
789                        Err(e) => problems.push(format!("[{i}] does not decode: {e}")),
790                    }
791                }
792                (decoded, problems)
793            }};
794        }
795
796        let (decoded, problems) = match module {
797            ModuleId::Locations => typed!(crate::v2_3_0::locations::Location),
798            ModuleId::Sessions => typed!(crate::v2_3_0::sessions::Session),
799            ModuleId::Cdrs => typed!(crate::v2_3_0::cdrs::Cdr),
800            ModuleId::Tariffs => typed!(crate::v2_3_0::tariffs::Tariff),
801            ModuleId::Tokens => typed!(crate::v2_3_0::tokens::Token),
802            _ => return,
803        };
804
805        if problems.is_empty() {
806            report.pass(
807                "module.objects",
808                &format!("{module} objects conform"),
809                format!("{decoded} checked"),
810                "2.3.0 §types_types",
811            );
812            return;
813        }
814        // Cap the detail: a peer with a systematic problem would otherwise print a page of it.
815        let shown = problems.iter().take(10).cloned().collect::<Vec<_>>().join("; ");
816        let suffix =
817            if problems.len() > 10 { format!(" (and {} more)", problems.len() - 10) } else { String::new() };
818        report.warn(
819            "module.objects",
820            &format!("{module} objects conform"),
821            format!("{shown}{suffix}"),
822            "2.3.0 §types_types",
823        );
824    }
825
826    fn check_echoed_ids(report: &mut Report, sent: &RequestIds, headers: &http::HeaderMap) {
827        const SPEC: &str = "2.3.0 §transport_and_format_request_id";
828        let got = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default().to_owned();
829
830        let request_id = got("x-request-id");
831        report.assert(
832            "headers.request_id",
833            "X-Request-ID is echoed",
834            request_id == sent.request_id.as_str(),
835            if request_id.is_empty() {
836                "absent from the response".to_owned()
837            } else {
838                format!("sent {}, got back {request_id}", sent.request_id)
839            },
840            SPEC,
841        );
842
843        let correlation_id = got("x-correlation-id");
844        report.assert(
845            "headers.correlation_id",
846            "X-Correlation-ID is echoed",
847            correlation_id == sent.correlation_id.as_str(),
848            if correlation_id.is_empty() {
849                "absent from the response".to_owned()
850            } else {
851                format!("sent {}, got back {correlation_id}", sent.correlation_id)
852            },
853            SPEC,
854        );
855    }
856
857    fn check_timestamp(&self, report: &mut Report, timestamp: DateTime) {
858        const SPEC: &str = "2.3.0 §transport_and_format_response_format";
859        let now = DateTime::now();
860        let skew = (now.unix_timestamp() - timestamp.unix_timestamp()).unsigned_abs();
861        let allowed = self.max_clock_skew.as_secs();
862        report.assert(
863            "headers.timestamp",
864            "the response timestamp is close to ours",
865            skew <= allowed,
866            format!("peer says {timestamp}, we say {now} — {skew}s apart, tolerance {allowed}s"),
867            SPEC,
868        );
869    }
870}
871
872/// The `last_updated` of an object, whatever module it belongs to.
873///
874/// Every OCPI object that a Sender interface lists carries one; it is what `date_from` filters on.
875fn last_updated(object: &serde_json::Value) -> Option<DateTime> {
876    object.get("last_updated")?.as_str()?.parse().ok()
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    fn check(outcome: Outcome) -> Check {
884        Check::new("t", "t", outcome, "", "spec")
885    }
886
887    #[test]
888    fn a_report_counts_by_outcome() {
889        let report = Report {
890            checks: vec![
891                check(Outcome::Pass),
892                check(Outcome::Pass),
893                check(Outcome::Fail),
894                check(Outcome::Warn),
895                check(Outcome::Skipped),
896            ],
897            version: None,
898        };
899        assert_eq!(report.count(Outcome::Pass), 2);
900        assert_eq!(report.count(Outcome::Fail), 1);
901        assert!(report.has_failures());
902        assert_eq!(report.failures().count(), 1);
903    }
904
905    #[test]
906    fn a_clean_report_has_no_failures() {
907        let report = Report { checks: vec![check(Outcome::Pass), check(Outcome::Warn)], version: None };
908        assert!(!report.has_failures(), "a warning is not a failure");
909    }
910
911    #[test]
912    fn the_summary_line_names_every_outcome() {
913        let report = Report { checks: vec![check(Outcome::Pass), check(Outcome::Fail)], version: None };
914        let text = report.to_string();
915        assert!(text.contains("1 passed"), "{text}");
916        assert!(text.contains("1 failed"), "{text}");
917    }
918
919    #[test]
920    fn only_read_only_modules_are_pulled() {
921        // A conformance run must not write, so the pull list holds no module whose Sender
922        // interface is anything but a list of objects the peer owns.
923        for module in PULLABLE {
924            assert!(
925                matches!(
926                    module,
927                    ModuleId::Locations
928                        | ModuleId::Sessions
929                        | ModuleId::Cdrs
930                        | ModuleId::Tariffs
931                        | ModuleId::Tokens
932                ),
933                "{module} is not a read-only Sender list endpoint"
934            );
935        }
936    }
937
938    #[test]
939    fn outcomes_order_worst_last_for_sorting() {
940        assert!(Outcome::Pass < Outcome::Warn);
941        assert!(Outcome::Warn < Outcome::Fail);
942    }
943}