Files
alkhttp/tests/client_tls.rs
T
glm-5.3-flash edbda6605b refactor(client): owned RetryConfig + TLS/mTLS test coverage (HY-06, COV-02)
- HttpClientConfig.retry_policy: ExponentialBackoff (semver anchor to a
  reqwest-retry concrete type) replaced by retry: RetryConfig — an
  owned struct of plain scalars (max_retries, initial_backoff,
  max_retry_interval, defaults matching the previous backoff exactly);
  the ExponentialBackoff policy is built internally by the middleware
  stack; no reqwest_retry type is public anymore
- ClientCertConfig fields documented (none had docs)
- new tests/client_tls.rs: per-test rcgen private PKI + tokio-rustls
  HTTPS server; drives the real SharedHttpClient through
  HttpClientConfig file paths — CA-bundle success path, private-roots
  rejection (source-chain assertion: invalid peer certificate),
  mTLS end-to-end with client identity, mTLS rejection without
  identity, and reload-to-CA-bundle interplay
- dev-deps: rcgen 0.14, tokio-rustls 0.26, rustls 0.23 (aws_lc_rs),
  rustls-pki-types 1, uuid

Verified: cargo test (288 + 5 TLS), --all-features (359 + suites),
--no-default-features (288; pre-existing warnings only), clippy
--all-targets -D warnings (default + all-features), fmt --check,
cargo doc --no-deps.

Tasks: review-001-client-config-and-cert-coverage
2026-08-30 07:24:34 +00:00

376 lines
14 KiB
Rust

