1pub mod client;
6pub mod response;
7
8use std::time::Instant;
9
10use crate::config::Config;
11use crate::error::SendraError;
12use crate::http::client::HttpClient;
13use crate::http::response::Response;
14use crate::request::Request;
15
16pub async fn send(
40 request: &Request,
41 client: &HttpClient,
42 config: &Config,
43) -> Result<Response, SendraError> {
44 send_prepared(&config.apply(request), client).await
47}
48
49pub async fn send_prepared(
66 request: &Request,
67 client: &HttpClient,
68) -> Result<Response, SendraError> {
69 let mut headers = reqwest::header::HeaderMap::new();
70 for (name, value) in &request.headers {
71 let header_name = reqwest::header::HeaderName::try_from(name.as_str()).map_err(|e| {
72 SendraError::InvalidHeader {
73 name: name.clone(),
74 reason: e.to_string(),
75 }
76 })?;
77 let header_value = reqwest::header::HeaderValue::try_from(value.as_str()).map_err(|e| {
78 SendraError::InvalidHeader {
79 name: name.clone(),
80 reason: e.to_string(),
81 }
82 })?;
83 headers.append(header_name, header_value);
87 }
88
89 let send_err = |source: reqwest::Error| {
93 if source.is_timeout() {
94 SendraError::Timeout {
95 url: request.url.clone(),
96 timeout: client.timeout,
97 source,
98 }
99 } else {
100 SendraError::Network {
101 url: request.url.clone(),
102 source,
103 }
104 }
105 };
106
107 let mut builder = client
108 .inner
109 .request(request.method.into(), &request.url)
110 .headers(headers);
111 if let Some(body) = &request.body {
112 builder = builder.body(body.clone());
113 }
114
115 client.redirects.lock().unwrap().clear();
121
122 let started = Instant::now();
123 let response = builder.send().await.map_err(send_err)?;
124 let redirects = std::mem::take(&mut *client.redirects.lock().unwrap());
125
126 let status = response.status();
127 let header_pairs = response
128 .headers()
129 .iter()
130 .map(|(name, value)| {
131 (
132 name.as_str().to_owned(),
133 value
134 .to_str()
135 .unwrap_or("<non-utf8 header value>")
136 .to_owned(),
137 )
138 })
139 .collect();
140 let bytes = response.bytes().await.map_err(send_err)?;
141 let elapsed = started.elapsed();
142
143 Ok(Response {
144 status: status.as_u16(),
145 status_text: status.canonical_reason().unwrap_or("").to_owned(),
146 headers: header_pairs,
147 body: String::from_utf8_lossy(&bytes).into_owned(),
152 elapsed,
153 redirects,
154 })
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::http::client::build_client;
161 use crate::http::response::RedirectHop;
162 use crate::test_support::{
163 get, ok_bytes, ok_response, redirect_response, redirect_with_cookie_response,
164 set_cookie_response, start_cookie_server, start_mutual_tls_server,
165 start_proxy_recording_server, start_route_server, start_self_signed_tls_server,
166 start_stalling_server, CountingServer, Stall,
167 };
168 use crate::{config, Method, SendraError};
169 use std::collections::BTreeMap;
170 use std::time::Duration;
171
172 #[tokio::test]
173 async fn invalid_header_name_is_reported_before_any_network_call() {
174 let request = Request {
175 name: None,
176 method: Method::Get,
177 url: "http://127.0.0.1:1/".to_string(),
180 headers: vec![("bad header".to_string(), "x".to_string())],
181 query: Vec::new(),
182 body: None,
183 json: None,
184 body_file: None,
185 form: Vec::new(),
186 multipart: Vec::new(),
187 auth: None,
188 assertions: None,
189 pre_request: None,
190 post_request: None,
191 capture: None,
192 retry: None,
193 };
194 let config = Config::default();
195 let client = build_client(&config).expect("a client builds");
196 let err = send(&request, &client, &config)
197 .await
198 .expect_err("invalid header must error");
199 assert!(
200 matches!(err, SendraError::InvalidHeader { .. }),
201 "got {err:?}"
202 );
203 }
204
205 #[tokio::test]
206 async fn an_invalid_header_from_the_config_is_reported_the_same_way() {
207 let request = Request {
211 name: None,
212 method: Method::Get,
213 url: "http://127.0.0.1:1/".to_string(),
214 headers: Vec::new(),
215 query: Vec::new(),
216 body: None,
217 json: None,
218 body_file: None,
219 form: Vec::new(),
220 multipart: Vec::new(),
221 auth: None,
222 assertions: None,
223 pre_request: None,
224 post_request: None,
225 capture: None,
226 retry: None,
227 };
228 let config = Config {
229 headers: BTreeMap::from([("bad header".to_string(), "x".to_string())]),
230 ..Config::default()
231 };
232 let client = build_client(&config).expect("a client builds");
233 let err = send(&request, &client, &config)
234 .await
235 .expect_err("invalid header must error");
236 assert!(
237 matches!(err, SendraError::InvalidHeader { .. }),
238 "got {err:?}"
239 );
240 }
241
242 #[tokio::test]
243 async fn one_client_sends_every_request_down_one_connection() {
244 let server = CountingServer::start();
248 let config = Config::default();
249 let client = build_client(&config).expect("a client builds");
250
251 for _ in 0..3 {
252 let response = send(&get(&server.url()), &client, &config)
253 .await
254 .expect("the mock server answers");
255 assert_eq!(response.status, 200);
256 }
257
258 assert_eq!(server.requests(), 3, "all three requests were served");
259 assert_eq!(
260 server.connections(),
261 1,
262 "three requests through one client must reuse one connection"
263 );
264 }
265
266 #[tokio::test]
267 async fn a_client_per_request_opens_a_connection_per_request() {
268 let server = CountingServer::start();
274 let config = Config::default();
275
276 for _ in 0..3 {
277 let client = build_client(&config).expect("a client builds");
278 let response = send(&get(&server.url()), &client, &config)
279 .await
280 .expect("the mock server answers");
281 assert_eq!(response.status, 200);
282 }
283
284 assert_eq!(server.requests(), 3, "all three requests were served");
285 assert_eq!(
286 server.connections(),
287 3,
288 "a fresh client per request cannot reuse anything"
289 );
290 }
291
292 #[tokio::test]
293 async fn a_gzip_encoded_response_is_decompressed_before_reaching_response_body() {
294 use std::io::Write;
299
300 let body = b"{\"hello\":\"world\"}";
301 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
302 encoder.write_all(body).expect("gzip encodes into memory");
303 let compressed = encoder.finish().expect("gzip stream finalises");
304
305 let listener =
306 std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
307 let addr = listener.local_addr().expect("the listener has an address");
308 std::thread::spawn(move || {
309 use std::io::{BufRead, BufReader};
310
311 if let Ok(stream) = listener.accept().map(|(s, _)| s) {
312 let mut writer = stream.try_clone().expect("the socket clones");
313 let mut reader = BufReader::new(stream);
314
315 let mut line = String::new();
316 reader.read_line(&mut line).expect("a request line arrives");
317 loop {
318 let mut header = String::new();
319 reader.read_line(&mut header).expect("headers keep coming");
320 if header == "\r\n" {
321 break;
322 }
323 }
324
325 writer
326 .write_all(
327 format!(
328 "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\n\r\n",
329 compressed.len()
330 )
331 .as_bytes(),
332 )
333 .expect("status line and headers write");
334 writer
335 .write_all(&compressed)
336 .expect("the compressed body writes");
337 writer.flush().expect("the response flushes");
338 }
339 });
340
341 let config = Config::default();
342 let client = build_client(&config).expect("a client builds");
343 let response = send(&get(&format!("http://{addr}/")), &client, &config)
344 .await
345 .expect("the mock server answers");
346
347 assert_eq!(response.status, 200);
348 assert_eq!(
349 response.body, "{\"hello\":\"world\"}",
350 "the body must be the decompressed text, not the raw gzip bytes"
351 );
352 }
353
354 #[tokio::test]
355 async fn a_repeated_header_actually_goes_out_twice_on_the_wire() {
356 use std::io::{BufRead, BufReader, Write};
361
362 let listener =
363 std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
364 let addr = listener.local_addr().expect("the listener has an address");
365 let seen: std::sync::Arc<std::sync::Mutex<Vec<String>>> = Default::default();
366 let seen_in_thread = seen.clone();
367 std::thread::spawn(move || {
368 if let Ok(stream) = listener.accept().map(|(s, _)| s) {
369 let mut writer = stream.try_clone().expect("the socket clones");
370 let mut reader = BufReader::new(stream);
371
372 let mut line = String::new();
373 reader.read_line(&mut line).expect("a request line arrives");
374 loop {
375 let mut header = String::new();
376 match reader.read_line(&mut header) {
377 Ok(0) | Err(_) => return,
378 Ok(_) if header == "\r\n" => break,
379 Ok(_) => seen_in_thread
380 .lock()
381 .unwrap()
382 .push(header.trim_end().to_string()),
383 }
384 }
385
386 writer
387 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
388 .expect("status line and headers write");
389 writer.flush().expect("the response flushes");
390 }
391 });
392
393 let request = Request {
394 headers: vec![
395 ("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
396 ("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
397 ],
398 ..get(&format!("http://{addr}/"))
399 };
400 let config = Config::default();
401 let client = build_client(&config).expect("a client builds");
402 let response = send(&request, &client, &config)
403 .await
404 .expect("the mock server answers");
405 assert_eq!(response.status, 200);
406
407 let lines = seen.lock().unwrap().clone();
408 let matching: Vec<&String> = lines
409 .iter()
410 .filter(|line| line.to_ascii_lowercase().starts_with("x-forwarded-for:"))
411 .collect();
412 assert_eq!(
413 matching.len(),
414 2,
415 "both values should have gone out as two separate header lines, got {lines:?}"
416 );
417 assert!(matching.iter().any(|l| l.contains("1.2.3.4")));
418 assert!(matching.iter().any(|l| l.contains("5.6.7.8")));
419 }
420
421 const STALL: Duration = Duration::from_secs(30);
426
427 #[tokio::test]
428 async fn a_server_slower_than_the_timeout_fails_with_a_timeout_error() {
429 let addr = start_stalling_server(Stall::BeforeResponding, STALL);
433 let config = Config {
434 timeout: Duration::from_millis(300),
435 ..Config::default()
436 };
437 let client = build_client(&config).expect("a client builds");
438 let url = format!("http://{addr}/");
439
440 let started = Instant::now();
441 let err = send(&get(&url), &client, &config)
442 .await
443 .expect_err("a server that never answers must not hang the run");
444 let waited = started.elapsed();
445
446 match &err {
447 SendraError::Timeout {
448 url: got, timeout, ..
449 } => {
450 assert_eq!(got, &url);
451 assert_eq!(
452 *timeout,
453 Duration::from_millis(300),
454 "the error must name the limit that was actually applied"
455 );
456 }
457 other => panic!("expected a timeout, got {other:?}"),
458 }
459
460 assert_eq!(
463 err.to_string(),
464 format!("request to `{url}` timed out after 0.3s")
465 );
466
467 assert!(
470 waited < STALL / 2,
471 "gave up after {waited:?}, which is not the configured 300ms"
472 );
473 }
474
475 #[tokio::test]
476 async fn the_timeout_covers_the_body_read_not_just_the_response_headers() {
477 let addr = start_stalling_server(Stall::MidBody, STALL);
482 let config = Config {
483 timeout: Duration::from_millis(300),
484 ..Config::default()
485 };
486 let client = build_client(&config).expect("a client builds");
487
488 let started = Instant::now();
489 let err = send(&get(&format!("http://{addr}/")), &client, &config)
490 .await
491 .expect_err("a body that never arrives must time out like a response that never does");
492 let waited = started.elapsed();
493
494 assert!(
495 matches!(err, SendraError::Timeout { .. }),
496 "a stall after the headers is still a timeout, got {err:?}"
497 );
498 assert!(waited < STALL / 2, "gave up after {waited:?}");
499 }
500
501 #[tokio::test]
502 async fn a_timeout_from_a_config_file_is_the_one_that_is_enforced() {
503 let temp = tempfile::tempdir().expect("a temp dir");
508 let project_dir = temp.path().join(".sendra");
509 std::fs::create_dir_all(&project_dir).expect("the project dir is created");
510 std::fs::write(project_dir.join("config.yaml"), "timeout_seconds: 1\n")
511 .expect("the config file writes");
512
513 let config = Config::resolve_from(temp.path(), None).expect("the config resolves");
514 assert_eq!(config.timeout, Duration::from_secs(1), "the file was read");
515
516 let addr = start_stalling_server(Stall::BeforeResponding, STALL);
517 let client = build_client(&config).expect("a client builds");
518
519 let started = Instant::now();
520 let err = send(&get(&format!("http://{addr}/")), &client, &config)
521 .await
522 .expect_err("the configured second must run out");
523 let waited = started.elapsed();
524
525 match err {
526 SendraError::Timeout { timeout, .. } => assert_eq!(timeout, Duration::from_secs(1)),
527 other => panic!("expected a timeout, got {other:?}"),
528 }
529 assert!(
530 waited >= Duration::from_millis(900),
531 "gave up after {waited:?}, sooner than the second the file asked for"
532 );
533 assert!(waited < STALL / 2, "gave up after {waited:?}");
534 }
535
536 #[tokio::test]
537 async fn a_connection_failure_is_still_a_network_error_not_a_timeout() {
538 let listener =
543 std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
544 let addr = listener.local_addr().expect("the listener has an address");
545 drop(listener);
546
547 let config = Config {
548 timeout: Duration::from_secs(30),
549 ..Config::default()
550 };
551 let client = build_client(&config).expect("a client builds");
552 let err = send(&get(&format!("http://{addr}/")), &client, &config)
553 .await
554 .expect_err("nothing is listening on that port");
555
556 assert!(
557 matches!(err, SendraError::Network { .. }),
558 "a refused connection is a fact about the network, not about the timeout, got {err:?}"
559 );
560 }
561
562 #[tokio::test]
565 async fn a_self_signed_endpoint_fails_verification_by_default() {
566 let addr = start_self_signed_tls_server();
567 let config = Config::default();
568 let client = build_client(&config).expect("a client builds");
569
570 let err = send(&get(&format!("https://{addr}/")), &client, &config)
571 .await
572 .expect_err("a self-signed certificate must not verify by default");
573
574 assert!(
575 matches!(err, SendraError::Network { .. }),
576 "a certificate failure is a fact about the connection, got {err:?}"
577 );
578 }
579
580 #[tokio::test]
581 async fn insecure_true_accepts_the_same_self_signed_endpoint() {
582 let addr = start_self_signed_tls_server();
583 let config = Config {
584 insecure: true,
585 ..Config::default()
586 };
587 let client = build_client(&config).expect("a client builds");
588
589 let response = send(&get(&format!("https://{addr}/")), &client, &config)
590 .await
591 .expect("--insecure must let the same handshake through");
592
593 assert_eq!(response.status, 200);
594 assert_eq!(response.body, "ok");
595 }
596
597 #[tokio::test]
600 async fn a_configured_proxy_actually_receives_the_request() {
601 let (proxy_addr, seen) = start_proxy_recording_server();
602 let config = Config {
603 proxy: Some(format!("http://{proxy_addr}")),
604 ..Config::default()
605 };
606 let client = build_client(&config).expect("a client builds");
607
608 let response = send(
612 &get("http://example-target.invalid/widgets"),
613 &client,
614 &config,
615 )
616 .await
617 .expect("the proxy stand-in answers 200 to whatever reaches it");
618
619 assert_eq!(response.status, 200);
620
621 let request_line = seen
622 .lock()
623 .unwrap()
624 .take()
625 .expect("the proxy should have seen exactly one request");
626 assert_eq!(
630 request_line, "GET http://example-target.invalid/widgets HTTP/1.1",
631 "the proxy did not see an absolute-form request line: {request_line:?}"
632 );
633 }
634
635 #[tokio::test]
636 async fn an_invalid_proxy_url_is_a_client_error() {
637 let config = Config {
638 proxy: Some("not a url".to_string()),
639 ..Config::default()
640 };
641
642 let Err(err) = build_client(&config) else {
643 panic!("a malformed proxy URL must not build a client");
644 };
645 assert!(matches!(err, SendraError::Client(_)), "got {err:?}");
646 }
647
648 fn write_pem(dir: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
654 let path = dir.join(name);
655 std::fs::write(&path, contents).unwrap();
656 path
657 }
658
659 #[tokio::test]
660 async fn a_request_without_a_client_certificate_is_rejected_by_the_mtls_server() {
661 let (addr, _client_cert_pem, _client_key_pem) = start_mutual_tls_server();
662 let config = Config {
666 insecure: true,
667 ..Config::default()
668 };
669 let client = build_client(&config).expect("a client with no identity still builds");
670
671 let err = send(&get(&format!("https://{addr}/")), &client, &config)
672 .await
673 .expect_err("the server demands a client certificate this client never presented");
674
675 assert!(
676 matches!(err, SendraError::Network { .. }),
677 "a rejected handshake is a fact about the connection, got {err:?}"
678 );
679 }
680
681 #[tokio::test]
682 async fn a_correctly_configured_client_certificate_authenticates() {
683 let (addr, client_cert_pem, client_key_pem) = start_mutual_tls_server();
684 let dir = tempfile::tempdir().unwrap();
685 let cert_path = write_pem(dir.path(), "client.pem", &client_cert_pem);
686 let key_path = write_pem(dir.path(), "client-key.pem", &client_key_pem);
687
688 let config = Config {
689 insecure: true,
690 client_cert: Some(cert_path),
691 client_key: Some(key_path),
692 ..Config::default()
693 };
694 let client = build_client(&config).expect("a matching cert/key pair builds a client");
695
696 let response = send(&get(&format!("https://{addr}/")), &client, &config)
697 .await
698 .expect("the server accepts a client certificate it issued the CA for");
699
700 assert_eq!(response.status, 200);
701 assert_eq!(response.body, "ok");
702 }
703
704 #[tokio::test]
705 async fn insecure_and_a_client_certificate_together_both_apply() {
706 let (addr, client_cert_pem, client_key_pem) = start_mutual_tls_server();
713 let dir = tempfile::tempdir().unwrap();
714 let cert_path = write_pem(dir.path(), "client.pem", &client_cert_pem);
715 let key_path = write_pem(dir.path(), "client-key.pem", &client_key_pem);
716
717 let config = Config {
718 insecure: true,
719 client_cert: Some(cert_path),
720 client_key: Some(key_path),
721 ..Config::default()
722 };
723 let client = build_client(&config).expect("a client builds");
724
725 let response = send(&get(&format!("https://{addr}/")), &client, &config)
726 .await
727 .expect("insecure + a valid client certificate together must succeed");
728
729 assert_eq!(response.status, 200);
730 }
731
732 #[tokio::test]
733 async fn a_missing_client_cert_file_is_a_typed_error_naming_the_path() {
734 let dir = tempfile::tempdir().unwrap();
735 let missing_cert = dir.path().join("nope.pem");
736 let key_path = write_pem(dir.path(), "client-key.pem", "irrelevant");
737
738 let config = Config {
739 client_cert: Some(missing_cert.clone()),
740 client_key: Some(key_path),
741 ..Config::default()
742 };
743
744 let Err(err) = build_client(&config) else {
745 panic!("a missing cert file must not build a client");
746 };
747 match err {
748 SendraError::ClientCertIo { path, .. } => assert_eq!(path, missing_cert),
749 other => panic!("expected ClientCertIo, got {other:?}"),
750 }
751 }
752
753 #[tokio::test]
754 async fn a_missing_client_key_file_is_a_typed_error_naming_the_path() {
755 let dir = tempfile::tempdir().unwrap();
756 let cert_path = write_pem(dir.path(), "client.pem", "irrelevant");
757 let missing_key = dir.path().join("nope-key.pem");
758
759 let config = Config {
760 client_cert: Some(cert_path),
761 client_key: Some(missing_key.clone()),
762 ..Config::default()
763 };
764
765 let Err(err) = build_client(&config) else {
766 panic!("a missing key file must not build a client");
767 };
768 match err {
769 SendraError::ClientCertIo { path, .. } => assert_eq!(path, missing_key),
770 other => panic!("expected ClientCertIo, got {other:?}"),
771 }
772 }
773
774 #[tokio::test]
775 async fn malformed_pem_content_is_a_client_error_not_a_panic() {
776 let dir = tempfile::tempdir().unwrap();
777 let cert_path = write_pem(dir.path(), "client.pem", "not a pem file at all");
778 let key_path = write_pem(dir.path(), "client-key.pem", "also not a pem file");
779
780 let config = Config {
781 client_cert: Some(cert_path),
782 client_key: Some(key_path),
783 ..Config::default()
784 };
785
786 let Err(err) = build_client(&config) else {
787 panic!("garbage PEM content must not build a client");
788 };
789 assert!(matches!(err, SendraError::Client(_)), "got {err:?}");
790 }
791
792 #[tokio::test]
793 async fn only_a_client_cert_with_no_key_is_refused() {
794 let dir = tempfile::tempdir().unwrap();
795 let cert_path = write_pem(dir.path(), "client.pem", "irrelevant");
796
797 let config = Config {
798 client_cert: Some(cert_path),
799 client_key: None,
800 ..Config::default()
801 };
802
803 let Err(err) = build_client(&config) else {
804 panic!("a cert with no key must be refused");
805 };
806 assert!(
807 matches!(err, SendraError::ClientCertIncomplete { which: "cert" }),
808 "got {err:?}"
809 );
810 }
811
812 #[tokio::test]
813 async fn only_a_client_key_with_no_cert_is_refused() {
814 let dir = tempfile::tempdir().unwrap();
815 let key_path = write_pem(dir.path(), "client-key.pem", "irrelevant");
816
817 let config = Config {
818 client_cert: None,
819 client_key: Some(key_path),
820 ..Config::default()
821 };
822
823 let Err(err) = build_client(&config) else {
824 panic!("a key with no cert must be refused");
825 };
826 assert!(
827 matches!(err, SendraError::ClientCertIncomplete { which: "key" }),
828 "got {err:?}"
829 );
830 }
831
832 #[tokio::test]
835 async fn invalid_utf8_in_a_body_is_replaced_rather_than_erroring() {
836 let body = b"ok \xff\xfe then \xe2\x28 end";
845 let addr = start_route_server(vec![("/", ok_bytes("text/plain", body))]);
846
847 let config = Config::default();
848 let client = build_client(&config).expect("a client builds");
849 let response = send(&get(&format!("http://{addr}/")), &client, &config)
850 .await
851 .expect("an undecodable body is not a failed request");
852
853 assert_eq!(response.status, 200, "the response itself is fine");
854 assert_eq!(
855 response.body, "ok \u{fffd}\u{fffd} then \u{fffd}( end",
856 "each invalid sequence becomes one replacement character, and the \
857 valid text around it survives unchanged"
858 );
859 }
860
861 #[tokio::test]
862 async fn a_wholly_binary_body_comes_back_as_a_response_not_an_error() {
863 let body: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
872 let addr = start_route_server(vec![("/", ok_bytes("image/png", body))]);
873
874 let config = Config::default();
875 let client = build_client(&config).expect("a client builds");
876 let response = send(&get(&format!("http://{addr}/")), &client, &config)
877 .await
878 .expect("a binary body is not a failed request");
879
880 assert_eq!(response.status, 200);
881 assert_eq!(
882 response
883 .headers
884 .iter()
885 .find(|(name, _)| name == "content-type")
886 .map(|(_, value)| value.as_str()),
887 Some("image/png"),
888 "everything but the body is unaffected"
889 );
890 assert_eq!(response.body, "\u{fffd}PNG\r\n\u{1a}\n");
891
892 assert_ne!(
896 response.body.as_bytes(),
897 body,
898 "the conversion is lossy, and `Response.body` is not round-trippable"
899 );
900 }
901
902 #[tokio::test]
905 async fn a_redirect_is_followed_and_the_chain_is_captured_on_the_final_response() {
906 let addr = start_route_server(vec![
907 (
908 "/start",
909 redirect_response(301, "Moved Permanently", "/next"),
910 ),
911 ("/next", redirect_response(302, "Found", "/end")),
912 ("/end", ok_response("done")),
913 ]);
914
915 let config = Config::default();
916 let client = build_client(&config).expect("a client builds");
917 let response = send(&get(&format!("http://{addr}/start")), &client, &config)
918 .await
919 .expect("the chain resolves");
920
921 assert_eq!(response.status, 200);
923 assert_eq!(response.body, "done");
924
925 assert_eq!(
929 response.redirects,
930 vec![
931 RedirectHop {
932 status: 301,
933 location: format!("http://{addr}/next"),
934 },
935 RedirectHop {
936 status: 302,
937 location: format!("http://{addr}/end"),
938 },
939 ]
940 );
941 }
942
943 #[tokio::test]
944 async fn a_request_with_no_redirect_reports_an_empty_chain() {
945 let addr = start_route_server(vec![("/", ok_response("hello"))]);
948
949 let config = Config::default();
950 let client = build_client(&config).expect("a client builds");
951 let response = send(&get(&format!("http://{addr}/")), &client, &config)
952 .await
953 .expect("a plain response");
954
955 assert_eq!(response.status, 200);
956 assert!(response.redirects.is_empty());
957 }
958
959 #[tokio::test]
960 async fn disabling_redirects_reports_the_3xx_response_itself_not_an_error() {
961 let addr = start_route_server(vec![
962 (
963 "/start",
964 redirect_response(301, "Moved Permanently", "/end"),
965 ),
966 ("/end", ok_response("done")),
967 ]);
968
969 let config = Config {
970 redirects: config::FollowRedirects::Disabled,
971 ..Config::default()
972 };
973 let client = build_client(&config).expect("a client builds");
974 let response = send(&get(&format!("http://{addr}/start")), &client, &config)
975 .await
976 .expect("a 3xx is a normal, inspectable response");
977
978 assert_eq!(response.status, 301);
981 assert_eq!(
982 response
983 .headers
984 .iter()
985 .find(|(name, _)| name.eq_ignore_ascii_case("location"))
986 .map(|(_, value)| value.as_str()),
987 Some("/end")
988 );
989 assert!(response.redirects.is_empty());
991 }
992
993 #[tokio::test]
994 async fn a_chain_longer_than_the_configured_maximum_is_an_error() {
995 let addr = start_route_server(vec![
998 ("/start", redirect_response(301, "Moved Permanently", "/a")),
999 ("/a", redirect_response(302, "Found", "/b")),
1000 ("/b", redirect_response(303, "See Other", "/end")),
1001 ("/end", ok_response("done")),
1002 ]);
1003
1004 let config = Config {
1005 redirects: config::FollowRedirects::Follow(1),
1006 ..Config::default()
1007 };
1008 let client = build_client(&config).expect("a client builds");
1009 let err = send(&get(&format!("http://{addr}/start")), &client, &config)
1010 .await
1011 .expect_err("a chain past the configured maximum must not resolve to a response");
1012
1013 match err {
1014 SendraError::Network { source, .. } => {
1015 let message = source.to_string();
1016 assert!(
1017 message.contains("redirect") || std::error::Error::source(&source).is_some(),
1018 "expected a redirect-shaped error, got {message}"
1019 );
1020 }
1021 other => panic!("expected Network, got {other:?}"),
1022 }
1023 }
1024
1025 #[tokio::test]
1026 async fn a_custom_maximum_higher_than_the_chain_still_resolves() {
1027 let addr = start_route_server(vec![
1030 ("/start", redirect_response(301, "Moved Permanently", "/a")),
1031 ("/a", redirect_response(302, "Found", "/end")),
1032 ("/end", ok_response("done")),
1033 ]);
1034
1035 let config = Config {
1036 redirects: config::FollowRedirects::Follow(5),
1037 ..Config::default()
1038 };
1039 let client = build_client(&config).expect("a client builds");
1040 let response = send(&get(&format!("http://{addr}/start")), &client, &config)
1041 .await
1042 .expect("two hops is well within a maximum of five");
1043
1044 assert_eq!(response.status, 200);
1045 assert_eq!(response.redirects.len(), 2);
1046 }
1047
1048 #[tokio::test]
1049 async fn each_request_through_a_reused_client_reports_only_its_own_chain() {
1050 let addr = start_route_server(vec![
1054 (
1055 "/redirected",
1056 redirect_response(301, "Moved Permanently", "/plain"),
1057 ),
1058 ("/plain", ok_response("done")),
1059 ]);
1060
1061 let config = Config::default();
1062 let client = build_client(&config).expect("a client builds");
1063
1064 let redirected = send(&get(&format!("http://{addr}/redirected")), &client, &config)
1065 .await
1066 .expect("the redirect resolves");
1067 assert_eq!(redirected.redirects.len(), 1);
1068
1069 let plain = send(&get(&format!("http://{addr}/plain")), &client, &config)
1070 .await
1071 .expect("a direct hit on the same client");
1072 assert!(
1073 plain.redirects.is_empty(),
1074 "the previous request's chain must not leak into this one"
1075 );
1076 }
1077
1078 #[tokio::test]
1081 async fn cookie_jar_disabled_does_not_carry_a_cookie_to_a_later_request() {
1082 let (addr, seen) = start_cookie_server(vec![
1086 (
1087 "/login",
1088 set_cookie_response("session=abc123; Path=/", "logged in"),
1089 ),
1090 ("/profile", ok_response("profile")),
1091 ]);
1092
1093 let config = Config::default();
1094 assert!(!config.cookie_jar, "off by default");
1095 let client = build_client(&config).expect("a client builds");
1096
1097 send(&get(&format!("http://{addr}/login")), &client, &config)
1098 .await
1099 .expect("login responds");
1100 send(&get(&format!("http://{addr}/profile")), &client, &config)
1101 .await
1102 .expect("profile responds");
1103
1104 let seen = seen.lock().unwrap();
1105 assert_eq!(seen.len(), 2);
1106 assert_eq!(
1107 seen[0], None,
1108 "no cookie existed to send on the first request"
1109 );
1110 assert_eq!(
1111 seen[1], None,
1112 "with the jar off, the session cookie from /login must not reach /profile"
1113 );
1114 }
1115
1116 #[tokio::test]
1117 async fn cookie_jar_enabled_carries_a_cookie_to_a_later_request() {
1118 let (addr, seen) = start_cookie_server(vec![
1122 (
1123 "/login",
1124 set_cookie_response("session=abc123; Path=/", "logged in"),
1125 ),
1126 ("/profile", ok_response("profile")),
1127 ]);
1128
1129 let config = Config {
1130 cookie_jar: true,
1131 ..Config::default()
1132 };
1133 let client = build_client(&config).expect("a client builds");
1134
1135 send(&get(&format!("http://{addr}/login")), &client, &config)
1136 .await
1137 .expect("login responds");
1138 send(&get(&format!("http://{addr}/profile")), &client, &config)
1139 .await
1140 .expect("profile responds");
1141
1142 let seen = seen.lock().unwrap();
1143 assert_eq!(seen.len(), 2);
1144 assert_eq!(seen[0], None, "no cookie existed yet for the login request");
1145 assert_eq!(
1146 seen[1].as_deref(),
1147 Some("session=abc123"),
1148 "the jar must resend the cookie /login set: got {:?}",
1149 seen[1]
1150 );
1151 }
1152
1153 #[tokio::test]
1154 async fn an_explicit_cookie_header_is_sent_as_is_and_the_jar_is_not_consulted() {
1155 let (addr, seen) = start_cookie_server(vec![
1162 (
1163 "/login",
1164 set_cookie_response("session=abc123; Path=/", "logged in"),
1165 ),
1166 ("/profile", ok_response("profile")),
1167 ]);
1168
1169 let config = Config {
1170 cookie_jar: true,
1171 ..Config::default()
1172 };
1173 let client = build_client(&config).expect("a client builds");
1174
1175 send(&get(&format!("http://{addr}/login")), &client, &config)
1176 .await
1177 .expect("login responds, and the jar stores its session cookie");
1178
1179 let mut request = get(&format!("http://{addr}/profile"));
1180 request.headers = vec![("Cookie".to_string(), "session=manual-override".to_string())];
1181 send(&request, &client, &config)
1182 .await
1183 .expect("profile responds");
1184
1185 let seen = seen.lock().unwrap();
1186 assert_eq!(
1187 seen[1].as_deref(),
1188 Some("session=manual-override"),
1189 "the request's own Cookie header must reach the server unchanged, \
1190 not merged with the jar's stored cookie: got {:?}",
1191 seen[1]
1192 );
1193 }
1194
1195 #[tokio::test]
1196 async fn cookie_jar_captures_a_set_cookie_from_an_intermediate_redirect_hop() {
1197 let (addr, seen) = start_cookie_server(vec![
1203 (
1204 "/start",
1205 redirect_with_cookie_response(
1206 302,
1207 "Found",
1208 "/end",
1209 "session=from-a-redirect-hop; Path=/",
1210 ),
1211 ),
1212 ("/end", ok_response("done")),
1213 ("/profile", ok_response("profile")),
1214 ]);
1215
1216 let config = Config {
1217 cookie_jar: true,
1218 ..Config::default()
1219 };
1220 let client = build_client(&config).expect("a client builds");
1221
1222 let response = send(&get(&format!("http://{addr}/start")), &client, &config)
1223 .await
1224 .expect("the redirect chain resolves");
1225 assert_eq!(response.body, "done");
1226
1227 send(&get(&format!("http://{addr}/profile")), &client, &config)
1228 .await
1229 .expect("profile responds");
1230
1231 let seen = seen.lock().unwrap();
1232 assert_eq!(seen.len(), 3);
1235 assert_eq!(
1236 seen[2].as_deref(),
1237 Some("session=from-a-redirect-hop"),
1238 "a Set-Cookie on the intermediate /start->/end hop must still \
1239 have been captured: got {:?}",
1240 seen[2]
1241 );
1242 }
1243}