use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
use a2a_protocol_types::push::{AuthenticationInfo, TaskPushNotificationConfig};
use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
use a2a_protocol_server::push::{HttpPushSender, PushSender};
use std::time::Duration;
async fn wait_for(what: &str, mut condition: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !condition() {
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for {what}"
);
tokio::time::sleep(Duration::from_millis(2)).await;
}
}
fn status_event() -> StreamResponse {
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId::new("task-1"),
context_id: ContextId::new("ctx"),
status: TaskStatus::new(TaskState::Working),
metadata: None,
})
}
fn base_config(url: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig::new("task-1", url)
}
async fn mock_server(
status: u16,
request_counter: Arc<AtomicUsize>,
) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
for _ in 0..5 {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let counter = Arc::clone(&request_counter);
tokio::spawn(async move {
counter.fetch_add(1, Ordering::SeqCst);
stream.readable().await.unwrap();
let mut buf = vec![0u8; 4096];
let _ = stream.try_read(&mut buf);
let response = format!(
"HTTP/1.1 {status} OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.writable().await.unwrap();
let _ = stream.try_write(response.as_bytes());
});
}
});
(addr, handle)
}
async fn mock_server_with_headers(
captured: Arc<std::sync::Mutex<Vec<String>>>,
) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
for _ in 0..3 {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let captured = Arc::clone(&captured);
tokio::spawn(async move {
stream.readable().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = stream.try_read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
captured.lock().unwrap().push(request);
let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
stream.writable().await.unwrap();
let _ = stream.try_write(response.as_bytes());
});
}
});
(addr, handle)
}
#[tokio::test]
async fn successful_delivery_on_first_attempt() {
let counter = Arc::new(AtomicUsize::new(0));
let (addr, handle) = mock_server(200, Arc::clone(&counter)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the delivery counter to reach 1", || {
counter.load(Ordering::SeqCst) == 1
})
.await;
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"should succeed on first attempt"
);
handle.abort();
}
#[tokio::test]
async fn retries_on_server_error_and_eventually_fails() {
let counter = Arc::new(AtomicUsize::new(0));
let (addr, handle) = mock_server(500, Arc::clone(&counter)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
let result = sender.send(&url, &status_event(), &config).await;
assert!(result.is_err(), "should fail after all retries");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("HTTP 500"),
"error should mention HTTP status: {err_msg}"
);
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"should retry exactly 3 times"
);
handle.abort();
}
#[tokio::test]
async fn backoff_is_paid_between_attempts_but_not_after_the_last() {
let counter = Arc::new(AtomicUsize::new(0));
let (addr, handle) = mock_server(500, Arc::clone(&counter)).await;
let policy = a2a_protocol_server::push::PushRetryPolicy::default()
.with_max_attempts(3)
.with_backoff(vec![
Duration::from_millis(500),
Duration::from_millis(1500),
]);
let sender = HttpPushSender::new()
.allow_private_urls()
.with_retry_policy(policy);
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
let started = tokio::time::Instant::now();
let result = sender.send(&url, &status_event(), &config).await;
let elapsed = started.elapsed();
assert!(result.is_err(), "all three attempts return HTTP 500");
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"the attempt count is set by the loop, not by the backoff guard"
);
assert!(
elapsed >= Duration::from_millis(1800) && elapsed < Duration::from_millis(2800),
"exactly two backoffs (500ms + 1500ms) must elapse — one after each \
non-final attempt, none after the last — got {elapsed:?}"
);
handle.abort();
}
#[tokio::test]
async fn non_retryable_client_error_fails_without_retry() {
let counter = Arc::new(AtomicUsize::new(0));
let (addr, handle) = mock_server(403, Arc::clone(&counter)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
let result = sender.send(&url, &status_event(), &config).await;
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("non-retryable") && err_msg.contains("403"),
"error should identify the non-retryable status: {err_msg}"
);
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"a non-retryable 4xx must not be retried"
);
handle.abort();
}
#[tokio::test]
async fn retries_on_rate_limit_status() {
let counter = Arc::new(AtomicUsize::new(0));
let (addr, handle) = mock_server(429, Arc::clone(&counter)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
let result = sender.send(&url, &status_event(), &config).await;
assert!(result.is_err(), "should fail after exhausting retries");
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"429 must be retried up to max_attempts"
);
handle.abort();
}
#[cfg(not(feature = "tls-rustls"))]
#[tokio::test]
async fn https_webhook_fails_fast_without_tls_feature() {
let sender = HttpPushSender::new().allow_private_urls();
let url = "https://example.com/webhook";
let config = base_config(url);
let result = sender.send(url, &status_event(), &config).await;
let err = result.expect_err("https delivery must fail on the HTTP-only sender");
let msg = err.to_string();
assert!(
msg.contains("HTTP only"),
"error should explain the HTTP-only limitation: {msg}"
);
}
#[cfg(feature = "tls-rustls")]
#[tokio::test]
async fn https_webhook_enforces_ssrf_with_tls_feature() {
let sender = HttpPushSender::new();
let url = "https://127.0.0.1:8443/webhook";
let config = base_config(url);
let result = sender.send(url, &status_event(), &config).await;
let err = result.expect_err("https to a loopback address must be rejected");
let msg = err.to_string();
assert!(
!msg.contains("HTTP only"),
"https should not hit the HTTP-only error with tls-rustls: {msg}"
);
assert!(
msg.contains("loopback") || msg.contains("private"),
"expected an SSRF rejection: {msg}"
);
}
#[tokio::test]
async fn connection_refused_returns_error() {
let sender = HttpPushSender::new().allow_private_urls();
let url = "http://127.0.0.1:1/webhook";
let config = base_config(url);
let result = sender.send(url, &status_event(), &config).await;
assert!(result.is_err(), "should fail on connection refused");
}
#[tokio::test]
async fn bearer_auth_header_is_sent() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let mut config = base_config(&url);
config.authentication = Some(AuthenticationInfo {
scheme: "bearer".into(),
credentials: Some("my-secret-token".into()),
});
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(
!reqs.is_empty(),
"should have captured at least one request"
);
let req = &reqs[0];
assert!(
req.contains("authorization: Bearer my-secret-token")
|| req.contains("Authorization: Bearer my-secret-token"),
"should contain Bearer auth header, got: {req}"
);
handle.abort();
}
#[tokio::test]
async fn basic_auth_header_is_sent() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let mut config = base_config(&url);
config.authentication = Some(AuthenticationInfo {
scheme: "basic".into(),
credentials: Some("dXNlcjpwYXNz".into()),
});
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
let req = &reqs[0];
assert!(
req.contains("authorization: Basic dXNlcjpwYXNz")
|| req.contains("Authorization: Basic dXNlcjpwYXNz"),
"should contain Basic auth header, got: {req}"
);
handle.abort();
}
#[tokio::test]
async fn mixed_case_scheme_still_sends_auth_header() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let mut config = base_config(&url);
config.authentication = Some(AuthenticationInfo {
scheme: "Bearer".into(),
credentials: Some("my-secret-token".into()),
});
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
let req = &reqs[0];
assert!(
req.contains("authorization: Bearer my-secret-token")
|| req.contains("Authorization: Bearer my-secret-token"),
"a \"Bearer\"-spelled scheme must still send the auth header, got: {req}"
);
handle.abort();
}
#[tokio::test]
async fn uppercase_basic_scheme_sends_canonical_header() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let mut config = base_config(&url);
config.authentication = Some(AuthenticationInfo {
scheme: "BASIC".into(),
credentials: Some("dXNlcjpwYXNz".into()),
});
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
let req = &reqs[0];
assert!(
req.contains("authorization: Basic dXNlcjpwYXNz")
|| req.contains("Authorization: Basic dXNlcjpwYXNz"),
"a \"BASIC\"-spelled scheme must emit the canonical Basic header, got: {req}"
);
handle.abort();
}
#[tokio::test]
async fn notification_token_header_is_sent() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let mut config = base_config(&url);
config.token = Some("my-notification-token".into());
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
let req = &reqs[0];
assert!(
req.contains("x-a2a-notification-token: my-notification-token"),
"should contain the canonical X-A2A-Notification-Token header \
(what official-SDK webhook receivers read), got: {req}"
);
assert!(
req.contains("a2a-notification-token: my-notification-token"),
"should still contain the legacy header until 0.8, got: {req}"
);
handle.abort();
}
#[tokio::test]
async fn both_auth_and_token_headers_are_sent() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let mut config = base_config(&url);
config.authentication = Some(AuthenticationInfo {
scheme: "bearer".into(),
credentials: Some("token-123".into()),
});
config.token = Some("notif-456".into());
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
let req = &reqs[0];
assert!(
req.contains("Bearer token-123") || req.contains("bearer token-123"),
"should contain Bearer auth"
);
assert!(
req.contains("x-a2a-notification-token: notif-456"),
"should contain the canonical notification token header"
);
assert!(
req.contains("a2a-notification-token: notif-456"),
"should still contain the legacy header until 0.8"
);
handle.abort();
}
#[tokio::test]
async fn request_has_json_content_type() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
let req = &reqs[0];
assert!(
req.contains("content-type: application/json")
|| req.contains("Content-Type: application/json"),
"should have JSON content type, got: {req}"
);
handle.abort();
}
#[tokio::test]
async fn request_uses_post_method() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let (addr, handle) = mock_server_with_headers(Arc::clone(&captured)).await;
let sender = HttpPushSender::new().allow_private_urls();
let url = format!("http://{addr}/webhook");
let config = base_config(&url);
sender.send(&url, &status_event(), &config).await.unwrap();
wait_for("the mock server to capture the request", || {
!captured.lock().unwrap().is_empty()
})
.await;
let reqs = captured.lock().unwrap();
assert!(!reqs.is_empty());
assert!(
reqs[0].starts_with("POST "),
"should use POST method, got: {}",
&reqs[0][..50.min(reqs[0].len())]
);
handle.abort();
}
#[test]
fn http_push_sender_default_creates_instance() {
let sender = HttpPushSender::default();
let dbg = format!("{sender:?}");
assert!(dbg.contains("HttpPushSender"));
}
#[test]
fn http_push_sender_debug_impl() {
let sender = HttpPushSender::new().allow_private_urls();
let dbg = format!("{sender:?}");
assert!(dbg.contains("HttpPushSender"));
}