//! TLS-path coverage for the outbound client (COV-02, review-001
//! follow-up): the `ca_bundle` and `client_cert` build paths had only
//! error-path tests (missing files); these tests exercise the success
//! end-to-end against a local private-roots TLS server —
//!
//! - private-roots verification: a client built with a CA bundle
//! connects to a server whose cert is issued by that CA;
//! - mTLS: a server requiring client certificates completes the
//! handshake only when the client presents its own identity;
//! - the full middleware stack (redirect policy + retry gate) rides on
//! the same builder, so a plain GET through `SharedHttpClient`
//! covers the TLS-configured construction path.
//!
//! Uses `rcgen` to mint a throwaway private PKI per test and
//! `tokio-rustls` for the server side; the client side is the real
//! `SharedHttpClient` configured via `HttpClientConfig`.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use alkhttp::client::{ClientCertConfig, HttpClientConfig, SharedHttpClient};
/// A throwaway private PKI: CA, server leaf for `127.0.0.1`/`localhost`,
/// and a client leaf, freshly minted per test.
struct TestPki {
ca_pem: Vec<u8>,
server_pem: Vec<u8>,
server_key_pem: Vec<u8>,
client_pem: Vec<u8>,
client_key_pem: Vec<u8>,
}
impl TestPki {
fn generate() -> Self {
let mut ca_params =
rcgen::CertificateParams::new(vec!["alkhttp test CA".to_string()]).expect("CA params");
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca_key = rcgen::KeyPair::generate().expect("CA key");
let ca_cert = ca_params.self_signed(&ca_key).expect("self-signed CA");
let issuer = rcgen::Issuer::from_params(&ca_params, &ca_key);
let mut server_params =
rcgen::CertificateParams::new(vec!["127.0.0.1".to_string(), "localhost".to_string()])
.expect("server params");
server_params.is_ca = rcgen::IsCa::NoCa;
let server_key = rcgen::KeyPair::generate().expect("server key");
let server_cert = server_params
.signed_by(&server_key, &issuer)
.expect("server leaf");
let mut client_params =
rcgen::CertificateParams::new(vec!["alkhttp test client".to_string()])
.expect("client params");
client_params.is_ca = rcgen::IsCa::NoCa;
client_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
let client_key = rcgen::KeyPair::generate().expect("client key");
let client_cert = client_params
.signed_by(&client_key, &issuer)
.expect("client leaf");
Self {
ca_pem: ca_cert.pem().into_bytes(),
server_pem: server_cert.pem().into_bytes(),
server_key_pem: server_key.serialize_pem().into_bytes(),
client_pem: client_cert.pem().into_bytes(),
client_key_pem: client_key.serialize_pem().into_bytes(),
}
}
/// Writes the CA bundle (and, when `with_client_cert`, the client
/// identity) to a fresh temp directory, as `HttpClientConfig`
/// expects paths. Returns the config pieces plus the temp dir.
fn write_config_files(
&self,
with_client_cert: bool,
) -> (Option<PathBuf>, Option<ClientCertConfig>, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"alkhttp-tls-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let write = |name: &str, bytes: &[u8]| {
let path = dir.join(name);
std::fs::write(&path, bytes).expect("write pem");
path
};
let ca = Some(write("ca.pem", &self.ca_pem));
let client = if with_client_cert {
Some(ClientCertConfig {
cert_pem: write("client-cert.pem", &self.client_pem),
key_pem: write("client-key.pem", &self.client_key_pem),
})
} else {
None
};
(ca, client, dir)
}
}
/// A minimal HTTPS/1.1 test server on 127.0.0.1 that either requires a
/// client certificate (mTLS) or accepts anonymous clients, answers
/// every request with `200 ok`, and counts completed TLS handshakes.
struct TlsTestServer {
origin: String,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
handshakes: Arc<AtomicU32>,
}
impl TlsTestServer {
fn handshakes(&self) -> u32 {
self.handshakes.load(Ordering::SeqCst)
}
async fn spawn(pki: &TestPki, require_client_cert: bool) -> Self {
use rustls_pki_types::pem::PemObject;
let server_certs: Vec<rustls_pki_types::CertificateDer<'static>> =
rustls_pki_types::pem::PemObject::pem_slice_iter(&pki.server_pem)
.map(|c: Result<rustls_pki_types::CertificateDer<'_>, _>| {
c.expect("server cert parses")
})
.collect();
let server_key = rustls_pki_types::PrivateKeyDer::from_pem_slice(&pki.server_key_pem)
.expect("server key parses");
let server_trust = if require_client_cert {
let mut trust = rustls::RootCertStore::empty();
let ca_iter = rustls_pki_types::pem::PemObject::pem_slice_iter(&pki.ca_pem).map(
|c: Result<rustls_pki_types::CertificateDer<'_>, _>| c.expect("CA cert parses"),
);
for ca in ca_iter {
trust.add(ca).expect("CA added to server trust store");
}
Some(trust)
} else {
None
};
let config = match &server_trust {
Some(trust) => {
let verifier =
rustls::server::WebPkiClientVerifier::builder(Arc::new(trust.clone()))
.build()
.expect("client verifier");
rustls::ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(server_certs, server_key)
.expect("server config with client auth")
}
None => rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(server_certs, server_key)
.expect("server config"),
};
let tls_config = Arc::new(config);
let handshakes = Arc::new(AtomicU32::new(0));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind 127.0.0.1:0");
let addr: SocketAddr = listener.local_addr().expect("local addr");
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let hs_counter = Arc::clone(&handshakes);
tokio::spawn(async move {
let acceptor = tokio_rustls::TlsAcceptor::from(tls_config);
let mut shutdown = std::pin::pin!(shutdown_rx);
loop {
let accept = tokio::select! {
_ = &mut shutdown => break,
accepted = listener.accept() => match accepted {
Ok((sock, _)) => sock,
Err(_) => break,
},
};
let acceptor = acceptor.clone();
let hs = Arc::clone(&hs_counter);
tokio::spawn(async move {
let Ok(mut tls_stream) = acceptor.accept(accept).await else {
return;
};
hs.fetch_add(1, Ordering::SeqCst);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 4096];
loop {
let n = tls_stream.read(&mut buf).await.unwrap_or(0);
if n == 0 {
break;
}
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
break;
}
}
let body = b"ok";
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
String::from_utf8_lossy(body),
);
let _ = tls_stream.write_all(response.as_bytes()).await;
let _ = tls_stream.shutdown().await;
});
}
});
Self {
origin: format!("https://127.0.0.1:{}", addr.port()),
shutdown: Some(shutdown_tx),
handshakes,
}
}
}
impl Drop for TlsTestServer {
fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
}
}
fn client_config(ca: Option<PathBuf>, cert: Option<ClientCertConfig>) -> HttpClientConfig {
HttpClientConfig {
ca_bundle: ca,
client_cert: cert,
..HttpClientConfig::default()
}
}
fn cleanup_dir(dir: &PathBuf) {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn client_with_ca_bundle_connects_to_private_roots_server() {
let pki = TestPki::generate();
let server = TlsTestServer::spawn(&pki, false).await;
let (ca, _cert, dir) = pki.write_config_files(false);
let http = SharedHttpClient::new(client_config(ca, None)).expect("client builds with CA");
let response = http
.client()
.get(format!("{}/ping", server.origin))
.send()
.await
.expect("request over private roots succeeds");
assert_eq!(response.status(), 200, "server answers over TLS");
assert_eq!(
response.text().await.unwrap(),
"ok",
"the TLS-secured body arrives intact"
);
assert_eq!(
server.handshakes(),
1,
"exactly one TLS handshake was completed"
);
cleanup_dir(&dir);
}
#[tokio::test]
async fn client_without_ca_bundle_rejects_private_roots_server() {
let pki = TestPki::generate();
let server = TlsTestServer::spawn(&pki, false).await;
let http = SharedHttpClient::new(HttpClientConfig::default())
.expect("client builds with default (webpki) roots");
let result = http
.client()
.get(format!("{}/ping", server.origin))
.send()
.await;
let error = result
.expect_err("a private-roots server must be rejected by a client without the CA bundle");
let text = error_chain_text(&error);
assert!(
text.contains("certificate"),
"the chain names the TLS verification failure, got: {text}"
);
}
/// Walks the full `std::error::Error` source chain (the retry
/// middleware wraps the transport error, so the TLS detail sits in the
/// `Caused by` chain) and joins it into one lowercase string.
fn error_chain_text(error: &reqwest_middleware::Error) -> String {
let mut text = error.to_string().to_lowercase();
let mut source = std::error::Error::source(error);
while let Some(err) = source {
text.push(' ');
text.push_str(&err.to_string().to_lowercase());
source = err.source();
}
text
}
#[tokio::test]
async fn mtls_client_cert_is_presented_and_accepted_end_to_end() {
let pki = TestPki::generate();
let server = TlsTestServer::spawn(&pki, true).await;
let (ca, cert, dir) = pki.write_config_files(true);
let http = SharedHttpClient::new(client_config(ca, cert))
.expect("client builds with CA bundle + client identity");
let response = http
.client()
.get(format!("{}/ping", server.origin))
.send()
.await
.expect("mTLS handshake with client identity succeeds");
assert_eq!(response.status(), 200, "server answers the mTLS client");
assert_eq!(response.text().await.unwrap(), "ok");
assert_eq!(
server.handshakes(),
1,
"the client-cert handshake completed through the full middleware stack"
);
cleanup_dir(&dir);
}
#[tokio::test]
async fn mtls_server_rejects_client_without_identity() {
let pki = TestPki::generate();
let server = TlsTestServer::spawn(&pki, true).await;
let (ca, _cert, dir) = pki.write_config_files(false);
let http = SharedHttpClient::new(client_config(ca, None))
.expect("client builds with CA bundle but no client identity");
let result = http
.client()
.get(format!("{}/ping", server.origin))
.send()
.await;
let error = result
.expect_err("an mTLS-requiring server must reject a client that presents no certificate");
let text = error_chain_text(&error);
assert!(
text.contains("certificate") || text.contains("alert") || text.contains("handshake"),
"the chain names a TLS/certificate-level rejection, got: {text}"
);
cleanup_dir(&dir);
}
#[tokio::test]
async fn reload_to_a_ca_bundle_backed_client_succeeds() {
let pki = TestPki::generate();
let server = TlsTestServer::spawn(&pki, false).await;
let (ca, _cert, dir) = pki.write_config_files(false);
let http = SharedHttpClient::new(HttpClientConfig::default()).expect("initial client");
assert!(
http.client()
.get(format!("{}/ping", server.origin))
.send()
.await
.is_err(),
"before the reload the private-roots server is unreachable"
);
let reloaded = client_config(ca, None);
http.reload(reloaded)
.await
.expect("reload with a valid CA bundle succeeds");
let response = http
.client()
.get(format!("{}/ping", server.origin))
.send()
.await
.expect("after the reload the CA bundle is trusted");
assert_eq!(response.status(), 200);
cleanup_dir(&dir);
tokio::time::sleep(Duration::from_millis(1)).await;
}