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:?}"
);
}