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::helpers::build_call_context;
18use super::RequestHandler;
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 if let Some(total) = self.push_config_store.count().await? {
123 if total >= self.limits.max_total_push_configs {
124 return Err(ServerError::Overloaded(format!(
125 "server is at the maximum of {} push notification configs; \
126 delete unused configs before creating more",
127 self.limits.max_total_push_configs
128 )));
129 }
130 }
131 }
132
133 Ok(self.push_config_store.set(config).await?)
134 }
135
136 #[allow(clippy::too_many_lines)]
142 pub async fn on_set_push_config(
143 &self,
144 config: TaskPushNotificationConfig,
145 headers: Option<&HashMap<String, String>>,
146 ) -> ServerResult<TaskPushNotificationConfig> {
147 let start = Instant::now();
148 self.metrics.on_request("CreateTaskPushNotificationConfig");
149
150 let tenant = self
151 .resolve_tenant(
152 "CreateTaskPushNotificationConfig",
153 headers,
154 config.tenant.as_deref(),
155 )
156 .await?;
157 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
158 if config.task_id.as_deref().unwrap_or("").is_empty() {
163 return Err(ServerError::InvalidParams(
164 "taskId is required for CreateTaskPushNotificationConfig".into(),
165 ));
166 }
167
168 let call_ctx = build_call_context("CreateTaskPushNotificationConfig", headers);
169 self.interceptors.run_before(&call_ctx).await?;
170 self.ensure_required_extensions(&call_ctx)?;
173
174 let result = self.validate_and_store_push_config(config).await?;
175 self.interceptors.run_after(&call_ctx).await?;
176 Ok(result)
177 })
178 .await;
179
180 let elapsed = start.elapsed();
181 match &result {
182 Ok(_) => {
183 self.metrics.on_response("CreateTaskPushNotificationConfig");
184 self.metrics
185 .on_latency("CreateTaskPushNotificationConfig", elapsed);
186 }
187 Err(e) => {
188 self.metrics
189 .on_error("CreateTaskPushNotificationConfig", e.metric_label());
190 self.metrics
191 .on_latency("CreateTaskPushNotificationConfig", elapsed);
192 }
193 }
194 result
195 }
196
197 pub async fn on_get_push_config(
205 &self,
206 params: GetPushConfigParams,
207 headers: Option<&HashMap<String, String>>,
208 ) -> ServerResult<TaskPushNotificationConfig> {
209 let start = Instant::now();
210 self.metrics.on_request("GetTaskPushNotificationConfig");
211
212 let tenant = self
213 .resolve_tenant(
214 "GetTaskPushNotificationConfig",
215 headers,
216 params.tenant.as_deref(),
217 )
218 .await?;
219 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
220 self.ensure_push_supported()?;
222 let call_ctx = build_call_context("GetTaskPushNotificationConfig", headers);
223 self.interceptors.run_before(&call_ctx).await?;
224 self.ensure_required_extensions(&call_ctx)?;
227
228 let config = self
231 .push_config_store
232 .get(¶ms.task_id, ¶ms.id)
233 .await?
234 .ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
235
236 self.interceptors.run_after(&call_ctx).await?;
237 Ok(config)
238 })
239 .await;
240
241 let elapsed = start.elapsed();
242 match &result {
243 Ok(_) => {
244 self.metrics.on_response("GetTaskPushNotificationConfig");
245 self.metrics
246 .on_latency("GetTaskPushNotificationConfig", elapsed);
247 }
248 Err(e) => {
249 self.metrics
250 .on_error("GetTaskPushNotificationConfig", e.metric_label());
251 self.metrics
252 .on_latency("GetTaskPushNotificationConfig", elapsed);
253 }
254 }
255 result
256 }
257
258 pub async fn on_list_push_configs(
264 &self,
265 task_id: &str,
266 tenant: Option<&str>,
267 headers: Option<&HashMap<String, String>>,
268 ) -> ServerResult<Vec<TaskPushNotificationConfig>> {
269 let start = Instant::now();
270 self.metrics.on_request("ListTaskPushNotificationConfigs");
271
272 let tenant_owned = self
273 .resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
274 .await?;
275 let result: ServerResult<_> =
276 crate::store::tenant::TenantContext::scope(tenant_owned, async {
277 self.ensure_push_supported()?;
279 let call_ctx = build_call_context("ListTaskPushNotificationConfigs", headers);
280 self.interceptors.run_before(&call_ctx).await?;
281 self.ensure_required_extensions(&call_ctx)?;
284 let configs = self.push_config_store.list(task_id).await?;
285 self.interceptors.run_after(&call_ctx).await?;
286 Ok(configs)
287 })
288 .await;
289
290 let elapsed = start.elapsed();
291 match &result {
292 Ok(_) => {
293 self.metrics.on_response("ListTaskPushNotificationConfigs");
294 self.metrics
295 .on_latency("ListTaskPushNotificationConfigs", elapsed);
296 }
297 Err(e) => {
298 self.metrics
299 .on_error("ListTaskPushNotificationConfigs", e.metric_label());
300 self.metrics
301 .on_latency("ListTaskPushNotificationConfigs", elapsed);
302 }
303 }
304 result
305 }
306
307 pub async fn on_delete_push_config(
313 &self,
314 params: DeletePushConfigParams,
315 headers: Option<&HashMap<String, String>>,
316 ) -> ServerResult<()> {
317 let start = Instant::now();
318 self.metrics.on_request("DeleteTaskPushNotificationConfig");
319
320 let tenant = self
321 .resolve_tenant(
322 "DeleteTaskPushNotificationConfig",
323 headers,
324 params.tenant.as_deref(),
325 )
326 .await?;
327 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
328 self.ensure_push_supported()?;
330 let call_ctx = build_call_context("DeleteTaskPushNotificationConfig", headers);
331 self.interceptors.run_before(&call_ctx).await?;
332 self.ensure_required_extensions(&call_ctx)?;
335 self.push_config_store
336 .delete(¶ms.task_id, ¶ms.id)
337 .await?;
338 self.interceptors.run_after(&call_ctx).await?;
339 Ok(())
340 })
341 .await;
342
343 let elapsed = start.elapsed();
344 match &result {
345 Ok(()) => {
346 self.metrics.on_response("DeleteTaskPushNotificationConfig");
347 self.metrics
348 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
349 }
350 Err(e) => {
351 self.metrics
352 .on_error("DeleteTaskPushNotificationConfig", e.metric_label());
353 self.metrics
354 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
355 }
356 }
357 result
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::agent_executor;
365 use crate::builder::RequestHandlerBuilder;
366
367 struct DummyExecutor;
368 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
369
370 fn make_handler() -> RequestHandler {
371 RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
372 }
373
374 fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
375 TaskPushNotificationConfig {
376 tenant: None,
377 id: Some("cfg-1".to_owned()),
378 task_id: Some(task_id.to_owned()),
379 url: "https://example.com/webhook".to_owned(),
380 token: None,
381 authentication: None,
382 }
383 }
384
385 async fn save_task(handler: &RequestHandler, id: &str) {
388 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
389 let task = Task {
390 id: TaskId::new(id),
391 context_id: ContextId::new("ctx"),
392 status: TaskStatus::new(TaskState::Submitted),
393 history: None,
394 artifacts: None,
395 metadata: None,
396 };
397 handler.task_store.save(&task).await.unwrap();
398 }
399
400 #[derive(Debug)]
405 struct CapTestSender;
406
407 impl crate::push::PushSender for CapTestSender {
408 fn send<'a>(
409 &'a self,
410 _url: &'a str,
411 _event: &'a a2a_protocol_types::events::StreamResponse,
412 _config: &'a TaskPushNotificationConfig,
413 ) -> std::pin::Pin<
414 Box<
415 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
416 + Send
417 + 'a,
418 >,
419 > {
420 Box::pin(async { Ok(()) })
421 }
422 }
423
424 #[derive(Debug, Default)]
432 struct SlowListStore(crate::push::InMemoryPushConfigStore);
433
434 type StoreFuture<'a, T> = std::pin::Pin<
435 Box<dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<T>> + Send + 'a>,
436 >;
437
438 impl crate::push::PushConfigStore for SlowListStore {
439 fn set(
440 &self,
441 config: TaskPushNotificationConfig,
442 ) -> StoreFuture<'_, TaskPushNotificationConfig> {
443 self.0.set(config)
444 }
445 fn get<'a>(
446 &'a self,
447 task_id: &'a str,
448 id: &'a str,
449 ) -> StoreFuture<'a, Option<TaskPushNotificationConfig>> {
450 self.0.get(task_id, id)
451 }
452 fn list<'a>(
453 &'a self,
454 task_id: &'a str,
455 ) -> StoreFuture<'a, Vec<TaskPushNotificationConfig>> {
456 Box::pin(async move {
457 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
458 self.0.list(task_id).await
459 })
460 }
461 fn delete<'a>(&'a self, task_id: &'a str, id: &'a str) -> StoreFuture<'a, ()> {
462 self.0.delete(task_id, id)
463 }
464 }
465
466 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
491 async fn concurrent_creates_cannot_exceed_the_per_task_cap() {
492 use std::sync::Arc;
493
494 const CAP: usize = 5;
495 const WRITERS: usize = 32;
496
497 let handler = Arc::new(
498 RequestHandlerBuilder::new(DummyExecutor)
499 .with_push_sender(CapTestSender)
500 .with_push_config_store(SlowListStore::default())
501 .with_handler_limits(
502 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(CAP),
503 )
504 .build()
505 .unwrap(),
506 );
507 save_task(&handler, "task-1").await;
508
509 let mut creates = Vec::new();
510 for i in 0..WRITERS {
511 let each = Arc::clone(&handler);
512 creates.push(tokio::spawn(async move {
513 each.on_set_push_config(
514 TaskPushNotificationConfig {
515 tenant: None,
516 id: Some(format!("cfg-{i}")),
517 task_id: Some("task-1".to_owned()),
518 url: format!("https://example.com/hook/{i}"),
519 token: None,
520 authentication: None,
521 },
522 None,
523 )
524 .await
525 .is_ok()
526 }));
527 }
528 let mut accepted = 0usize;
529 for create in creates {
530 if create.await.unwrap_or(false) {
531 accepted += 1;
532 }
533 }
534
535 let stored = handler
536 .on_list_push_configs("task-1", None, None)
537 .await
538 .expect("list")
539 .len();
540
541 assert_eq!(
542 stored, CAP,
543 "the store must hold exactly the cap; {WRITERS} concurrent creates stored {stored}"
544 );
545 assert_eq!(
546 accepted, CAP,
547 "and exactly {CAP} callers must have been told they succeeded, not {accepted} — \
548 a caller handed Ok for a config that breaks the cap was lied to"
549 );
550 }
551
552 #[tokio::test]
555 async fn set_push_config_without_sender_returns_push_not_supported() {
556 let handler = make_handler();
557 let config = make_push_config("task-1");
558 let result = handler.on_set_push_config(config, None).await;
559 assert!(
560 matches!(result, Err(crate::error::ServerError::PushNotSupported)),
561 "expected PushNotSupported, got: {result:?}"
562 );
563 }
564
565 #[tokio::test]
570 async fn set_push_config_without_task_id_returns_invalid_params() {
571 use crate::push::PushSender;
572 use a2a_protocol_types::events::StreamResponse;
573 use std::future::Future;
574 use std::pin::Pin;
575
576 struct NoopSender;
577 impl PushSender for NoopSender {
578 fn send<'a>(
579 &'a self,
580 _url: &'a str,
581 _event: &'a StreamResponse,
582 _config: &'a TaskPushNotificationConfig,
583 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
584 {
585 Box::pin(async { Ok(()) })
586 }
587 fn allows_private_urls(&self) -> bool {
588 true
589 }
590 }
591
592 let handler = RequestHandlerBuilder::new(DummyExecutor)
593 .with_push_sender(NoopSender)
594 .build()
595 .unwrap();
596
597 let config = TaskPushNotificationConfig {
598 tenant: None,
599 id: None,
600 task_id: None,
601 url: "https://example.com/webhook".to_owned(),
602 token: None,
603 authentication: None,
604 };
605 let result = handler.on_set_push_config(config, None).await;
606 match result {
607 Err(crate::error::ServerError::InvalidParams(msg)) => {
608 assert!(msg.contains("taskId"), "got: {msg}");
609 }
610 other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
611 }
612 }
613
614 #[tokio::test]
628 async fn set_push_config_rejects_private_url_under_the_default_sender_policy() {
629 use crate::push::PushSender;
630 use a2a_protocol_types::events::StreamResponse;
631 use std::future::Future;
632 use std::pin::Pin;
633
634 struct DefaultPolicySender;
637 impl PushSender for DefaultPolicySender {
638 fn send<'a>(
639 &'a self,
640 _url: &'a str,
641 _event: &'a StreamResponse,
642 _config: &'a TaskPushNotificationConfig,
643 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
644 {
645 Box::pin(async { Ok(()) })
646 }
647 }
648
649 let handler = RequestHandlerBuilder::new(DummyExecutor)
650 .with_push_sender(DefaultPolicySender)
651 .build()
652 .unwrap();
653 save_task(&handler, "task-ssrf").await;
654
655 let config = TaskPushNotificationConfig {
656 tenant: None,
657 id: Some("cfg-ssrf".to_owned()),
658 task_id: Some("task-ssrf".to_owned()),
659 url: "http://127.0.0.1:9000/webhook".to_owned(),
660 token: None,
661 authentication: None,
662 };
663
664 match handler.on_set_push_config(config, None).await {
669 Err(e) => {
670 let msg = e.to_string();
671 assert!(
672 msg.contains("private/loopback"),
673 "expected the SSRF rejection, got: {msg}"
674 );
675 }
676 Ok(v) => panic!(
677 "a sender taking the default policy must refuse a loopback webhook URL, got: Ok({v:?})"
678 ),
679 }
680 }
681
682 #[tokio::test]
686 async fn set_push_config_enforces_global_cap() {
687 use crate::push::PushSender;
688 use a2a_protocol_types::events::StreamResponse;
689 use std::future::Future;
690 use std::pin::Pin;
691
692 struct NoopSender;
693 impl PushSender for NoopSender {
694 fn send<'a>(
695 &'a self,
696 _url: &'a str,
697 _event: &'a StreamResponse,
698 _config: &'a TaskPushNotificationConfig,
699 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
700 {
701 Box::pin(async { Ok(()) })
702 }
703 }
704
705 let handler = RequestHandlerBuilder::new(DummyExecutor)
706 .with_push_sender(NoopSender)
707 .with_handler_limits(
708 crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
709 )
710 .build()
711 .unwrap();
712
713 for i in 0..2 {
715 save_task(&handler, &format!("task-{i}")).await;
716 let cfg = TaskPushNotificationConfig {
717 tenant: None,
718 id: Some(format!("cfg-{i}")),
719 task_id: Some(format!("task-{i}")),
720 url: "https://example.com/webhook".to_owned(),
721 token: None,
722 authentication: None,
723 };
724 handler
725 .on_set_push_config(cfg, None)
726 .await
727 .expect("creates under the global cap should succeed");
728 }
729
730 save_task(&handler, "task-x").await;
732 let cfg = TaskPushNotificationConfig {
733 tenant: None,
734 id: Some("cfg-x".to_owned()),
735 task_id: Some("task-x".to_owned()),
736 url: "https://example.com/webhook".to_owned(),
737 token: None,
738 authentication: None,
739 };
740 let result = handler.on_set_push_config(cfg, None).await;
741 assert!(
742 matches!(result, Err(crate::error::ServerError::Overloaded(_))),
743 "global push-config cap must reject, got {result:?}"
744 );
745 }
746
747 #[tokio::test]
751 async fn set_push_config_update_allowed_at_per_task_cap() {
752 use crate::push::PushSender;
753 use a2a_protocol_types::events::StreamResponse;
754 use std::future::Future;
755 use std::pin::Pin;
756
757 struct NoopSender;
758 impl PushSender for NoopSender {
759 fn send<'a>(
760 &'a self,
761 _url: &'a str,
762 _event: &'a StreamResponse,
763 _config: &'a TaskPushNotificationConfig,
764 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
765 {
766 Box::pin(async { Ok(()) })
767 }
768 }
769
770 let handler = RequestHandlerBuilder::new(DummyExecutor)
772 .with_push_sender(NoopSender)
773 .with_handler_limits(
774 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
775 )
776 .build()
777 .unwrap();
778
779 save_task(&handler, "task-1").await;
780 let make = |url: &str| TaskPushNotificationConfig {
781 tenant: None,
782 id: Some("cfg-1".to_owned()),
783 task_id: Some("task-1".to_owned()),
784 url: url.to_owned(),
785 token: None,
786 authentication: None,
787 };
788
789 handler
791 .on_set_push_config(make("https://example.com/a"), None)
792 .await
793 .expect("first create should succeed");
794
795 handler
797 .on_set_push_config(make("https://example.com/b"), None)
798 .await
799 .expect("updating an existing config at the cap must be allowed");
800
801 let mut newcfg = make("https://example.com/c");
803 newcfg.id = Some("cfg-2".to_owned());
804 let rejected = handler.on_set_push_config(newcfg, None).await;
805 assert!(
806 matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
807 "a new config beyond the per-task cap must be rejected, got {rejected:?}"
808 );
809 }
810
811 struct NoopSender;
814 impl crate::push::PushSender for NoopSender {
815 fn send<'a>(
816 &'a self,
817 _url: &'a str,
818 _event: &'a a2a_protocol_types::events::StreamResponse,
819 _config: &'a TaskPushNotificationConfig,
820 ) -> std::pin::Pin<
821 Box<
822 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
823 + Send
824 + 'a,
825 >,
826 > {
827 Box::pin(async { Ok(()) })
828 }
829 fn allows_private_urls(&self) -> bool {
830 true
831 }
832 }
833
834 fn card_with(
836 caps: a2a_protocol_types::agent_card::AgentCapabilities,
837 ) -> a2a_protocol_types::agent_card::AgentCard {
838 use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
839 AgentCard {
840 url: None,
841 name: "Test Agent".into(),
842 description: "A test agent".into(),
843 version: "1.0.0".into(),
844 supported_interfaces: vec![AgentInterface {
845 url: "http://localhost:8080".into(),
846 protocol_binding: "JSONRPC".into(),
847 protocol_version: "1.0.0".into(),
848 tenant: None,
849 }],
850 default_input_modes: vec![],
851 default_output_modes: vec![],
852 skills: vec![],
853 capabilities: caps,
854 provider: None,
855 icon_url: None,
856 documentation_url: None,
857 security_schemes: None,
858 security_requirements: None,
859 signatures: None,
860 }
861 }
862
863 #[tokio::test]
866 async fn set_push_config_for_missing_task_returns_task_not_found() {
867 let handler = RequestHandlerBuilder::new(DummyExecutor)
868 .with_push_sender(NoopSender)
869 .build()
870 .unwrap();
871 let config = make_push_config("ghost-task");
872 let result = handler.on_set_push_config(config, None).await;
873 assert!(
874 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
875 "expected TaskNotFound for a config targeting a missing task, got: {result:?}"
876 );
877 }
878
879 #[tokio::test]
883 async fn push_ops_rejected_when_card_lacks_capability() {
884 use a2a_protocol_types::agent_card::AgentCapabilities;
885 use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
886
887 let handler = RequestHandlerBuilder::new(DummyExecutor)
888 .with_push_sender(NoopSender)
889 .with_agent_card(card_with(AgentCapabilities::none()))
890 .build()
891 .unwrap();
892
893 let set = handler
894 .on_set_push_config(make_push_config("t1"), None)
895 .await;
896 assert!(
897 matches!(set, Err(crate::error::ServerError::PushNotSupported)),
898 "set must be rejected, got: {set:?}"
899 );
900
901 let get = handler
902 .on_get_push_config(
903 GetPushConfigParams {
904 tenant: None,
905 task_id: "t1".into(),
906 id: "cfg-1".into(),
907 },
908 None,
909 )
910 .await;
911 assert!(
912 matches!(get, Err(crate::error::ServerError::PushNotSupported)),
913 "get must be rejected, got: {get:?}"
914 );
915
916 let list = handler.on_list_push_configs("t1", None, None).await;
917 assert!(
918 matches!(list, Err(crate::error::ServerError::PushNotSupported)),
919 "list must be rejected, got: {list:?}"
920 );
921
922 let delete = handler
923 .on_delete_push_config(
924 DeletePushConfigParams {
925 tenant: None,
926 task_id: "t1".into(),
927 id: "cfg-1".into(),
928 },
929 None,
930 )
931 .await;
932 assert!(
933 matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
934 "delete must be rejected, got: {delete:?}"
935 );
936 }
937
938 #[tokio::test]
941 async fn push_ops_allowed_when_card_has_capability() {
942 use a2a_protocol_types::agent_card::AgentCapabilities;
943
944 let handler = RequestHandlerBuilder::new(DummyExecutor)
945 .with_push_sender(NoopSender)
946 .with_agent_card(card_with(
947 AgentCapabilities::none().with_push_notifications(true),
948 ))
949 .build()
950 .unwrap();
951 save_task(&handler, "t1").await;
952
953 handler
954 .on_set_push_config(make_push_config("t1"), None)
955 .await
956 .expect("set should succeed when push capability is advertised");
957 let configs = handler
958 .on_list_push_configs("t1", None, None)
959 .await
960 .expect("list should succeed");
961 assert_eq!(configs.len(), 1, "the created config should be listed");
962 }
963
964 #[tokio::test]
967 async fn get_push_config_not_found_returns_task_not_found() {
968 use a2a_protocol_types::params::GetPushConfigParams;
971
972 let handler = make_handler();
973 let params = GetPushConfigParams {
974 tenant: None,
975 task_id: "no-task".to_owned(),
976 id: "no-id".to_owned(),
977 };
978 let result = handler.on_get_push_config(params, None).await;
979 assert!(
980 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
981 "expected TaskNotFound for missing config, got: {result:?}"
982 );
983 }
984
985 #[tokio::test]
988 async fn list_push_configs_empty_returns_empty_vec() {
989 let handler = make_handler();
990 let result = handler
991 .on_list_push_configs("no-task", None, None)
992 .await
993 .expect("list should succeed on empty store");
994 assert!(
995 result.is_empty(),
996 "listing configs for an unknown task should return an empty vec"
997 );
998 }
999
1000 #[tokio::test]
1003 async fn delete_push_config_nonexistent_returns_ok() {
1004 use a2a_protocol_types::params::DeletePushConfigParams;
1005
1006 let handler = make_handler();
1007 let params = DeletePushConfigParams {
1008 tenant: None,
1009 task_id: "no-task".to_owned(),
1010 id: "no-id".to_owned(),
1011 };
1012 let result = handler.on_delete_push_config(params, None).await;
1015 assert!(
1016 result.is_ok(),
1017 "deleting a non-existent push config should return Ok, got: {result:?}"
1018 );
1019 }
1020
1021 #[tokio::test]
1024 async fn list_push_configs_error_path_records_metrics() {
1025 use crate::call_context::CallContext;
1028 use crate::interceptor::ServerInterceptor;
1029 use std::future::Future;
1030 use std::pin::Pin;
1031
1032 struct FailInterceptor;
1033 impl ServerInterceptor for FailInterceptor {
1034 fn before<'a>(
1035 &'a self,
1036 _ctx: &'a CallContext,
1037 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1038 {
1039 Box::pin(async {
1040 Err(a2a_protocol_types::error::A2aError::internal(
1041 "forced failure",
1042 ))
1043 })
1044 }
1045 fn after<'a>(
1046 &'a self,
1047 _ctx: &'a CallContext,
1048 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1049 {
1050 Box::pin(async { Ok(()) })
1051 }
1052 }
1053
1054 let handler = RequestHandlerBuilder::new(DummyExecutor)
1055 .with_interceptor(FailInterceptor)
1056 .build()
1057 .unwrap();
1058
1059 let result = handler.on_list_push_configs("task-1", None, None).await;
1060 assert!(
1061 result.is_err(),
1062 "list_push_configs should fail when interceptor rejects"
1063 );
1064 }
1065
1066 #[tokio::test]
1067 async fn delete_push_config_error_path_records_metrics() {
1068 use crate::call_context::CallContext;
1071 use crate::interceptor::ServerInterceptor;
1072 use a2a_protocol_types::params::DeletePushConfigParams;
1073 use std::future::Future;
1074 use std::pin::Pin;
1075
1076 struct FailInterceptor;
1077 impl ServerInterceptor for FailInterceptor {
1078 fn before<'a>(
1079 &'a self,
1080 _ctx: &'a CallContext,
1081 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1082 {
1083 Box::pin(async {
1084 Err(a2a_protocol_types::error::A2aError::internal(
1085 "forced failure",
1086 ))
1087 })
1088 }
1089 fn after<'a>(
1090 &'a self,
1091 _ctx: &'a CallContext,
1092 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
1093 {
1094 Box::pin(async { Ok(()) })
1095 }
1096 }
1097
1098 let handler = RequestHandlerBuilder::new(DummyExecutor)
1099 .with_interceptor(FailInterceptor)
1100 .build()
1101 .unwrap();
1102
1103 let params = DeletePushConfigParams {
1104 tenant: None,
1105 task_id: "task-1".to_owned(),
1106 id: "cfg-1".to_owned(),
1107 };
1108 let result = handler.on_delete_push_config(params, None).await;
1109 assert!(
1110 result.is_err(),
1111 "delete_push_config should fail when interceptor rejects"
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn set_push_config_error_path_records_metrics() {
1117 let handler = make_handler();
1120 let config = make_push_config("task-err");
1121 let result = handler.on_set_push_config(config, None).await;
1122 assert!(
1123 result.is_err(),
1124 "set_push_config without push sender should hit error metrics path"
1125 );
1126 }
1127
1128 #[tokio::test]
1129 async fn get_push_config_error_path_records_metrics() {
1130 use a2a_protocol_types::params::GetPushConfigParams;
1133
1134 let handler = make_handler();
1135 let params = GetPushConfigParams {
1136 tenant: None,
1137 task_id: "missing-task".to_owned(),
1138 id: "missing-id".to_owned(),
1139 };
1140 let result = handler.on_get_push_config(params, None).await;
1141 assert!(
1142 result.is_err(),
1143 "get_push_config for missing config should hit error metrics path"
1144 );
1145 }
1146}