use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use a2a_protocol_client::discovery::fetch_card_from_url;
async fn spawn_raw<F, Fut>(handler: F) -> std::net::SocketAddr
where
F: Fn(tokio::net::TcpStream) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(handler(stream));
}
});
addr
}
async fn drain_request(stream: &mut tokio::net::TcpStream) {
let mut buf = [0u8; 1024];
let mut seen = Vec::new();
loop {
match tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await {
Ok(Ok(0)) | Err(_) => break,
Ok(Ok(n)) => {
seen.extend_from_slice(&buf[..n]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
Ok(Err(_)) => break,
}
}
}
fn card_url(addr: std::net::SocketAddr) -> String {
format!("http://{addr}/.well-known/agent-card.json")
}
#[tokio::test]
async fn oversized_card_body_rejected() {
let addr = spawn_raw(|mut stream| async move {
drain_request(&mut stream).await;
let big = 8 * 1024 * 1024;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {big}\r\n\r\n"
);
let _ = stream.write_all(head.as_bytes()).await;
let chunk = vec![b'x'; 64 * 1024];
for _ in 0..(big / chunk.len()) {
if stream.write_all(&chunk).await.is_err() {
break;
}
}
})
.await;
let result = tokio::time::timeout(
Duration::from_secs(15),
fetch_card_from_url(&card_url(addr)),
)
.await
.expect("client must not hang on an oversized body");
assert!(
result.is_err(),
"an oversized card body must be rejected, got: {result:?}"
);
}
#[tokio::test]
async fn slow_drip_card_body_times_out() {
let addr = spawn_raw(|mut stream| async move {
drain_request(&mut stream).await;
let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n";
let _ = stream.write_all(head.as_bytes()).await;
for b in b"{\"name\":".iter() {
if stream.write_all(&[*b]).await.is_err() {
return;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
tokio::time::sleep(Duration::from_secs(60)).await;
})
.await;
let result = tokio::time::timeout(
Duration::from_secs(45),
fetch_card_from_url(&card_url(addr)),
)
.await
.expect("client's own body-read timeout must fire before the outer guard");
assert!(
result.is_err(),
"slow-drip junk must fail with a bounded timeout, not parse as a card"
);
}
#[tokio::test]
async fn short_body_under_declared_length_errors() {
let addr = spawn_raw(|mut stream| async move {
drain_request(&mut stream).await;
let head =
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n";
let _ = stream.write_all(head.as_bytes()).await;
let _ = stream.write_all(b"{\"name\":\"x\"}").await;
})
.await;
let result = tokio::time::timeout(
Duration::from_secs(15),
fetch_card_from_url(&card_url(addr)),
)
.await
.expect("client must not hang on a truncated body");
assert!(
result.is_err(),
"a body shorter than Content-Length must error, got: {result:?}"
);
}
#[tokio::test]
async fn immediate_close_errors() {
let addr = spawn_raw(|stream| async move {
drop(stream);
})
.await;
let result = tokio::time::timeout(
Duration::from_secs(10),
fetch_card_from_url(&card_url(addr)),
)
.await
.expect("client must not hang on a reset");
assert!(
result.is_err(),
"an immediate close must surface as an error, got: {result:?}"
);
}
#[tokio::test]
async fn valid_json_wrong_shape_rejected() {
let addr = spawn_raw(|mut stream| async move {
drain_request(&mut stream).await;
let body = br#"[1,2,3]"#;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes()).await;
let _ = stream.write_all(body).await;
})
.await;
let result = tokio::time::timeout(
Duration::from_secs(10),
fetch_card_from_url(&card_url(addr)),
)
.await
.expect("client must not hang");
assert!(
result.is_err(),
"a non-card JSON document must be rejected, got: {result:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_stall_then_a_drip_gets_one_budget_not_two() {
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let stall = std::time::Duration::from_secs(20);
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = [0_u8; 2048];
let _ = sock.read(&mut buf).await;
tokio::time::sleep(stall).await;
let _ = sock
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
Transfer-Encoding: chunked\r\n\r\n",
)
.await;
let _ = sock.flush().await;
loop {
let _ = sock.write_all(b"1\r\n{\r\n").await;
let _ = sock.flush().await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
});
let url = format!(
"http://127.0.0.1:{}/.well-known/agent-card.json",
addr.port()
);
let start = Instant::now();
let result = a2a_protocol_client::discovery::fetch_card_from_url(&url).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "a dripping server must not yield a card");
assert!(
elapsed < std::time::Duration::from_secs(40),
"the fetch took {elapsed:?}; one 30s budget covers the whole call, and \
anything near 50s means the body read got a second one"
);
assert!(
elapsed >= stall,
"sanity: the stall must actually have been waited out, got {elapsed:?}"
);
}