1use std::collections::HashMap;
9use std::time::Instant;
10
11use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
12use a2a_protocol_types::push::TaskPushNotificationConfig;
13use a2a_protocol_types::task::TaskId;
14
15use crate::error::{ServerError, ServerResult};
16
17use super::RequestHandler;
18use super::helpers::build_call_context;
19
20impl RequestHandler {
21 pub(super) async fn validate_and_store_push_config(
41 &self,
42 config: TaskPushNotificationConfig,
43 ) -> ServerResult<TaskPushNotificationConfig> {
44 self.ensure_push_supported()?;
47 let Some(ref sender) = self.push_sender else {
48 return Err(ServerError::PushNotSupported);
49 };
50
51 let target_task = TaskId::new(config.task_id.clone().unwrap_or_default());
54 if self.task_store.get(&target_task).await?.is_none() {
55 return Err(ServerError::TaskNotFound(target_task));
56 }
57
58 if !sender.allows_private_urls() {
70 crate::push::sender::validate_webhook_url(&config.url)?;
71 }
72
73 let task_key = config.task_id.clone().unwrap_or_default();
78
79 let cap_lock = self.keyed_lock(&format!("push:{task_key}")).await;
92 let _cap_guard = cap_lock.lock().await;
93
94 let existing = self.push_config_store.list(&task_key).await?;
95 let is_update = config
96 .id
97 .as_deref()
98 .is_some_and(|id| existing.iter().any(|c| c.id.as_deref() == Some(id)));
99 if !is_update && existing.len() >= self.limits.max_push_configs_per_task {
100 return Err(ServerError::InvalidParams(format!(
101 "task {task_key} already has the maximum of {} push notification configs",
102 self.limits.max_push_configs_per_task
103 )));
104 }
105
106 if !is_update
122 && let Some(total) = self.push_config_store.count().await?
123 && total >= self.limits.max_total_push_configs
124 {
125 return Err(ServerError::Overloaded(format!(
126 "server is at the maximum of {} push notification configs; \
127 delete unused configs before creating more",
128 self.limits.max_total_push_configs
129 )));
130 }
131
132 Ok(self.push_config_store.set(config).await?)
133 }
134
135 #[allow(clippy::too_many_lines)]
141 pub async fn on_set_push_config(
142 &self,
143 config: TaskPushNotificationConfig,
144 headers: Option<&HashMap<String, String>>,
145 ) -> ServerResult<TaskPushNotificationConfig> {
146 let start = Instant::now();
147 self.metrics.on_request("CreateTaskPushNotificationConfig");
148
149 let tenant = self
150 .resolve_tenant(
151 "CreateTaskPushNotificationConfig",
152 headers,
153 config.tenant.as_deref(),
154 )
155 .await?;
156 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
157 if config.task_id.as_deref().unwrap_or("").is_empty() {
162 return Err(ServerError::InvalidParams(
163 "taskId is required for CreateTaskPushNotificationConfig".into(),
164 ));
165 }
166
167 let call_ctx = build_call_context(
168 "CreateTaskPushNotificationConfig",
169 headers,
170 self.inbound_trace_policy,
171 );
172 let mut call = self.interceptors.begin(&call_ctx);
173 let result = async {
174 call.before().await?;
175 self.ensure_required_extensions(&call_ctx)?;
178
179 let result = self.validate_and_store_push_config(config).await?;
180 Ok(result)
181 }
182 .await;
183 call.finish(result).await
184 })
185 .await;
186
187 let elapsed = start.elapsed();
188 match &result {
189 Ok(_) => {
190 self.metrics.on_response("CreateTaskPushNotificationConfig");
191 self.metrics
192 .on_latency("CreateTaskPushNotificationConfig", elapsed);
193 }
194 Err(e) => {
195 self.metrics
196 .on_error("CreateTaskPushNotificationConfig", e.metric_label());
197 self.metrics
198 .on_latency("CreateTaskPushNotificationConfig", elapsed);
199 }
200 }
201 result
202 }
203
204 pub async fn on_get_push_config(
212 &self,
213 params: GetPushConfigParams,
214 headers: Option<&HashMap<String, String>>,
215 ) -> ServerResult<TaskPushNotificationConfig> {
216 let start = Instant::now();
217 self.metrics.on_request("GetTaskPushNotificationConfig");
218
219 let tenant = self
220 .resolve_tenant(
221 "GetTaskPushNotificationConfig",
222 headers,
223 params.tenant.as_deref(),
224 )
225 .await?;
226 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
227 self.ensure_push_supported()?;
229 let call_ctx = build_call_context(
230 "GetTaskPushNotificationConfig",
231 headers,
232 self.inbound_trace_policy,
233 );
234 let mut call = self.interceptors.begin(&call_ctx);
235 let result = async {
236 call.before().await?;
237 self.ensure_required_extensions(&call_ctx)?;
240
241 let config = self
244 .push_config_store
245 .get(¶ms.task_id, ¶ms.id)
246 .await?
247 .ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
248
249 Ok(config)
250 }
251 .await;
252 call.finish(result).await
253 })
254 .await;
255
256 let elapsed = start.elapsed();
257 match &result {
258 Ok(_) => {
259 self.metrics.on_response("GetTaskPushNotificationConfig");
260 self.metrics
261 .on_latency("GetTaskPushNotificationConfig", elapsed);
262 }
263 Err(e) => {
264 self.metrics
265 .on_error("GetTaskPushNotificationConfig", e.metric_label());
266 self.metrics
267 .on_latency("GetTaskPushNotificationConfig", elapsed);
268 }
269 }
270 result
271 }
272
273 pub async fn on_list_push_configs(
279 &self,
280 task_id: &str,
281 tenant: Option<&str>,
282 headers: Option<&HashMap<String, String>>,
283 ) -> ServerResult<Vec<TaskPushNotificationConfig>> {
284 let start = Instant::now();
285 self.metrics.on_request("ListTaskPushNotificationConfigs");
286
287 let tenant_owned = self
288 .resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
289 .await?;
290 let result: ServerResult<_> =
291 crate::store::tenant::TenantContext::scope(tenant_owned, async {
292 self.ensure_push_supported()?;
294 let call_ctx = build_call_context(
295 "ListTaskPushNotificationConfigs",
296 headers,
297 self.inbound_trace_policy,
298 );
299 let mut call = self.interceptors.begin(&call_ctx);
300 let result = async {
301 call.before().await?;
302 self.ensure_required_extensions(&call_ctx)?;
305 let configs = self.push_config_store.list(task_id).await?;
306 Ok(configs)
307 }
308 .await;
309 call.finish(result).await
310 })
311 .await;
312
313 let elapsed = start.elapsed();
314 match &result {
315 Ok(_) => {
316 self.metrics.on_response("ListTaskPushNotificationConfigs");
317 self.metrics
318 .on_latency("ListTaskPushNotificationConfigs", elapsed);
319 }
320 Err(e) => {
321 self.metrics
322 .on_error("ListTaskPushNotificationConfigs", e.metric_label());
323 self.metrics
324 .on_latency("ListTaskPushNotificationConfigs", elapsed);
325 }
326 }
327 result
328 }
329
330 pub async fn on_delete_push_config(
336 &self,
337 params: DeletePushConfigParams,
338 headers: Option<&HashMap<String, String>>,
339 ) -> ServerResult<()> {
340 let start = Instant::now();
341 self.metrics.on_request("DeleteTaskPushNotificationConfig");
342
343 let tenant = self
344 .resolve_tenant(
345 "DeleteTaskPushNotificationConfig",
346 headers,
347 params.tenant.as_deref(),
348 )
349 .await?;
350 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
351 self.ensure_push_supported()?;
353 let call_ctx = build_call_context(
354 "DeleteTaskPushNotificationConfig",
355 headers,
356 self.inbound_trace_policy,
357 );
358 let mut call = self.interceptors.begin(&call_ctx);
359 let result = async {
360 call.before().await?;
361 self.ensure_required_extensions(&call_ctx)?;
364 self.push_config_store
365 .delete(¶ms.task_id, ¶ms.id)
366 .await?;
367 Ok(())
368 }
369 .await;
370 call.finish(result).await
371 })
372 .await;
373
374 let elapsed = start.elapsed();
375 match &result {
376 Ok(()) => {
377 self.metrics.on_response("DeleteTaskPushNotificationConfig");
378 self.metrics
379 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
380 }
381 Err(e) => {
382 self.metrics
383 .on_error("DeleteTaskPushNotificationConfig", e.metric_label());
384 self.metrics
385 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
386 }
387 }
388 result
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::agent_executor;
396 use crate::builder::RequestHandlerBuilder;
397
398 struct DummyExecutor;
399 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
400
401 fn make_handler() -> RequestHandler {
402 RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
403 }
404
405 fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
406 TaskPushNotificationConfig {
407 tenant: None,
408 id: Some("cfg-1".to_owned()),
409 task_id: Some(task_id.to_owned()),
410 url: "https://example.com/webhook".to_owned(),
411 token: None,
412 authentication: None,
413 }
414 }
415
416 async fn save_task(handler: &RequestHandler, id: &str) {
419 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
420 let task = Task {
421 id: TaskId::new(id),
422 context_id: ContextId::new("ctx"),
423 status: TaskStatus::new(TaskState::Submitted),
424 history: None,
425 artifacts: None,
426 metadata: None,
427 };
428 handler.task_store.save(&task).await.unwrap();
429 }
430
431 #[derive(Debug)]
436 struct CapTestSender;
437
438 impl crate::push::PushSender for CapTestSender {
439 fn send<'a>(
440 &'a self,
441 _url: &'a str,
442 _event: &'a a2a_protocol_types::events::StreamResponse,
443 _config: &'a TaskPushNotificationConfig,
444 ) -> std::pin::Pin<
445 Box<
446 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
447 + Send
448 + 'a,
449 >,
450 > {
451 Box::pin(async { Ok(()) })
452 }
453 }
454
455 #[derive(Debug, Default)]
463 struct SlowListStore(crate::push::InMemoryPushConfigStore);
464
465 type StoreFuture<'a, T> = std::pin::Pin<
466 Box<dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<T>> + Send + 'a>,
467 >;
468
469 impl crate::push::PushConfigStore for SlowListStore {
470 fn set(
471 &self,
472 config: TaskPushNotificationConfig,
473 ) -> StoreFuture<'_, TaskPushNotificationConfig> {
474 self.0.set(config)
475 }
476 fn get<'a>(
477 &'a self,
478 task_id: &'a str,
479 id: &'a str,
480 ) -> StoreFuture<'a, Option<TaskPushNotificationConfig>> {
481 self.0.get(task_id, id)
482 }
483 fn list<'a>(
484 &'a self,
485 task_id: &'a str,
486 ) -> StoreFuture<'a, Vec<TaskPushNotificationConfig>> {
487 Box::pin(async move {
488 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
489 self.0.list(task_id).await
490 })
491 }
492 fn delete<'a>(&'a self, task_id: &'a str, id: &'a str) -> StoreFuture<'a, ()> {
493 self.0.delete(task_id, id)
494 }
495 }
496
497 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
522 async fn concurrent_creates_cannot_exceed_the_per_task_cap() {
523 use std::sync::Arc;
524
525 const CAP: usize = 5;
526 const WRITERS: usize = 32;
527
528 let handler = Arc::new(
529 RequestHandlerBuilder::new(DummyExecutor)
530 .with_push_sender(CapTestSender)
531 .with_push_config_store(SlowListStore::default())
532 .with_handler_limits(
533 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(CAP),
534 )
535 .build()
536 .unwrap(),
537 );
538 save_task(&handler, "task-1").await;
539
540 let mut creates = Vec::new();
541 for i in 0..WRITERS {
542 let each = Arc::clone(&handler);
543 creates.push(tokio::spawn(async move {
544 each.on_set_push_config(
545 TaskPushNotificationConfig {
546 tenant: None,
547 id: Some(format!("cfg-{i}")),
548 task_id: Some("task-1".to_owned()),
549 url: format!("https://example.com/hook/{i}"),
550 token: None,
551 authentication: None,
552 },
553 None,
554 )
555 .await
556 .is_ok()
557 }));
558 }
559 let mut accepted = 0usize;
560 for create in creates {
561 if create.await.unwrap_or(false) {
562 accepted += 1;
563 }
564 }
565
566 let stored = handler
567 .on_list_push_configs("task-1", None, None)
568 .await
569 .expect("list")
570 .len();
571
572 assert_eq!(
573 stored, CAP,
574 "the store must hold exactly the cap; {WRITERS} concurrent creates stored {stored}"
575 );
576 assert_eq!(
577 accepted, CAP,
578 "and exactly {CAP} callers must have been told they succeeded, not {accepted} — \
579 a caller handed Ok for a config that breaks the cap was lied to"
580 );
581 }
582
583 #[tokio::test]
586 async fn set_push_config_without_sender_returns_push_not_supported() {
587 let handler = make_handler();
588 let config = make_push_config("task-1");
589 let result = handler.on_set_push_config(config, None).await;
590 assert!(
591 matches!(result, Err(crate::error::ServerError::PushNotSupported)),
592 "expected PushNotSupported, got: {result:?}"
593 );
594 }
595
596 #[tokio::test]
601 async fn set_push_config_without_task_id_returns_invalid_params() {
602 use crate::push::PushSender;
603 use a2a_protocol_types::events::StreamResponse;
604 use std::future::Future;
605 use std::pin::Pin;
606
607 struct NoopSender;
608 impl PushSender for NoopSender {
609 fn send<'a>(
610 &'a self,
611 _url: &'a str,
612 _event: &'a StreamResponse,
613 _config: &'a TaskPushNotificationConfig,
614 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
615 {
616 Box::pin(async { Ok(()) })
617 }
618 fn allows_private_urls(&self) -> bool {
619 true
620 }
621 }
622
623 let handler = RequestHandlerBuilder::new(DummyExecutor)
624 .with_push_sender(NoopSender)
625 .build()
626 .unwrap();
627
628 let config = TaskPushNotificationConfig {
629 tenant: None,
630 id: None,
631 task_id: None,
632 url: "https://example.com/webhook".to_owned(),
633 token: None,
634 authentication: None,
635 };
636 let result = handler.on_set_push_config(config, None).await;
637 match result {
638 Err(crate::error::ServerError::InvalidParams(msg)) => {
639 assert!(msg.contains("taskId"), "got: {msg}");
640 }
641 other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
642 }
643 }
644
645 #[tokio::test]
659 async fn set_push_config_rejects_private_url_under_the_default_sender_policy() {
660 use crate::push::PushSender;
661 use a2a_protocol_types::events::StreamResponse;
662 use std::future::Future;
663 use std::pin::Pin;
664
665 struct DefaultPolicySender;
668 impl PushSender for DefaultPolicySender {
669 fn send<'a>(
670 &'a self,
671 _url: &'a str,
672 _event: &'a StreamResponse,
673 _config: &'a TaskPushNotificationConfig,
674 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
675 {
676 Box::pin(async { Ok(()) })
677 }
678 }
679
680 let handler = RequestHandlerBuilder::new(DummyExecutor)
681 .with_push_sender(DefaultPolicySender)
682 .build()
683 .unwrap();
684 save_task(&handler, "task-ssrf").await;
685
686 let config = TaskPushNotificationConfig {
687 tenant: None,
688 id: Some("cfg-ssrf".to_owned()),
689 task_id: Some("task-ssrf".to_owned()),
690 url: "http://127.0.0.1:9000/webhook".to_owned(),
691 token: None,
692 authentication: None,
693 };
694
695 match handler.on_set_push_config(config, None).await {
700 Err(e) => {
701 let msg = e.to_string();
702 assert!(
703 msg.contains("private/loopback"),
704 "expected the SSRF rejection, got: {msg}"
705 );
706 }
707 Ok(v) => panic!(
708 "a sender taking the default policy must refuse a loopback webhook URL, got: Ok({v:?})"
709 ),
710 }
711 }
712
713 #[tokio::test]
717 async fn set_push_config_enforces_global_cap() {
718 use crate::push::PushSender;
719 use a2a_protocol_types::events::StreamResponse;
720 use std::future::Future;
721 use std::pin::Pin;
722
723 struct NoopSender;
724 impl PushSender for NoopSender {
725 fn send<'a>(
726 &'a self,
727 _url: &'a str,
728 _event: &'a StreamResponse,
729 _config: &'a TaskPushNotificationConfig,
730 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
731 {
732 Box::pin(async { Ok(()) })
733 }
734 }
735
736 let handler = RequestHandlerBuilder::new(DummyExecutor)
737 .with_push_sender(NoopSender)
738 .with_handler_limits(
739 crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
740 )
741 .build()
742 .unwrap();
743
744 for i in 0..2 {
746 save_task(&handler, &format!("task-{i}")).await;
747 let cfg = TaskPushNotificationConfig {
748 tenant: None,
749 id: Some(format!("cfg-{i}")),
750 task_id: Some(format!("task-{i}")),
751 url: "https://example.com/webhook".to_owned(),
752 token: None,
753 authentication: None,
754 };
755 handler
756 .on_set_push_config(cfg, None)
757 .await
758 .expect("creates under the global cap should succeed");
759 }
760
761 save_task(&handler, "task-x").await;
763 let cfg = TaskPushNotificationConfig {
764 tenant: None,
765 id: Some("cfg-x".to_owned()),
766 task_id: Some("task-x".to_owned()),
767 url: "https://example.com/webhook".to_owned(),
768 token: None,
769 authentication: None,
770 };
771 let result = handler.on_set_push_config(cfg, None).await;
772 assert!(
773 matches!(result, Err(crate::error::ServerError::Overloaded(_))),
774 "global push-config cap must reject, got {result:?}"
775 );
776 }
777
778 #[tokio::test]
782 async fn set_push_config_update_allowed_at_per_task_cap() {
783 use crate::push::PushSender;
784 use a2a_protocol_types::events::StreamResponse;
785 use std::future::Future;
786 use std::pin::Pin;
787
788 struct NoopSender;
789 impl PushSender for NoopSender {
790 fn send<'a>(
791 &'a self,
792 _url: &'a str,
793 _event: &'a StreamResponse,
794 _config: &'a TaskPushNotificationConfig,
795 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
796 {
797 Box::pin(async { Ok(()) })
798 }
799 }
800
801 let handler = RequestHandlerBuilder::new(DummyExecutor)
803 .with_push_sender(NoopSender)
804 .with_handler_limits(
805 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
806 )
807 .build()
808 .unwrap();
809
810 save_task(&handler, "task-1").await;
811 let make = |url: &str| TaskPushNotificationConfig {
812 tenant: None,
813 id: Some("cfg-1".to_owned()),
814 task_id: Some("task-1".to_owned()),
815 url: url.to_owned(),
816 token: None,
817 authentication: None,
818 };
819
820 handler
822 .on_set_push_config(make("https://example.com/a"), None)
823 .await
824 .expect("first create should succeed");
825
826 handler
828 .on_set_push_config(make("https://example.com/b"), None)
829 .await
830 .expect("updating an existing config at the cap must be allowed");
831
832 let mut newcfg = make("https://example.com/c");
834 newcfg.id = Some("cfg-2".to_owned());
835 let rejected = handler.on_set_push_config(newcfg, None).await;
836 assert!(
837 matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
838 "a new config beyond the per-task cap must be rejected, got {rejected:?}"
839 );
840 }
841
842 struct NoopSender;
845 impl crate::push::PushSender for NoopSender {
846 fn send<'a>(
847 &'a self,
848 _url: &'a str,
849 _event: &'a a2a_protocol_types::events::StreamResponse,
850 _config: &'a TaskPushNotificationConfig,
851 ) -> std::pin::Pin<
852 Box<
853 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
854 + Send
855 + 'a,
856 >,
857 > {
858 Box::pin(async { Ok(()) })
859 }
860 fn allows_private_urls(&self) -> bool {
861 true
862 }
863 }
864
865 fn card_with(
867 caps: a2a_protocol_types::agent_card::AgentCapabilities,
868 ) -> a2a_protocol_types::agent_card::AgentCard {
869 use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
870 AgentCard {
871 url: None,
872 name: "Test Agent".into(),
873 description: "A test agent".into(),
874 version: "1.0.0".into(),
875 supported_interfaces: vec![AgentInterface {
876 url: "http://localhost:8080".into(),
877 protocol_binding: "JSONRPC".into(),
878 protocol_version: "1.0.0".into(),
879 tenant: None,
880 }],
881 default_input_modes: vec![],
882 default_output_modes: vec![],
883 skills: vec![],
884 capabilities: caps,
885 provider: None,
886 icon_url: None,
887 documentation_url: None,
888 security_schemes: None,
889 security_requirements: None,
890 signatures: None,
891 }
892 }
893
894 #[tokio::test]
897 async fn set_push_config_for_missing_task_returns_task_not_found() {
898 let handler = RequestHandlerBuilder::new(DummyExecutor)
899 .with_push_sender(NoopSender)
900 .build()
901 .unwrap();
902 let config = make_push_config("ghost-task");
903 let result = handler.on_set_push_config(config, None).await;
904 assert!(
905 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
906 "expected TaskNotFound for a config targeting a missing task, got: {result:?}"
907 );
908 }
909
910 #[tokio::test]
914 async fn push_ops_rejected_when_card_lacks_capability() {
915 use a2a_protocol_types::agent_card::AgentCapabilities;
916 use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
917
918 let handler = RequestHandlerBuilder::new(DummyExecutor)
919 .with_push_sender(NoopSender)
920 .with_agent_card(card_with(AgentCapabilities::none()))
921 .build()
922 .unwrap();
923
924 let set = handler
925 .on_set_push_config(make_push_config("t1"), None)
926 .await;
927 assert!(
928 matches!(set, Err(crate::error::ServerError::PushNotSupported)),
929 "set must be rejected, got: {set:?}"
930 );
931
932 let get = handler
933 .on_get_push_config(
934 GetPushConfigParams {
935 tenant: None,
936 task_id: "t1".into(),
937 id: "cfg-1".into(),
938 },
939 None,
940 )
941 .await;
942 assert!(
943 matches!(get, Err(crate::error::ServerError::PushNotSupported)),
944 "get must be rejected, got: {get:?}"
945 );
946
947 let list = handler.on_list_push_configs("t1", None, None).await;
948 assert!(
949 matches!(list, Err(crate::error::ServerError::PushNotSupported)),
950 "list must be rejected, got: {list:?}"
951 );
952
953 let delete = handler
954 .on_delete_push_config(
955 DeletePushConfigParams {
956 tenant: None,
957 task_id: "t1".into(),
958 id: "cfg-1".into(),
959 },
960 None,
961 )
962 .await;
963 assert!(
964 matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
965 "delete must be rejected, got: {delete:?}"
966 );
967 }
968
969 #[tokio::test]
972 async fn push_ops_allowed_when_card_has_capability() {
973 use a2a_protocol_types::agent_card::AgentCapabilities;
974
975 let handler = RequestHandlerBuilder::new(DummyExecutor)
976 .with_push_sender(NoopSender)
977 .with_agent_card(card_with(
978 AgentCapabilities::none().with_push_notifications(true),
979 ))
980 .build()
981 .unwrap();
982 save_task(&handler, "t1").await;
983
984 handler
985 .on_set_push_config(make_push_config("t1"), None)
986 .await
987 .expect("set should succeed when push capability is advertised");
988 let configs = handler
989 .on_list_push_configs("t1", None, None)
990 .await
991 .expect("list should succeed");
992 assert_eq!(configs.len(), 1, "the created config should be listed");
993 }
994
995 #[tokio::test]
998 async fn get_push_config_not_found_returns_task_not_found() {
999 use a2a_protocol_types::params::GetPushConfigParams;
1002
1003 let handler = make_handler();
1004 let params = GetPushConfigParams {
1005 tenant: None,
1006 task_id: "no-task".to_owned(),
1007 id: "no-id".to_owned(),
1008 };
1009 let result = handler.on_get_push_config(params, None).await;
1010 assert!(
1011 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
1012 "expected TaskNotFound for missing config, got: {result:?}"
1013 );
1014 }
1015
1016 #[tokio::test]
1019 async fn list_push_configs_empty_returns_empty_vec() {
1020 let handler = make_handler();
1021 let result = handler
1022 .on_list_push_configs("no-task", None, None)
1023 .await
1024 .expect("list should succeed on empty store");
1025 assert!(
1026 result.is_empty(),
1027 "listing configs for an unknown task should return an empty vec"
1028 );
1029 }
1030
1031 #[tokio::test]
1034 async fn delete_push_config_nonexistent_returns_ok() {
1035 use a2a_protocol_types::params::DeletePushConfigParams;
1036
1037 let handler = make_handler();
1038 let params = DeletePushConfigParams {
1039 tenant: None,
1040 task_id: "no-task".to_owned(),
1041 id: "no-id".to_owned(),
1042 };
1043 let result = handler.on_delete_push_config(params, None).await;
1046 assert!(
1047 result.is_ok(),
1048 "deleting a non-existent push config should return Ok, got: {result:?}"
1049 );
1050 }
1051
1052 #[tokio::test]
1055 async fn list_push_configs_error_path_records_metrics() {
1056 use crate::call_context::CallContext;
1059 use crate::interceptor::ServerInterceptor;
1060 use std::future::Future;
1061 use std::pin::Pin;
1062
1063 struct FailInterceptor;
1064 impl ServerInterceptor for FailInterceptor {
1065 fn before<'a>(
1066 &'a self,
1067 _ctx: &'a CallContext,
1068 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1069 {
1070 Box::pin(async {
1071 Err(a2a_protocol_types::error::A2aError::internal(
1072 "forced failure",
1073 ))
1074 })
1075 }
1076 fn after<'a>(
1077 &'a self,
1078 _ctx: &'a CallContext,
1079 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1080 {
1081 Box::pin(async { Ok(()) })
1082 }
1083 }
1084
1085 let handler = RequestHandlerBuilder::new(DummyExecutor)
1086 .with_interceptor(FailInterceptor)
1087 .build()
1088 .unwrap();
1089
1090 let result = handler.on_list_push_configs("task-1", None, None).await;
1091 assert!(
1092 result.is_err(),
1093 "list_push_configs should fail when interceptor rejects"
1094 );
1095 }
1096
1097 #[tokio::test]
1098 async fn delete_push_config_error_path_records_metrics() {
1099 use crate::call_context::CallContext;
1102 use crate::interceptor::ServerInterceptor;
1103 use a2a_protocol_types::params::DeletePushConfigParams;
1104 use std::future::Future;
1105 use std::pin::Pin;
1106
1107 struct FailInterceptor;
1108 impl ServerInterceptor for FailInterceptor {
1109 fn before<'a>(
1110 &'a self,
1111 _ctx: &'a CallContext,
1112 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1113 {
1114 Box::pin(async {
1115 Err(a2a_protocol_types::error::A2aError::internal(
1116 "forced failure",
1117 ))
1118 })
1119 }
1120 fn after<'a>(
1121 &'a self,
1122 _ctx: &'a CallContext,
1123 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1124 {
1125 Box::pin(async { Ok(()) })
1126 }
1127 }
1128
1129 let handler = RequestHandlerBuilder::new(DummyExecutor)
1130 .with_interceptor(FailInterceptor)
1131 .build()
1132 .unwrap();
1133
1134 let params = DeletePushConfigParams {
1135 tenant: None,
1136 task_id: "task-1".to_owned(),
1137 id: "cfg-1".to_owned(),
1138 };
1139 let result = handler.on_delete_push_config(params, None).await;
1140 assert!(
1141 result.is_err(),
1142 "delete_push_config should fail when interceptor rejects"
1143 );
1144 }
1145
1146 #[tokio::test]
1147 async fn set_push_config_error_path_records_metrics() {
1148 let handler = make_handler();
1151 let config = make_push_config("task-err");
1152 let result = handler.on_set_push_config(config, None).await;
1153 assert!(
1154 result.is_err(),
1155 "set_push_config without push sender should hit error metrics path"
1156 );
1157 }
1158
1159 #[tokio::test]
1160 async fn get_push_config_error_path_records_metrics() {
1161 use a2a_protocol_types::params::GetPushConfigParams;
1164
1165 let handler = make_handler();
1166 let params = GetPushConfigParams {
1167 tenant: None,
1168 task_id: "missing-task".to_owned(),
1169 id: "missing-id".to_owned(),
1170 };
1171 let result = handler.on_get_push_config(params, None).await;
1172 assert!(
1173 result.is_err(),
1174 "get_push_config for missing config should hit error metrics path"
1175 );
1176 }
1177}