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 self.interceptors.run_before(&call_ctx).await?;
173 self.ensure_required_extensions(&call_ctx)?;
176
177 let result = self.validate_and_store_push_config(config).await?;
178 self.interceptors.run_after(&call_ctx).await?;
179 Ok(result)
180 })
181 .await;
182
183 let elapsed = start.elapsed();
184 match &result {
185 Ok(_) => {
186 self.metrics.on_response("CreateTaskPushNotificationConfig");
187 self.metrics
188 .on_latency("CreateTaskPushNotificationConfig", elapsed);
189 }
190 Err(e) => {
191 self.metrics
192 .on_error("CreateTaskPushNotificationConfig", e.metric_label());
193 self.metrics
194 .on_latency("CreateTaskPushNotificationConfig", elapsed);
195 }
196 }
197 result
198 }
199
200 pub async fn on_get_push_config(
208 &self,
209 params: GetPushConfigParams,
210 headers: Option<&HashMap<String, String>>,
211 ) -> ServerResult<TaskPushNotificationConfig> {
212 let start = Instant::now();
213 self.metrics.on_request("GetTaskPushNotificationConfig");
214
215 let tenant = self
216 .resolve_tenant(
217 "GetTaskPushNotificationConfig",
218 headers,
219 params.tenant.as_deref(),
220 )
221 .await?;
222 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
223 self.ensure_push_supported()?;
225 let call_ctx = build_call_context(
226 "GetTaskPushNotificationConfig",
227 headers,
228 self.inbound_trace_policy,
229 );
230 self.interceptors.run_before(&call_ctx).await?;
231 self.ensure_required_extensions(&call_ctx)?;
234
235 let config = self
238 .push_config_store
239 .get(¶ms.task_id, ¶ms.id)
240 .await?
241 .ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
242
243 self.interceptors.run_after(&call_ctx).await?;
244 Ok(config)
245 })
246 .await;
247
248 let elapsed = start.elapsed();
249 match &result {
250 Ok(_) => {
251 self.metrics.on_response("GetTaskPushNotificationConfig");
252 self.metrics
253 .on_latency("GetTaskPushNotificationConfig", elapsed);
254 }
255 Err(e) => {
256 self.metrics
257 .on_error("GetTaskPushNotificationConfig", e.metric_label());
258 self.metrics
259 .on_latency("GetTaskPushNotificationConfig", elapsed);
260 }
261 }
262 result
263 }
264
265 pub async fn on_list_push_configs(
271 &self,
272 task_id: &str,
273 tenant: Option<&str>,
274 headers: Option<&HashMap<String, String>>,
275 ) -> ServerResult<Vec<TaskPushNotificationConfig>> {
276 let start = Instant::now();
277 self.metrics.on_request("ListTaskPushNotificationConfigs");
278
279 let tenant_owned = self
280 .resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
281 .await?;
282 let result: ServerResult<_> =
283 crate::store::tenant::TenantContext::scope(tenant_owned, async {
284 self.ensure_push_supported()?;
286 let call_ctx = build_call_context(
287 "ListTaskPushNotificationConfigs",
288 headers,
289 self.inbound_trace_policy,
290 );
291 self.interceptors.run_before(&call_ctx).await?;
292 self.ensure_required_extensions(&call_ctx)?;
295 let configs = self.push_config_store.list(task_id).await?;
296 self.interceptors.run_after(&call_ctx).await?;
297 Ok(configs)
298 })
299 .await;
300
301 let elapsed = start.elapsed();
302 match &result {
303 Ok(_) => {
304 self.metrics.on_response("ListTaskPushNotificationConfigs");
305 self.metrics
306 .on_latency("ListTaskPushNotificationConfigs", elapsed);
307 }
308 Err(e) => {
309 self.metrics
310 .on_error("ListTaskPushNotificationConfigs", e.metric_label());
311 self.metrics
312 .on_latency("ListTaskPushNotificationConfigs", elapsed);
313 }
314 }
315 result
316 }
317
318 pub async fn on_delete_push_config(
324 &self,
325 params: DeletePushConfigParams,
326 headers: Option<&HashMap<String, String>>,
327 ) -> ServerResult<()> {
328 let start = Instant::now();
329 self.metrics.on_request("DeleteTaskPushNotificationConfig");
330
331 let tenant = self
332 .resolve_tenant(
333 "DeleteTaskPushNotificationConfig",
334 headers,
335 params.tenant.as_deref(),
336 )
337 .await?;
338 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
339 self.ensure_push_supported()?;
341 let call_ctx = build_call_context(
342 "DeleteTaskPushNotificationConfig",
343 headers,
344 self.inbound_trace_policy,
345 );
346 self.interceptors.run_before(&call_ctx).await?;
347 self.ensure_required_extensions(&call_ctx)?;
350 self.push_config_store
351 .delete(¶ms.task_id, ¶ms.id)
352 .await?;
353 self.interceptors.run_after(&call_ctx).await?;
354 Ok(())
355 })
356 .await;
357
358 let elapsed = start.elapsed();
359 match &result {
360 Ok(()) => {
361 self.metrics.on_response("DeleteTaskPushNotificationConfig");
362 self.metrics
363 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
364 }
365 Err(e) => {
366 self.metrics
367 .on_error("DeleteTaskPushNotificationConfig", e.metric_label());
368 self.metrics
369 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
370 }
371 }
372 result
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::agent_executor;
380 use crate::builder::RequestHandlerBuilder;
381
382 struct DummyExecutor;
383 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
384
385 fn make_handler() -> RequestHandler {
386 RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
387 }
388
389 fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
390 TaskPushNotificationConfig {
391 tenant: None,
392 id: Some("cfg-1".to_owned()),
393 task_id: Some(task_id.to_owned()),
394 url: "https://example.com/webhook".to_owned(),
395 token: None,
396 authentication: None,
397 }
398 }
399
400 async fn save_task(handler: &RequestHandler, id: &str) {
403 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
404 let task = Task {
405 id: TaskId::new(id),
406 context_id: ContextId::new("ctx"),
407 status: TaskStatus::new(TaskState::Submitted),
408 history: None,
409 artifacts: None,
410 metadata: None,
411 };
412 handler.task_store.save(&task).await.unwrap();
413 }
414
415 #[derive(Debug)]
420 struct CapTestSender;
421
422 impl crate::push::PushSender for CapTestSender {
423 fn send<'a>(
424 &'a self,
425 _url: &'a str,
426 _event: &'a a2a_protocol_types::events::StreamResponse,
427 _config: &'a TaskPushNotificationConfig,
428 ) -> std::pin::Pin<
429 Box<
430 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
431 + Send
432 + 'a,
433 >,
434 > {
435 Box::pin(async { Ok(()) })
436 }
437 }
438
439 #[derive(Debug, Default)]
447 struct SlowListStore(crate::push::InMemoryPushConfigStore);
448
449 type StoreFuture<'a, T> = std::pin::Pin<
450 Box<dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<T>> + Send + 'a>,
451 >;
452
453 impl crate::push::PushConfigStore for SlowListStore {
454 fn set(
455 &self,
456 config: TaskPushNotificationConfig,
457 ) -> StoreFuture<'_, TaskPushNotificationConfig> {
458 self.0.set(config)
459 }
460 fn get<'a>(
461 &'a self,
462 task_id: &'a str,
463 id: &'a str,
464 ) -> StoreFuture<'a, Option<TaskPushNotificationConfig>> {
465 self.0.get(task_id, id)
466 }
467 fn list<'a>(
468 &'a self,
469 task_id: &'a str,
470 ) -> StoreFuture<'a, Vec<TaskPushNotificationConfig>> {
471 Box::pin(async move {
472 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
473 self.0.list(task_id).await
474 })
475 }
476 fn delete<'a>(&'a self, task_id: &'a str, id: &'a str) -> StoreFuture<'a, ()> {
477 self.0.delete(task_id, id)
478 }
479 }
480
481 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
506 async fn concurrent_creates_cannot_exceed_the_per_task_cap() {
507 use std::sync::Arc;
508
509 const CAP: usize = 5;
510 const WRITERS: usize = 32;
511
512 let handler = Arc::new(
513 RequestHandlerBuilder::new(DummyExecutor)
514 .with_push_sender(CapTestSender)
515 .with_push_config_store(SlowListStore::default())
516 .with_handler_limits(
517 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(CAP),
518 )
519 .build()
520 .unwrap(),
521 );
522 save_task(&handler, "task-1").await;
523
524 let mut creates = Vec::new();
525 for i in 0..WRITERS {
526 let each = Arc::clone(&handler);
527 creates.push(tokio::spawn(async move {
528 each.on_set_push_config(
529 TaskPushNotificationConfig {
530 tenant: None,
531 id: Some(format!("cfg-{i}")),
532 task_id: Some("task-1".to_owned()),
533 url: format!("https://example.com/hook/{i}"),
534 token: None,
535 authentication: None,
536 },
537 None,
538 )
539 .await
540 .is_ok()
541 }));
542 }
543 let mut accepted = 0usize;
544 for create in creates {
545 if create.await.unwrap_or(false) {
546 accepted += 1;
547 }
548 }
549
550 let stored = handler
551 .on_list_push_configs("task-1", None, None)
552 .await
553 .expect("list")
554 .len();
555
556 assert_eq!(
557 stored, CAP,
558 "the store must hold exactly the cap; {WRITERS} concurrent creates stored {stored}"
559 );
560 assert_eq!(
561 accepted, CAP,
562 "and exactly {CAP} callers must have been told they succeeded, not {accepted} — \
563 a caller handed Ok for a config that breaks the cap was lied to"
564 );
565 }
566
567 #[tokio::test]
570 async fn set_push_config_without_sender_returns_push_not_supported() {
571 let handler = make_handler();
572 let config = make_push_config("task-1");
573 let result = handler.on_set_push_config(config, None).await;
574 assert!(
575 matches!(result, Err(crate::error::ServerError::PushNotSupported)),
576 "expected PushNotSupported, got: {result:?}"
577 );
578 }
579
580 #[tokio::test]
585 async fn set_push_config_without_task_id_returns_invalid_params() {
586 use crate::push::PushSender;
587 use a2a_protocol_types::events::StreamResponse;
588 use std::future::Future;
589 use std::pin::Pin;
590
591 struct NoopSender;
592 impl PushSender for NoopSender {
593 fn send<'a>(
594 &'a self,
595 _url: &'a str,
596 _event: &'a StreamResponse,
597 _config: &'a TaskPushNotificationConfig,
598 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
599 {
600 Box::pin(async { Ok(()) })
601 }
602 fn allows_private_urls(&self) -> bool {
603 true
604 }
605 }
606
607 let handler = RequestHandlerBuilder::new(DummyExecutor)
608 .with_push_sender(NoopSender)
609 .build()
610 .unwrap();
611
612 let config = TaskPushNotificationConfig {
613 tenant: None,
614 id: None,
615 task_id: None,
616 url: "https://example.com/webhook".to_owned(),
617 token: None,
618 authentication: None,
619 };
620 let result = handler.on_set_push_config(config, None).await;
621 match result {
622 Err(crate::error::ServerError::InvalidParams(msg)) => {
623 assert!(msg.contains("taskId"), "got: {msg}");
624 }
625 other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
626 }
627 }
628
629 #[tokio::test]
643 async fn set_push_config_rejects_private_url_under_the_default_sender_policy() {
644 use crate::push::PushSender;
645 use a2a_protocol_types::events::StreamResponse;
646 use std::future::Future;
647 use std::pin::Pin;
648
649 struct DefaultPolicySender;
652 impl PushSender for DefaultPolicySender {
653 fn send<'a>(
654 &'a self,
655 _url: &'a str,
656 _event: &'a StreamResponse,
657 _config: &'a TaskPushNotificationConfig,
658 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
659 {
660 Box::pin(async { Ok(()) })
661 }
662 }
663
664 let handler = RequestHandlerBuilder::new(DummyExecutor)
665 .with_push_sender(DefaultPolicySender)
666 .build()
667 .unwrap();
668 save_task(&handler, "task-ssrf").await;
669
670 let config = TaskPushNotificationConfig {
671 tenant: None,
672 id: Some("cfg-ssrf".to_owned()),
673 task_id: Some("task-ssrf".to_owned()),
674 url: "http://127.0.0.1:9000/webhook".to_owned(),
675 token: None,
676 authentication: None,
677 };
678
679 match handler.on_set_push_config(config, None).await {
684 Err(e) => {
685 let msg = e.to_string();
686 assert!(
687 msg.contains("private/loopback"),
688 "expected the SSRF rejection, got: {msg}"
689 );
690 }
691 Ok(v) => panic!(
692 "a sender taking the default policy must refuse a loopback webhook URL, got: Ok({v:?})"
693 ),
694 }
695 }
696
697 #[tokio::test]
701 async fn set_push_config_enforces_global_cap() {
702 use crate::push::PushSender;
703 use a2a_protocol_types::events::StreamResponse;
704 use std::future::Future;
705 use std::pin::Pin;
706
707 struct NoopSender;
708 impl PushSender for NoopSender {
709 fn send<'a>(
710 &'a self,
711 _url: &'a str,
712 _event: &'a StreamResponse,
713 _config: &'a TaskPushNotificationConfig,
714 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
715 {
716 Box::pin(async { Ok(()) })
717 }
718 }
719
720 let handler = RequestHandlerBuilder::new(DummyExecutor)
721 .with_push_sender(NoopSender)
722 .with_handler_limits(
723 crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
724 )
725 .build()
726 .unwrap();
727
728 for i in 0..2 {
730 save_task(&handler, &format!("task-{i}")).await;
731 let cfg = TaskPushNotificationConfig {
732 tenant: None,
733 id: Some(format!("cfg-{i}")),
734 task_id: Some(format!("task-{i}")),
735 url: "https://example.com/webhook".to_owned(),
736 token: None,
737 authentication: None,
738 };
739 handler
740 .on_set_push_config(cfg, None)
741 .await
742 .expect("creates under the global cap should succeed");
743 }
744
745 save_task(&handler, "task-x").await;
747 let cfg = TaskPushNotificationConfig {
748 tenant: None,
749 id: Some("cfg-x".to_owned()),
750 task_id: Some("task-x".to_owned()),
751 url: "https://example.com/webhook".to_owned(),
752 token: None,
753 authentication: None,
754 };
755 let result = handler.on_set_push_config(cfg, None).await;
756 assert!(
757 matches!(result, Err(crate::error::ServerError::Overloaded(_))),
758 "global push-config cap must reject, got {result:?}"
759 );
760 }
761
762 #[tokio::test]
766 async fn set_push_config_update_allowed_at_per_task_cap() {
767 use crate::push::PushSender;
768 use a2a_protocol_types::events::StreamResponse;
769 use std::future::Future;
770 use std::pin::Pin;
771
772 struct NoopSender;
773 impl PushSender for NoopSender {
774 fn send<'a>(
775 &'a self,
776 _url: &'a str,
777 _event: &'a StreamResponse,
778 _config: &'a TaskPushNotificationConfig,
779 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
780 {
781 Box::pin(async { Ok(()) })
782 }
783 }
784
785 let handler = RequestHandlerBuilder::new(DummyExecutor)
787 .with_push_sender(NoopSender)
788 .with_handler_limits(
789 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
790 )
791 .build()
792 .unwrap();
793
794 save_task(&handler, "task-1").await;
795 let make = |url: &str| TaskPushNotificationConfig {
796 tenant: None,
797 id: Some("cfg-1".to_owned()),
798 task_id: Some("task-1".to_owned()),
799 url: url.to_owned(),
800 token: None,
801 authentication: None,
802 };
803
804 handler
806 .on_set_push_config(make("https://example.com/a"), None)
807 .await
808 .expect("first create should succeed");
809
810 handler
812 .on_set_push_config(make("https://example.com/b"), None)
813 .await
814 .expect("updating an existing config at the cap must be allowed");
815
816 let mut newcfg = make("https://example.com/c");
818 newcfg.id = Some("cfg-2".to_owned());
819 let rejected = handler.on_set_push_config(newcfg, None).await;
820 assert!(
821 matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
822 "a new config beyond the per-task cap must be rejected, got {rejected:?}"
823 );
824 }
825
826 struct NoopSender;
829 impl crate::push::PushSender for NoopSender {
830 fn send<'a>(
831 &'a self,
832 _url: &'a str,
833 _event: &'a a2a_protocol_types::events::StreamResponse,
834 _config: &'a TaskPushNotificationConfig,
835 ) -> std::pin::Pin<
836 Box<
837 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
838 + Send
839 + 'a,
840 >,
841 > {
842 Box::pin(async { Ok(()) })
843 }
844 fn allows_private_urls(&self) -> bool {
845 true
846 }
847 }
848
849 fn card_with(
851 caps: a2a_protocol_types::agent_card::AgentCapabilities,
852 ) -> a2a_protocol_types::agent_card::AgentCard {
853 use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
854 AgentCard {
855 url: None,
856 name: "Test Agent".into(),
857 description: "A test agent".into(),
858 version: "1.0.0".into(),
859 supported_interfaces: vec![AgentInterface {
860 url: "http://localhost:8080".into(),
861 protocol_binding: "JSONRPC".into(),
862 protocol_version: "1.0.0".into(),
863 tenant: None,
864 }],
865 default_input_modes: vec![],
866 default_output_modes: vec![],
867 skills: vec![],
868 capabilities: caps,
869 provider: None,
870 icon_url: None,
871 documentation_url: None,
872 security_schemes: None,
873 security_requirements: None,
874 signatures: None,
875 }
876 }
877
878 #[tokio::test]
881 async fn set_push_config_for_missing_task_returns_task_not_found() {
882 let handler = RequestHandlerBuilder::new(DummyExecutor)
883 .with_push_sender(NoopSender)
884 .build()
885 .unwrap();
886 let config = make_push_config("ghost-task");
887 let result = handler.on_set_push_config(config, None).await;
888 assert!(
889 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
890 "expected TaskNotFound for a config targeting a missing task, got: {result:?}"
891 );
892 }
893
894 #[tokio::test]
898 async fn push_ops_rejected_when_card_lacks_capability() {
899 use a2a_protocol_types::agent_card::AgentCapabilities;
900 use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
901
902 let handler = RequestHandlerBuilder::new(DummyExecutor)
903 .with_push_sender(NoopSender)
904 .with_agent_card(card_with(AgentCapabilities::none()))
905 .build()
906 .unwrap();
907
908 let set = handler
909 .on_set_push_config(make_push_config("t1"), None)
910 .await;
911 assert!(
912 matches!(set, Err(crate::error::ServerError::PushNotSupported)),
913 "set must be rejected, got: {set:?}"
914 );
915
916 let get = handler
917 .on_get_push_config(
918 GetPushConfigParams {
919 tenant: None,
920 task_id: "t1".into(),
921 id: "cfg-1".into(),
922 },
923 None,
924 )
925 .await;
926 assert!(
927 matches!(get, Err(crate::error::ServerError::PushNotSupported)),
928 "get must be rejected, got: {get:?}"
929 );
930
931 let list = handler.on_list_push_configs("t1", None, None).await;
932 assert!(
933 matches!(list, Err(crate::error::ServerError::PushNotSupported)),
934 "list must be rejected, got: {list:?}"
935 );
936
937 let delete = handler
938 .on_delete_push_config(
939 DeletePushConfigParams {
940 tenant: None,
941 task_id: "t1".into(),
942 id: "cfg-1".into(),
943 },
944 None,
945 )
946 .await;
947 assert!(
948 matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
949 "delete must be rejected, got: {delete:?}"
950 );
951 }
952
953 #[tokio::test]
956 async fn push_ops_allowed_when_card_has_capability() {
957 use a2a_protocol_types::agent_card::AgentCapabilities;
958
959 let handler = RequestHandlerBuilder::new(DummyExecutor)
960 .with_push_sender(NoopSender)
961 .with_agent_card(card_with(
962 AgentCapabilities::none().with_push_notifications(true),
963 ))
964 .build()
965 .unwrap();
966 save_task(&handler, "t1").await;
967
968 handler
969 .on_set_push_config(make_push_config("t1"), None)
970 .await
971 .expect("set should succeed when push capability is advertised");
972 let configs = handler
973 .on_list_push_configs("t1", None, None)
974 .await
975 .expect("list should succeed");
976 assert_eq!(configs.len(), 1, "the created config should be listed");
977 }
978
979 #[tokio::test]
982 async fn get_push_config_not_found_returns_task_not_found() {
983 use a2a_protocol_types::params::GetPushConfigParams;
986
987 let handler = make_handler();
988 let params = GetPushConfigParams {
989 tenant: None,
990 task_id: "no-task".to_owned(),
991 id: "no-id".to_owned(),
992 };
993 let result = handler.on_get_push_config(params, None).await;
994 assert!(
995 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
996 "expected TaskNotFound for missing config, got: {result:?}"
997 );
998 }
999
1000 #[tokio::test]
1003 async fn list_push_configs_empty_returns_empty_vec() {
1004 let handler = make_handler();
1005 let result = handler
1006 .on_list_push_configs("no-task", None, None)
1007 .await
1008 .expect("list should succeed on empty store");
1009 assert!(
1010 result.is_empty(),
1011 "listing configs for an unknown task should return an empty vec"
1012 );
1013 }
1014
1015 #[tokio::test]
1018 async fn delete_push_config_nonexistent_returns_ok() {
1019 use a2a_protocol_types::params::DeletePushConfigParams;
1020
1021 let handler = make_handler();
1022 let params = DeletePushConfigParams {
1023 tenant: None,
1024 task_id: "no-task".to_owned(),
1025 id: "no-id".to_owned(),
1026 };
1027 let result = handler.on_delete_push_config(params, None).await;
1030 assert!(
1031 result.is_ok(),
1032 "deleting a non-existent push config should return Ok, got: {result:?}"
1033 );
1034 }
1035
1036 #[tokio::test]
1039 async fn list_push_configs_error_path_records_metrics() {
1040 use crate::call_context::CallContext;
1043 use crate::interceptor::ServerInterceptor;
1044 use std::future::Future;
1045 use std::pin::Pin;
1046
1047 struct FailInterceptor;
1048 impl ServerInterceptor for FailInterceptor {
1049 fn before<'a>(
1050 &'a self,
1051 _ctx: &'a CallContext,
1052 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1053 {
1054 Box::pin(async {
1055 Err(a2a_protocol_types::error::A2aError::internal(
1056 "forced failure",
1057 ))
1058 })
1059 }
1060 fn after<'a>(
1061 &'a self,
1062 _ctx: &'a CallContext,
1063 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1064 {
1065 Box::pin(async { Ok(()) })
1066 }
1067 }
1068
1069 let handler = RequestHandlerBuilder::new(DummyExecutor)
1070 .with_interceptor(FailInterceptor)
1071 .build()
1072 .unwrap();
1073
1074 let result = handler.on_list_push_configs("task-1", None, None).await;
1075 assert!(
1076 result.is_err(),
1077 "list_push_configs should fail when interceptor rejects"
1078 );
1079 }
1080
1081 #[tokio::test]
1082 async fn delete_push_config_error_path_records_metrics() {
1083 use crate::call_context::CallContext;
1086 use crate::interceptor::ServerInterceptor;
1087 use a2a_protocol_types::params::DeletePushConfigParams;
1088 use std::future::Future;
1089 use std::pin::Pin;
1090
1091 struct FailInterceptor;
1092 impl ServerInterceptor for FailInterceptor {
1093 fn before<'a>(
1094 &'a self,
1095 _ctx: &'a CallContext,
1096 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1097 {
1098 Box::pin(async {
1099 Err(a2a_protocol_types::error::A2aError::internal(
1100 "forced failure",
1101 ))
1102 })
1103 }
1104 fn after<'a>(
1105 &'a self,
1106 _ctx: &'a CallContext,
1107 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1108 {
1109 Box::pin(async { Ok(()) })
1110 }
1111 }
1112
1113 let handler = RequestHandlerBuilder::new(DummyExecutor)
1114 .with_interceptor(FailInterceptor)
1115 .build()
1116 .unwrap();
1117
1118 let params = DeletePushConfigParams {
1119 tenant: None,
1120 task_id: "task-1".to_owned(),
1121 id: "cfg-1".to_owned(),
1122 };
1123 let result = handler.on_delete_push_config(params, None).await;
1124 assert!(
1125 result.is_err(),
1126 "delete_push_config should fail when interceptor rejects"
1127 );
1128 }
1129
1130 #[tokio::test]
1131 async fn set_push_config_error_path_records_metrics() {
1132 let handler = make_handler();
1135 let config = make_push_config("task-err");
1136 let result = handler.on_set_push_config(config, None).await;
1137 assert!(
1138 result.is_err(),
1139 "set_push_config without push sender should hit error metrics path"
1140 );
1141 }
1142
1143 #[tokio::test]
1144 async fn get_push_config_error_path_records_metrics() {
1145 use a2a_protocol_types::params::GetPushConfigParams;
1148
1149 let handler = make_handler();
1150 let params = GetPushConfigParams {
1151 tenant: None,
1152 task_id: "missing-task".to_owned(),
1153 id: "missing-id".to_owned(),
1154 };
1155 let result = handler.on_get_push_config(params, None).await;
1156 assert!(
1157 result.is_err(),
1158 "get_push_config for missing config should hit error metrics path"
1159 );
1160 }
1161}