Skip to main content

zeph_a2a/
client.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! A2A protocol HTTP client with optional TLS enforcement and SSRF protection.
5
6use 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
24/// A pinned, heap-allocated stream of [`TaskEvent`]s from a streaming A2A call.
25///
26/// Produced by [`A2aClient::stream_message`]. Each item is either a status update
27/// or an artifact update; errors are surfaced inline as `Err(A2aError)`.
28pub type TaskEventStream = Pin<Box<dyn Stream<Item = Result<TaskEvent, A2aError>> + Send>>;
29
30/// A single event received on a streaming (`message/stream`) A2A connection.
31///
32/// The A2A spec multiplexes two event kinds over the same SSE channel. This enum
33/// uses `#[serde(untagged)]` so that the deserializer inspects the `kind` field
34/// inside the inner struct to determine the variant.
35#[non_exhaustive]
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(untagged)]
38pub enum TaskEvent {
39    /// A task lifecycle transition (e.g., `submitted` → `working` → `completed`).
40    StatusUpdate(TaskStatusUpdateEvent),
41    /// A new or updated output artifact from the agent.
42    ArtifactUpdate(TaskArtifactUpdateEvent),
43}
44
45/// Security posture applied to outbound [`A2aClient`] requests.
46///
47/// Named fields eliminate the transposition hazard of a two-bool builder method
48/// (`with_security(true, false)` vs. `with_security(false, true)` are easy to swap
49/// by accident) and group the security boundary as one reviewable unit.
50///
51/// # Examples
52///
53/// ```rust
54/// use zeph_a2a::{A2aClient, SecurityPolicy};
55///
56/// // Recommended for production: reject HTTP and private/loopback targets. Pass the
57/// // policy directly to `new` so it can never be silently inherited by omission.
58/// let client = A2aClient::new(reqwest::Client::new(), SecurityPolicy::hardened());
59///
60/// // Partial policy via named fields — no ambiguity about which flag is which.
61/// let tls_only = SecurityPolicy {
62///     require_tls: true,
63///     ssrf_protection: false,
64/// };
65/// let _ = client.with_security(tls_only);
66/// ```
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct SecurityPolicy {
69    /// Reject any endpoint that does not start with `https://`, and build requests with
70    /// `https_only(true)` so a redirect cannot silently downgrade the connection to `http://`.
71    pub require_tls: bool,
72    /// Resolve the endpoint hostname via DNS, reject private/loopback/link-local ranges,
73    /// and pin the validated address for the actual connection so it cannot be re-resolved
74    /// to a different (attacker-controlled) address between the check and the connect.
75    pub ssrf_protection: bool,
76}
77
78impl SecurityPolicy {
79    /// Both protections enabled. The recommended posture for production deployments
80    /// that talk to untrusted or third-party A2A endpoints.
81    ///
82    /// # Examples
83    ///
84    /// ```rust
85    /// use zeph_a2a::SecurityPolicy;
86    ///
87    /// let policy = SecurityPolicy::hardened();
88    /// assert!(policy.require_tls);
89    /// assert!(policy.ssrf_protection);
90    /// ```
91    #[must_use]
92    pub const fn hardened() -> Self {
93        Self {
94            require_tls: true,
95            ssrf_protection: true,
96        }
97    }
98
99    /// Both protections disabled. Suitable only for local development against
100    /// trusted, non-adversarial endpoints (e.g. `http://localhost`).
101    ///
102    /// # Examples
103    ///
104    /// ```rust
105    /// use zeph_a2a::SecurityPolicy;
106    ///
107    /// let policy = SecurityPolicy::permissive();
108    /// assert!(!policy.require_tls);
109    /// assert!(!policy.ssrf_protection);
110    /// ```
111    #[must_use]
112    pub const fn permissive() -> Self {
113        Self {
114            require_tls: false,
115            ssrf_protection: false,
116        }
117    }
118}
119
120/// A DNS-validated hostname and its resolved addresses, used to pin the actual HTTP
121/// connection to the exact addresses that passed SSRF validation (see [`SecurityPolicy`]).
122#[derive(Debug)]
123struct PinnedTarget {
124    host: String,
125    addrs: Vec<SocketAddr>,
126}
127
128/// HTTP client for the A2A protocol.
129///
130/// `A2aClient` wraps a `reqwest::Client` and provides typed methods for the four
131/// A2A JSON-RPC operations: `message/send`, `message/stream`, `tasks/get`, and
132/// `tasks/cancel`. Each call optionally accepts a bearer token for authentication.
133///
134/// # Security
135///
136/// [`new`](Self::new) requires an explicit [`SecurityPolicy`] so a call site can never
137/// silently inherit a permissive default by omission (issue #6553) — use
138/// [`SecurityPolicy::hardened()`] for production, or [`new_insecure`](Self::new_insecure)
139/// to opt into [`SecurityPolicy::permissive()`] explicitly for local/dev use.
140/// [`with_security`](A2aClient::with_security) can still override the policy after
141/// construction. When either flag is enabled, each request is sent through a dedicated
142/// per-request `reqwest::Client` with redirects disabled and, when `ssrf_protection` is
143/// on, the connection pinned to the exact addresses that were validated (no re-resolution
144/// at connect time).
145///
146/// # Examples
147///
148/// ```rust,no_run
149/// use zeph_a2a::{A2aClient, SecurityPolicy, SendMessageParams, Message};
150///
151/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
152/// let client = A2aClient::new(reqwest::Client::new(), SecurityPolicy::hardened());
153///
154/// let params = SendMessageParams {
155///     message: Message::user_text("Summarize this page."),
156///     configuration: None,
157/// };
158/// let task = client.send_message("https://agent.example.com/a2a", params, Some("tok")).await?;
159/// println!("Task state: {:?}", task.status.state);
160/// # Ok(())
161/// # }
162/// ```
163pub struct A2aClient {
164    client: reqwest::Client,
165    security: SecurityPolicy,
166    /// Per-request timeout applied to `rpc_call` (send + JSON parse) and to the initial
167    /// `send()` in `stream_message`. The SSE body stream itself is not bounded — that
168    /// is the caller's responsibility.
169    ///
170    /// If the underlying `reqwest::Client` was also built with `.timeout()`, both limits
171    /// race: whichever fires first wins. `request_timeout` takes semantic priority because
172    /// it maps to `A2aError::Timeout`; the reqwest-level timeout maps to `A2aError::Http`.
173    request_timeout: Duration,
174    /// IBCT signing key. When set, every request carries a scoped `X-Zeph-IBCT` header
175    /// alongside the bearer token (see [`with_ibct_key`](Self::with_ibct_key)).
176    ibct_key: Option<IbctKey>,
177    /// TTL for issued IBCT tokens. Has no effect unless `ibct_key` is set.
178    ibct_ttl: Duration,
179}
180
181impl A2aClient {
182    /// Create a new `A2aClient` with an explicit [`SecurityPolicy`].
183    ///
184    /// The policy is a mandatory argument (issue #6553) so a call site states its
185    /// security intent at construction time instead of silently inheriting a
186    /// permissive default by omission. Use [`SecurityPolicy::hardened()`] for
187    /// production deployments, or [`new_insecure`](Self::new_insecure) to opt into
188    /// [`SecurityPolicy::permissive()`] explicitly for local/dev use.
189    ///
190    /// # Examples
191    ///
192    /// ```rust
193    /// use zeph_a2a::{A2aClient, SecurityPolicy};
194    ///
195    /// let client = A2aClient::new(reqwest::Client::new(), SecurityPolicy::hardened());
196    /// ```
197    #[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    /// Create a new `A2aClient` with [`SecurityPolicy::permissive()`] — no TLS
209    /// enforcement or SSRF protection.
210    ///
211    /// Suitable only for local development against trusted, non-adversarial endpoints
212    /// (e.g. `http://localhost`). Production code should call [`new`](Self::new) with
213    /// an explicit policy instead of relying on this insecure default.
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use zeph_a2a::A2aClient;
219    ///
220    /// let client = A2aClient::new_insecure(reqwest::Client::new());
221    /// ```
222    #[must_use]
223    pub fn new_insecure(client: reqwest::Client) -> Self {
224        Self::new(client, SecurityPolicy::permissive())
225    }
226
227    /// Configure the [`SecurityPolicy`] for this client.
228    ///
229    /// Overrides whatever policy was set at construction time ([`new`](Self::new) or
230    /// [`new_insecure`](Self::new_insecure)). This method uses the builder pattern and
231    /// can be chained directly after either constructor.
232    ///
233    /// # Examples
234    ///
235    /// ```rust
236    /// use zeph_a2a::{A2aClient, SecurityPolicy};
237    ///
238    /// let client = A2aClient::new_insecure(reqwest::Client::new())
239    ///     .with_security(SecurityPolicy::hardened());
240    /// ```
241    #[must_use]
242    pub fn with_security(mut self, policy: SecurityPolicy) -> Self {
243        self.security = policy;
244        self
245    }
246
247    /// Set the per-request timeout for RPC and streaming connection calls (default: 30 seconds).
248    ///
249    /// Applied to the full send + JSON response parse in `rpc_call`, and to the initial
250    /// HTTP `send()` in `stream_message`. The SSE body stream after connection is intentionally
251    /// unbounded — streams can legitimately run for a long time.
252    #[must_use]
253    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
254        self.request_timeout = timeout;
255        self
256    }
257
258    /// Configure an IBCT signing key so every request carries a scoped `X-Zeph-IBCT` header
259    /// alongside the bearer token.
260    ///
261    /// The token is scoped to the exact `endpoint` string passed to `send_message`/
262    /// `get_task`/`cancel_task`/`stream_message`, and to the request's `task_id` —
263    /// `params.id` for `get_task`/`cancel_task`, `message.task_id` for `send_message`/
264    /// `stream_message` (the empty-string sentinel when the message has no `task_id` yet,
265    /// i.e. it starts a brand-new task the server has not assigned an ID to).
266    ///
267    /// Issuance failures (e.g. this crate compiled without the `ibct` feature) are logged
268    /// and the request proceeds without the header — the server decides whether to reject it.
269    ///
270    /// # Examples
271    ///
272    /// ```rust
273    /// use zeph_a2a::{A2aClient, IbctKey};
274    ///
275    /// let key = IbctKey { key_id: "k1".into(), key_bytes: b"secret".to_vec() };
276    /// let client = A2aClient::new_insecure(reqwest::Client::new()).with_ibct_key(key);
277    /// ```
278    #[must_use]
279    pub fn with_ibct_key(mut self, key: IbctKey) -> Self {
280        self.ibct_key = Some(key);
281        self
282    }
283
284    /// Set the TTL for issued IBCT tokens (default: 5 minutes). Has no effect unless
285    /// [`with_ibct_key`](Self::with_ibct_key) is also configured.
286    #[must_use]
287    pub fn with_ibct_ttl(mut self, ttl: Duration) -> Self {
288        self.ibct_ttl = ttl;
289        self
290    }
291
292    /// Issues and base64-encodes an `X-Zeph-IBCT` header value scoped to `endpoint` +
293    /// `task_id`. Returns `None` when no key is configured, or when issuance/encoding
294    /// fails (logged as a warning).
295    ///
296    /// `endpoint` is normalized to its origin (`scheme://host[:port]`, no path) before
297    /// being embedded in the token — see [`ibct_scope_origin`] for why: the server verifies
298    /// against its own advertised `AgentCard::url`, which is documented as the base URL with
299    /// no path suffix, and is shared by both the `/a2a` and `/a2a/stream` routes. Scoping the
300    /// token to the full request URL (including the route-specific path) would make it valid
301    /// for at most one of the two routes even against the correct server (#6260 review S1).
302    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    /// # Errors
322    /// Returns `A2aError` on network, JSON, or JSON-RPC errors, or `A2aError::Timeout`
323    /// if the request exceeds the configured `request_timeout`.
324    #[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    /// # Errors
337    /// Returns `A2aError` on network failure or if the SSE connection cannot be established.
338    #[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            // Truncate body to avoid leaking large upstream error responses.
368            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    /// # Errors
399    /// Returns `A2aError` on network, JSON, or JSON-RPC errors, or `A2aError::Timeout`
400    /// if the request exceeds the configured `request_timeout`.
401    #[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    /// # Errors
414    /// Returns `A2aError` on network, JSON, or JSON-RPC errors, or `A2aError::Timeout`
415    /// if the request exceeds the configured `request_timeout`.
416    #[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    /// Validates `endpoint` against the configured [`SecurityPolicy`] and, when
429    /// `ssrf_protection` is enabled, resolves its hostname once and returns the
430    /// validated addresses to be pinned for the actual connection.
431    ///
432    /// Returning `Ok(None)` means either security is off for that check, or the
433    /// endpoint has no host (validation is skipped, matching prior behavior).
434    #[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    /// Returns `true` when either half of the [`SecurityPolicy`] requires requests
465    /// to be sent through a dedicated per-request client instead of `self.client`.
466    fn needs_hardened_client(&self) -> bool {
467        self.security.require_tls || self.security.ssrf_protection
468    }
469
470    /// Selects the `reqwest::Client` to use for a single request: the shared
471    /// injected client when no security is configured, or a fresh hardened client
472    /// (redirects disabled, optionally TLS-enforced and address-pinned) otherwise.
473    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    /// Builds a per-request client hardened per the configured [`SecurityPolicy`].
482    ///
483    /// Redirects are always disabled (`Policy::none()`) so a malicious `3xx` response
484    /// cannot silently redirect the connection to a private address or downgrade to
485    /// `http://` — the caller (`rpc_call`/`stream_message`) treats any non-2xx or
486    /// unparseable response as an error instead of following it. When `pinned` is
487    /// `Some`, the client is additionally locked to the exact addresses that passed
488    /// SSRF validation via `resolve_to_addrs`, so reqwest cannot re-resolve the
489    /// hostname to a different address at connect time (closing the DNS-rebinding
490    /// TOCTOU window).
491    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    // Regression tests for #6260 review S1: the server verifies every IBCT against its own
1084    // pathless `AgentCard::url`, shared by both the `/a2a` and `/a2a/stream` routes. A token
1085    // scoped to the full per-route POST URL (including the route-specific path) could match
1086    // at most one of the two routes even against the correct server. `ibct_scope_origin` (and
1087    // therefore `ibct_header_value`) must always strip the path before issuing.
1088
1089    #[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    /// Proves the DNS-rebinding TOCTOU is closed: `resolve_to_addrs` pins the connection to
1369    /// the address validated by `resolve_and_validate`, so reqwest never re-resolves `fake_host`
1370    /// (a hostname reserved by RFC 2606 and guaranteed to never resolve via real DNS) at connect
1371    /// time. If the client re-resolved instead of using the pinned address, this request would
1372    /// fail with a DNS lookup error rather than reaching the mock server.
1373    #[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    /// Proves the redirect-based SSRF bypass is closed: the hardened client does not
1404    /// automatically follow a `3xx` response, even when `Location` points at a private
1405    /// address. `rpc_call`/`stream_message` treat the raw redirect response as a normal
1406    /// (non-2xx) response and surface an error instead of connecting to `Location`.
1407    #[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    /// Proves TLS enforcement holds even on the hardened per-request client: `https_only(true)`
1444    /// rejects a plaintext `http://` connection outright, closing the https-to-http downgrade
1445    /// gap that an unvalidated redirect could otherwise exploit.
1446    #[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}