1#[cfg(feature = "recording")]
52use std::collections::HashMap;
53#[cfg(feature = "recording")]
54use std::path::PathBuf;
55use std::sync::Arc;
56#[cfg(feature = "recording")]
57use std::sync::atomic::{AtomicU64, Ordering};
58use std::time::Duration;
59
60use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
61use reqwest::{Body, Client, IntoUrl, Method, RequestBuilder};
62use serde::Serialize;
63use tokio::runtime::Handle;
64
65use self::retry::RetryExt;
66use self::service::{HttpService, make_service};
67pub use self::token::{TemporaryToken, TokenCache};
68
69pub mod aws;
70pub mod azure;
71mod backoff;
72mod client;
73mod config;
74#[cfg(feature = "connectrpc")]
75pub mod connectrpc;
76mod credential;
77pub mod databricks;
78mod error;
79pub mod gcp;
80pub mod service;
81
82mod retry;
83mod token;
84mod util;
85
86pub use client::{Certificate, ClientConfigKey, ClientOptions};
87pub use credential::*;
88pub use error::*;
89pub use retry::RetryConfig;
90pub use service::{ReqwestService, SpawnService};
91
92type SharedSigner = Arc<dyn RequestSigner>;
94
95#[derive(Debug)]
97struct NoopSigner;
98
99impl RequestSigner for NoopSigner {
100 fn sign<'a>(
101 &'a self,
102 req: RequestBuilder,
103 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
104 Box::pin(async move { Ok(req) })
105 }
106}
107
108#[derive(Debug)]
110struct BearerTokenSigner {
111 token: String,
112}
113
114impl RequestSigner for BearerTokenSigner {
115 fn sign<'a>(
116 &'a self,
117 req: RequestBuilder,
118 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
119 let token = self.token.clone();
120 Box::pin(async move { Ok(req.bearer_auth(&token)) })
121 }
122}
123
124#[cfg(feature = "recording")]
125#[derive(Debug, Clone)]
126struct RecordingState {
127 out_dir: PathBuf,
128 counter: Arc<AtomicU64>,
129}
130
131#[derive(Clone)]
138pub struct CloudClient {
139 signer: SharedSigner,
140 reqwest_client: Client,
141 service: Arc<dyn HttpService>,
142 pub retry_config: RetryConfig,
149 #[cfg(feature = "recording")]
150 recording: Option<RecordingState>,
151}
152
153impl CloudClient {
154 fn new_with_signer(
155 signer: SharedSigner,
156 reqwest_client: Client,
157 service: Arc<dyn HttpService>,
158 retry_config: RetryConfig,
159 ) -> Self {
160 Self {
161 signer,
162 reqwest_client,
163 service,
164 retry_config,
165 #[cfg(feature = "recording")]
166 recording: None,
167 }
168 }
169
170 pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
175 where
176 I: IntoIterator<Item = (K, V)>,
177 K: AsRef<str>,
178 V: Into<String>,
179 {
180 let config = options
181 .into_iter()
182 .fold(
183 aws::AmazonBuilder::new(),
184 |builder, (key, value)| match key.as_ref().parse() {
185 Ok(k) => builder.with_config(k, value),
186 Err(_) => builder,
187 },
188 )
189 .build(runtime)?;
190
191 let reqwest_client = config.client_options.client()?;
192 let service = make_service(reqwest_client.clone(), runtime);
193 let retry_config = config.retry_config.clone();
194 Ok(Self::new_with_signer(
195 Arc::new(config),
196 reqwest_client,
197 service,
198 retry_config,
199 ))
200 }
201
202 pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
207 where
208 I: IntoIterator<Item = (K, V)>,
209 K: AsRef<str>,
210 V: Into<String>,
211 {
212 let config = options
213 .into_iter()
214 .fold(
215 gcp::GoogleBuilder::new(),
216 |builder, (key, value)| match key.as_ref().parse() {
217 Ok(k) => builder.with_config(k, value),
218 Err(_) => builder,
219 },
220 )
221 .build(runtime)?;
222
223 let reqwest_client = config.client_options.client()?;
224 let service = make_service(reqwest_client.clone(), runtime);
225 let retry_config = config.retry_config.clone();
226 Ok(Self::new_with_signer(
227 Arc::new(config),
228 reqwest_client,
229 service,
230 retry_config,
231 ))
232 }
233
234 pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
239 where
240 I: IntoIterator<Item = (K, V)>,
241 K: AsRef<str>,
242 V: Into<String>,
243 {
244 let config = options
245 .into_iter()
246 .fold(
247 azure::AzureBuilder::new(),
248 |builder, (key, value)| match key.as_ref().parse() {
249 Ok(k) => builder.with_config(k, value),
250 Err(_) => builder,
251 },
252 )
253 .build(runtime)?;
254
255 let reqwest_client = config.client_options.client()?;
256 let service = make_service(reqwest_client.clone(), runtime);
257 let retry_config = config.retry_config.clone();
258 Ok(Self::new_with_signer(
259 Arc::new(config),
260 reqwest_client,
261 service,
262 retry_config,
263 ))
264 }
265
266 pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
271 where
272 I: IntoIterator<Item = (K, V)>,
273 K: AsRef<str>,
274 V: Into<String>,
275 {
276 use databricks::DatabricksBuilder;
277
278 let config = options
279 .into_iter()
280 .fold(
281 DatabricksBuilder::new(),
282 |builder, (key, value)| match key.as_ref().parse() {
283 Ok(k) => builder.with_config(k, value),
284 Err(_) => builder,
285 },
286 )
287 .build(runtime)?;
288
289 let reqwest_client = config.client_options.client()?;
290 let service = make_service(reqwest_client.clone(), runtime);
291 let retry_config = config.retry_config.clone();
292 Ok(Self::new_with_signer(
293 Arc::new(config),
294 reqwest_client,
295 service,
296 retry_config,
297 ))
298 }
299
300 pub fn new_with_token(token: impl ToString) -> Self {
302 let reqwest_client = Client::new();
303 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
304 Self::new_with_signer(
305 Arc::new(BearerTokenSigner {
306 token: token.to_string(),
307 }),
308 reqwest_client,
309 service,
310 RetryConfig::default(),
311 )
312 }
313
314 pub fn new_unauthenticated() -> Self {
316 let reqwest_client = Client::new();
317 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
318 Self::new_with_signer(
319 Arc::new(NoopSigner),
320 reqwest_client,
321 service,
322 RetryConfig::default(),
323 )
324 }
325
326 pub fn with_runtime(mut self, handle: Handle) -> Self {
332 self.service = Arc::new(SpawnService::new(self.service, handle));
333 self
334 }
335
336 pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
338 self.service = service;
339 self
340 }
341
342 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
350 self.retry_config = retry_config;
351 self
352 }
353
354 pub async fn sign_and_send(&self, request: reqwest::Request) -> Result<reqwest::Response> {
372 let builder = self
376 .reqwest_client
377 .request(request.method().clone(), request.url().clone());
378 let builder = builder.headers(request.headers().clone());
379 let builder = match request.body().and_then(|b| b.as_bytes()) {
380 Some(bytes) => builder.body(bytes.to_vec()),
381 None => builder,
382 };
383 let builder = self.signer.sign(builder).await?;
384 builder
385 .send_retry(&self.retry_config, self.service.clone())
386 .await
387 .map_err(|e| e.error())
388 }
389
390 pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
396 CloudRequestBuilder {
397 builder: self.reqwest_client.request(method, url),
398 client: self.clone(),
399 #[cfg(feature = "recording")]
400 out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
401 }
402 }
403
404 pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
406 self.request(Method::GET, url)
407 }
408
409 pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
411 self.request(Method::POST, url)
412 }
413
414 pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
416 self.request(Method::PUT, url)
417 }
418
419 pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
421 self.request(Method::DELETE, url)
422 }
423
424 pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
426 self.request(Method::HEAD, url)
427 }
428
429 pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
431 self.request(Method::PATCH, url)
432 }
433
434 pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
436 self.request(Method::OPTIONS, url)
437 }
438
439 pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
441 self.request(Method::TRACE, url)
442 }
443
444 pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
446 self.request(Method::CONNECT, url)
447 }
448
449 #[cfg(feature = "recording")]
468 pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
469 let out_dir = std::fs::canonicalize(out_dir)?;
470 self.recording = Some(RecordingState {
471 out_dir,
472 counter: Arc::new(AtomicU64::new(0)),
473 });
474 Ok(())
475 }
476}
477
478pub struct CloudRequestBuilder {
484 builder: RequestBuilder,
485 client: CloudClient,
486 #[cfg(feature = "recording")]
487 out_dir: Option<PathBuf>,
488}
489
490impl CloudRequestBuilder {
491 pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
493 where
494 HeaderName: TryFrom<K>,
495 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
496 HeaderValue: TryFrom<V>,
497 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
498 {
499 self.builder = self.builder.header(key, value);
500 self
501 }
502
503 pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
507 self.builder = self.builder.headers(headers);
508 self
509 }
510
511 pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
513 self.builder = self.builder.body(body);
514 self
515 }
516
517 pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
523 self.builder = self.builder.timeout(timeout);
524 self
525 }
526
527 pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
546 self.builder = self.builder.query(query);
547 self
548 }
549
550 pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
557 self.builder = self.builder.json(json);
558 self
559 }
560
561 pub async fn send(mut self) -> Result<reqwest::Response> {
584 self.builder = self.client.signer.sign(self.builder).await?;
585
586 #[cfg(not(feature = "recording"))]
587 {
588 self.builder
589 .send_retry(&self.client.retry_config, self.client.service.clone())
590 .await
591 .map_err(|e| e.error())
592 }
593 #[cfg(feature = "recording")]
594 {
595 let response = send_record(self).await?;
596 Ok(response)
597 }
598 }
599}
600
601#[cfg(feature = "recording")]
602#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
603pub struct RequestResponseInfo {
604 pub request: RequestInfo,
605 pub response: ResponseInfo,
606}
607
608#[cfg(feature = "recording")]
609#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
610pub struct RequestInfo {
611 pub method: String,
612 pub url_path: String,
613 pub body: Option<String>,
614}
615
616#[cfg(feature = "recording")]
617#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
618pub struct ResponseInfo {
619 pub status: u16,
620 pub headers: HashMap<String, String>,
621 pub body: Option<String>,
622}
623
624#[cfg(feature = "recording")]
629const SENSITIVE_HEADERS: &[&str] = &[
630 "authorization",
631 "x-amz-security-token",
632 "x-amz-content-sha256",
633 "x-databricks-authorization",
634 "x-ms-identity-principal-id",
635 "x-goog-iam-credentials-token",
636 "cookie",
637 "set-cookie",
638];
639
640#[cfg(feature = "recording")]
641fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
642 headers
643 .iter()
644 .map(|(k, v)| {
645 let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
646 "<REDACTED>".to_string()
647 } else {
648 v.clone()
649 };
650 (k.clone(), v)
651 })
652 .collect()
653}
654
655#[cfg(feature = "recording")]
656async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response> {
657 let Some(out_dir) = builder.out_dir else {
658 let request = builder.builder.build().expect("request to be valid");
659 return builder.client.service.call(request).await;
660 };
661 let (_client, request) = builder.builder.build_split();
662 let request = request.expect("request to be valid");
663
664 let request_info = RequestInfo {
665 method: request.method().as_str().to_string(),
666 url_path: {
667 let url = request.url();
668 match url.query() {
669 Some(query) => format!("{}?{}", url.path(), query),
670 None => url.path().to_string(),
671 }
672 },
673 body: request
674 .body()
675 .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
676 };
677
678 let response = builder.client.service.call(request).await?;
679
680 let status = response.status().as_u16();
682 let raw_headers: HashMap<String, String> = response
683 .headers()
684 .iter()
685 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
686 .collect();
687
688 let response_bytes = response.bytes().await?;
690 let response_body = if response_bytes.is_empty() {
691 None
692 } else {
693 Some(String::from_utf8_lossy(&response_bytes).to_string())
694 };
695
696 let recording = RequestResponseInfo {
697 request: request_info,
698 response: ResponseInfo {
699 status,
700 headers: redact_headers(&raw_headers),
702 body: response_body,
703 },
704 };
705
706 let counter = builder
707 .client
708 .recording
709 .as_ref()
710 .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
711 .unwrap_or(0);
712 let file_path = out_dir.join(format!("{:04}.json", counter));
713 if let Err(e) = std::fs::File::create(&file_path)
714 .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
715 {
716 tracing::warn!(
717 "Failed to write recording to {}: {}",
718 file_path.display(),
719 e
720 );
721 }
722
723 let mut mock_response = http::Response::builder().status(status);
725 for (k, v) in &raw_headers {
726 mock_response = mock_response.header(k, v);
727 }
728 let mock_response = mock_response
729 .body(response_bytes)
730 .expect("valid status code and headers");
731
732 Ok(reqwest::Response::from(mock_response))
733}
734
735#[cfg(all(test, feature = "recording"))]
736mod tests {
737 use super::*;
738 use std::fs;
739 use tempfile::TempDir;
740
741 #[tokio::test]
742 async fn test_request_response_recording() {
743 let temp_dir = TempDir::new().unwrap();
745 let temp_path = temp_dir.path().to_path_buf();
746
747 let mut server = mockito::Server::new_async().await;
749 let mock = server
750 .mock("GET", "/test")
751 .with_status(200)
752 .with_header("content-type", "application/json")
753 .with_body(r#"{"message": "Hello, World!"}"#)
754 .create_async()
755 .await;
756
757 let mut client = CloudClient::new_unauthenticated();
759 client.set_recording_dir(temp_path.clone()).unwrap();
760
761 let url = format!("{}/test", server.url());
763 let response = client.get(&url).send().await.unwrap();
764
765 assert_eq!(response.status(), 200);
767 let body = response.text().await.unwrap();
768 assert_eq!(body, r#"{"message": "Hello, World!"}"#);
769
770 let recordings: Vec<_> = fs::read_dir(&temp_path)
772 .unwrap()
773 .filter_map(|entry| {
774 let entry = entry.ok()?;
775 let path = entry.path();
776 if path.extension()? == "json" {
777 Some(path)
778 } else {
779 None
780 }
781 })
782 .collect();
783
784 assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
785
786 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
788 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
789
790 assert_eq!(recording.request.method, "GET");
792 assert_eq!(recording.request.url_path, "/test");
793 assert_eq!(recording.request.body, None);
794
795 assert_eq!(recording.response.status, 200);
797 assert_eq!(
798 recording.response.headers.get("content-type").unwrap(),
799 "application/json"
800 );
801 assert_eq!(
802 recording.response.body.as_ref().unwrap(),
803 r#"{"message": "Hello, World!"}"#
804 );
805
806 mock.assert_async().await;
807 }
808
809 #[tokio::test]
810 async fn test_recording_with_request_body() {
811 let temp_dir = TempDir::new().unwrap();
813 let temp_path = temp_dir.path().to_path_buf();
814
815 let mut server = mockito::Server::new_async().await;
817 let mock = server
818 .mock("POST", "/create")
819 .with_status(201)
820 .with_header("location", "/resource/123")
821 .with_body(r#"{"id": 123, "status": "created"}"#)
822 .create_async()
823 .await;
824
825 let mut client = CloudClient::new_unauthenticated();
827 client.set_recording_dir(temp_path.clone()).unwrap();
828
829 let url = format!("{}/create", server.url());
831 let response = client
832 .post(&url)
833 .json(&serde_json::json!({"name": "test resource"}))
834 .send()
835 .await
836 .unwrap();
837
838 assert_eq!(response.status(), 201);
840
841 let recordings: Vec<_> = fs::read_dir(&temp_path)
843 .unwrap()
844 .filter_map(|entry| {
845 let entry = entry.ok()?;
846 let path = entry.path();
847 if path.extension()? == "json" {
848 Some(path)
849 } else {
850 None
851 }
852 })
853 .collect();
854
855 assert_eq!(recordings.len(), 1);
856
857 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
859 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
860
861 assert_eq!(recording.request.method, "POST");
863 assert_eq!(recording.request.url_path, "/create");
864 assert!(recording.request.body.is_some());
865 assert!(recording.request.body.unwrap().contains("test resource"));
866
867 assert_eq!(recording.response.status, 201);
869 assert_eq!(
870 recording.response.headers.get("location").unwrap(),
871 "/resource/123"
872 );
873 assert!(recording.response.body.unwrap().contains("created"));
874
875 mock.assert_async().await;
876 }
877
878 #[tokio::test]
879 async fn test_counter_based_file_naming() {
880 let temp_dir = TempDir::new().unwrap();
882 let temp_path = temp_dir.path().to_path_buf();
883
884 let mut server = mockito::Server::new_async().await;
886 let mock1 = server
887 .mock("GET", "/first")
888 .with_status(200)
889 .with_body("first response")
890 .create_async()
891 .await;
892 let mock2 = server
893 .mock("GET", "/second")
894 .with_status(200)
895 .with_body("second response")
896 .create_async()
897 .await;
898
899 let mut client = CloudClient::new_unauthenticated();
901 client.set_recording_dir(temp_path.clone()).unwrap();
902
903 let url1 = format!("{}/first", server.url());
905 let url2 = format!("{}/second", server.url());
906
907 let _response1 = client.get(&url1).send().await.unwrap();
908 let _response2 = client.get(&url2).send().await.unwrap();
909
910 let mut recordings: Vec<_> = fs::read_dir(&temp_path)
912 .unwrap()
913 .filter_map(|entry| {
914 let entry = entry.ok()?;
915 let path = entry.path();
916 if path.extension()? == "json" {
917 Some(path)
918 } else {
919 None
920 }
921 })
922 .collect();
923
924 recordings.sort();
925 assert_eq!(recordings.len(), 2);
926
927 assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
929 assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
930
931 let first_content = fs::read_to_string(&recordings[0]).unwrap();
933 let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
934 assert_eq!(first_recording.request.url_path, "/first");
935 assert_eq!(
936 first_recording.response.body.as_ref().unwrap(),
937 "first response"
938 );
939
940 let second_content = fs::read_to_string(&recordings[1]).unwrap();
941 let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
942 assert_eq!(second_recording.request.url_path, "/second");
943 assert_eq!(
944 second_recording.response.body.as_ref().unwrap(),
945 "second response"
946 );
947
948 mock1.assert_async().await;
949 mock2.assert_async().await;
950 }
951
952 #[tokio::test]
953 async fn test_query_parameter_recording() {
954 let temp_dir = TempDir::new().unwrap();
956 let temp_path = temp_dir.path().to_path_buf();
957
958 let mut server = mockito::Server::new_async().await;
960
961 let mock = server
963 .mock("GET", "/catalogs?max_results=10&page_token=abc123")
964 .with_status(200)
965 .with_header("content-type", "application/json")
966 .with_body(r#"{"catalogs": []}"#)
967 .create_async()
968 .await;
969
970 let mut client = CloudClient::new_unauthenticated();
972 client.set_recording_dir(temp_path.clone()).unwrap();
973
974 let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
976 let response = client.get(&url).send().await.unwrap();
977
978 assert!(response.status().is_success());
979
980 let recordings: Vec<_> = fs::read_dir(&temp_path)
982 .unwrap()
983 .filter_map(|entry| {
984 let entry = entry.ok()?;
985 let path = entry.path();
986 if path.extension()? == "json" {
987 Some(path)
988 } else {
989 None
990 }
991 })
992 .collect();
993
994 assert_eq!(recordings.len(), 1);
995
996 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
998 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
999
1000 assert_eq!(recording.request.method, "GET");
1002 assert_eq!(
1003 recording.request.url_path,
1004 "/catalogs?max_results=10&page_token=abc123"
1005 );
1006 assert_eq!(recording.request.body, None);
1007
1008 assert_eq!(recording.response.status, 200);
1010 assert_eq!(
1011 recording.response.body.as_ref().unwrap(),
1012 r#"{"catalogs": []}"#
1013 );
1014
1015 mock.assert_async().await;
1016 }
1017
1018 #[tokio::test]
1023 async fn test_recording_does_not_contain_bearer_token_value() {
1024 let temp_dir = TempDir::new().unwrap();
1025 let temp_path = temp_dir.path().to_path_buf();
1026
1027 let mut server = mockito::Server::new_async().await;
1028 let mock = server
1029 .mock("GET", "/secret")
1030 .match_header("authorization", mockito::Matcher::Any)
1031 .with_status(200)
1032 .with_body("ok")
1034 .create_async()
1035 .await;
1036
1037 let mut client = CloudClient::new_with_token("super-secret-token-12345");
1038 client.set_recording_dir(temp_path.clone()).unwrap();
1039
1040 let url = format!("{}/secret", server.url());
1041 client.get(&url).send().await.unwrap();
1042
1043 let recording_path = temp_path.join("0000.json");
1044 let content = fs::read_to_string(&recording_path).unwrap();
1045
1046 assert!(
1048 !content.contains("super-secret-token-12345"),
1049 "raw bearer token leaked into recording: {content}"
1050 );
1051
1052 mock.assert_async().await;
1053 }
1054
1055 #[tokio::test]
1059 async fn test_recording_redacts_sensitive_response_headers() {
1060 let temp_dir = TempDir::new().unwrap();
1061 let temp_path = temp_dir.path().to_path_buf();
1062
1063 let mut server = mockito::Server::new_async().await;
1064 let mock = server
1066 .mock("GET", "/s3")
1067 .with_status(200)
1068 .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
1069 .with_header("authorization", "Bearer should-be-redacted")
1070 .with_body("{}")
1071 .create_async()
1072 .await;
1073
1074 let mut client = CloudClient::new_unauthenticated();
1075 client.set_recording_dir(temp_path.clone()).unwrap();
1076
1077 let url = format!("{}/s3", server.url());
1078 client.get(&url).send().await.unwrap();
1079
1080 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1081 assert!(
1082 !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
1083 "x-amz-security-token leaked into recording: {content}"
1084 );
1085 assert!(
1086 !content.contains("should-be-redacted"),
1087 "Authorization value leaked into recording: {content}"
1088 );
1089 assert_eq!(
1091 content.matches("<REDACTED>").count(),
1092 2,
1093 "expected 2 <REDACTED> entries in recording: {content}"
1094 );
1095
1096 mock.assert_async().await;
1097 }
1098
1099 #[tokio::test]
1101 async fn test_recording_remains_valid_json_after_redaction() {
1102 let temp_dir = TempDir::new().unwrap();
1103 let temp_path = temp_dir.path().to_path_buf();
1104
1105 let mut server = mockito::Server::new_async().await;
1106 let _mock = server
1107 .mock("GET", "/check")
1108 .with_status(200)
1109 .with_header("authorization", "Bearer should-be-redacted")
1110 .with_body(r#"{"ok":true}"#)
1111 .create_async()
1112 .await;
1113
1114 let mut client = CloudClient::new_unauthenticated();
1115 client.set_recording_dir(temp_path.clone()).unwrap();
1116
1117 let url = format!("{}/check", server.url());
1118 client.get(&url).send().await.unwrap();
1119
1120 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1121 let parsed: RequestResponseInfo = serde_json::from_str(&content)
1123 .expect("recording file must be valid JSON even after redaction");
1124 assert_eq!(parsed.response.status, 200);
1125 }
1126}
1127
1128#[cfg(all(test, not(feature = "recording")))]
1133mod retry_integration_tests {
1134 use super::*;
1135 use std::time::Duration;
1136
1137 fn fast_retry(max_retries: usize) -> RetryConfig {
1138 RetryConfig {
1139 backoff: crate::backoff::BackoffConfig {
1140 init_backoff: Duration::from_millis(1),
1141 max_backoff: Duration::from_millis(5),
1142 base: 2.,
1143 },
1144 max_retries,
1145 retry_timeout: Duration::from_secs(30),
1146 }
1147 }
1148
1149 #[tokio::test]
1153 async fn send_retries_server_error_then_succeeds() {
1154 let mut server = mockito::Server::new_async().await;
1155 let fail = server
1156 .mock("GET", "/r")
1157 .with_status(503)
1158 .expect(1)
1159 .create_async()
1160 .await;
1161 let ok = server
1162 .mock("GET", "/r")
1163 .with_status(200)
1164 .with_body("ok")
1165 .expect(1)
1166 .create_async()
1167 .await;
1168
1169 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1170 let resp = client
1171 .get(format!("{}/r", server.url()))
1172 .send()
1173 .await
1174 .unwrap();
1175
1176 assert_eq!(resp.status(), 200);
1177 assert_eq!(resp.text().await.unwrap(), "ok");
1178 fail.assert_async().await;
1179 ok.assert_async().await;
1180 }
1181
1182 #[tokio::test]
1185 async fn send_does_not_retry_client_error() {
1186 let mut server = mockito::Server::new_async().await;
1187 let mock = server
1188 .mock("GET", "/r")
1189 .with_status(404)
1190 .expect(1)
1191 .create_async()
1192 .await;
1193
1194 let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1195 let err = client
1196 .get(format!("{}/r", server.url()))
1197 .send()
1198 .await
1199 .unwrap_err();
1200
1201 assert!(matches!(err, Error::NotFound { .. }), "got {err:?}");
1202 mock.assert_async().await;
1203 }
1204
1205 #[tokio::test]
1208 async fn sign_and_send_signs_and_retries() {
1209 let mut server = mockito::Server::new_async().await;
1210 let fail = server
1211 .mock("POST", "/rpc")
1212 .match_header("authorization", "Bearer tok")
1213 .with_status(503)
1214 .expect(1)
1215 .create_async()
1216 .await;
1217 let ok = server
1218 .mock("POST", "/rpc")
1219 .match_header("authorization", "Bearer tok")
1220 .with_status(200)
1221 .with_body("pong")
1222 .expect(1)
1223 .create_async()
1224 .await;
1225
1226 let client = CloudClient::new_with_token("tok").with_retry_config(fast_retry(3));
1227 let request = reqwest::Client::new()
1228 .post(format!("{}/rpc", server.url()))
1229 .body("ping")
1230 .build()
1231 .unwrap();
1232 let resp = client.sign_and_send(request).await.unwrap();
1233
1234 assert_eq!(resp.status(), 200);
1235 assert_eq!(resp.text().await.unwrap(), "pong");
1236 fail.assert_async().await;
1237 ok.assert_async().await;
1238 }
1239}