#![cfg(feature = "tls-rustls")]
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_rustls::TlsAcceptor;
use a2a_protocol_server::push::{HttpPushSender, PushSender};
use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
use a2a_protocol_types::push::{AuthenticationInfo, TaskPushNotificationConfig};
use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
struct TestCerts {
ca_cert_der: rustls_pki_types::CertificateDer<'static>,
server_cert_der: rustls_pki_types::CertificateDer<'static>,
server_key_der: rustls_pki_types::PrivateKeyDer<'static>,
}
fn generate_test_certs(san: &str) -> TestCerts {
let mut ca_params = rcgen::CertificateParams::new(vec![]).unwrap();
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params
.distinguished_name
.push(rcgen::DnType::CommonName, "Push E2E Test CA");
let ca_key = rcgen::KeyPair::generate().unwrap();
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let ca_issuer = rcgen::Issuer::new(ca_params, ca_key);
let mut server_params = rcgen::CertificateParams::new(vec![san.into()]).unwrap();
server_params
.distinguished_name
.push(rcgen::DnType::CommonName, san);
let server_key = rcgen::KeyPair::generate().unwrap();
let server_cert = server_params.signed_by(&server_key, &ca_issuer).unwrap();
TestCerts {
ca_cert_der: ca_cert.der().clone(),
server_cert_der: server_cert.der().clone(),
server_key_der: rustls_pki_types::PrivateKeyDer::Pkcs8(
rustls_pki_types::PrivatePkcs8KeyDer::from(server_key.serialize_der()),
),
}
}
fn ring_provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(rustls::crypto::ring::default_provider())
}
#[derive(Debug)]
struct Captured {
method: String,
path: String,
headers: HashMap<String, String>,
body: serde_json::Value,
}
async fn start_https_webhook(certs: &TestCerts) -> (SocketAddr, mpsc::UnboundedReceiver<Captured>) {
let server_config = rustls::ServerConfig::builder_with_provider(ring_provider())
.with_safe_default_protocol_versions()
.expect("ring provider supports default protocol versions")
.with_no_client_auth()
.with_single_cert(
vec![certs.server_cert_der.clone()],
certs.server_key_der.clone_key(),
)
.expect("build server TLS config");
let acceptor = TlsAcceptor::from(Arc::new(server_config));
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let (tx, rx) = mpsc::unbounded_channel::<Captured>();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let acceptor = acceptor.clone();
let tx = tx.clone();
tokio::spawn(async move {
let Ok(tls_stream) = acceptor.accept(stream).await else {
return;
};
let io = TokioIo::new(tls_stream);
let service = service_fn(move |req: Request<Incoming>| {
let tx = tx.clone();
async move {
let method = req.method().to_string();
let path = req.uri().path().to_string();
let headers = req
.headers()
.iter()
.map(|(k, v)| {
(k.as_str().to_owned(), v.to_str().unwrap_or("").to_owned())
})
.collect::<HashMap<_, _>>();
let body_bytes = req.collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).unwrap_or(serde_json::Value::Null);
let _ = tx.send(Captured {
method,
path,
headers,
body,
});
Ok::<_, std::convert::Infallible>(Response::new(Full::new(Bytes::from(
"ok",
))))
}
});
let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
.serve_connection(io, service)
.await;
});
}
});
(addr, rx)
}
fn status_event() -> StreamResponse {
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId::new("task-e2e"),
context_id: ContextId::new("ctx-e2e"),
status: TaskStatus::with_timestamp(TaskState::Completed),
metadata: None,
})
}
fn config_with_auth(url: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
tenant: None,
id: Some("cfg-e2e".to_owned()),
task_id: Some("task-e2e".to_owned()),
url: url.to_owned(),
token: Some("notif-token-xyz".to_owned()),
authentication: Some(AuthenticationInfo {
scheme: "bearer".to_owned(),
credentials: Some("secret-bearer-abc".to_owned()),
}),
}
}
fn client_config_trusting(ca: &rustls_pki_types::CertificateDer<'static>) -> rustls::ClientConfig {
let mut roots = rustls::RootCertStore::empty();
roots.add(ca.clone()).expect("add test CA");
rustls::ClientConfig::builder_with_provider(ring_provider())
.with_safe_default_protocol_versions()
.expect("ring provider supports default protocol versions")
.with_root_certificates(roots)
.with_no_client_auth()
}
#[tokio::test]
async fn https_push_delivered_over_real_tls() {
let certs = generate_test_certs("localhost");
let (addr, mut rx) = start_https_webhook(&certs).await;
let sender = HttpPushSender::with_tls_config(client_config_trusting(&certs.ca_cert_der))
.allow_private_urls();
let url = format!("https://localhost:{}/webhook", addr.port());
let event = status_event();
let config = config_with_auth(&url);
sender
.send(&url, &event, &config)
.await
.expect("HTTPS push delivery over real TLS should succeed");
let captured = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("webhook should be called within timeout")
.expect("channel should yield the captured request");
assert_eq!(captured.method, "POST");
assert_eq!(captured.path, "/webhook");
assert_eq!(
captured.headers.get("authorization").map(String::as_str),
Some("Bearer secret-bearer-abc"),
"bearer auth header should be delivered, got {:?}",
captured.headers.get("authorization")
);
assert_eq!(
captured
.headers
.get("x-a2a-notification-token")
.map(String::as_str),
Some("notif-token-xyz"),
"canonical token header (official-SDK receiver convention)"
);
assert_eq!(
captured
.headers
.get("a2a-notification-token")
.map(String::as_str),
None,
"the legacy bare token header was removed in 0.8; only the canonical \
x-a2a-notification-token is sent"
);
assert!(
captured.body.get("statusUpdate").is_some(),
"delivered body should be the StreamResponse event, got: {}",
captured.body
);
}
#[tokio::test]
async fn https_push_rejects_untrusted_certificate() {
let certs = generate_test_certs("localhost");
let (addr, _rx) = start_https_webhook(&certs).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("https://localhost:{}/webhook", addr.port());
let event = status_event();
let config = config_with_auth(&url);
let result = sender.send(&url, &event, &config).await;
assert!(
result.is_err(),
"delivery to a server with an untrusted certificate must fail"
);
}