Skip to main content

sendra_core/http/
mod.rs

1//! Sending a [`crate::Request`] over the wire: [`send`] and [`send_prepared`],
2//! the client that carries them ([`client`]), and what comes back
3//! ([`response`]).
4
5pub 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
16/// Send `request` under `config` and collect the full response.
17///
18/// The elapsed time covers connect, send and body read — i.e. what a user
19/// waits for, not just time-to-first-byte.
20///
21/// `config` is a parameter rather than something resolved in here, and is not
22/// optional, so that a caller cannot send a request without deciding what
23/// configuration applies to it. Callers with nothing to apply pass
24/// [`Config::default`], which is the same defaults resolution falls back to. It
25/// contributes one thing here — default headers, merged by [`Config::apply`]
26/// with the request winning ties. The other thing it decides, the timeout, was
27/// applied when `client` was built; see [`build_client`](client::build_client).
28///
29/// `client` is borrowed rather than built here so that a run sending more than
30/// one request sends them all down the same connection pool. See
31/// [`build_client`](client::build_client) for what that is worth and where the client should come
32/// from.
33///
34/// This is the whole pipeline in one call, for a caller that has no reason to
35/// step between the two halves. A caller that does — one running a
36/// `pre_request` script, which by definition is the *last* thing to touch the
37/// request — applies the config itself and calls [`send_prepared`]. That is the
38/// only reason the seam exists; see there.
39pub async fn send(
40    request: &Request,
41    client: &HttpClient,
42    config: &Config,
43) -> Result<Response, SendraError> {
44    // Everything below works from the merged request, so a config header is
45    // validated and sent exactly like one written in the file.
46    send_prepared(&config.apply(request), client).await
47}
48
49/// Send a request that is already exactly what should go over the wire.
50///
51/// Identical to [`send`] except that [`Config::apply`] is the caller's job and
52/// has already happened. There is no `&Config` here at all: the only thing this
53/// half ever read from it was the timeout, and that now lives in the `client`
54/// it is handed.
55///
56/// It exists because of `pre_request`. The ordering the scripting feature is
57/// built on puts the script strictly after the config and strictly before the
58/// wire, and a script's most obvious use — *removing* a header the config
59/// injected — only works if nothing re-merges the config afterwards. So the
60/// seam has to be somewhere, and here it is named, and says in its own
61/// signature that configuration is not its problem because it has already been
62/// handled.
63///
64/// Prefer [`send`] unless there is something to do in between.
65pub 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        // `append`, not `insert`: `insert` replaces any existing value under
84        // that name, which would silently drop every occurrence but the last
85        // of a header this crate now allows to repeat.
86        headers.append(header_name, header_value);
87    }
88
89    // Every failure below comes back as a `reqwest::Error`, and exactly one
90    // kind of it is worth its own variant: the timeout, because it is the
91    // only one Sendra itself caused. See `SendraError::Timeout`.
92    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    // Cleared here rather than trusted to already be empty — see
116    // `RedirectLog`. This assumes `send_prepared` calls through one
117    // `HttpClient` never overlap; a concurrent send through the same client
118    // would race on this log and misattribute hops between requests. See
119    // `RedirectLog`'s doc comment before changing that.
120    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        // Lossy by contract, and explicitly so: `.bytes()` then
148        // `from_utf8_lossy`, rather than reqwest's `.text()`, which reaches
149        // the same result by a route that reads like an accident. See the
150        // note on `Response::body`.
151        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            // Port 1 on localhost: if we ever got as far as connecting, this
178            // would surface as a Network error instead, which the assert catches.
179            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        // A config default is merged in before validation, so a bad header name
208        // in `.sendra/config.yaml` fails as loudly as one in a request file
209        // rather than being dropped on the way to the wire.
210        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        // The point of `build_client` being per-run rather than per-request,
245        // stated as an observation a server can make: three requests, one
246        // handshake.
247        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        // The counterpart, and the reason the test above is worth anything: it
269        // is what the code did before the client was hoisted out of
270        // `send_prepared`, and it is what the counter looks like when a client
271        // is *not* reused. Without this, a server that closed connections on
272        // its own would make the assertion above pass for the wrong reason.
273        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        // Many APIs compress their response regardless of what the client
295        // negotiated; without the "gzip" feature enabled on the client, this
296        // response body would be handed to `Response.body` as raw compressed
297        // bytes rather than the JSON text they hold.
298        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        // Confirms the bytes a real server receives, not just that
357        // `Request.headers` holds two entries: `send_prepared` has to use
358        // `HeaderMap::append` rather than `insert`, or the second value would
359        // silently replace the first before anything hits a socket.
360        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    // --- timeouts ----------------------------------------------------------
422
423    /// Comfortably longer than any timeout these tests configure: the server
424    /// is still holding the connection when the assertions run.
425    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        // The timeout has only ever been checked as a resolved `Config` value.
430        // This is it applied: a server that never answers, and a client that
431        // stops waiting on its own.
432        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        // The message a user sees, rather than only the variant a front-end
461        // matches on: "failed" alone would not tell them a setting caused it.
462        assert_eq!(
463            err.to_string(),
464            format!("request to `{url}` timed out after 0.3s")
465        );
466
467        // The clock that fired was the client's, not the server's: the server
468        // is still asleep, and has another twenty-nine-odd seconds to go.
469        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        // The config calls this a whole-request timeout, so a server that
478        // sends its headers promptly and then stalls forever mid-body has to
479        // be caught too — a different await in `send_prepared`, and one that
480        // would quietly return `Network` if only the first were classified.
481        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        // The half config-resolution tests cannot reach: that the number
504        // written in `.sendra/config.yaml` is the number the socket obeys.
505        // Resolved from a real file on disk, exactly as a run would, then put
506        // against a server that never answers.
507        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        // The counterpart that makes the variant above worth having: if every
539        // failed send came back as `Timeout`, the split would say nothing. A
540        // port with nothing behind it refuses immediately, so this is a
541        // connection failure and cannot be a slow one.
542        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    // --- `insecure` ----------------------------------------------------------
563
564    #[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    // --- `proxy` ---------------------------------------------------------------
598
599    #[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        // A target host nothing in this test binds or listens on: if the
609        // request reached it directly rather than through the proxy, this
610        // would fail to connect instead of succeeding.
611        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        // Absolute-form, target URL and all — the proof this went *through*
627        // the proxy rather than being sent directly to a server that just
628        // happened to be listening at `proxy_addr`.
629        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    // --- `client_cert` ---------------------------------------------------------
649
650    /// Write `contents` to `dir/name`, returning the path — the shared setup
651    /// every `client_cert` test below needs, for both the certificate and the
652    /// key.
653    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        // `insecure: true` because the server's own certificate is
663        // self-signed — see `start_mutual_tls_server`'s doc comment — and
664        // this test is about the *client* certificate, not the server's.
665        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        // The two settings are orthogonal — `insecure` is about verifying the
707        // *server's* certificate, `client_cert` is about presenting the
708        // *client's* — and this is both of them exercised in the one request
709        // that actually needs both: the mTLS server's self-signed identity
710        // requires `insecure`, and its client-verification requires
711        // `client_cert`. Neither alone gets a `200` here.
712        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    // --- non-UTF-8 response bodies -----------------------------------------
833
834    #[tokio::test]
835    async fn invalid_utf8_in_a_body_is_replaced_rather_than_erroring() {
836        // `Response.body` is a `String`, so bytes that are not UTF-8 have to
837        // go somewhere. They are replaced, and this pins exactly what with:
838        // U+FFFD per invalid sequence, the surrounding text untouched, and no
839        // error — see the contract on `Response::body`.
840        //
841        // 0xFF and 0xFE cannot begin a UTF-8 sequence at all, and 0xE2 0x28 is
842        // a truncated three-byte sequence: the shape a body cut off at the
843        // wrong boundary actually has.
844        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        // The everyday case: an endpoint that answers with an image. Status,
864        // headers and elapsed time are all still true and worth showing, so
865        // the response comes back rather than the request failing over its
866        // body's encoding.
867        //
868        // A PNG signature, whose second byte (0x50, 'P') is deliberately
869        // printable — proof the substitution is per invalid sequence and not a
870        // blanket rewrite of the whole body.
871        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        // Stated as a test rather than only as a doc comment, because it is
893        // the part that bites: what comes back is not what was sent, and no
894        // caller can recover the original bytes from here.
895        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    // --- redirect handling -------------------------------------------------
903
904    #[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        // The final response is what Sendra reports as *the* response...
922        assert_eq!(response.status, 200);
923        assert_eq!(response.body, "done");
924
925        // ...and the chain that got there is captured alongside it, oldest
926        // hop first, each carrying the status that redirected and the
927        // location it pointed at, resolved to an absolute URL.
928        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        // The overwhelmingly common case: nothing about an ordinary response
946        // should look any different from before this feature existed.
947        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        // The redirect itself is what came back — status, Location header and
979        // all — not the response at the far end of it.
980        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        // No chain: this response is not the result of following anything.
990        assert!(response.redirects.is_empty());
991    }
992
993    #[tokio::test]
994    async fn a_chain_longer_than_the_configured_maximum_is_an_error() {
995        // Three hops to reach `/end`; a maximum of one allows the first and
996        // must refuse the second.
997        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        // The other side of the same setting: a maximum generous enough for
1028        // the chain still reaches the end and still reports every hop.
1029        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        // The client — and its redirect log — is built once per run and
1051        // reused by every request; a chain from an earlier request must not
1052        // bleed into a later one that had none of its own.
1053        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    // --- `cookie_jar` ---------------------------------------------------------
1079
1080    #[tokio::test]
1081    async fn cookie_jar_disabled_does_not_carry_a_cookie_to_a_later_request() {
1082        // The opt-in default: without `cookie_jar`, a `Set-Cookie` from one
1083        // request must not show up as a `Cookie` header on the next one, even
1084        // through the one shared client every run already reuses.
1085        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        // The counterpart to the test above, and the whole feature: with
1119        // `cookie_jar` on, the same two requests through the same client now
1120        // carry the session cookie automatically.
1121        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        // Investigated directly against reqwest's own `CookieService` rather
1156        // than assumed: it fills in the jar's `Cookie` header only when the
1157        // request does not already carry one, so an explicit `Cookie:`
1158        // header on a request wins outright — no merge, and Sendra raises no
1159        // conflict over it, unlike `auth:` plus an explicit `Authorization`
1160        // header.
1161        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        // The advantage over `capture`'s manual `Set-Cookie` capture, which
1198        // can only see the final response's headers: reqwest's cookie
1199        // handling sits underneath its redirect-following, so a `Set-Cookie`
1200        // on an intermediate hop — never the final response here — is still
1201        // picked up.
1202        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        // Request 0 is `/start`, request 1 is `/end` (the followed redirect),
1233        // request 2 is `/profile`.
1234        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}