pub mod client;
pub mod response;
use std::time::Instant;
use crate::config::Config;
use crate::error::SendraError;
use crate::http::client::HttpClient;
use crate::http::response::Response;
use crate::request::Request;
pub async fn send(
request: &Request,
client: &HttpClient,
config: &Config,
) -> Result<Response, SendraError> {
send_prepared(&config.apply(request), client).await
}
pub async fn send_prepared(
request: &Request,
client: &HttpClient,
) -> Result<Response, SendraError> {
let mut headers = reqwest::header::HeaderMap::new();
for (name, value) in &request.headers {
let header_name = reqwest::header::HeaderName::try_from(name.as_str()).map_err(|e| {
SendraError::InvalidHeader {
name: name.clone(),
reason: e.to_string(),
}
})?;
let header_value = reqwest::header::HeaderValue::try_from(value.as_str()).map_err(|e| {
SendraError::InvalidHeader {
name: name.clone(),
reason: e.to_string(),
}
})?;
headers.append(header_name, header_value);
}
let send_err = |source: reqwest::Error| {
if source.is_timeout() {
SendraError::Timeout {
url: request.url.clone(),
timeout: client.timeout,
source,
}
} else {
SendraError::Network {
url: request.url.clone(),
source,
}
}
};
let mut builder = client
.inner
.request(request.method.into(), &request.url)
.headers(headers);
if let Some(body) = &request.body {
builder = builder.body(body.clone());
}
client.redirects.lock().unwrap().clear();
let started = Instant::now();
let response = builder.send().await.map_err(send_err)?;
let redirects = std::mem::take(&mut *client.redirects.lock().unwrap());
let status = response.status();
let header_pairs = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_owned(),
value
.to_str()
.unwrap_or("<non-utf8 header value>")
.to_owned(),
)
})
.collect();
let bytes = response.bytes().await.map_err(send_err)?;
let elapsed = started.elapsed();
Ok(Response {
status: status.as_u16(),
status_text: status.canonical_reason().unwrap_or("").to_owned(),
headers: header_pairs,
body: String::from_utf8_lossy(&bytes).into_owned(),
elapsed,
redirects,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::client::build_client;
use crate::http::response::RedirectHop;
use crate::test_support::{
get, ok_bytes, ok_response, redirect_response, redirect_with_cookie_response,
set_cookie_response, start_cookie_server, start_mutual_tls_server,
start_proxy_recording_server, start_route_server, start_self_signed_tls_server,
start_stalling_server, CountingServer, Stall,
};
use crate::{config, Method, SendraError};
use std::collections::BTreeMap;
use std::time::Duration;
#[tokio::test]
async fn invalid_header_name_is_reported_before_any_network_call() {
let request = Request {
name: None,
method: Method::Get,
url: "http://127.0.0.1:1/".to_string(),
headers: vec![("bad header".to_string(), "x".to_string())],
query: Vec::new(),
body: None,
json: None,
body_file: None,
form: Vec::new(),
multipart: Vec::new(),
auth: None,
assertions: None,
pre_request: None,
post_request: None,
capture: None,
retry: None,
};
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let err = send(&request, &client, &config)
.await
.expect_err("invalid header must error");
assert!(
matches!(err, SendraError::InvalidHeader { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn an_invalid_header_from_the_config_is_reported_the_same_way() {
let request = Request {
name: None,
method: Method::Get,
url: "http://127.0.0.1:1/".to_string(),
headers: Vec::new(),
query: Vec::new(),
body: None,
json: None,
body_file: None,
form: Vec::new(),
multipart: Vec::new(),
auth: None,
assertions: None,
pre_request: None,
post_request: None,
capture: None,
retry: None,
};
let config = Config {
headers: BTreeMap::from([("bad header".to_string(), "x".to_string())]),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let err = send(&request, &client, &config)
.await
.expect_err("invalid header must error");
assert!(
matches!(err, SendraError::InvalidHeader { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn one_client_sends_every_request_down_one_connection() {
let server = CountingServer::start();
let config = Config::default();
let client = build_client(&config).expect("a client builds");
for _ in 0..3 {
let response = send(&get(&server.url()), &client, &config)
.await
.expect("the mock server answers");
assert_eq!(response.status, 200);
}
assert_eq!(server.requests(), 3, "all three requests were served");
assert_eq!(
server.connections(),
1,
"three requests through one client must reuse one connection"
);
}
#[tokio::test]
async fn a_client_per_request_opens_a_connection_per_request() {
let server = CountingServer::start();
let config = Config::default();
for _ in 0..3 {
let client = build_client(&config).expect("a client builds");
let response = send(&get(&server.url()), &client, &config)
.await
.expect("the mock server answers");
assert_eq!(response.status, 200);
}
assert_eq!(server.requests(), 3, "all three requests were served");
assert_eq!(
server.connections(),
3,
"a fresh client per request cannot reuse anything"
);
}
#[tokio::test]
async fn a_gzip_encoded_response_is_decompressed_before_reaching_response_body() {
use std::io::Write;
let body = b"{\"hello\":\"world\"}";
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(body).expect("gzip encodes into memory");
let compressed = encoder.finish().expect("gzip stream finalises");
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
let addr = listener.local_addr().expect("the listener has an address");
std::thread::spawn(move || {
use std::io::{BufRead, BufReader};
if let Ok(stream) = listener.accept().map(|(s, _)| s) {
let mut writer = stream.try_clone().expect("the socket clones");
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).expect("a request line arrives");
loop {
let mut header = String::new();
reader.read_line(&mut header).expect("headers keep coming");
if header == "\r\n" {
break;
}
}
writer
.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\n\r\n",
compressed.len()
)
.as_bytes(),
)
.expect("status line and headers write");
writer
.write_all(&compressed)
.expect("the compressed body writes");
writer.flush().expect("the response flushes");
}
});
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect("the mock server answers");
assert_eq!(response.status, 200);
assert_eq!(
response.body, "{\"hello\":\"world\"}",
"the body must be the decompressed text, not the raw gzip bytes"
);
}
#[tokio::test]
async fn a_repeated_header_actually_goes_out_twice_on_the_wire() {
use std::io::{BufRead, BufReader, Write};
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
let addr = listener.local_addr().expect("the listener has an address");
let seen: std::sync::Arc<std::sync::Mutex<Vec<String>>> = Default::default();
let seen_in_thread = seen.clone();
std::thread::spawn(move || {
if let Ok(stream) = listener.accept().map(|(s, _)| s) {
let mut writer = stream.try_clone().expect("the socket clones");
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).expect("a request line arrives");
loop {
let mut header = String::new();
match reader.read_line(&mut header) {
Ok(0) | Err(_) => return,
Ok(_) if header == "\r\n" => break,
Ok(_) => seen_in_thread
.lock()
.unwrap()
.push(header.trim_end().to_string()),
}
}
writer
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.expect("status line and headers write");
writer.flush().expect("the response flushes");
}
});
let request = Request {
headers: vec![
("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
],
..get(&format!("http://{addr}/"))
};
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let response = send(&request, &client, &config)
.await
.expect("the mock server answers");
assert_eq!(response.status, 200);
let lines = seen.lock().unwrap().clone();
let matching: Vec<&String> = lines
.iter()
.filter(|line| line.to_ascii_lowercase().starts_with("x-forwarded-for:"))
.collect();
assert_eq!(
matching.len(),
2,
"both values should have gone out as two separate header lines, got {lines:?}"
);
assert!(matching.iter().any(|l| l.contains("1.2.3.4")));
assert!(matching.iter().any(|l| l.contains("5.6.7.8")));
}
const STALL: Duration = Duration::from_secs(30);
#[tokio::test]
async fn a_server_slower_than_the_timeout_fails_with_a_timeout_error() {
let addr = start_stalling_server(Stall::BeforeResponding, STALL);
let config = Config {
timeout: Duration::from_millis(300),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let url = format!("http://{addr}/");
let started = Instant::now();
let err = send(&get(&url), &client, &config)
.await
.expect_err("a server that never answers must not hang the run");
let waited = started.elapsed();
match &err {
SendraError::Timeout {
url: got, timeout, ..
} => {
assert_eq!(got, &url);
assert_eq!(
*timeout,
Duration::from_millis(300),
"the error must name the limit that was actually applied"
);
}
other => panic!("expected a timeout, got {other:?}"),
}
assert_eq!(
err.to_string(),
format!("request to `{url}` timed out after 0.3s")
);
assert!(
waited < STALL / 2,
"gave up after {waited:?}, which is not the configured 300ms"
);
}
#[tokio::test]
async fn the_timeout_covers_the_body_read_not_just_the_response_headers() {
let addr = start_stalling_server(Stall::MidBody, STALL);
let config = Config {
timeout: Duration::from_millis(300),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let started = Instant::now();
let err = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect_err("a body that never arrives must time out like a response that never does");
let waited = started.elapsed();
assert!(
matches!(err, SendraError::Timeout { .. }),
"a stall after the headers is still a timeout, got {err:?}"
);
assert!(waited < STALL / 2, "gave up after {waited:?}");
}
#[tokio::test]
async fn a_timeout_from_a_config_file_is_the_one_that_is_enforced() {
let temp = tempfile::tempdir().expect("a temp dir");
let project_dir = temp.path().join(".sendra");
std::fs::create_dir_all(&project_dir).expect("the project dir is created");
std::fs::write(project_dir.join("config.yaml"), "timeout_seconds: 1\n")
.expect("the config file writes");
let config = Config::resolve_from(temp.path(), None).expect("the config resolves");
assert_eq!(config.timeout, Duration::from_secs(1), "the file was read");
let addr = start_stalling_server(Stall::BeforeResponding, STALL);
let client = build_client(&config).expect("a client builds");
let started = Instant::now();
let err = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect_err("the configured second must run out");
let waited = started.elapsed();
match err {
SendraError::Timeout { timeout, .. } => assert_eq!(timeout, Duration::from_secs(1)),
other => panic!("expected a timeout, got {other:?}"),
}
assert!(
waited >= Duration::from_millis(900),
"gave up after {waited:?}, sooner than the second the file asked for"
);
assert!(waited < STALL / 2, "gave up after {waited:?}");
}
#[tokio::test]
async fn a_connection_failure_is_still_a_network_error_not_a_timeout() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
let addr = listener.local_addr().expect("the listener has an address");
drop(listener);
let config = Config {
timeout: Duration::from_secs(30),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let err = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect_err("nothing is listening on that port");
assert!(
matches!(err, SendraError::Network { .. }),
"a refused connection is a fact about the network, not about the timeout, got {err:?}"
);
}
#[tokio::test]
async fn a_self_signed_endpoint_fails_verification_by_default() {
let addr = start_self_signed_tls_server();
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let err = send(&get(&format!("https://{addr}/")), &client, &config)
.await
.expect_err("a self-signed certificate must not verify by default");
assert!(
matches!(err, SendraError::Network { .. }),
"a certificate failure is a fact about the connection, got {err:?}"
);
}
#[tokio::test]
async fn insecure_true_accepts_the_same_self_signed_endpoint() {
let addr = start_self_signed_tls_server();
let config = Config {
insecure: true,
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("https://{addr}/")), &client, &config)
.await
.expect("--insecure must let the same handshake through");
assert_eq!(response.status, 200);
assert_eq!(response.body, "ok");
}
#[tokio::test]
async fn a_configured_proxy_actually_receives_the_request() {
let (proxy_addr, seen) = start_proxy_recording_server();
let config = Config {
proxy: Some(format!("http://{proxy_addr}")),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let response = send(
&get("http://example-target.invalid/widgets"),
&client,
&config,
)
.await
.expect("the proxy stand-in answers 200 to whatever reaches it");
assert_eq!(response.status, 200);
let request_line = seen
.lock()
.unwrap()
.take()
.expect("the proxy should have seen exactly one request");
assert_eq!(
request_line, "GET http://example-target.invalid/widgets HTTP/1.1",
"the proxy did not see an absolute-form request line: {request_line:?}"
);
}
#[tokio::test]
async fn an_invalid_proxy_url_is_a_client_error() {
let config = Config {
proxy: Some("not a url".to_string()),
..Config::default()
};
let Err(err) = build_client(&config) else {
panic!("a malformed proxy URL must not build a client");
};
assert!(matches!(err, SendraError::Client(_)), "got {err:?}");
}
fn write_pem(dir: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, contents).unwrap();
path
}
#[tokio::test]
async fn a_request_without_a_client_certificate_is_rejected_by_the_mtls_server() {
let (addr, _client_cert_pem, _client_key_pem) = start_mutual_tls_server();
let config = Config {
insecure: true,
..Config::default()
};
let client = build_client(&config).expect("a client with no identity still builds");
let err = send(&get(&format!("https://{addr}/")), &client, &config)
.await
.expect_err("the server demands a client certificate this client never presented");
assert!(
matches!(err, SendraError::Network { .. }),
"a rejected handshake is a fact about the connection, got {err:?}"
);
}
#[tokio::test]
async fn a_correctly_configured_client_certificate_authenticates() {
let (addr, client_cert_pem, client_key_pem) = start_mutual_tls_server();
let dir = tempfile::tempdir().unwrap();
let cert_path = write_pem(dir.path(), "client.pem", &client_cert_pem);
let key_path = write_pem(dir.path(), "client-key.pem", &client_key_pem);
let config = Config {
insecure: true,
client_cert: Some(cert_path),
client_key: Some(key_path),
..Config::default()
};
let client = build_client(&config).expect("a matching cert/key pair builds a client");
let response = send(&get(&format!("https://{addr}/")), &client, &config)
.await
.expect("the server accepts a client certificate it issued the CA for");
assert_eq!(response.status, 200);
assert_eq!(response.body, "ok");
}
#[tokio::test]
async fn insecure_and_a_client_certificate_together_both_apply() {
let (addr, client_cert_pem, client_key_pem) = start_mutual_tls_server();
let dir = tempfile::tempdir().unwrap();
let cert_path = write_pem(dir.path(), "client.pem", &client_cert_pem);
let key_path = write_pem(dir.path(), "client-key.pem", &client_key_pem);
let config = Config {
insecure: true,
client_cert: Some(cert_path),
client_key: Some(key_path),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("https://{addr}/")), &client, &config)
.await
.expect("insecure + a valid client certificate together must succeed");
assert_eq!(response.status, 200);
}
#[tokio::test]
async fn a_missing_client_cert_file_is_a_typed_error_naming_the_path() {
let dir = tempfile::tempdir().unwrap();
let missing_cert = dir.path().join("nope.pem");
let key_path = write_pem(dir.path(), "client-key.pem", "irrelevant");
let config = Config {
client_cert: Some(missing_cert.clone()),
client_key: Some(key_path),
..Config::default()
};
let Err(err) = build_client(&config) else {
panic!("a missing cert file must not build a client");
};
match err {
SendraError::ClientCertIo { path, .. } => assert_eq!(path, missing_cert),
other => panic!("expected ClientCertIo, got {other:?}"),
}
}
#[tokio::test]
async fn a_missing_client_key_file_is_a_typed_error_naming_the_path() {
let dir = tempfile::tempdir().unwrap();
let cert_path = write_pem(dir.path(), "client.pem", "irrelevant");
let missing_key = dir.path().join("nope-key.pem");
let config = Config {
client_cert: Some(cert_path),
client_key: Some(missing_key.clone()),
..Config::default()
};
let Err(err) = build_client(&config) else {
panic!("a missing key file must not build a client");
};
match err {
SendraError::ClientCertIo { path, .. } => assert_eq!(path, missing_key),
other => panic!("expected ClientCertIo, got {other:?}"),
}
}
#[tokio::test]
async fn malformed_pem_content_is_a_client_error_not_a_panic() {
let dir = tempfile::tempdir().unwrap();
let cert_path = write_pem(dir.path(), "client.pem", "not a pem file at all");
let key_path = write_pem(dir.path(), "client-key.pem", "also not a pem file");
let config = Config {
client_cert: Some(cert_path),
client_key: Some(key_path),
..Config::default()
};
let Err(err) = build_client(&config) else {
panic!("garbage PEM content must not build a client");
};
assert!(matches!(err, SendraError::Client(_)), "got {err:?}");
}
#[tokio::test]
async fn only_a_client_cert_with_no_key_is_refused() {
let dir = tempfile::tempdir().unwrap();
let cert_path = write_pem(dir.path(), "client.pem", "irrelevant");
let config = Config {
client_cert: Some(cert_path),
client_key: None,
..Config::default()
};
let Err(err) = build_client(&config) else {
panic!("a cert with no key must be refused");
};
assert!(
matches!(err, SendraError::ClientCertIncomplete { which: "cert" }),
"got {err:?}"
);
}
#[tokio::test]
async fn only_a_client_key_with_no_cert_is_refused() {
let dir = tempfile::tempdir().unwrap();
let key_path = write_pem(dir.path(), "client-key.pem", "irrelevant");
let config = Config {
client_cert: None,
client_key: Some(key_path),
..Config::default()
};
let Err(err) = build_client(&config) else {
panic!("a key with no cert must be refused");
};
assert!(
matches!(err, SendraError::ClientCertIncomplete { which: "key" }),
"got {err:?}"
);
}
#[tokio::test]
async fn invalid_utf8_in_a_body_is_replaced_rather_than_erroring() {
let body = b"ok \xff\xfe then \xe2\x28 end";
let addr = start_route_server(vec![("/", ok_bytes("text/plain", body))]);
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect("an undecodable body is not a failed request");
assert_eq!(response.status, 200, "the response itself is fine");
assert_eq!(
response.body, "ok \u{fffd}\u{fffd} then \u{fffd}( end",
"each invalid sequence becomes one replacement character, and the \
valid text around it survives unchanged"
);
}
#[tokio::test]
async fn a_wholly_binary_body_comes_back_as_a_response_not_an_error() {
let body: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let addr = start_route_server(vec![("/", ok_bytes("image/png", body))]);
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect("a binary body is not a failed request");
assert_eq!(response.status, 200);
assert_eq!(
response
.headers
.iter()
.find(|(name, _)| name == "content-type")
.map(|(_, value)| value.as_str()),
Some("image/png"),
"everything but the body is unaffected"
);
assert_eq!(response.body, "\u{fffd}PNG\r\n\u{1a}\n");
assert_ne!(
response.body.as_bytes(),
body,
"the conversion is lossy, and `Response.body` is not round-trippable"
);
}
#[tokio::test]
async fn a_redirect_is_followed_and_the_chain_is_captured_on_the_final_response() {
let addr = start_route_server(vec![
(
"/start",
redirect_response(301, "Moved Permanently", "/next"),
),
("/next", redirect_response(302, "Found", "/end")),
("/end", ok_response("done")),
]);
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/start")), &client, &config)
.await
.expect("the chain resolves");
assert_eq!(response.status, 200);
assert_eq!(response.body, "done");
assert_eq!(
response.redirects,
vec![
RedirectHop {
status: 301,
location: format!("http://{addr}/next"),
},
RedirectHop {
status: 302,
location: format!("http://{addr}/end"),
},
]
);
}
#[tokio::test]
async fn a_request_with_no_redirect_reports_an_empty_chain() {
let addr = start_route_server(vec![("/", ok_response("hello"))]);
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/")), &client, &config)
.await
.expect("a plain response");
assert_eq!(response.status, 200);
assert!(response.redirects.is_empty());
}
#[tokio::test]
async fn disabling_redirects_reports_the_3xx_response_itself_not_an_error() {
let addr = start_route_server(vec![
(
"/start",
redirect_response(301, "Moved Permanently", "/end"),
),
("/end", ok_response("done")),
]);
let config = Config {
redirects: config::FollowRedirects::Disabled,
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/start")), &client, &config)
.await
.expect("a 3xx is a normal, inspectable response");
assert_eq!(response.status, 301);
assert_eq!(
response
.headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("location"))
.map(|(_, value)| value.as_str()),
Some("/end")
);
assert!(response.redirects.is_empty());
}
#[tokio::test]
async fn a_chain_longer_than_the_configured_maximum_is_an_error() {
let addr = start_route_server(vec![
("/start", redirect_response(301, "Moved Permanently", "/a")),
("/a", redirect_response(302, "Found", "/b")),
("/b", redirect_response(303, "See Other", "/end")),
("/end", ok_response("done")),
]);
let config = Config {
redirects: config::FollowRedirects::Follow(1),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let err = send(&get(&format!("http://{addr}/start")), &client, &config)
.await
.expect_err("a chain past the configured maximum must not resolve to a response");
match err {
SendraError::Network { source, .. } => {
let message = source.to_string();
assert!(
message.contains("redirect") || std::error::Error::source(&source).is_some(),
"expected a redirect-shaped error, got {message}"
);
}
other => panic!("expected Network, got {other:?}"),
}
}
#[tokio::test]
async fn a_custom_maximum_higher_than_the_chain_still_resolves() {
let addr = start_route_server(vec![
("/start", redirect_response(301, "Moved Permanently", "/a")),
("/a", redirect_response(302, "Found", "/end")),
("/end", ok_response("done")),
]);
let config = Config {
redirects: config::FollowRedirects::Follow(5),
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/start")), &client, &config)
.await
.expect("two hops is well within a maximum of five");
assert_eq!(response.status, 200);
assert_eq!(response.redirects.len(), 2);
}
#[tokio::test]
async fn each_request_through_a_reused_client_reports_only_its_own_chain() {
let addr = start_route_server(vec![
(
"/redirected",
redirect_response(301, "Moved Permanently", "/plain"),
),
("/plain", ok_response("done")),
]);
let config = Config::default();
let client = build_client(&config).expect("a client builds");
let redirected = send(&get(&format!("http://{addr}/redirected")), &client, &config)
.await
.expect("the redirect resolves");
assert_eq!(redirected.redirects.len(), 1);
let plain = send(&get(&format!("http://{addr}/plain")), &client, &config)
.await
.expect("a direct hit on the same client");
assert!(
plain.redirects.is_empty(),
"the previous request's chain must not leak into this one"
);
}
#[tokio::test]
async fn cookie_jar_disabled_does_not_carry_a_cookie_to_a_later_request() {
let (addr, seen) = start_cookie_server(vec![
(
"/login",
set_cookie_response("session=abc123; Path=/", "logged in"),
),
("/profile", ok_response("profile")),
]);
let config = Config::default();
assert!(!config.cookie_jar, "off by default");
let client = build_client(&config).expect("a client builds");
send(&get(&format!("http://{addr}/login")), &client, &config)
.await
.expect("login responds");
send(&get(&format!("http://{addr}/profile")), &client, &config)
.await
.expect("profile responds");
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[0], None,
"no cookie existed to send on the first request"
);
assert_eq!(
seen[1], None,
"with the jar off, the session cookie from /login must not reach /profile"
);
}
#[tokio::test]
async fn cookie_jar_enabled_carries_a_cookie_to_a_later_request() {
let (addr, seen) = start_cookie_server(vec![
(
"/login",
set_cookie_response("session=abc123; Path=/", "logged in"),
),
("/profile", ok_response("profile")),
]);
let config = Config {
cookie_jar: true,
..Config::default()
};
let client = build_client(&config).expect("a client builds");
send(&get(&format!("http://{addr}/login")), &client, &config)
.await
.expect("login responds");
send(&get(&format!("http://{addr}/profile")), &client, &config)
.await
.expect("profile responds");
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert_eq!(seen[0], None, "no cookie existed yet for the login request");
assert_eq!(
seen[1].as_deref(),
Some("session=abc123"),
"the jar must resend the cookie /login set: got {:?}",
seen[1]
);
}
#[tokio::test]
async fn an_explicit_cookie_header_is_sent_as_is_and_the_jar_is_not_consulted() {
let (addr, seen) = start_cookie_server(vec![
(
"/login",
set_cookie_response("session=abc123; Path=/", "logged in"),
),
("/profile", ok_response("profile")),
]);
let config = Config {
cookie_jar: true,
..Config::default()
};
let client = build_client(&config).expect("a client builds");
send(&get(&format!("http://{addr}/login")), &client, &config)
.await
.expect("login responds, and the jar stores its session cookie");
let mut request = get(&format!("http://{addr}/profile"));
request.headers = vec![("Cookie".to_string(), "session=manual-override".to_string())];
send(&request, &client, &config)
.await
.expect("profile responds");
let seen = seen.lock().unwrap();
assert_eq!(
seen[1].as_deref(),
Some("session=manual-override"),
"the request's own Cookie header must reach the server unchanged, \
not merged with the jar's stored cookie: got {:?}",
seen[1]
);
}
#[tokio::test]
async fn cookie_jar_captures_a_set_cookie_from_an_intermediate_redirect_hop() {
let (addr, seen) = start_cookie_server(vec![
(
"/start",
redirect_with_cookie_response(
302,
"Found",
"/end",
"session=from-a-redirect-hop; Path=/",
),
),
("/end", ok_response("done")),
("/profile", ok_response("profile")),
]);
let config = Config {
cookie_jar: true,
..Config::default()
};
let client = build_client(&config).expect("a client builds");
let response = send(&get(&format!("http://{addr}/start")), &client, &config)
.await
.expect("the redirect chain resolves");
assert_eq!(response.body, "done");
send(&get(&format!("http://{addr}/profile")), &client, &config)
.await
.expect("profile responds");
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 3);
assert_eq!(
seen[2].as_deref(),
Some("session=from-a-redirect-hop"),
"a Set-Cookie on the intermediate /start->/end hop must still \
have been captured: got {:?}",
seen[2]
);
}
}