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::ibct::{Ibct, IbctKey, ibct_scope_origin};
18use crate::jsonrpc::{
19 JsonRpcRequest, JsonRpcResponse, METHOD_CANCEL_TASK, METHOD_GET_TASK, METHOD_SEND_MESSAGE,
20 METHOD_SEND_STREAMING_MESSAGE, SendMessageParams, TaskIdParams,
21};
22use crate::types::{Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent};
23
24pub type TaskEventStream = Pin<Box<dyn Stream<Item = Result<TaskEvent, A2aError>> + Send>>;
29
30#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(untagged)]
38pub enum TaskEvent {
39 StatusUpdate(TaskStatusUpdateEvent),
41 ArtifactUpdate(TaskArtifactUpdateEvent),
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct SecurityPolicy {
69 pub require_tls: bool,
72 pub ssrf_protection: bool,
76}
77
78impl SecurityPolicy {
79 #[must_use]
92 pub const fn hardened() -> Self {
93 Self {
94 require_tls: true,
95 ssrf_protection: true,
96 }
97 }
98
99 #[must_use]
112 pub const fn permissive() -> Self {
113 Self {
114 require_tls: false,
115 ssrf_protection: false,
116 }
117 }
118}
119
120#[derive(Debug)]
123struct PinnedTarget {
124 host: String,
125 addrs: Vec<SocketAddr>,
126}
127
128pub struct A2aClient {
164 client: reqwest::Client,
165 security: SecurityPolicy,
166 request_timeout: Duration,
174 ibct_key: Option<IbctKey>,
177 ibct_ttl: Duration,
179}
180
181impl A2aClient {
182 #[must_use]
198 pub fn new(client: reqwest::Client, security: SecurityPolicy) -> Self {
199 Self {
200 client,
201 security,
202 request_timeout: Duration::from_secs(30),
203 ibct_key: None,
204 ibct_ttl: Duration::from_mins(5),
205 }
206 }
207
208 #[must_use]
223 pub fn new_insecure(client: reqwest::Client) -> Self {
224 Self::new(client, SecurityPolicy::permissive())
225 }
226
227 #[must_use]
242 pub fn with_security(mut self, policy: SecurityPolicy) -> Self {
243 self.security = policy;
244 self
245 }
246
247 #[must_use]
253 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
254 self.request_timeout = timeout;
255 self
256 }
257
258 #[must_use]
279 pub fn with_ibct_key(mut self, key: IbctKey) -> Self {
280 self.ibct_key = Some(key);
281 self
282 }
283
284 #[must_use]
287 pub fn with_ibct_ttl(mut self, ttl: Duration) -> Self {
288 self.ibct_ttl = ttl;
289 self
290 }
291
292 fn ibct_header_value(&self, endpoint: &str, task_id: &str) -> Option<String> {
303 let key = self.ibct_key.as_ref()?;
304 let scope = ibct_scope_origin(endpoint);
305 let token = match Ibct::issue(task_id, &scope, self.ibct_ttl, key) {
306 Ok(t) => t,
307 Err(e) => {
308 tracing::warn!("failed to issue IBCT token: {e}");
309 return None;
310 }
311 };
312 match token.encode() {
313 Ok(encoded) => Some(encoded),
314 Err(e) => {
315 tracing::warn!("failed to encode IBCT token: {e}");
316 None
317 }
318 }
319 }
320
321 #[tracing::instrument(name = "a2a.client.send_message", skip_all, err)]
325 pub async fn send_message(
326 &self,
327 endpoint: &str,
328 params: SendMessageParams,
329 token: Option<&str>,
330 ) -> Result<Task, A2aError> {
331 let task_id = params.message.task_id.clone().unwrap_or_default();
332 self.rpc_call(endpoint, METHOD_SEND_MESSAGE, params, token, &task_id)
333 .await
334 }
335
336 #[tracing::instrument(name = "a2a.client.stream_message", skip_all, err)]
339 pub async fn stream_message(
340 &self,
341 endpoint: &str,
342 params: SendMessageParams,
343 token: Option<&str>,
344 ) -> Result<TaskEventStream, A2aError> {
345 let task_id = params.message.task_id.clone().unwrap_or_default();
346 let pinned = self.validate_endpoint(endpoint).await?;
347 let request_client = self.request_client(pinned.as_ref())?;
348 let request = JsonRpcRequest::new(METHOD_SEND_STREAMING_MESSAGE, params);
349 let mut req = request_client.post(endpoint).json(&request);
350 if let Some(t) = token {
351 req = req.bearer_auth(t);
352 }
353 if let Some(ibct_header) = self.ibct_header_value(endpoint, &task_id) {
354 req = req.header("X-Zeph-IBCT", ibct_header);
355 }
356 let resp = tokio::time::timeout(self.request_timeout, req.send())
357 .await
358 .map_err(|_| A2aError::Timeout(self.request_timeout))?
359 .map_err(A2aError::Http)?;
360
361 if !resp.status().is_success() {
362 let status = resp.status();
363 let body = tokio::time::timeout(Duration::from_secs(5), resp.text())
364 .await
365 .unwrap_or(Ok(String::new()))
366 .unwrap_or_default();
367 let truncated = if body.len() > 256 {
369 format!("{}…", &body[..256])
370 } else {
371 body
372 };
373 return Err(A2aError::Stream(format!("HTTP {status}: {truncated}")));
374 }
375
376 let event_stream = resp.bytes_stream().eventsource();
377 let mapped = event_stream.filter_map(|event| match event {
378 Ok(event) => {
379 if event.data.is_empty() || event.data == "[DONE]" {
380 return None;
381 }
382 match serde_json::from_str::<JsonRpcResponse<TaskEvent>>(&event.data) {
383 Ok(rpc_resp) => match rpc_resp.into_result() {
384 Ok(task_event) => Some(Ok(task_event)),
385 Err(rpc_err) => Some(Err(A2aError::from(rpc_err))),
386 },
387 Err(e) => Some(Err(A2aError::Stream(format!(
388 "failed to parse SSE event: {e}"
389 )))),
390 }
391 }
392 Err(e) => Some(Err(A2aError::Stream(format!("SSE stream error: {e}")))),
393 });
394
395 Ok(Box::pin(mapped))
396 }
397
398 #[tracing::instrument(name = "a2a.client.get_task", skip_all, err)]
402 pub async fn get_task(
403 &self,
404 endpoint: &str,
405 params: TaskIdParams,
406 token: Option<&str>,
407 ) -> Result<Task, A2aError> {
408 let task_id = params.id.clone();
409 self.rpc_call(endpoint, METHOD_GET_TASK, params, token, &task_id)
410 .await
411 }
412
413 #[tracing::instrument(name = "a2a.client.cancel_task", skip_all, err)]
417 pub async fn cancel_task(
418 &self,
419 endpoint: &str,
420 params: TaskIdParams,
421 token: Option<&str>,
422 ) -> Result<Task, A2aError> {
423 let task_id = params.id.clone();
424 self.rpc_call(endpoint, METHOD_CANCEL_TASK, params, token, &task_id)
425 .await
426 }
427
428 #[tracing::instrument(name = "a2a.client.validate_endpoint", skip_all, err)]
435 async fn validate_endpoint(&self, endpoint: &str) -> Result<Option<PinnedTarget>, A2aError> {
436 if self.security.require_tls && !endpoint.starts_with("https://") {
437 return Err(A2aError::Security(format!(
438 "TLS required but endpoint uses HTTP: {endpoint}"
439 )));
440 }
441
442 if !self.security.ssrf_protection {
443 return Ok(None);
444 }
445
446 let url: url::Url = endpoint
447 .parse()
448 .map_err(|e| A2aError::Security(format!("invalid URL: {e}")))?;
449
450 let Some(host) = url.host_str() else {
451 return Ok(None);
452 };
453 let port = url.port_or_known_default().unwrap_or(443);
454 let addrs = resolve_and_validate(host, port)
455 .await
456 .map_err(|e| A2aError::Security(e.to_string()))?;
457
458 Ok(Some(PinnedTarget {
459 host: host.to_owned(),
460 addrs,
461 }))
462 }
463
464 fn needs_hardened_client(&self) -> bool {
467 self.security.require_tls || self.security.ssrf_protection
468 }
469
470 fn request_client(&self, pinned: Option<&PinnedTarget>) -> Result<reqwest::Client, A2aError> {
474 if self.needs_hardened_client() {
475 self.build_hardened_client(pinned)
476 } else {
477 Ok(self.client.clone())
478 }
479 }
480
481 fn build_hardened_client(
492 &self,
493 pinned: Option<&PinnedTarget>,
494 ) -> Result<reqwest::Client, A2aError> {
495 let mut builder = reqwest::Client::builder()
496 .user_agent(concat!("zeph-a2a/", env!("CARGO_PKG_VERSION")))
497 .redirect(reqwest::redirect::Policy::none());
498
499 if self.security.require_tls {
500 builder = builder.https_only(true);
501 }
502 if let Some(target) = pinned {
503 builder = builder.resolve_to_addrs(&target.host, &target.addrs);
504 }
505
506 builder
507 .build()
508 .map_err(|e| A2aError::Security(format!("failed to build hardened client: {e}")))
509 }
510
511 #[tracing::instrument(name = "a2a.client.rpc_call", skip_all, err)]
512 async fn rpc_call<P: Serialize, R: DeserializeOwned>(
513 &self,
514 endpoint: &str,
515 method: &str,
516 params: P,
517 token: Option<&str>,
518 task_id: &str,
519 ) -> Result<R, A2aError> {
520 let pinned = self.validate_endpoint(endpoint).await?;
521 let request_client = self.request_client(pinned.as_ref())?;
522 let request = JsonRpcRequest::new(method, params);
523 let mut req = request_client.post(endpoint).json(&request);
524 if let Some(t) = token {
525 req = req.bearer_auth(t);
526 }
527 if let Some(ibct_header) = self.ibct_header_value(endpoint, task_id) {
528 req = req.header("X-Zeph-IBCT", ibct_header);
529 }
530 let rpc_response: JsonRpcResponse<R> = tokio::time::timeout(self.request_timeout, async {
531 let resp = req.send().await?;
532 resp.json().await
533 })
534 .await
535 .map_err(|_| A2aError::Timeout(self.request_timeout))?
536 .map_err(A2aError::Http)?;
537 rpc_response.into_result().map_err(A2aError::from)
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use std::assert_matches;
544 use std::net::IpAddr;
545
546 use super::*;
547 use zeph_common::net::is_private_ip;
548
549 use crate::jsonrpc::{JsonRpcError, JsonRpcResponse};
550 use crate::types::{
551 Artifact, Message, Part, Task, TaskArtifactUpdateEvent, TaskState, TaskStatus,
552 TaskStatusUpdateEvent,
553 };
554
555 #[test]
556 fn task_event_deserialize_status_update() {
557 let event = TaskStatusUpdateEvent {
558 kind: "status-update".into(),
559 task_id: "t-1".into(),
560 context_id: None,
561 status: TaskStatus {
562 state: TaskState::Working,
563 timestamp: "ts".into(),
564 message: Some(Message::user_text("thinking...")),
565 },
566 is_final: false,
567 };
568 let json = serde_json::to_string(&event).unwrap();
569 let parsed: TaskEvent = serde_json::from_str(&json).unwrap();
570 assert_matches!(parsed, TaskEvent::StatusUpdate(_));
571 }
572
573 #[test]
574 fn task_event_deserialize_artifact_update() {
575 let event = TaskArtifactUpdateEvent {
576 kind: "artifact-update".into(),
577 task_id: "t-1".into(),
578 context_id: None,
579 artifact: Artifact {
580 artifact_id: "a-1".into(),
581 name: None,
582 parts: vec![Part::text("result")],
583 metadata: None,
584 },
585 is_final: true,
586 };
587 let json = serde_json::to_string(&event).unwrap();
588 let parsed: TaskEvent = serde_json::from_str(&json).unwrap();
589 assert_matches!(parsed, TaskEvent::ArtifactUpdate(_));
590 }
591
592 #[test]
593 fn rpc_response_with_task_result() {
594 let task = Task {
595 id: "t-1".into(),
596 context_id: None,
597 status: TaskStatus {
598 state: TaskState::Completed,
599 timestamp: "ts".into(),
600 message: None,
601 },
602 artifacts: vec![],
603 history: vec![],
604 metadata: None,
605 };
606 let resp = JsonRpcResponse {
607 jsonrpc: "2.0".into(),
608 id: serde_json::Value::String("req-1".into()),
609 result: Some(task),
610 error: None,
611 };
612 let json = serde_json::to_string(&resp).unwrap();
613 let back: JsonRpcResponse<Task> = serde_json::from_str(&json).unwrap();
614 let task = back.into_result().unwrap();
615 assert_eq!(task.id, "t-1");
616 assert_eq!(task.status.state, TaskState::Completed);
617 }
618
619 #[test]
620 fn rpc_response_with_error() {
621 let resp: JsonRpcResponse<Task> = JsonRpcResponse {
622 jsonrpc: "2.0".into(),
623 id: serde_json::Value::String("req-1".into()),
624 result: None,
625 error: Some(JsonRpcError {
626 code: -32001,
627 message: "task not found".into(),
628 data: None,
629 }),
630 };
631 let json = serde_json::to_string(&resp).unwrap();
632 let back: JsonRpcResponse<Task> = serde_json::from_str(&json).unwrap();
633 let err = back.into_result().unwrap_err();
634 assert_eq!(err.code, -32001);
635 }
636
637 #[test]
638 fn a2a_client_construction() {
639 let client = A2aClient::new_insecure(reqwest::Client::new());
640 drop(client);
641 }
642
643 #[test]
644 fn is_private_ip_loopback() {
645 assert!(is_private_ip(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)));
646 assert!(is_private_ip(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)));
647 }
648
649 #[test]
650 fn is_private_ip_private_ranges() {
651 assert!(is_private_ip("10.0.0.1".parse().unwrap()));
652 assert!(is_private_ip("172.16.0.1".parse().unwrap()));
653 assert!(is_private_ip("192.168.1.1".parse().unwrap()));
654 }
655
656 #[test]
657 fn is_private_ip_link_local() {
658 assert!(is_private_ip("169.254.0.1".parse().unwrap()));
659 }
660
661 #[test]
662 fn is_private_ip_unspecified() {
663 assert!(is_private_ip("0.0.0.0".parse().unwrap()));
664 assert!(is_private_ip("::".parse().unwrap()));
665 }
666
667 #[test]
668 fn is_private_ip_public() {
669 assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
670 assert!(!is_private_ip("1.1.1.1".parse().unwrap()));
671 }
672
673 #[tokio::test]
674 async fn tls_enforcement_rejects_http() {
675 let client =
676 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
677 require_tls: true,
678 ssrf_protection: false,
679 });
680 let result = client.validate_endpoint("http://example.com/rpc").await;
681 assert!(result.is_err());
682 let err = result.unwrap_err();
683 assert_matches!(err, A2aError::Security(_));
684 assert!(err.to_string().contains("TLS required"));
685 }
686
687 #[tokio::test]
688 async fn tls_enforcement_allows_https() {
689 let client =
690 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
691 require_tls: true,
692 ssrf_protection: false,
693 });
694 let result = client.validate_endpoint("https://example.com/rpc").await;
695 assert!(result.is_ok());
696 }
697
698 #[tokio::test]
699 async fn ssrf_protection_rejects_localhost() {
700 let client =
701 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
702 require_tls: false,
703 ssrf_protection: true,
704 });
705 let result = client.validate_endpoint("http://127.0.0.1:8080/rpc").await;
706 assert!(result.is_err());
707 assert!(result.unwrap_err().to_string().contains("SSRF"));
708 }
709
710 #[tokio::test]
711 async fn no_security_allows_http_localhost() {
712 let client = A2aClient::new_insecure(reqwest::Client::new());
713 let result = client.validate_endpoint("http://127.0.0.1:8080/rpc").await;
714 assert!(result.is_ok());
715 }
716
717 #[test]
718 fn jsonrpc_request_serialization_for_send_message() {
719 let params = SendMessageParams {
720 message: Message::user_text("hello"),
721 configuration: None,
722 };
723 let req = JsonRpcRequest::new(METHOD_SEND_MESSAGE, params);
724 let json = serde_json::to_string(&req).unwrap();
725 assert!(json.contains("\"method\":\"message/send\""));
726 assert!(json.contains("\"jsonrpc\":\"2.0\""));
727 assert!(json.contains("\"hello\""));
728 }
729
730 #[test]
731 fn jsonrpc_request_serialization_for_get_task() {
732 let params = TaskIdParams {
733 id: "task-123".into(),
734 history_length: Some(5),
735 };
736 let req = JsonRpcRequest::new(METHOD_GET_TASK, params);
737 let json = serde_json::to_string(&req).unwrap();
738 assert!(json.contains("\"method\":\"tasks/get\""));
739 assert!(json.contains("\"task-123\""));
740 assert!(json.contains("\"historyLength\":5"));
741 }
742
743 #[test]
744 fn jsonrpc_request_serialization_for_cancel_task() {
745 let params = TaskIdParams {
746 id: "task-456".into(),
747 history_length: None,
748 };
749 let req = JsonRpcRequest::new(METHOD_CANCEL_TASK, params);
750 let json = serde_json::to_string(&req).unwrap();
751 assert!(json.contains("\"method\":\"tasks/cancel\""));
752 assert!(!json.contains("historyLength"));
753 }
754
755 #[test]
756 fn jsonrpc_request_serialization_for_stream() {
757 let params = SendMessageParams {
758 message: Message::user_text("stream me"),
759 configuration: None,
760 };
761 let req = JsonRpcRequest::new(METHOD_SEND_STREAMING_MESSAGE, params);
762 let json = serde_json::to_string(&req).unwrap();
763 assert!(json.contains("\"method\":\"message/stream\""));
764 }
765
766 #[tokio::test]
767 async fn send_message_connection_error() {
768 let client = A2aClient::new_insecure(reqwest::Client::new());
769 let params = SendMessageParams {
770 message: Message::user_text("hello"),
771 configuration: None,
772 };
773 let result = client
774 .send_message("http://127.0.0.1:1/rpc", params, None)
775 .await;
776 assert!(result.is_err());
777 assert_matches!(result.unwrap_err(), A2aError::Http(_));
778 }
779
780 #[tokio::test]
781 async fn get_task_connection_error() {
782 let client = A2aClient::new_insecure(reqwest::Client::new());
783 let params = TaskIdParams {
784 id: "t-1".into(),
785 history_length: None,
786 };
787 let result = client
788 .get_task("http://127.0.0.1:1/rpc", params, None)
789 .await;
790 assert!(result.is_err());
791 assert_matches!(result.unwrap_err(), A2aError::Http(_));
792 }
793
794 #[tokio::test]
795 async fn cancel_task_connection_error() {
796 let client = A2aClient::new_insecure(reqwest::Client::new());
797 let params = TaskIdParams {
798 id: "t-1".into(),
799 history_length: None,
800 };
801 let result = client
802 .cancel_task("http://127.0.0.1:1/rpc", params, None)
803 .await;
804 assert!(result.is_err());
805 assert_matches!(result.unwrap_err(), A2aError::Http(_));
806 }
807
808 #[tokio::test]
809 async fn stream_message_connection_error() {
810 let client = A2aClient::new_insecure(reqwest::Client::new());
811 let params = SendMessageParams {
812 message: Message::user_text("stream me"),
813 configuration: None,
814 };
815 let result = client
816 .stream_message("http://127.0.0.1:1/rpc", params, None)
817 .await;
818 assert!(result.is_err());
819 }
820
821 #[tokio::test]
822 async fn stream_message_tls_required_rejects_http() {
823 let client =
824 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
825 require_tls: true,
826 ssrf_protection: false,
827 });
828 let params = SendMessageParams {
829 message: Message::user_text("hello"),
830 configuration: None,
831 };
832 let result = client
833 .stream_message("http://example.com/rpc", params, None)
834 .await;
835 match result {
836 Err(A2aError::Security(msg)) => assert!(msg.contains("TLS required")),
837 _ => panic!("expected Security error"),
838 }
839 }
840
841 #[tokio::test]
842 async fn send_message_tls_required_rejects_http() {
843 let client =
844 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
845 require_tls: true,
846 ssrf_protection: false,
847 });
848 let params = SendMessageParams {
849 message: Message::user_text("hello"),
850 configuration: None,
851 };
852 let result = client
853 .send_message("http://example.com/rpc", params, None)
854 .await;
855 assert!(result.is_err());
856 assert_matches!(result.unwrap_err(), A2aError::Security(_));
857 }
858
859 #[tokio::test]
860 async fn get_task_tls_required_rejects_http() {
861 let client =
862 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
863 require_tls: true,
864 ssrf_protection: false,
865 });
866 let params = TaskIdParams {
867 id: "t-1".into(),
868 history_length: None,
869 };
870 let result = client
871 .get_task("http://example.com/rpc", params, None)
872 .await;
873 assert!(result.is_err());
874 assert_matches!(result.unwrap_err(), A2aError::Security(_));
875 }
876
877 #[tokio::test]
878 async fn cancel_task_tls_required_rejects_http() {
879 let client =
880 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
881 require_tls: true,
882 ssrf_protection: false,
883 });
884 let params = TaskIdParams {
885 id: "t-1".into(),
886 history_length: None,
887 };
888 let result = client
889 .cancel_task("http://example.com/rpc", params, None)
890 .await;
891 assert!(result.is_err());
892 assert_matches!(result.unwrap_err(), A2aError::Security(_));
893 }
894
895 #[tokio::test]
896 async fn validate_endpoint_invalid_url_with_ssrf() {
897 let client =
898 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
899 require_tls: false,
900 ssrf_protection: true,
901 });
902 let result = client.validate_endpoint("not-a-url").await;
903 assert!(result.is_err());
904 assert_matches!(result.unwrap_err(), A2aError::Security(_));
905 }
906
907 #[test]
908 fn with_security_returns_configured_client() {
909 let client = A2aClient::new_insecure(reqwest::Client::new())
910 .with_security(SecurityPolicy::hardened());
911 assert!(client.security.require_tls);
912 assert!(client.security.ssrf_protection);
913 }
914
915 #[test]
916 fn default_client_no_security() {
917 let client = A2aClient::new_insecure(reqwest::Client::new());
918 assert!(!client.security.require_tls);
919 assert!(!client.security.ssrf_protection);
920 }
921
922 #[test]
923 fn needs_hardened_client_reflects_policy() {
924 assert!(!A2aClient::new_insecure(reqwest::Client::new()).needs_hardened_client());
925 assert!(
926 A2aClient::new_insecure(reqwest::Client::new())
927 .with_security(SecurityPolicy {
928 require_tls: true,
929 ssrf_protection: false,
930 })
931 .needs_hardened_client()
932 );
933 assert!(
934 A2aClient::new_insecure(reqwest::Client::new())
935 .with_security(SecurityPolicy {
936 require_tls: false,
937 ssrf_protection: true,
938 })
939 .needs_hardened_client()
940 );
941 assert!(
942 A2aClient::new_insecure(reqwest::Client::new())
943 .with_security(SecurityPolicy::hardened())
944 .needs_hardened_client()
945 );
946 }
947
948 #[test]
949 fn task_event_clone() {
950 let event = TaskEvent::StatusUpdate(TaskStatusUpdateEvent {
951 kind: "status-update".into(),
952 task_id: "t-1".into(),
953 context_id: None,
954 status: TaskStatus {
955 state: TaskState::Working,
956 timestamp: "ts".into(),
957 message: None,
958 },
959 is_final: false,
960 });
961 let cloned = event.clone();
962 let json1 = serde_json::to_string(&event).unwrap();
963 let json2 = serde_json::to_string(&cloned).unwrap();
964 assert_eq!(json1, json2);
965 }
966
967 #[test]
968 fn task_event_debug() {
969 let event = TaskEvent::ArtifactUpdate(TaskArtifactUpdateEvent {
970 kind: "artifact-update".into(),
971 task_id: "t-1".into(),
972 context_id: None,
973 artifact: Artifact {
974 artifact_id: "a-1".into(),
975 name: None,
976 parts: vec![Part::text("data")],
977 metadata: None,
978 },
979 is_final: true,
980 });
981 let dbg = format!("{event:?}");
982 assert!(dbg.contains("ArtifactUpdate"));
983 }
984
985 #[test]
986 fn is_private_ip_ipv4_non_private() {
987 assert!(!is_private_ip("93.184.216.34".parse().unwrap()));
988 }
989
990 #[test]
991 fn is_private_ip_ipv6_non_private() {
992 assert!(!is_private_ip("2001:db8::1".parse().unwrap()));
993 }
994
995 #[test]
996 fn rpc_response_error_takes_priority_over_result() {
997 let resp = JsonRpcResponse {
998 jsonrpc: "2.0".into(),
999 id: serde_json::Value::String("1".into()),
1000 result: Some(Task {
1001 id: "t-1".into(),
1002 context_id: None,
1003 status: TaskStatus {
1004 state: TaskState::Completed,
1005 timestamp: "ts".into(),
1006 message: None,
1007 },
1008 artifacts: vec![],
1009 history: vec![],
1010 metadata: None,
1011 }),
1012 error: Some(JsonRpcError {
1013 code: -32001,
1014 message: "error".into(),
1015 data: None,
1016 }),
1017 };
1018 let err = resp.into_result().unwrap_err();
1019 assert_eq!(err.code, -32001);
1020 }
1021
1022 #[test]
1023 fn rpc_response_neither_result_nor_error() {
1024 let resp: JsonRpcResponse<Task> = JsonRpcResponse {
1025 jsonrpc: "2.0".into(),
1026 id: serde_json::Value::String("1".into()),
1027 result: None,
1028 error: None,
1029 };
1030 let err = resp.into_result().unwrap_err();
1031 assert_eq!(err.code, -32603);
1032 }
1033
1034 #[test]
1035 fn with_ibct_key_sets_key_and_default_ttl() {
1036 let key = IbctKey {
1037 key_id: "k1".into(),
1038 key_bytes: b"secret".to_vec(),
1039 };
1040 let client = A2aClient::new_insecure(reqwest::Client::new()).with_ibct_key(key);
1041 assert!(client.ibct_key.is_some());
1042 assert_eq!(client.ibct_ttl, Duration::from_mins(5));
1043 }
1044
1045 #[test]
1046 fn with_ibct_ttl_overrides_default() {
1047 let key = IbctKey {
1048 key_id: "k1".into(),
1049 key_bytes: b"secret".to_vec(),
1050 };
1051 let client = A2aClient::new_insecure(reqwest::Client::new())
1052 .with_ibct_key(key)
1053 .with_ibct_ttl(Duration::from_mins(1));
1054 assert_eq!(client.ibct_ttl, Duration::from_mins(1));
1055 }
1056
1057 #[test]
1058 fn no_ibct_key_configured_yields_no_header() {
1059 let client = A2aClient::new_insecure(reqwest::Client::new());
1060 assert!(
1061 client
1062 .ibct_header_value("https://agent.example.com", "task-1")
1063 .is_none()
1064 );
1065 }
1066
1067 #[cfg(feature = "ibct")]
1068 #[test]
1069 fn ibct_key_configured_yields_encoded_header() {
1070 let key = IbctKey {
1071 key_id: "k1".into(),
1072 key_bytes: b"secret".to_vec(),
1073 };
1074 let client = A2aClient::new_insecure(reqwest::Client::new()).with_ibct_key(key);
1075 let header = client
1076 .ibct_header_value("https://agent.example.com", "task-1")
1077 .expect("header should be issued when ibct feature is enabled");
1078 let decoded = crate::ibct::Ibct::decode(&header).unwrap();
1079 assert_eq!(decoded.task_id, "task-1");
1080 assert_eq!(decoded.endpoint, "https://agent.example.com");
1081 }
1082
1083 #[test]
1090 fn ibct_scope_origin_strips_path_and_query() {
1091 assert_eq!(
1092 ibct_scope_origin("https://agent.example.com/a2a"),
1093 "https://agent.example.com"
1094 );
1095 assert_eq!(
1096 ibct_scope_origin("https://agent.example.com/a2a/stream"),
1097 "https://agent.example.com"
1098 );
1099 assert_eq!(
1100 ibct_scope_origin("http://127.0.0.1:8080/a2a/stream?x=1"),
1101 "http://127.0.0.1:8080"
1102 );
1103 }
1104
1105 #[test]
1106 fn ibct_scope_origin_agrees_across_both_a2a_routes() {
1107 let a2a = ibct_scope_origin("http://127.0.0.1:8080/a2a");
1108 let stream = ibct_scope_origin("http://127.0.0.1:8080/a2a/stream");
1109 assert_eq!(
1110 a2a, stream,
1111 "both routes on the same agent must scope to the same IBCT endpoint"
1112 );
1113 }
1114
1115 #[test]
1116 fn ibct_scope_origin_falls_back_to_input_on_unparseable_url() {
1117 assert_eq!(ibct_scope_origin("not-a-url"), "not-a-url");
1118 }
1119
1120 #[cfg(feature = "ibct")]
1121 #[test]
1122 fn ibct_header_value_scopes_token_to_origin_not_full_path() {
1123 let key = IbctKey {
1124 key_id: "k1".into(),
1125 key_bytes: b"secret".to_vec(),
1126 };
1127 let client = A2aClient::new_insecure(reqwest::Client::new()).with_ibct_key(key);
1128
1129 let header_a2a = client
1130 .ibct_header_value("http://127.0.0.1:8080/a2a", "task-1")
1131 .unwrap();
1132 let header_stream = client
1133 .ibct_header_value("http://127.0.0.1:8080/a2a/stream", "task-1")
1134 .unwrap();
1135
1136 let decoded_a2a = crate::ibct::Ibct::decode(&header_a2a).unwrap();
1137 let decoded_stream = crate::ibct::Ibct::decode(&header_stream).unwrap();
1138 assert_eq!(decoded_a2a.endpoint, "http://127.0.0.1:8080");
1139 assert_eq!(
1140 decoded_a2a.endpoint, decoded_stream.endpoint,
1141 "a token issued for /a2a and one issued for /a2a/stream on the same agent must \
1142 carry the same endpoint scope, since the server verifies both against one \
1143 pathless card.url"
1144 );
1145 }
1146
1147 #[test]
1148 fn task_event_serialize_round_trip() {
1149 let event = TaskEvent::StatusUpdate(TaskStatusUpdateEvent {
1150 kind: "status-update".into(),
1151 task_id: "t-1".into(),
1152 context_id: Some("ctx-1".into()),
1153 status: TaskStatus {
1154 state: TaskState::Completed,
1155 timestamp: "2025-01-01T00:00:00Z".into(),
1156 message: Some(Message::user_text("done")),
1157 },
1158 is_final: true,
1159 });
1160 let json = serde_json::to_string(&event).unwrap();
1161 let back: TaskEvent = serde_json::from_str(&json).unwrap();
1162 assert_matches!(back, TaskEvent::StatusUpdate(_));
1163 }
1164}
1165
1166#[cfg(test)]
1167mod wiremock_tests {
1168 use std::assert_matches;
1169 use tokio_stream::StreamExt;
1170 use wiremock::matchers::{header, method, path};
1171 use wiremock::{Mock, MockServer, ResponseTemplate};
1172
1173 use crate::client::{A2aClient, PinnedTarget, SecurityPolicy};
1174 use crate::jsonrpc::{SendMessageParams, TaskIdParams};
1175 use crate::testing::*;
1176 use crate::types::Message;
1177
1178 #[tokio::test]
1179 async fn send_message_success() {
1180 let server = MockServer::start().await;
1181 Mock::given(method("POST"))
1182 .and(path("/rpc"))
1183 .respond_with(task_rpc_response("task-1", "submitted"))
1184 .mount(&server)
1185 .await;
1186
1187 let client = A2aClient::new_insecure(reqwest::Client::new());
1188 let params = SendMessageParams {
1189 message: Message::user_text("hello"),
1190 configuration: None,
1191 };
1192 let task = client
1193 .send_message(&format!("{}/rpc", server.uri()), params, None)
1194 .await
1195 .unwrap();
1196 assert_eq!(task.id, "task-1");
1197 }
1198
1199 #[tokio::test]
1200 async fn send_message_rpc_error() {
1201 let server = MockServer::start().await;
1202 Mock::given(method("POST"))
1203 .and(path("/rpc"))
1204 .respond_with(task_rpc_error_response(-32001, "task not found"))
1205 .mount(&server)
1206 .await;
1207
1208 let client = A2aClient::new_insecure(reqwest::Client::new());
1209 let params = SendMessageParams {
1210 message: Message::user_text("hi"),
1211 configuration: None,
1212 };
1213 let result = client
1214 .send_message(&format!("{}/rpc", server.uri()), params, None)
1215 .await;
1216 assert!(result.is_err());
1217 let err = result.unwrap_err();
1218 assert_matches!(err, crate::error::A2aError::JsonRpc { code: -32001, .. });
1219 }
1220
1221 #[tokio::test]
1222 async fn send_message_with_bearer_auth() {
1223 let server = MockServer::start().await;
1224 Mock::given(method("POST"))
1225 .and(path("/rpc"))
1226 .and(header("authorization", "Bearer secret-token"))
1227 .respond_with(task_rpc_response("task-auth", "submitted"))
1228 .mount(&server)
1229 .await;
1230
1231 let client = A2aClient::new_insecure(reqwest::Client::new());
1232 let params = SendMessageParams {
1233 message: Message::user_text("secure"),
1234 configuration: None,
1235 };
1236 let task = client
1237 .send_message(
1238 &format!("{}/rpc", server.uri()),
1239 params,
1240 Some("secret-token"),
1241 )
1242 .await
1243 .unwrap();
1244 assert_eq!(task.id, "task-auth");
1245 }
1246
1247 #[tokio::test]
1248 async fn get_task_success() {
1249 let server = MockServer::start().await;
1250 Mock::given(method("POST"))
1251 .and(path("/rpc"))
1252 .respond_with(task_rpc_response("task-get", "completed"))
1253 .mount(&server)
1254 .await;
1255
1256 let client = A2aClient::new_insecure(reqwest::Client::new());
1257 let params = TaskIdParams {
1258 id: "task-get".into(),
1259 history_length: None,
1260 };
1261 let task = client
1262 .get_task(&format!("{}/rpc", server.uri()), params, None)
1263 .await
1264 .unwrap();
1265 assert_eq!(task.id, "task-get");
1266 }
1267
1268 #[tokio::test]
1269 async fn cancel_task_success() {
1270 let server = MockServer::start().await;
1271 Mock::given(method("POST"))
1272 .and(path("/rpc"))
1273 .respond_with(task_rpc_response("task-cancel", "canceled"))
1274 .mount(&server)
1275 .await;
1276
1277 let client = A2aClient::new_insecure(reqwest::Client::new());
1278 let params = TaskIdParams {
1279 id: "task-cancel".into(),
1280 history_length: None,
1281 };
1282 let task = client
1283 .cancel_task(&format!("{}/rpc", server.uri()), params, None)
1284 .await
1285 .unwrap();
1286 assert_eq!(task.id, "task-cancel");
1287 }
1288
1289 #[tokio::test]
1290 async fn stream_message_success() {
1291 let server = MockServer::start().await;
1292 Mock::given(method("POST"))
1293 .and(path("/rpc"))
1294 .respond_with(sse_task_events_response("task-stream", "result content"))
1295 .mount(&server)
1296 .await;
1297
1298 let client = A2aClient::new_insecure(reqwest::Client::new());
1299 let params = SendMessageParams {
1300 message: Message::user_text("stream"),
1301 configuration: None,
1302 };
1303 let stream = client
1304 .stream_message(&format!("{}/rpc", server.uri()), params, None)
1305 .await
1306 .unwrap();
1307 let events: Vec<_> = stream.collect().await;
1308 assert!(!events.is_empty());
1309 }
1310
1311 #[tokio::test]
1312 async fn stream_message_http_error() {
1313 let server = MockServer::start().await;
1314 Mock::given(method("POST"))
1315 .and(path("/rpc"))
1316 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
1317 .mount(&server)
1318 .await;
1319
1320 let client = A2aClient::new_insecure(reqwest::Client::new());
1321 let params = SendMessageParams {
1322 message: Message::user_text("fail"),
1323 configuration: None,
1324 };
1325 let result = client
1326 .stream_message(&format!("{}/rpc", server.uri()), params, None)
1327 .await;
1328 let err = result.err().expect("expected error");
1329 assert_matches!(err, crate::error::A2aError::Stream(_));
1330 }
1331
1332 #[tokio::test]
1333 async fn rpc_call_times_out() {
1334 let server = MockServer::start().await;
1335 Mock::given(method("POST"))
1336 .and(path("/rpc"))
1337 .respond_with(
1338 ResponseTemplate::new(200)
1339 .set_delay(std::time::Duration::from_secs(5))
1340 .set_body_json(serde_json::json!({
1341 "jsonrpc": "2.0",
1342 "id": "req-1",
1343 "result": {
1344 "id": "t-1",
1345 "status": {"state": "completed", "timestamp": "2026-01-01T00:00:00Z"}
1346 }
1347 })),
1348 )
1349 .mount(&server)
1350 .await;
1351
1352 let client = A2aClient::new_insecure(reqwest::Client::new())
1353 .with_request_timeout(std::time::Duration::from_millis(100));
1354 let params = SendMessageParams {
1355 message: Message::user_text("hello"),
1356 configuration: None,
1357 };
1358 let result = client
1359 .send_message(&format!("{}/rpc", server.uri()), params, None)
1360 .await;
1361 assert!(result.is_err());
1362 assert!(
1363 matches!(result.unwrap_err(), crate::error::A2aError::Timeout(_)),
1364 "expected Timeout error"
1365 );
1366 }
1367
1368 #[tokio::test]
1374 async fn hardened_client_pins_connection_bypassing_dns() {
1375 let server = MockServer::start().await;
1376 Mock::given(method("GET"))
1377 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
1378 .mount(&server)
1379 .await;
1380
1381 let addr = *server.address();
1382 let fake_host = "zeph-a2a-pin-test.invalid";
1383 let client =
1384 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
1385 require_tls: false,
1386 ssrf_protection: true,
1387 });
1388 let pinned = PinnedTarget {
1389 host: fake_host.to_owned(),
1390 addrs: vec![addr],
1391 };
1392 let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1393
1394 let resp = hardened
1395 .get(format!("http://{fake_host}:{}/", addr.port()))
1396 .send()
1397 .await
1398 .unwrap_or_else(|e| panic!("pinned request to unresolvable host failed: {e}"));
1399 assert_eq!(resp.status(), 200);
1400 assert_eq!(resp.text().await.unwrap(), "ok");
1401 }
1402
1403 #[tokio::test]
1408 async fn hardened_client_does_not_auto_follow_redirect_to_private_ip() {
1409 let server = MockServer::start().await;
1410 Mock::given(method("GET"))
1411 .respond_with(
1412 ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1:9/internal"),
1413 )
1414 .mount(&server)
1415 .await;
1416
1417 let addr = *server.address();
1418 let fake_host = "zeph-a2a-redirect-test.invalid";
1419 let client =
1420 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
1421 require_tls: false,
1422 ssrf_protection: true,
1423 });
1424 let pinned = PinnedTarget {
1425 host: fake_host.to_owned(),
1426 addrs: vec![addr],
1427 };
1428 let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1429
1430 let resp = hardened
1431 .get(format!("http://{fake_host}:{}/start", addr.port()))
1432 .send()
1433 .await
1434 .unwrap();
1435
1436 assert_eq!(resp.status(), reqwest::StatusCode::FOUND);
1437 assert_eq!(
1438 resp.headers().get(reqwest::header::LOCATION).unwrap(),
1439 "http://127.0.0.1:9/internal"
1440 );
1441 }
1442
1443 #[tokio::test]
1447 async fn hardened_client_with_require_tls_rejects_plaintext_connection() {
1448 let server = MockServer::start().await;
1449 Mock::given(method("GET"))
1450 .respond_with(ResponseTemplate::new(200))
1451 .mount(&server)
1452 .await;
1453
1454 let addr = *server.address();
1455 let fake_host = "zeph-a2a-tls-test.invalid";
1456 let client =
1457 A2aClient::new_insecure(reqwest::Client::new()).with_security(SecurityPolicy {
1458 require_tls: true,
1459 ssrf_protection: true,
1460 });
1461 let pinned = PinnedTarget {
1462 host: fake_host.to_owned(),
1463 addrs: vec![addr],
1464 };
1465 let hardened = client.build_hardened_client(Some(&pinned)).unwrap();
1466
1467 let result = hardened
1468 .get(format!("http://{fake_host}:{}/", addr.port()))
1469 .send()
1470 .await;
1471 assert!(
1472 result.is_err(),
1473 "https_only(true) must reject a plain http:// URL"
1474 );
1475 }
1476
1477 #[cfg(feature = "ibct")]
1478 #[tokio::test]
1479 async fn send_message_attaches_ibct_header_when_configured() {
1480 use wiremock::matchers::header_exists;
1481
1482 let server = MockServer::start().await;
1483 Mock::given(method("POST"))
1484 .and(path("/rpc"))
1485 .and(header_exists("x-zeph-ibct"))
1486 .respond_with(task_rpc_response("task-ibct", "submitted"))
1487 .mount(&server)
1488 .await;
1489
1490 let key = crate::ibct::IbctKey {
1491 key_id: "k1".into(),
1492 key_bytes: b"secret".to_vec(),
1493 };
1494 let client = A2aClient::new_insecure(reqwest::Client::new()).with_ibct_key(key);
1495 let params = SendMessageParams {
1496 message: Message::user_text("hello"),
1497 configuration: None,
1498 };
1499 let task = client
1500 .send_message(&format!("{}/rpc", server.uri()), params, None)
1501 .await
1502 .unwrap();
1503 assert_eq!(task.id, "task-ibct");
1504 }
1505}