Skip to main content

tapes_client/
http.rs

1//! The HTTP engine, and the credential seam consumers plug into it.
2//!
3//! # What a consumer used to have to write
4//!
5//! [`crate::transport::TapesTransport`] is a small trait, and that made it look
6//! cheap to implement. It was not. Every implementation had to parse the verb,
7//! join the contract-relative path onto a base in the right mode, copy the
8//! headers, attach a content type when there was a body, decide what a redirect
9//! means, split the response into status, headers, and bytes, map three
10//! different failures into the crate's taxonomy, and then write the whole thing
11//! again for the streaming variant with a different rule about non-success
12//! statuses. Every consumer got that right in its own way, and the ways
13//! differed: one refused redirects, one did not; one surfaced a stream's 500 as
14//! an error, one had not been asked to yet.
15//!
16//! None of that is a consumer's decision. The only genuine difference between
17//! the implementations was **what makes a request authorised**.
18//!
19//! # The shape this module settles on
20//!
21//! [`HttpEngine`] owns the HTTP: request building, the redirect refusal, the
22//! streaming variant, the error mapping, and the retry loop. [`HttpAuth`] is
23//! what a consumer writes instead of a transport, and it is three things:
24//!
25//! 1. **The client.** Injected at construction with
26//!    [`HttpEngine::with_client`], because TLS policy is a property of the
27//!    client and not of the credential: a deployment that pins a root but sends
28//!    no credential should not have to implement an auth trait to say so, and
29//!    one that mints a token but has no TLS opinion should not have to build a
30//!    client. Omitted, the engine builds its own no-redirect client.
31//! 2. [`HttpAuth::authorize`] — the headers this attempt carries. Called once
32//!    per attempt, so a consumer that mints a fresh credential per request
33//!    keeps doing exactly that, including on the retry.
34//! 3. [`HttpAuth::on_unauthorized`] — what a rejected credential means. The
35//!    hook returns a *decision*; the engine owns the loop. That is the
36//!    difference between a retry policy that is data and one that is a
37//!    reimplemented `while` loop in every consumer, each with its own answer to
38//!    "how many times?".
39//!
40//! [`DirectHttp`] is the trivial instance: [`NoAuth`], no headers, no retry.
41//! The name and the `direct-http` feature are unchanged, because a consumer
42//! that only wanted an unauthenticated client should not have to learn that a
43//! seam appeared underneath it.
44//!
45//! # No auth is still the default
46//!
47//! The tapes read API carries no authentication of its own; tenancy is settled
48//! by the deployment before a request reaches the process. A consumer that
49//! holds a credential does so because *its* edge demands one, which is why the
50//! credential is a hook rather than a configuration field on the engine.
51//!
52//! # Redirects are refused, not followed
53//!
54//! This engine speaks to exactly the server the caller configured. Both the
55//! discovery document and a cassette's own spec are *data*, and data must not
56//! be able to steer a request — least of all one carrying a user-provided body
57//! or a credential — onto another host. The tapes API never redirects, so a 3xx
58//! is always either a misconfiguration or an attempt to move the client.
59//!
60//! The defence is two-layered on purpose: the engine's own client is built with
61//! `Policy::none`, and every response is checked to have come from the
62//! configured origin. The second layer is what holds when the client was
63//! injected by a consumer whose redirect policy is its own.
64
65use serde_json::Value;
66use snafu::ResultExt;
67use url::Url;
68
69use crate::cassettes;
70use crate::error::{Error, Result, error};
71use crate::path::{PathMode, call_url};
72use crate::transport::{
73    Call, SpecFetch, SpecTransport, StreamingTransport, TapesTransport, TransportError,
74    WireRequest, WireResponse,
75};
76
77/// How many attempts one request may cost, however eager a hook is to retry.
78///
79/// A hook that always answers [`Unauthorized::Retry`] is a bug, but an
80/// unbounded loop in a client reads as a hang — and a hang is the hardest
81/// failure to attribute to its cause. Same reasoning as the repeated-cursor
82/// guard in [`crate::page::walk`].
83const MAX_ATTEMPTS: u32 = 4;
84
85/// The status that puts the credential in question.
86///
87/// Only 401. A 403 is an authorization decision about a credential the server
88/// understood, so minting the same credential again answers nothing and turns
89/// a refusal into a retry storm.
90const UNAUTHORIZED: u16 = 401;
91
92/// One rejected attempt, as the hook sees it.
93///
94/// Deliberately not the response itself: a streaming request's body is not read
95/// before this decision is made, and handing over a half-consumed response
96/// would make the decision depend on which of the two paths asked for it.
97#[derive(Debug, Clone, Copy)]
98#[non_exhaustive]
99pub struct Rejected<'a> {
100    /// The status the server answered with — always `401` today. It is carried
101    /// rather than assumed so that a future engine which routes another status
102    /// through this hook does not have to change the hook's shape.
103    pub status: u16,
104    /// The URL that was refused, for diagnostics.
105    pub endpoint: &'a str,
106    /// Which attempt this was, counting from one.
107    pub attempt: u32,
108}
109
110/// What to do about a rejected credential.
111#[derive(Debug)]
112#[non_exhaustive]
113pub enum Unauthorized {
114    /// Send it again. [`HttpAuth::authorize`] runs first, so a hook that mints
115    /// per request gets a fresh credential without holding one itself.
116    Retry,
117    /// Hand the 401 back to the caller as the answer it is.
118    Surface,
119    /// Fail the call with this error instead of the response.
120    ///
121    /// For a consumer whose own error type says something the status cannot —
122    /// "the refresh token is spent, run the login command" is a different fact
123    /// from "401", and the one a user can act on.
124    Fail(TransportError),
125}
126
127/// What makes a request authorised, for consumers that need it to be.
128///
129/// The whole trait is two methods, one of them defaulted, because everything
130/// else a transport used to do is [`HttpEngine`]'s.
131pub trait HttpAuth {
132    /// Headers to attach to this attempt.
133    ///
134    /// Returned rather than applied to the request so the engine stays the only
135    /// thing that builds one: a hook that could edit the path or the query
136    /// would be a second route to the wire, and this crate has spent its whole
137    /// existence removing those.
138    fn authorize(
139        &self,
140        request: &WireRequest<'_>,
141        attempt: u32,
142    ) -> impl Future<Output = std::result::Result<Vec<(String, String)>, TransportError>>;
143
144    /// What a rejected credential means.
145    ///
146    /// Defaults to [`Unauthorized::Surface`]: a client with no credential to
147    /// refresh has nothing to gain from sending the same request again, and the
148    /// 401 is the server's answer.
149    fn on_unauthorized(&self, rejected: Rejected<'_>) -> impl Future<Output = Unauthorized> {
150        let _ = rejected;
151        async { Unauthorized::Surface }
152    }
153}
154
155/// The credential-free instance: no headers, no retry.
156#[derive(Debug, Clone, Copy, Default)]
157pub struct NoAuth;
158
159impl HttpAuth for NoAuth {
160    async fn authorize(
161        &self,
162        _request: &WireRequest<'_>,
163        _attempt: u32,
164    ) -> std::result::Result<Vec<(String, String)>, TransportError> {
165        Ok(Vec::new())
166    }
167}
168
169/// An HTTP transport for one tapes deployment, with the credential half left
170/// to an [`HttpAuth`].
171///
172/// `http` is `None` only if the no-redirect client could not be built at all —
173/// in which case every request errors, rather than any fallback silently
174/// following redirects.
175#[derive(Clone)]
176pub struct HttpEngine<A> {
177    http: Option<reqwest::Client>,
178    base: Url,
179    mode: PathMode,
180    auth: A,
181}
182
183impl<A> std::fmt::Debug for HttpEngine<A> {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        // The hook is a closure over a live credential source in every real
186        // implementation and has no useful Debug; the base and the join mode
187        // are the fields worth seeing in a trace.
188        f.debug_struct("HttpEngine")
189            .field("base", &self.base)
190            .field("mode", &self.mode)
191            .finish_non_exhaustive()
192    }
193}
194
195/// A direct, unauthenticated HTTP transport for one tapes server.
196///
197/// The engine with [`NoAuth`] in it. Kept as a name of its own because it is
198/// the shape most consumers want and the one this crate has always shipped.
199pub type DirectHttp = HttpEngine<NoAuth>;
200
201impl HttpEngine<NoAuth> {
202    /// Build an unauthenticated transport against `base`.
203    #[must_use]
204    pub fn new(base: Url) -> Self {
205        Self::with_auth(base, NoAuth)
206    }
207}
208
209impl<A> HttpEngine<A> {
210    /// Build a transport against `base`, authorised by `auth`.
211    ///
212    /// The join mode is [`PathMode::Direct`]; a deployment that mounts tapes
213    /// under a gateway prefix adds [`HttpEngine::under_base`].
214    #[must_use]
215    pub fn with_auth(base: Url, auth: A) -> Self {
216        // There is deliberately NO fallback client: if this build fails (which
217        // a redirect policy alone cannot cause in practice), every request
218        // errors instead of any default client quietly following redirects.
219        let http = reqwest::Client::builder()
220            .redirect(reqwest::redirect::Policy::none())
221            .build()
222            .ok();
223        Self {
224            http,
225            base,
226            mode: PathMode::Direct,
227            auth,
228        }
229    }
230
231    /// Join contract paths *under* the base's own prefix rather than at its
232    /// root.
233    ///
234    /// What a deployment that mounts tapes behind a gateway needs: a
235    /// root-absolute join would send `/v1/sessions` to the edge root — a 404 at
236    /// best, a wrong-gateway route at worst, and neither looks like a URL bug
237    /// at the call site. The base must end in a slash.
238    #[must_use]
239    pub fn under_base(mut self) -> Self {
240        self.mode = PathMode::UnderBase;
241        self
242    }
243
244    /// Send on the caller's own client rather than the engine's.
245    ///
246    /// The injection point for a TLS policy, a proxy configuration, or a
247    /// connection pool a consumer already holds. The per-response origin check
248    /// still applies, so an injected client that follows redirects cannot walk
249    /// a request onto another host unnoticed.
250    #[must_use]
251    pub fn with_client(mut self, http: reqwest::Client) -> Self {
252        self.http = Some(http);
253        self
254    }
255
256    /// The base URL, for logging.
257    #[must_use]
258    pub fn base(&self) -> &Url {
259        &self.base
260    }
261
262    /// The credential hook this engine authorises with.
263    #[must_use]
264    pub fn auth(&self) -> &A {
265        &self.auth
266    }
267
268    /// The one HTTP client, or a hard error — never a redirect-following one.
269    fn http(&self) -> Result<&reqwest::Client> {
270        self.http
271            .as_ref()
272            .ok_or_else(|| error::ClientInitSnafu.build())
273    }
274
275    /// Refuse a response that is a redirect or that a redirect produced.
276    ///
277    /// The primary defence is the client's `Policy::none`; this backstop makes
278    /// the property visible per-response: any 3xx is refused, and the origin
279    /// that answered must be the origin the caller configured. It is the only
280    /// defence when the client was injected, and the one that matters most
281    /// there, because an authorised request carries a credential.
282    ///
283    /// `304 Not Modified` is exempt. It is numerically a 3xx and semantically
284    /// the opposite of one: it carries no `Location`, moves nothing, and is the
285    /// successful answer to the conditional spec fetch the surface cache is
286    /// built around. Refusing it — which this client used to, because the
287    /// status check came first — made `SpecFetch::Unchanged` unreachable and
288    /// left the two surfaces answering the same server differently.
289    fn refuse_moved(
290        &self,
291        response: &reqwest::Response,
292    ) -> std::result::Result<(), TransportError> {
293        if response.status().is_redirection()
294            && response.status() != reqwest::StatusCode::NOT_MODIFIED
295        {
296            return Err(TransportError::new(
297                "the server answered with a redirect; this client does not follow them",
298            ));
299        }
300        if response.url().origin() != self.base.origin() {
301            return Err(TransportError::new(
302                "the response came from a different origin than the configured server",
303            ));
304        }
305        Ok(())
306    }
307}
308
309impl<A: HttpAuth> HttpEngine<A> {
310    /// Build and send one attempt, without reading the response body.
311    async fn attempt(
312        &self,
313        call: &WireRequest<'_>,
314        url: &Url,
315        attempt: u32,
316    ) -> std::result::Result<reqwest::Response, TransportError> {
317        // A verb the contract declared that reqwest will not parse is refused,
318        // not defaulted. Falling back to GET would turn a mutating operation
319        // into a read that quietly succeeds against the wrong route.
320        let method = reqwest::Method::from_bytes(call.method.as_bytes())
321            .map_err(|_| TransportError::new(format!("unusable HTTP method {:?}", call.method)))?;
322
323        let client = self
324            .http()
325            .map_err(|error| TransportError::new(error.to_string()))?;
326        let mut request = client.request(method, url.clone());
327        for (name, value) in &call.headers {
328            request = request.header(name, value);
329        }
330        for (name, value) in self.auth.authorize(call, attempt).await? {
331            request = request.header(name, value);
332        }
333        if let Some(body) = &call.body {
334            request = request
335                .header(http::header::CONTENT_TYPE, "application/json")
336                .body(body.clone());
337        }
338
339        let response = request.send().await.map_err(|source| {
340            TransportError::with_source("could not reach the tapes API", source)
341        })?;
342        // Described calls can carry user-provided bodies, headers, and a
343        // credential, so of all requests this client makes these are the ones a
344        // redirect must never be able to move.
345        self.refuse_moved(&response)?;
346        Ok(response)
347    }
348
349    /// Send one described call, running the credential hook's retry policy.
350    ///
351    /// The loop is here, once, rather than in every consumer that needs one.
352    async fn send_call(
353        &self,
354        call: &WireRequest<'_>,
355    ) -> std::result::Result<(reqwest::Response, Url), TransportError> {
356        let url = call_url(&self.base, call, self.mode)
357            .map_err(|error| TransportError::new(error.to_string()))?;
358
359        let endpoint = url.to_string();
360        for attempt in 1..=MAX_ATTEMPTS {
361            let response = self.attempt(call, &url, attempt).await?;
362            if response.status().as_u16() != UNAUTHORIZED {
363                return Ok((response, url));
364            }
365            // The response is held, unread, while the hook decides: a surfaced
366            // 401 must reach the caller with the body that explains it, and a
367            // bare status is a fact without a reason.
368            let decision = self
369                .auth
370                .on_unauthorized(Rejected {
371                    status: UNAUTHORIZED,
372                    endpoint: &endpoint,
373                    attempt,
374                })
375                .await;
376            match decision {
377                Unauthorized::Retry if attempt < MAX_ATTEMPTS => {
378                    // Dropped here so the refused body and its connection are
379                    // released before the next attempt goes out.
380                    drop(response);
381                }
382                Unauthorized::Retry => {
383                    return Err(TransportError::new(format!(
384                        "the credential was refused {MAX_ATTEMPTS} times running",
385                    )));
386                }
387                Unauthorized::Surface => return Ok((response, url)),
388                Unauthorized::Fail(error) => return Err(error),
389            }
390        }
391        Err(TransportError::new("no attempt was made"))
392    }
393
394    /// `GET /v1/cassettes` — the cassette discovery document, raw.
395    ///
396    /// # Errors
397    ///
398    /// Any transport, status, or decode failure; see [`crate::Error`].
399    pub async fn fetch_discovery(&self) -> Result<Value> {
400        cassettes::fetch_discovery(self).await
401    }
402
403    /// `GET /v1/cassettes/{name}/openapi.json` — one cassette's own document.
404    ///
405    /// `path` is the `openapi_path` discovery published rather than a path this
406    /// client builds, so the server stays free to move the route; it is
407    /// therefore untrusted, and refused unless it is plainly server-relative.
408    ///
409    /// # Errors
410    ///
411    /// Any transport, status, or decode failure; see [`crate::Error`].
412    pub async fn fetch_spec(&self, path: &str, etag: Option<&str>) -> Result<SpecFetch> {
413        cassettes::fetch_spec(self, path, etag).await
414    }
415
416    /// Make one call described by an OpenAPI document and decode the JSON.
417    ///
418    /// # Errors
419    ///
420    /// Any transport, status, or decode failure; see [`crate::Error`].
421    pub async fn execute(&self, call: &Call<'_>) -> Result<Value> {
422        cassettes::invoke(self, call).await
423    }
424
425    /// Make one described call and hand back the live response for streaming.
426    ///
427    /// A non-success status is read and surfaced as [`Error::ApiStatus`] here,
428    /// so a caller streaming to a file can never write an error page into it.
429    ///
430    /// # Errors
431    ///
432    /// [`Error::ApiStatus`] for any non-success answer, or the transport's own
433    /// failure.
434    pub async fn execute_stream(&self, call: &Call<'_>) -> Result<reqwest::Response> {
435        let (response, url) = self.send_call(call).await.context(error::TransportSnafu)?;
436        let status = response.status();
437        if !status.is_success() {
438            let body = response.text().await.unwrap_or_default();
439            return Err(Error::ApiStatus {
440                status: status.as_u16(),
441                endpoint: url.to_string(),
442                body,
443            });
444        }
445        Ok(response)
446    }
447}
448
449impl<A: HttpAuth> TapesTransport for HttpEngine<A> {
450    async fn send(
451        &self,
452        request: &WireRequest<'_>,
453    ) -> std::result::Result<WireResponse, TransportError> {
454        let (response, url) = self.send_call(request).await?;
455        let status = response.status().as_u16();
456        let headers = response
457            .headers()
458            .iter()
459            .filter_map(|(name, value)| {
460                value
461                    .to_str()
462                    .ok()
463                    .map(|value| (name.as_str().to_owned(), value.to_owned()))
464            })
465            .collect();
466        let body = response
467            .bytes()
468            .await
469            .map_err(|source| TransportError::with_source("could not read the response", source))?
470            .to_vec();
471        // The status travels rather than being judged here: a non-success body
472        // is what names the offending parameter, and `decode` above this layer
473        // is the one place that turns it into an error.
474        Ok(WireResponse::new(status, url.to_string(), headers, body))
475    }
476}
477
478impl<A: HttpAuth> StreamingTransport for HttpEngine<A> {
479    type Body = reqwest::Response;
480
481    async fn send_stream(&self, request: &WireRequest<'_>) -> Result<Self::Body> {
482        self.execute_stream(request).await
483    }
484}
485
486impl<A: HttpAuth> SpecTransport for HttpEngine<A> {
487    type Error = Error;
488
489    async fn fetch_discovery(&self) -> Result<Value> {
490        Self::fetch_discovery(self).await
491    }
492
493    async fn fetch_spec(&self, path: &str, etag: Option<&str>) -> Result<SpecFetch> {
494        Self::fetch_spec(self, path, etag).await
495    }
496
497    async fn execute(&self, call: &Call<'_>) -> Result<Value> {
498        Self::execute(self, call).await
499    }
500}
501
502#[cfg(test)]
503#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
504mod tests {
505    use super::*;
506    use std::sync::Arc;
507    use std::sync::atomic::{AtomicU32, Ordering};
508    use wiremock::matchers::{header, method, path};
509    use wiremock::{Mock, MockServer, ResponseTemplate};
510
511    fn base(raw: &str) -> DirectHttp {
512        DirectHttp::new(Url::parse(raw).unwrap())
513    }
514
515    /// A hook shaped like the one a credentialled consumer writes: mint a token
516    /// per attempt, retry a 401 once, then say something the status cannot.
517    struct Minting {
518        mints: Arc<AtomicU32>,
519        retries: u32,
520    }
521
522    impl HttpAuth for Minting {
523        async fn authorize(
524            &self,
525            _request: &WireRequest<'_>,
526            attempt: u32,
527        ) -> std::result::Result<Vec<(String, String)>, TransportError> {
528            self.mints.fetch_add(1, Ordering::SeqCst);
529            Ok(vec![(
530                "x-tapes-auth".to_owned(),
531                format!("Bearer token-{attempt}"),
532            )])
533        }
534
535        async fn on_unauthorized(&self, rejected: Rejected<'_>) -> Unauthorized {
536            if rejected.attempt <= self.retries {
537                Unauthorized::Retry
538            } else {
539                Unauthorized::Fail(TransportError::new(
540                    "not authenticated; run the login command",
541                ))
542            }
543        }
544    }
545
546    /// A hook that never accepts a refusal, so the engine's own cap is the
547    /// only thing that ends the call.
548    struct Forever;
549
550    impl HttpAuth for Forever {
551        async fn authorize(
552            &self,
553            _request: &WireRequest<'_>,
554            _attempt: u32,
555        ) -> std::result::Result<Vec<(String, String)>, TransportError> {
556            Ok(Vec::new())
557        }
558
559        async fn on_unauthorized(&self, _rejected: Rejected<'_>) -> Unauthorized {
560            Unauthorized::Retry
561        }
562    }
563
564    fn minting(server: &MockServer, retries: u32) -> (HttpEngine<Minting>, Arc<AtomicU32>) {
565        let mints = Arc::new(AtomicU32::new(0));
566        let engine = HttpEngine::with_auth(
567            Url::parse(&server.uri()).unwrap(),
568            Minting {
569                mints: Arc::clone(&mints),
570                retries,
571            },
572        );
573        (engine, mints)
574    }
575
576    #[tokio::test]
577    async fn a_hooks_headers_ride_the_request_the_engine_built() {
578        // The division of labour, in one assertion: the engine resolved the
579        // path and sent it, the hook only said what makes it authorised.
580        let server = MockServer::start().await;
581        Mock::given(method("GET"))
582            .and(path("/v1/sessions"))
583            .and(header("x-tapes-auth", "Bearer token-1"))
584            .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
585            .mount(&server)
586            .await;
587
588        let (engine, mints) = minting(&server, 1);
589        let response = engine
590            .send(&WireRequest {
591                method: "GET",
592                path: "/v1/sessions",
593                ..Default::default()
594            })
595            .await
596            .unwrap();
597        assert_eq!(response.status, 200);
598        assert_eq!(mints.load(Ordering::SeqCst), 1);
599    }
600
601    #[tokio::test]
602    async fn a_401_is_retried_once_with_a_freshly_authorised_request() {
603        // The policy is data — the hook said "retry" — and the loop that acts
604        // on it is the engine's, so no consumer writes one again.
605        let server = MockServer::start().await;
606        Mock::given(method("GET"))
607            .and(path("/v1/sessions"))
608            .respond_with(ResponseTemplate::new(401))
609            .up_to_n_times(1)
610            .mount(&server)
611            .await;
612        Mock::given(method("GET"))
613            .and(path("/v1/sessions"))
614            .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
615            .mount(&server)
616            .await;
617
618        let (engine, mints) = minting(&server, 1);
619        let response = engine
620            .send(&WireRequest {
621                method: "GET",
622                path: "/v1/sessions",
623                ..Default::default()
624            })
625            .await
626            .unwrap();
627        assert_eq!(response.status, 200);
628        assert_eq!(
629            mints.load(Ordering::SeqCst),
630            2,
631            "the retry was not authorised again"
632        );
633    }
634
635    #[tokio::test]
636    async fn an_exhausted_retry_fails_with_the_hooks_own_words() {
637        // "run the login command" is a fact a user can act on; "401" is not.
638        let server = MockServer::start().await;
639        Mock::given(method("GET"))
640            .and(path("/v1/sessions"))
641            .respond_with(ResponseTemplate::new(401))
642            .mount(&server)
643            .await;
644
645        let (engine, _) = minting(&server, 1);
646        let err = engine
647            .send(&WireRequest {
648                method: "GET",
649                path: "/v1/sessions",
650                ..Default::default()
651            })
652            .await
653            .unwrap_err();
654        assert!(err.to_string().contains("login"), "got: {err}");
655    }
656
657    #[tokio::test]
658    async fn a_hook_that_never_gives_up_is_stopped_by_the_engine() {
659        // An unbounded loop in a client reads as a hang, which is the hardest
660        // failure to attribute. The cap is the engine's, not the hook's.
661        let server = MockServer::start().await;
662        Mock::given(method("GET"))
663            .and(path("/v1/sessions"))
664            .respond_with(ResponseTemplate::new(401))
665            .mount(&server)
666            .await;
667
668        let engine = HttpEngine::with_auth(Url::parse(&server.uri()).unwrap(), Forever);
669        let err = engine
670            .send(&WireRequest {
671                method: "GET",
672                path: "/v1/sessions",
673                ..Default::default()
674            })
675            .await
676            .unwrap_err();
677        assert!(err.to_string().contains("times running"), "got: {err}");
678        assert_eq!(
679            server.received_requests().await.unwrap_or_default().len(),
680            MAX_ATTEMPTS as usize,
681        );
682    }
683
684    #[tokio::test]
685    async fn a_surfaced_401_reaches_the_caller_with_the_body_that_explains_it() {
686        // The default hook's answer. A bare status is a fact without a reason;
687        // the body is where the server names what was wrong.
688        let server = MockServer::start().await;
689        Mock::given(method("GET"))
690            .and(path("/v1/sessions"))
691            .respond_with(ResponseTemplate::new(401).set_body_string(r#"{"error":"no tenant"}"#))
692            .mount(&server)
693            .await;
694
695        let response = base(&server.uri())
696            .send(&WireRequest {
697                method: "GET",
698                path: "/v1/sessions",
699                ..Default::default()
700            })
701            .await
702            .unwrap();
703        assert_eq!(response.status, 401);
704        assert_eq!(response.body, br#"{"error":"no tenant"}"#.to_vec());
705    }
706
707    #[tokio::test]
708    async fn an_under_base_join_lands_beneath_the_gateway_prefix() {
709        // The other half of what a gateway-fronted deployment needs, and the
710        // reason the mode is a builder rather than a second engine.
711        let server = MockServer::start().await;
712        Mock::given(method("GET"))
713            .and(path("/primary/tapes/v1/sessions/s-1/traces"))
714            .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
715            .mount(&server)
716            .await;
717
718        let engine =
719            DirectHttp::new(Url::parse(&format!("{}/primary/tapes/", server.uri())).unwrap())
720                .under_base();
721        let response = engine
722            .send(&WireRequest {
723                method: "GET",
724                path: "/v1/sessions/{id}/traces",
725                path_params: vec![("id".to_owned(), "s-1".to_owned())],
726                ..Default::default()
727            })
728            .await
729            .unwrap();
730        assert_eq!(response.status, 200);
731    }
732
733    #[tokio::test]
734    async fn an_injected_client_is_the_one_that_sends() {
735        // The TLS/proxy injection point. Proved by giving the engine a client
736        // that cannot reach anything but the mock, and seeing the request land.
737        let server = MockServer::start().await;
738        Mock::given(method("GET"))
739            .and(path("/v1/cassettes"))
740            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"cassettes":[]}"#))
741            .mount(&server)
742            .await;
743
744        let client = reqwest::Client::builder().no_proxy().build().unwrap();
745        let engine = DirectHttp::new(Url::parse(&server.uri()).unwrap()).with_client(client);
746        assert_eq!(
747            engine.fetch_discovery().await.unwrap()["cassettes"],
748            serde_json::json!([]),
749        );
750    }
751
752    #[tokio::test]
753    async fn a_spec_path_may_not_change_the_request_authority() {
754        // `//host/path` is protocol-relative: it survives a naive
755        // leading-slash check while a join moves the request onto a different
756        // host. Refused before anything is sent.
757        let client = base("http://tapes.local:8081");
758        for path in ["//evil.example/spec.json", "relative/spec.json", ""] {
759            let err = client.fetch_spec(path, None).await.unwrap_err();
760            assert!(
761                err.to_string().contains("non-relative OpenAPI path"),
762                "{path:?} produced the wrong error: {err}",
763            );
764        }
765    }
766
767    #[tokio::test]
768    async fn a_redirected_spec_fetch_may_not_leave_the_configured_origin() {
769        // The URL guards validate what this client builds; a 30x can still try
770        // to walk the request onto another host. The redirect is refused
771        // outright, so the foreign host never sees a request at all.
772        let elsewhere = MockServer::start().await;
773        Mock::given(method("GET"))
774            .and(path("/spec.json"))
775            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
776                "openapi": "3.1.0"
777            })))
778            .mount(&elsewhere)
779            .await;
780        let server = MockServer::start().await;
781        Mock::given(method("GET"))
782            .and(path("/v1/cassettes/x/openapi.json"))
783            .respond_with(ResponseTemplate::new(302).insert_header(
784                "location",
785                format!("{}/spec.json", elsewhere.uri()).as_str(),
786            ))
787            .mount(&server)
788            .await;
789
790        let client = base(&server.uri());
791        let err = client
792            .fetch_spec("/v1/cassettes/x/openapi.json", None)
793            .await
794            .unwrap_err();
795        assert!(err.to_string().contains("redirect"), "wrong error: {err}");
796        assert!(
797            elsewhere
798                .received_requests()
799                .await
800                .unwrap_or_default()
801                .is_empty(),
802            "the foreign host must never see a request",
803        );
804    }
805
806    #[tokio::test]
807    async fn a_matched_validator_reads_as_unchanged_over_real_http() {
808        // The behaviour this crate unified: a 304 is a successful conditional
809        // answer, not a redirect to refuse. Pinned here as well as at the
810        // surface-agnostic layer, because the redirect backstop lives in this
811        // transport and is the thing that used to swallow it.
812        let server = MockServer::start().await;
813        Mock::given(method("GET"))
814            .and(path("/v1/cassettes/x/openapi.json"))
815            .and(header("if-none-match", "\"sha256:abc\""))
816            .respond_with(ResponseTemplate::new(304))
817            .mount(&server)
818            .await;
819
820        let got = base(&server.uri())
821            .fetch_spec("/v1/cassettes/x/openapi.json", Some("\"sha256:abc\""))
822            .await
823            .unwrap();
824        assert!(matches!(got, SpecFetch::Unchanged), "got: {got:?}");
825    }
826
827    #[tokio::test]
828    async fn an_error_body_is_surfaced_with_the_status() {
829        let server = MockServer::start().await;
830        Mock::given(method("GET"))
831            .and(path("/v1/cassettes"))
832            .respond_with(
833                ResponseTemplate::new(400).set_body_string(r#"{"error":"invalid cursor"}"#),
834            )
835            .mount(&server)
836            .await;
837
838        let err = base(&server.uri()).fetch_discovery().await.unwrap_err();
839        let rendered = format!("{err}");
840        assert!(rendered.contains("400"), "got: {rendered}");
841        assert!(rendered.contains("invalid cursor"), "got: {rendered}");
842    }
843
844    #[tokio::test]
845    async fn a_successful_empty_body_is_null_not_a_decode_failure() {
846        let server = MockServer::start().await;
847        Mock::given(method("GET"))
848            .and(path("/v1/cassettes"))
849            .respond_with(ResponseTemplate::new(204))
850            .mount(&server)
851            .await;
852
853        let got = base(&server.uri()).fetch_discovery().await.unwrap();
854        assert_eq!(got, Value::Null);
855    }
856
857    #[tokio::test]
858    async fn a_stream_refuses_a_non_success_status() {
859        // An export writes its body to a file. If a 500's error page were
860        // handed back as a readable stream, that page would be the file.
861        let server = MockServer::start().await;
862        Mock::given(method("GET"))
863            .and(path("/v1/sessions/s-1/export"))
864            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
865            .mount(&server)
866            .await;
867
868        let err = base(&server.uri())
869            .execute_stream(&WireRequest {
870                method: "GET",
871                path: "/v1/sessions/{id}/export",
872                path_params: vec![("id".to_owned(), "s-1".to_owned())],
873                ..Default::default()
874            })
875            .await
876            .unwrap_err();
877        assert!(
878            matches!(err, Error::ApiStatus { status: 500, .. }),
879            "got {err:?}",
880        );
881    }
882
883    #[tokio::test]
884    async fn the_sealed_surface_rides_the_same_transport() {
885        // The property the whole crate exists for: a sealed-contract call and
886        // a cassette call are the same request through the same pipeline.
887        use crate::core::CoreClient;
888
889        let server = MockServer::start().await;
890        Mock::given(method("GET"))
891            .and(path("/v1/sessions"))
892            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"items":[{"id":"s1"}]}"#))
893            .mount(&server)
894            .await;
895
896        let client = CoreClient::new(base(&server.uri()));
897        let got: Value = client.call("listSessions", Vec::new()).await.unwrap();
898        assert_eq!(got["items"][0]["id"], "s1");
899    }
900}