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}