1use std::net::SocketAddr;
7use std::pin::Pin;
8use std::time::Duration;
9
10use eventsource_stream::Eventsource;
11use futures_core::Stream;
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13use tokio_stream::StreamExt;
14use zeph_common::net::resolve_and_validate;
15
16use crate::error::A2aError;
17use crate::jsonrpc::{
18 JsonRpcRequest, JsonRpcResponse, METHOD_CANCEL_TASK, METHOD_GET_TASK, METHOD_SEND_MESSAGE,
19 METHOD_SEND_STREAMING_MESSAGE, SendMessageParams, TaskIdParams,
20};
21use crate::types::{Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent};
22
23pub type TaskEventStream = Pin<Box<dyn Stream<Item = Result<TaskEvent, A2aError>> + Send>>;
28
29#[non_exhaustive]
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(untagged)]
37pub enum TaskEvent {
38 StatusUpdate(TaskStatusUpdateEvent),
40 ArtifactUpdate(TaskArtifactUpdateEvent),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct SecurityPolicy {
67 pub require_tls: bool,
70 pub ssrf_protection: bool,
74}
75
76impl SecurityPolicy {
77 #[must_use]
90 pub const fn hardened() -> Self {
91 Self {
92 require_tls: true,
93 ssrf_protection: true,
94 }
95 }
96
97 #[must_use]
110 pub const fn permissive() -> Self {
111 Self {
112 require_tls: false,
113 ssrf_protection: false,
114 }
115 }
116}
117
118#[derive(Debug)]
121struct PinnedTarget {
122 host: String,
123 addrs: Vec<SocketAddr>,
124}
125
126pub struct A2aClient {
159 client: reqwest::Client,
160 security: SecurityPolicy,
161 request_timeout: Duration,
169}
170
171impl A2aClient {
172 #[must_use]
177 pub fn new(client: reqwest::Client) -> Self {
178 Self {
179 client,
180 security: SecurityPolicy::permissive(),
181 request_timeout: Duration::from_secs(30),
182 }
183 }
184
185 #[must_use]
199 pub fn with_security(mut self, policy: SecurityPolicy) -> Self {
200 self.security = policy;
201 self
202 }
203
204 #[must_use]
210 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
211 self.request_timeout = timeout;
212 self
213 }
214
215 #[tracing::instrument(name = "a2a.client.send_message", skip_all, err)]
219 pub async fn send_message(
220 &self,
221 endpoint: &str,
222 params: SendMessageParams,
223 token: Option<&str>,
224 ) -> Result<Task, A2aError> {
225 self.rpc_call(endpoint, METHOD_SEND_MESSAGE, params, token)
226 .await
227 }
228
229 #[tracing::instrument(name = "a2a.client.stream_message", skip_all, err)]
232 pub async fn stream_message(
233 &self,
234 endpoint: &str,
235 params: SendMessageParams,
236 token: Option<&str>,
237 ) -> Result<TaskEventStream, A2aError> {
238 let pinned = self.validate_endpoint(endpoint).await?;
239 let request_client = self.request_client(pinned.as_ref())?;
240 let request = JsonRpcRequest::new(METHOD_SEND_STREAMING_MESSAGE, params);
241 let mut req = request_client.post(endpoint).json(&request);
242 if let Some(t) = token {
243 req = req.bearer_auth(t);
244 }
245 let resp = tokio::time::timeout(self.request_timeout, req.send())
246 .await
247 .map_err(|_| A2aError::Timeout(self.request_timeout))?
248 .map_err(A2aError::Http)?;
249
250 if !resp.status().is_success() {
251 let status = resp.status();
252 let body = tokio::time::timeout(Duration::from_secs(5), resp.text())
253 .await
254 .unwrap_or(Ok(String::new()))
255 .unwrap_or_default();
256 let truncated = if body.len() > 256 {
258 format!("{}…", &body[..256])
259 } else {
260 body
261 };
262 return Err(A2aError::Stream(format!("HTTP {status}: {truncated}")));
263 }
264
265 let event_stream = resp.bytes_stream().eventsource();
266 let mapped = event_stream.filter_map(|event| match event {
267 Ok(event) => {
268 if event.data.is_empty() || event.data == "[DONE]" {
269 return None;
270 }
271 match serde_json::from_str::<JsonRpcResponse<TaskEvent>>(&event.data) {
272 Ok(rpc_resp) => match rpc_resp.into_result() {
273 Ok(task_event) => Some(Ok(task_event)),
274 Err(rpc_err) => Some(Err(A2aError::from(rpc_err))),
275 },
276 Err(e) => Some(Err(A2aError::Stream(format!(
277 "failed to parse SSE event: {e}"
278 )))),
279 }
280 }
281 Err(e) => Some(Err(A2aError::Stream(format!("SSE stream error: {e}")))),
282 });
283
284 Ok(Box::pin(mapped))
285 }
286
287 #[tracing::instrument(name = "a2a.client.get_task", skip_all, err)]
291 pub async fn get_task(
292 &self,
293 endpoint: &str,
294 params: TaskIdParams,
295 token: Option<&str>,
296 ) -> Result<Task, A2aError> {
297 self.rpc_call(endpoint, METHOD_GET_TASK, params, token)
298 .await
299 }
300
301 #[tracing::instrument(name = "a2a.client.cancel_task", skip_all, err)]
305 pub async fn cancel_task(
306 &self,
307 endpoint: &str,
308 params: TaskIdParams,
309 token: Option<&str>,
310 ) -> Result<Task, A2aError> {
311 self.rpc_call(endpoint, METHOD_CANCEL_TASK, params, token)
312 .await
313 }
314
315 #[tracing::instrument(name = "a2a.client.validate_endpoint", skip_all, err)]
322 async fn validate_endpoint(&self, endpoint: &str) -> Result<Option<PinnedTarget>, A2aError> {
323 if self.security.require_tls && !endpoint.starts_with("https://") {
324 return Err(A2aError::Security(format!(
325 "TLS required but endpoint uses HTTP: {endpoint}"
326 )));
327 }
328
329 if !self.security.ssrf_protection {
330 return Ok(None);
331 }
332
333 let url: url::Url = endpoint
334 .parse()
335 .map_err(|e| A2aError::Security(format!("invalid URL: {e}")))?;
336
337 let Some(host) = url.host_str() else {
338 return Ok(None);
339 };
340 let port = url.port_or_known_default().unwrap_or(443);
341 let addrs = resolve_and_validate(host, port)
342 .await
343 .map_err(|e| A2aError::Security(e.to_string()))?;
344
345 Ok(Some(PinnedTarget {
346 host: host.to_owned(),
347 addrs,
348 }))
349 }
350
351 fn needs_hardened_client(&self) -> bool {
354 self.security.require_tls || self.security.ssrf_protection
355 }
356
357 fn request_client(&self, pinned: Option<&PinnedTarget>) -> Result<reqwest::Client, A2aError> {
361 if self.needs_hardened_client() {
362 self.build_hardened_client(pinned)
363 } else {
364 Ok(self.client.clone())
365 }
366 }
367
368 fn build_hardened_client(
379 &self,
380 pinned: Option<&PinnedTarget>,
381 ) -> Result<reqwest::Client, A2aError> {
382 let mut builder = reqwest::Client::builder()
383 .user_agent(concat!("zeph-a2a/", env!("CARGO_PKG_VERSION")))
384 .redirect(reqwest::redirect::Policy::none());
385
386 if self.security.require_tls {
387 builder = builder.https_only(true);
388 }
389 if let Some(target) = pinned {
390 builder = builder.resolve_to_addrs(&target.host, &target.addrs);
391 }
392
393 builder
394 .build()
395 .map_err(|e| A2aError::Security(format!("failed to build hardened client: {e}")))
396 }
397
398 #[tracing::instrument(name = "a2a.client.rpc_call", skip_all, err)]
399 async fn rpc_call<P: Serialize, R: DeserializeOwned>(
400 &self,
401 endpoint: &str,
402 method: &str,
403 params: P,
404 token: Option<&str>,
405 ) -> Result<R, A2aError> {
406 let pinned = self.validate_endpoint(endpoint).await?;
407 let request_client = self.request_client(pinned.as_ref())?;
408 let request = JsonRpcRequest::new(method, params);
409 let mut req = request_client.post(endpoint).json(&request);
410 if let Some(t) = token {
411 req = req.bearer_auth(t);
412 }
413 let rpc_response: JsonRpcResponse<R> = tokio::time::timeout(self.request_timeout, async {
414 let resp = req.send().await?;
415 resp.json().await
416 })
417 .await
418 .map_err(|_| A2aError::Timeout(self.request_timeout))?
419 .map_err(A2aError::Http)?;
420 rpc_response.into_result().map_err(A2aError::from)
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use std::assert_matches;
427 use std::net::IpAddr;
428
429 use super::*;
430 use zeph_common::net::is_private_ip;
431
432 use crate::jsonrpc::{JsonRpcError, JsonRpcResponse};
433 use crate::types::{
434 Artifact, Message, Part, Task, TaskArtifactUpdateEvent, TaskState, TaskStatus,
435 TaskStatusUpdateEvent,
436 };
437
438 #[test]
439 fn task_event_deserialize_status_update() {
440 let event = TaskStatusUpdateEvent {
441 kind: "status-update".into(),
442 task_id: "t-1".into(),
443 context_id: None,
444 status: TaskStatus {
445 state: TaskState::Working,
446 timestamp: "ts".into(),
447 message: Some(Message::user_text("thinking...")),
448 },
449 is_final: false,
450 };
451 let json = serde_json::to_string(&event).unwrap();
452 let parsed: TaskEvent = serde_json::from_str(&json).unwrap();
453 assert_matches!(parsed, TaskEvent::StatusUpdate(_));
454 }
455
456 #[test]
457 fn task_event_deserialize_artifact_update() {
458 let event = TaskArtifactUpdateEvent {
459 kind: "artifact-update".into(),
460 task_id: "t-1".into(),
461 context_id: None,
462 artifact: Artifact {
463 artifact_id: "a-1".into(),
464 name: None,
465 parts: vec![Part::text("result")],
466 metadata: None,
467 },
468 is_final: true,
469 };
470 let json = serde_json::to_string(&event).unwrap();
471 let parsed: TaskEvent = serde_json::from_str(&json).unwrap();
472 assert_matches!(parsed, TaskEvent::ArtifactUpdate(_));
473 }
474
475 #[test]
476 fn rpc_response_with_task_result() {
477 let task = Task {
478 id: "t-1".into(),
479 context_id: None,
480 status: TaskStatus {
481 state: TaskState::Completed,
482 timestamp: "ts".into(),
483 message: None,
484 },
485 artifacts: vec![],
486 history: vec![],
487 metadata: None,
488 };
489 let resp = JsonRpcResponse {
490 jsonrpc: "2.0".into(),
491 id: serde_json::Value::String("req-1".into()),
492 result: Some(task),
493 error: None,
494 };
495 let json = serde_json::to_string(&resp).unwrap();
496 let back: JsonRpcResponse<Task> = serde_json::from_str(&json).unwrap();
497 let task = back.into_result().unwrap();
498 assert_eq!(task.id, "t-1");
499 assert_eq!(task.status.state, TaskState::Completed);
500 }
501
502 #[test]
503 fn rpc_response_with_error() {
504 let resp: JsonRpcResponse<Task> = JsonRpcResponse {
505 jsonrpc: "2.0".into(),
506 id: serde_json::Value::String("req-1".into()),
507 result: None,
508 error: Some(JsonRpcError {
509 code: -32001,
510 message: "task not found".into(),
511 data: None,
512 }),
513 };
514 let json = serde_json::to_string(&resp).unwrap();
515 let back: JsonRpcResponse<Task> = serde_json::from_str(&json).unwrap();
516 let err = back.into_result().unwrap_err();
517 assert_eq!(err.code, -32001);
518 }
519
520 #[test]
521 fn a2a_client_construction() {
522 let client = A2aClient::new(reqwest::Client::new());
523 drop(client);
524 }
525
526 #[test]
527 fn is_private_ip_loopback() {
528 assert!(is_private_ip(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)));
529 assert!(is_private_ip(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
530 }
531
532 #[test]
533 fn is_private_ip_private_ranges() {
534 assert!(is_private_ip("10.0.0.1".parse().unwrap()));
535 assert!(is_private_ip("172.16.0.1".parse().unwrap()));
536 assert!(is_private_ip("192.168.1.1".parse().unwrap()));
537 }
538
539 #[test]
540 fn is_private_ip_link_local() {
541 assert!(is_private_ip("169.254.0.1".parse().unwrap()));
542 }
543
544 #[test]
545 fn is_private_ip_unspecified() {
546 assert!(is_private_ip("0.0.0.0".parse().unwrap()));
547 assert!(is_private_ip("::".parse().unwrap()));
548 }
549
550 #[test]
551 fn is_private_ip_public() {
552 assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
553 assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
554 }
555
556 #[tokio::test]
557 async fn tls_enforcement_rejects_http() {
558 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
559 require_tls: true,
560 ssrf_protection: false,
561 });
562 let result = client.validate_endpoint("http://example.com/rpc").await;
563 assert!(result.is_err());
564 let err = result.unwrap_err();
565 assert_matches!(err, A2aError::Security(_));
566 assert!(err.to_string().contains("TLS required"));
567 }
568
569 #[tokio::test]
570 async fn tls_enforcement_allows_https() {
571 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
572 require_tls: true,
573 ssrf_protection: false,
574 });
575 let result = client.validate_endpoint("https://example.com/rpc").await;
576 assert!(result.is_ok());
577 }
578
579 #[tokio::test]
580 async fn ssrf_protection_rejects_localhost() {
581 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
582 require_tls: false,
583 ssrf_protection: true,
584 });
585 let result = client.validate_endpoint("http://127.0.0.1:8080/rpc").await;
586 assert!(result.is_err());
587 assert!(result.unwrap_err().to_string().contains("SSRF"));
588 }
589
590 #[tokio::test]
591 async fn no_security_allows_http_localhost() {
592 let client = A2aClient::new(reqwest::Client::new());
593 let result = client.validate_endpoint("http://127.0.0.1:8080/rpc").await;
594 assert!(result.is_ok());
595 }
596
597 #[test]
598 fn jsonrpc_request_serialization_for_send_message() {
599 let params = SendMessageParams {
600 message: Message::user_text("hello"),
601 configuration: None,
602 };
603 let req = JsonRpcRequest::new(METHOD_SEND_MESSAGE, params);
604 let json = serde_json::to_string(&req).unwrap();
605 assert!(json.contains("\"method\":\"message/send\""));
606 assert!(json.contains("\"jsonrpc\":\"2.0\""));
607 assert!(json.contains("\"hello\""));
608 }
609
610 #[test]
611 fn jsonrpc_request_serialization_for_get_task() {
612 let params = TaskIdParams {
613 id: "task-123".into(),
614 history_length: Some(5),
615 };
616 let req = JsonRpcRequest::new(METHOD_GET_TASK, params);
617 let json = serde_json::to_string(&req).unwrap();
618 assert!(json.contains("\"method\":\"tasks/get\""));
619 assert!(json.contains("\"task-123\""));
620 assert!(json.contains("\"historyLength\":5"));
621 }
622
623 #[test]
624 fn jsonrpc_request_serialization_for_cancel_task() {
625 let params = TaskIdParams {
626 id: "task-456".into(),
627 history_length: None,
628 };
629 let req = JsonRpcRequest::new(METHOD_CANCEL_TASK, params);
630 let json = serde_json::to_string(&req).unwrap();
631 assert!(json.contains("\"method\":\"tasks/cancel\""));
632 assert!(!json.contains("historyLength"));
633 }
634
635 #[test]
636 fn jsonrpc_request_serialization_for_stream() {
637 let params = SendMessageParams {
638 message: Message::user_text("stream me"),
639 configuration: None,
640 };
641 let req = JsonRpcRequest::new(METHOD_SEND_STREAMING_MESSAGE, params);
642 let json = serde_json::to_string(&req).unwrap();
643 assert!(json.contains("\"method\":\"message/stream\""));
644 }
645
646 #[tokio::test]
647 async fn send_message_connection_error() {
648 let client = A2aClient::new(reqwest::Client::new());
649 let params = SendMessageParams {
650 message: Message::user_text("hello"),
651 configuration: None,
652 };
653 let result = client
654 .send_message("http://127.0.0.1:1/rpc", params, None)
655 .await;
656 assert!(result.is_err());
657 assert_matches!(result.unwrap_err(), A2aError::Http(_));
658 }
659
660 #[tokio::test]
661 async fn get_task_connection_error() {
662 let client = A2aClient::new(reqwest::Client::new());
663 let params = TaskIdParams {
664 id: "t-1".into(),
665 history_length: None,
666 };
667 let result = client
668 .get_task("http://127.0.0.1:1/rpc", params, None)
669 .await;
670 assert!(result.is_err());
671 assert_matches!(result.unwrap_err(), A2aError::Http(_));
672 }
673
674 #[tokio::test]
675 async fn cancel_task_connection_error() {
676 let client = A2aClient::new(reqwest::Client::new());
677 let params = TaskIdParams {
678 id: "t-1".into(),
679 history_length: None,
680 };
681 let result = client
682 .cancel_task("http://127.0.0.1:1/rpc", params, None)
683 .await;
684 assert!(result.is_err());
685 assert_matches!(result.unwrap_err(), A2aError::Http(_));
686 }
687
688 #[tokio::test]
689 async fn stream_message_connection_error() {
690 let client = A2aClient::new(reqwest::Client::new());
691 let params = SendMessageParams {
692 message: Message::user_text("stream me"),
693 configuration: None,
694 };
695 let result = client
696 .stream_message("http://127.0.0.1:1/rpc", params, None)
697 .await;
698 assert!(result.is_err());
699 }
700
701 #[tokio::test]
702 async fn stream_message_tls_required_rejects_http() {
703 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
704 require_tls: true,
705 ssrf_protection: false,
706 });
707 let params = SendMessageParams {
708 message: Message::user_text("hello"),
709 configuration: None,
710 };
711 let result = client
712 .stream_message("http://example.com/rpc", params, None)
713 .await;
714 match result {
715 Err(A2aError::Security(msg)) => assert!(msg.contains("TLS required")),
716 _ => panic!("expected Security error"),
717 }
718 }
719
720 #[tokio::test]
721 async fn send_message_tls_required_rejects_http() {
722 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
723 require_tls: true,
724 ssrf_protection: false,
725 });
726 let params = SendMessageParams {
727 message: Message::user_text("hello"),
728 configuration: None,
729 };
730 let result = client
731 .send_message("http://example.com/rpc", params, None)
732 .await;
733 assert!(result.is_err());
734 assert_matches!(result.unwrap_err(), A2aError::Security(_));
735 }
736
737 #[tokio::test]
738 async fn get_task_tls_required_rejects_http() {
739 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
740 require_tls: true,
741 ssrf_protection: false,
742 });
743 let params = TaskIdParams {
744 id: "t-1".into(),
745 history_length: None,
746 };
747 let result = client
748 .get_task("http://example.com/rpc", params, None)
749 .await;
750 assert!(result.is_err());
751 assert_matches!(result.unwrap_err(), A2aError::Security(_));
752 }
753
754 #[tokio::test]
755 async fn cancel_task_tls_required_rejects_http() {
756 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
757 require_tls: true,
758 ssrf_protection: false,
759 });
760 let params = TaskIdParams {
761 id: "t-1".into(),
762 history_length: None,
763 };
764 let result = client
765 .cancel_task("http://example.com/rpc", params, None)
766 .await;
767 assert!(result.is_err());
768 assert_matches!(result.unwrap_err(), A2aError::Security(_));
769 }
770
771 #[tokio::test]
772 async fn validate_endpoint_invalid_url_with_ssrf() {
773 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
774 require_tls: false,
775 ssrf_protection: true,
776 });
777 let result = client.validate_endpoint("not-a-url").await;
778 assert!(result.is_err());
779 assert_matches!(result.unwrap_err(), A2aError::Security(_));
780 }
781
782 #[test]
783 fn with_security_returns_configured_client() {
784 let client =
785 A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy::hardened());
786 assert!(client.security.require_tls);
787 assert!(client.security.ssrf_protection);
788 }
789
790 #[test]
791 fn default_client_no_security() {
792 let client = A2aClient::new(reqwest::Client::new());
793 assert!(!client.security.require_tls);
794 assert!(!client.security.ssrf_protection);
795 }
796
797 #[test]
798 fn needs_hardened_client_reflects_policy() {
799 assert!(!A2aClient::new(reqwest::Client::new()).needs_hardened_client());
800 assert!(
801 A2aClient::new(reqwest::Client::new())
802 .with_security(SecurityPolicy {
803 require_tls: true,
804 ssrf_protection: false,
805 })
806 .needs_hardened_client()
807 );
808 assert!(
809 A2aClient::new(reqwest::Client::new())
810 .with_security(SecurityPolicy {
811 require_tls: false,
812 ssrf_protection: true,
813 })
814 .needs_hardened_client()
815 );
816 assert!(
817 A2aClient::new(reqwest::Client::new())
818 .with_security(SecurityPolicy::hardened())
819 .needs_hardened_client()
820 );
821 }
822
823 #[test]
824 fn task_event_clone() {
825 let event = TaskEvent::StatusUpdate(TaskStatusUpdateEvent {
826 kind: "status-update".into(),
827 task_id: "t-1".into(),
828 context_id: None,
829 status: TaskStatus {
830 state: TaskState::Working,
831 timestamp: "ts".into(),
832 message: None,
833 },
834 is_final: false,
835 });
836 let cloned = event.clone();
837 let json1 = serde_json::to_string(&event).unwrap();
838 let json2 = serde_json::to_string(&cloned).unwrap();
839 assert_eq!(json1, json2);
840 }
841
842 #[test]
843 fn task_event_debug() {
844 let event = TaskEvent::ArtifactUpdate(TaskArtifactUpdateEvent {
845 kind: "artifact-update".into(),
846 task_id: "t-1".into(),
847 context_id: None,
848 artifact: Artifact {
849 artifact_id: "a-1".into(),
850 name: None,
851 parts: vec![Part::text("data")],
852 metadata: None,
853 },
854 is_final: true,
855 });
856 let dbg = format!("{event:?}");
857 assert!(dbg.contains("ArtifactUpdate"));
858 }
859
860 #[test]
861 fn is_private_ip_ipv4_non_private() {
862 assert!(!is_private_ip("93.184.216.34".parse().unwrap()));
863 }
864
865 #[test]
866 fn is_private_ip_ipv6_non_private() {
867 assert!(!is_private_ip("2001:db8::1".parse().unwrap()));
868 }
869
870 #[test]
871 fn rpc_response_error_takes_priority_over_result() {
872 let resp = JsonRpcResponse {
873 jsonrpc: "2.0".into(),
874 id: serde_json::Value::String("1".into()),
875 result: Some(Task {
876 id: "t-1".into(),
877 context_id: None,
878 status: TaskStatus {
879 state: TaskState::Completed,
880 timestamp: "ts".into(),
881 message: None,
882 },
883 artifacts: vec![],
884 history: vec![],
885 metadata: None,
886 }),
887 error: Some(JsonRpcError {
888 code: -32001,
889 message: "error".into(),
890 data: None,
891 }),
892 };
893 let err = resp.into_result().unwrap_err();
894 assert_eq!(err.code, -32001);
895 }
896
897 #[test]
898 fn rpc_response_neither_result_nor_error() {
899 let resp: JsonRpcResponse<Task> = JsonRpcResponse {
900 jsonrpc: "2.0".into(),
901 id: serde_json::Value::String("1".into()),
902 result: None,
903 error: None,
904 };
905 let err = resp.into_result().unwrap_err();
906 assert_eq!(err.code, -32603);
907 }
908
909 #[test]
910 fn task_event_serialize_round_trip() {
911 let event = TaskEvent::StatusUpdate(TaskStatusUpdateEvent {
912 kind: "status-update".into(),
913 task_id: "t-1".into(),
914 context_id: Some("ctx-1".into()),
915 status: TaskStatus {
916 state: TaskState::Completed,
917 timestamp: "2025-01-01T00:00:00Z".into(),
918 message: Some(Message::user_text("done")),
919 },
920 is_final: true,
921 });
922 let json = serde_json::to_string(&event).unwrap();
923 let back: TaskEvent = serde_json::from_str(&json).unwrap();
924 assert_matches!(back, TaskEvent::StatusUpdate(_));
925 }
926}
927
928#[cfg(test)]
929mod wiremock_tests {
930 use std::assert_matches;
931 use tokio_stream::StreamExt;
932 use wiremock::matchers::{header, method, path};
933 use wiremock::{Mock, MockServer, ResponseTemplate};
934
935 use crate::client::{A2aClient, PinnedTarget, SecurityPolicy};
936 use crate::jsonrpc::{SendMessageParams, TaskIdParams};
937 use crate::testing::*;
938 use crate::types::Message;
939
940 #[tokio::test]
941 async fn send_message_success() {
942 let server = MockServer::start().await;
943 Mock::given(method("POST"))
944 .and(path("/rpc"))
945 .respond_with(task_rpc_response("task-1", "submitted"))
946 .mount(&server)
947 .await;
948
949 let client = A2aClient::new(reqwest::Client::new());
950 let params = SendMessageParams {
951 message: Message::user_text("hello"),
952 configuration: None,
953 };
954 let task = client
955 .send_message(&format!("{}/rpc", server.uri()), params, None)
956 .await
957 .unwrap();
958 assert_eq!(task.id, "task-1");
959 }
960
961 #[tokio::test]
962 async fn send_message_rpc_error() {
963 let server = MockServer::start().await;
964 Mock::given(method("POST"))
965 .and(path("/rpc"))
966 .respond_with(task_rpc_error_response(-32001, "task not found"))
967 .mount(&server)
968 .await;
969
970 let client = A2aClient::new(reqwest::Client::new());
971 let params = SendMessageParams {
972 message: Message::user_text("hi"),
973 configuration: None,
974 };
975 let result = client
976 .send_message(&format!("{}/rpc", server.uri()), params, None)
977 .await;
978 assert!(result.is_err());
979 let err = result.unwrap_err();
980 assert_matches!(err, crate::error::A2aError::JsonRpc { code: -32001, .. });
981 }
982
983 #[tokio::test]
984 async fn send_message_with_bearer_auth() {
985 let server = MockServer::start().await;
986 Mock::given(method("POST"))
987 .and(path("/rpc"))
988 .and(header("authorization", "Bearer secret-token"))
989 .respond_with(task_rpc_response("task-auth", "submitted"))
990 .mount(&server)
991 .await;
992
993 let client = A2aClient::new(reqwest::Client::new());
994 let params = SendMessageParams {
995 message: Message::user_text("secure"),
996 configuration: None,
997 };
998 let task = client
999 .send_message(
1000 &format!("{}/rpc", server.uri()),
1001 params,
1002 Some("secret-token"),
1003 )
1004 .await
1005 .unwrap();
1006 assert_eq!(task.id, "task-auth");
1007 }
1008
1009 #[tokio::test]
1010 async fn get_task_success() {
1011 let server = MockServer::start().await;
1012 Mock::given(method("POST"))
1013 .and(path("/rpc"))
1014 .respond_with(task_rpc_response("task-get", "completed"))
1015 .mount(&server)
1016 .await;
1017
1018 let client = A2aClient::new(reqwest::Client::new());
1019 let params = TaskIdParams {
1020 id: "task-get".into(),
1021 history_length: None,
1022 };
1023 let task = client
1024 .get_task(&format!("{}/rpc", server.uri()), params, None)
1025 .await
1026 .unwrap();
1027 assert_eq!(task.id, "task-get");
1028 }
1029
1030 #[tokio::test]
1031 async fn cancel_task_success() {
1032 let server = MockServer::start().await;
1033 Mock::given(method("POST"))
1034 .and(path("/rpc"))
1035 .respond_with(task_rpc_response("task-cancel", "canceled"))
1036 .mount(&server)
1037 .await;
1038
1039 let client = A2aClient::new(reqwest::Client::new());
1040 let params = TaskIdParams {
1041 id: "task-cancel".into(),
1042 history_length: None,
1043 };
1044 let task = client
1045 .cancel_task(&format!("{}/rpc", server.uri()), params, None)
1046 .await
1047 .unwrap();
1048 assert_eq!(task.id, "task-cancel");
1049 }
1050
1051 #[tokio::test]
1052 async fn stream_message_success() {
1053 let server = MockServer::start().await;
1054 Mock::given(method("POST"))
1055 .and(path("/rpc"))
1056 .respond_with(sse_task_events_response("task-stream", "result content"))
1057 .mount(&server)
1058 .await;
1059
1060 let client = A2aClient::new(reqwest::Client::new());
1061 let params = SendMessageParams {
1062 message: Message::user_text("stream"),
1063 configuration: None,
1064 };
1065 let stream = client
1066 .stream_message(&format!("{}/rpc", server.uri()), params, None)
1067 .await
1068 .unwrap();
1069 let events: Vec<_> = stream.collect().await;
1070 assert!(!events.is_empty());
1071 }
1072
1073 #[tokio::test]
1074 async fn stream_message_http_error() {
1075 let server = MockServer::start().await;
1076 Mock::given(method("POST"))
1077 .and(path("/rpc"))
1078 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
1079 .mount(&server)
1080 .await;
1081
1082 let client = A2aClient::new(reqwest::Client::new());
1083 let params = SendMessageParams {
1084 message: Message::user_text("fail"),
1085 configuration: None,
1086 };
1087 let result = client
1088 .stream_message(&format!("{}/rpc", server.uri()), params, None)
1089 .await;
1090 let err = result.err().expect("expected error");
1091 assert_matches!(err, crate::error::A2aError::Stream(_));
1092 }
1093
1094 #[tokio::test]
1095 async fn rpc_call_times_out() {
1096 let server = MockServer::start().await;
1097 Mock::given(method("POST"))
1098 .and(path("/rpc"))
1099 .respond_with(
1100 ResponseTemplate::new(200)
1101 .set_delay(std::time::Duration::from_secs(5))
1102 .set_body_json(serde_json::json!({
1103 "jsonrpc": "2.0",
1104 "id": "req-1",
1105 "result": {
1106 "id": "t-1",
1107 "status": {"state": "completed", "timestamp": "2026-01-01T00:00:00Z"}
1108 }
1109 })),
1110 )
1111 .mount(&server)
1112 .await;
1113
1114 let client = A2aClient::new(reqwest::Client::new())
1115 .with_request_timeout(std::time::Duration::from_millis(100));
1116 let params = SendMessageParams {
1117 message: Message::user_text("hello"),
1118 configuration: None,
1119 };
1120 let result = client
1121 .send_message(&format!("{}/rpc", server.uri()), params, None)
1122 .await;
1123 assert!(result.is_err());
1124 assert!(
1125 matches!(result.unwrap_err(), crate::error::A2aError::Timeout(_)),
1126 "expected Timeout error"
1127 );
1128 }
1129
1130 #[tokio::test]
1136 async fn hardened_client_pins_connection_bypassing_dns() {
1137 let server = MockServer::start().await;
1138 Mock::given(method("GET"))
1139 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1140 .mount(&server)
1141 .await;
1142
1143 let addr = *server.address();
1144 let fake_host = "zeph-a2a-pin-test.invalid";
1145 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
1146 require_tls: false,
1147 ssrf_protection: true,
1148 });
1149 let pinned = PinnedTarget {
1150 host: fake_host.to_owned(),
1151 addrs: vec![addr],
1152 };
1153 let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1154
1155 let resp = hardened
1156 .get(format!("http://{fake_host}:{}/", addr.port()))
1157 .send()
1158 .await
1159 .unwrap_or_else(|e| panic!("pinned request to unresolvable host failed: {e}"));
1160 assert_eq!(resp.status(), 200);
1161 assert_eq!(resp.text().await.unwrap(), "ok");
1162 }
1163
1164 #[tokio::test]
1169 async fn hardened_client_does_not_auto_follow_redirect_to_private_ip() {
1170 let server = MockServer::start().await;
1171 Mock::given(method("GET"))
1172 .respond_with(
1173 ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1:9/internal"),
1174 )
1175 .mount(&server)
1176 .await;
1177
1178 let addr = *server.address();
1179 let fake_host = "zeph-a2a-redirect-test.invalid";
1180 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
1181 require_tls: false,
1182 ssrf_protection: true,
1183 });
1184 let pinned = PinnedTarget {
1185 host: fake_host.to_owned(),
1186 addrs: vec![addr],
1187 };
1188 let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1189
1190 let resp = hardened
1191 .get(format!("http://{fake_host}:{}/start", addr.port()))
1192 .send()
1193 .await
1194 .unwrap();
1195
1196 assert_eq!(resp.status(), reqwest::StatusCode::FOUND);
1197 assert_eq!(
1198 resp.headers().get(reqwest::header::LOCATION).unwrap(),
1199 "http://127.0.0.1:9/internal"
1200 );
1201 }
1202
1203 #[tokio::test]
1207 async fn hardened_client_with_require_tls_rejects_plaintext_connection() {
1208 let server = MockServer::start().await;
1209 Mock::given(method("GET"))
1210 .respond_with(ResponseTemplate::new(200))
1211 .mount(&server)
1212 .await;
1213
1214 let addr = *server.address();
1215 let fake_host = "zeph-a2a-tls-test.invalid";
1216 let client = A2aClient::new(reqwest::Client::new()).with_security(SecurityPolicy {
1217 require_tls: true,
1218 ssrf_protection: true,
1219 });
1220 let pinned = PinnedTarget {
1221 host: fake_host.to_owned(),
1222 addrs: vec![addr],
1223 };
1224 let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1225
1226 let result = hardened
1227 .get(format!("http://{fake_host}:{}/", addr.port()))
1228 .send()
1229 .await;
1230 assert!(
1231 result.is_err(),
1232 "https_only(true) must reject a plain http:// URL"
1233 );
1234 }
1235}