use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard};
use a2a_protocol_types::message::{Message, MessageId, MessageRole, Part};
use a2a_protocol_types::params::{MessageSendParams, SendMessageConfiguration};
use a2a_protocol_types::push::{AuthenticationInfo, TaskPushNotificationConfig};
use a2a_protocol_types::task::ContextId;
use a2a_protocol_server::builder::RequestHandlerBuilder;
use a2a_protocol_server::error::ServerError;
use a2a_protocol_server::handler::{HandlerLimits, SendMessageResult};
use a2a_protocol_server::push::HttpPushSender;
use a2a_protocol_server::{agent_executor, RequestHandler};
struct NoopExecutor;
agent_executor!(NoopExecutor, |_ctx, _queue| async { Ok(()) });
fn push_card() -> AgentCard {
AgentCard {
url: None,
name: "Push Test Agent".into(),
description: "Advertises push notifications".into(),
version: "1.0.0".into(),
supported_interfaces: vec![a2a_protocol_types::agent_card::AgentInterface {
url: "https://agent.example.com/rpc".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
default_input_modes: vec!["text/plain".into()],
default_output_modes: vec!["text/plain".into()],
skills: vec![],
capabilities: AgentCapabilities::none().with_push_notifications(true),
provider: None,
icon_url: None,
documentation_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
fn handler_with_push() -> RequestHandler {
RequestHandlerBuilder::new(NoopExecutor)
.with_agent_card(push_card())
.with_push_sender(HttpPushSender::new().allow_private_urls())
.build()
.expect("handler with push support must build")
}
fn inline_config(url: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
tenant: None,
task_id: None,
id: None,
url: url.to_owned(),
token: Some("validation-token".to_owned()),
authentication: Some(AuthenticationInfo {
scheme: "Bearer".to_owned(),
credentials: Some("tck-test-token".to_owned()),
}),
}
}
fn send_with(config: Option<TaskPushNotificationConfig>, ctx: &str) -> MessageSendParams {
MessageSendParams {
tenant: None,
message: Message {
id: MessageId::new(format!("msg-{ctx}")),
role: MessageRole::User,
parts: vec![Part::text("hello")],
context_id: Some(ContextId::new(ctx)),
task_id: None,
reference_task_ids: None,
extensions: None,
metadata: None,
},
configuration: Some(SendMessageConfiguration {
accepted_output_modes: vec!["text/plain".to_owned()],
task_push_notification_config: config,
history_length: None,
return_immediately: Some(true),
}),
metadata: None,
}
}
fn task_id_of(result: SendMessageResult) -> String {
match result {
SendMessageResult::Response(a2a_protocol_types::responses::SendMessageResponse::Task(
t,
)) => t.id.0,
other => panic!("expected a Task response, got: {other:?}"),
}
}
async fn configs_for(handler: &RequestHandler, task_id: &str) -> Vec<TaskPushNotificationConfig> {
handler
.on_list_push_configs(task_id, None, None)
.await
.expect("listing push configs must succeed")
}
#[tokio::test]
async fn inline_config_is_registered_against_the_created_task() {
let handler = handler_with_push();
let sent = handler
.on_send_message(
send_with(Some(inline_config("http://127.0.0.1:9/hook")), "ctx-inline"),
false,
None,
)
.await
.expect("SendMessage carrying an inline push config must succeed");
let task_id = task_id_of(sent);
let configs = configs_for(&handler, &task_id).await;
assert_eq!(
configs.len(),
1,
"the inline push config must be registered, not dropped — got {configs:?}"
);
let stored = &configs[0];
assert_eq!(
stored.task_id.as_deref(),
Some(task_id.as_str()),
"the server must fill in taskId from the task it created"
);
assert_eq!(stored.url, "http://127.0.0.1:9/hook");
assert_eq!(
stored
.authentication
.as_ref()
.and_then(|a| a.credentials.as_deref()),
Some("tck-test-token"),
"authentication must survive registration — PUSH-DELIVER-001 asserts \
the Authorization header on delivery"
);
assert!(
stored.id.is_some(),
"the store must assign an id so the config is addressable by Get/Delete"
);
}
#[tokio::test]
async fn no_inline_config_registers_nothing() {
let handler = handler_with_push();
let sent = handler
.on_send_message(send_with(None, "ctx-none"), false, None)
.await
.expect("SendMessage without a push config must succeed");
let task_id = task_id_of(sent);
let configs = configs_for(&handler, &task_id).await;
assert!(
configs.is_empty(),
"no config was sent, so none may be registered — got {configs:?}"
);
}
#[tokio::test]
async fn inline_config_is_rejected_when_push_is_unsupported() {
let handler = RequestHandlerBuilder::new(NoopExecutor)
.build()
.expect("handler must build");
let err = handler
.on_send_message(
send_with(Some(inline_config("https://example.com/hook")), "ctx-unsup"),
false,
None,
)
.await
.expect_err(
"asking a non-push server for push must be an error, not a silently \
dropped config — that silence is the defect being fixed",
);
assert!(
matches!(err, ServerError::PushNotSupported),
"expected PushNotSupported, got: {err:?}"
);
}
#[tokio::test]
async fn inline_config_is_screened_for_ssrf() {
let handler = RequestHandlerBuilder::new(NoopExecutor)
.with_agent_card(push_card())
.with_push_sender(HttpPushSender::new())
.build()
.expect("handler must build");
let err = handler
.on_send_message(
send_with(Some(inline_config("http://127.0.0.1:9/hook")), "ctx-ssrf"),
false,
None,
)
.await
.expect_err("a loopback webhook URL must be rejected on the inline path too");
assert!(
!matches!(err, ServerError::PushNotSupported),
"must fail SSRF screening, not the capability check: {err:?}"
);
}
#[tokio::test]
async fn inline_config_respects_the_per_task_quota() {
let handler = RequestHandlerBuilder::new(NoopExecutor)
.with_agent_card(push_card())
.with_push_sender(HttpPushSender::new().allow_private_urls())
.with_handler_limits(HandlerLimits {
max_push_configs_per_task: 1,
..Default::default()
})
.build()
.expect("handler must build");
let sent = handler
.on_send_message(
send_with(Some(inline_config("http://127.0.0.1:9/a")), "ctx-quota"),
false,
None,
)
.await
.expect("first inline config must be accepted");
let task_id = task_id_of(sent);
let standalone = handler
.on_set_push_config(
TaskPushNotificationConfig {
task_id: Some(task_id.clone()),
..inline_config("http://127.0.0.1:9/b")
},
None,
)
.await;
assert!(
standalone.is_err(),
"standalone create must be rejected once the per-task quota is full"
);
let mut followup = send_with(Some(inline_config("http://127.0.0.1:9/c")), "ctx-quota");
followup.message.id = MessageId::new("msg-followup");
followup.message.task_id = Some(a2a_protocol_types::task::TaskId::new(&task_id));
let inline = handler.on_send_message(followup, false, None).await;
assert!(
inline.is_err(),
"the inline path must enforce the same per-task quota as the standalone create"
);
}