use std::sync::{Arc, Once};
use std::time::Duration;
use platform_core::automation::http_client::AsyncHttpClientService;
use platform_core::automation::{AsyncHttpRequest, ASYNC_HTTP_REQUEST};
use platform_core::platform::FunctionOptions;
use platform_core::{overrides, resources, AppConfigReader, EventEnvelope, Platform, PostOffice};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_rustls::rustls;
use tokio_rustls::TlsAcceptor;
fn setup_config() {
static INIT: Once = Once::new();
INIT.call_once(|| {
resources::prepend_resource_root("tests/resources");
let holding = std::env::temp_dir().join(format!("mercury-tls-test-{}", std::process::id()));
overrides::set("transient.data.store", &holding.display().to_string());
let _ = AppConfigReader::get_instance();
});
}
fn register_http_client(platform: &Platform) {
platform
.register_with_options(
ASYNC_HTTP_REQUEST,
Arc::new(AsyncHttpClientService::new(platform)),
10,
FunctionOptions {
zero_traced: false,
interceptor: true,
private: false,
},
)
.expect("http client route");
}
async fn start_tls_server() -> u16 {
let signed =
rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.expect("self-signed cert");
let cert_der = signed.cert.der().clone();
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(signed.key_pair.serialize_der().into());
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)
.expect("server tls config");
let acceptor = TlsAcceptor::from(Arc::new(config));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
tokio::spawn(async move {
loop {
let Ok((tcp, _)) = listener.accept().await else {
break;
};
let acceptor = acceptor.clone();
tokio::spawn(async move {
let Ok(mut tls) = acceptor.accept(tcp).await else {
return;
};
let mut buffer = vec![0u8; 4096];
let mut request = Vec::new();
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
match tls.read(&mut buffer).await {
Ok(0) | Err(_) => return,
Ok(n) => request.extend_from_slice(&buffer[..n]),
}
}
let body = "{\"hello\":\"tls\"}";
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
content-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = tls.write_all(response.as_bytes()).await;
let _ = tls.shutdown().await;
});
}
});
port
}
async fn https_request(platform: &Platform, port: u16, trust_all_cert: bool) -> EventEnvelope {
let request = AsyncHttpRequest::new()
.set_method("GET")
.set_url("/api/hello")
.set_target_host(&format!("https://127.0.0.1:{port}"))
.set_trust_all_cert(trust_all_cert);
let po = PostOffice::new(platform);
po.request(
EventEnvelope::new()
.set_to(ASYNC_HTTP_REQUEST)
.set_raw_body(request.to_value()),
Duration::from_secs(10),
)
.await
.expect("reply envelope")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn https_with_trust_all_cert_reaches_a_self_signed_endpoint() {
setup_config();
let platform = Platform::new();
register_http_client(&platform);
let port = start_tls_server().await;
let reply = https_request(&platform, port, true).await;
assert_eq!(200, reply.status(), "unexpected reply: {:?}", reply.body());
let body = reply.body();
let map = body.as_map().expect("json body decoded to a map");
let hello = map
.iter()
.find(|(k, _)| k.as_str() == Some("hello"))
.map(|(_, v)| v.as_str().unwrap_or_default())
.unwrap_or_default();
assert_eq!("tls", hello);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn https_strict_mode_rejects_a_self_signed_certificate() {
setup_config();
let platform = Platform::new();
register_http_client(&platform);
let port = start_tls_server().await;
let reply = https_request(&platform, port, false).await;
assert_eq!(500, reply.status(), "unexpected reply: {:?}", reply.body());
let message = reply.body().as_str().unwrap_or_default().to_string();
assert!(
message.contains("TLS handshake failed"),
"error should surface the TLS failure in-band: {message}"
);
}
#[test]
fn trust_all_cert_round_trips_with_the_target_host() {
let request = AsyncHttpRequest::new()
.set_method("GET")
.set_url("/")
.set_target_host("https://example.com")
.set_trust_all_cert(true);
let restored = AsyncHttpRequest::from_value(&request.to_value());
assert!(restored.trust_all_cert());
assert_eq!(Some("https://example.com"), restored.target_host());
let plain = AsyncHttpRequest::new().set_target_host("https://example.com");
let restored = AsyncHttpRequest::from_value(&plain.to_value());
assert!(!restored.trust_all_cert());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unsupported_protocol_is_rejected_in_band() {
setup_config();
let platform = Platform::new();
register_http_client(&platform);
let request = AsyncHttpRequest::new()
.set_method("GET")
.set_url("/")
.set_target_host("ftp://example.com");
let po = PostOffice::new(&platform);
let reply = po
.request(
EventEnvelope::new()
.set_to(ASYNC_HTTP_REQUEST)
.set_raw_body(request.to_value()),
Duration::from_secs(5),
)
.await
.expect("reply envelope");
assert_eq!(400, reply.status());
}