Skip to main content

pmcp_server_toolkit/http/
client.rs

1//! reqwest-backed [`HttpConnector`] implementation (OAPI-01).
2//!
3//! Lifts the pmcp-run reference `HttpClient::execute_with_options` body into a
4//! toolkit-owned [`HttpClient`] that implements [`HttpConnector`]. The concrete
5//! shape mirrors `crate::sql::sqlite::SqliteConnector` (a concrete connector impl
6//! + constructor). Construction is LAZY — `new` parses the base URL but contacts
7//! no backend (CF-2). URL building uses the shared [`crate::http::join_url`]
8//! helper so an API-Gateway stage prefix (`/v1`) survives (Pitfall 2 — explicit
9//! path concatenation, never the RFC-3986 url-crate path merge). Error messages
10//! never echo the URL or a credential (Pitfall 5).
11
12use super::auth::HttpAuthProvider;
13use super::{join_url, HttpConnector, HttpConnectorError, Operation, Parameter, ParameterLocation};
14use async_trait::async_trait;
15use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21/// HTTP client configuration (OWNED here in `http`, mirroring [`super::AuthConfig`]
22/// ownership so Plan 02 re-exports it rather than redefining).
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct HttpConfig {
26    /// Request timeout in seconds.
27    #[serde(default = "default_timeout")]
28    pub timeout_seconds: u64,
29    /// Number of retry attempts on 5xx / connect / timeout.
30    #[serde(default = "default_retries")]
31    pub retries: u32,
32    /// Base backoff in milliseconds (exponential per attempt).
33    #[serde(default = "default_retry_backoff")]
34    pub retry_backoff_ms: u64,
35    /// `User-Agent` header for all requests.
36    #[serde(default = "default_user_agent")]
37    pub user_agent: String,
38    /// Extra headers applied to every request.
39    #[serde(default)]
40    pub default_headers: HashMap<String, String>,
41}
42
43fn default_timeout() -> u64 {
44    30
45}
46fn default_retries() -> u32 {
47    3
48}
49fn default_retry_backoff() -> u64 {
50    1000
51}
52fn default_user_agent() -> String {
53    format!("pmcp-server-toolkit/{}", env!("CARGO_PKG_VERSION"))
54}
55
56impl Default for HttpConfig {
57    fn default() -> Self {
58        Self {
59            timeout_seconds: default_timeout(),
60            retries: default_retries(),
61            retry_backoff_ms: default_retry_backoff(),
62            user_agent: default_user_agent(),
63            default_headers: HashMap::new(),
64        }
65    }
66}
67
68/// reqwest-backed [`HttpConnector`].
69pub struct HttpClient {
70    client: reqwest::Client,
71    base_url: url::Url,
72    auth: Arc<dyn HttpAuthProvider>,
73    http_config: HttpConfig,
74    /// The E1 outbound-request policy, when one was registered (Phase 128).
75    ///
76    /// `None` on every client built by [`HttpClient::new`], which is what keeps
77    /// that constructor's signature unchanged and what keeps a server that
78    /// registers no policy allocating nothing extra on the request path.
79    policy: Option<Arc<dyn crate::policy::RequestPolicy>>,
80}
81
82impl HttpClient {
83    /// Construct a client. LAZY: parses `base_url` but contacts no backend (CF-2).
84    ///
85    /// # Errors
86    ///
87    /// Returns [`HttpConnectorError::Backend`] when `base_url` is unparseable or
88    /// the reqwest client cannot be built. The error message does NOT echo the URL.
89    pub fn new(
90        client: reqwest::Client,
91        base_url: String,
92        auth: Arc<dyn HttpAuthProvider>,
93    ) -> Result<Self, HttpConnectorError> {
94        Self::with_config(client, base_url, auth, HttpConfig::default())
95    }
96
97    /// Construct a client with an explicit [`HttpConfig`]. LAZY (CF-2).
98    ///
99    /// # Errors
100    ///
101    /// As [`HttpClient::new`].
102    pub fn with_config(
103        client: reqwest::Client,
104        base_url: String,
105        auth: Arc<dyn HttpAuthProvider>,
106        http_config: HttpConfig,
107    ) -> Result<Self, HttpConnectorError> {
108        let base_url = url::Url::parse(&base_url)
109            .map_err(|_| HttpConnectorError::Backend("invalid base URL".to_string()))?;
110        Ok(Self {
111            client,
112            base_url,
113            auth,
114            http_config,
115            policy: None,
116        })
117    }
118
119    /// Attach the E1 [`crate::policy::RequestPolicy`] consulted before every
120    /// outbound request (Phase 128).
121    ///
122    /// Cheap clone-with-builder, the same shape as
123    /// `HttpCodeExecutor::with_inbound_token`. Neither [`HttpClient::new`] nor
124    /// [`HttpClient::with_config`] changed signature.
125    #[must_use]
126    pub fn with_request_policy(mut self, policy: Arc<dyn crate::policy::RequestPolicy>) -> Self {
127        self.policy = Some(policy);
128        self
129    }
130
131    /// Build a client from an [`HttpConfig`], constructing the reqwest client with
132    /// the configured timeout, user-agent, and default headers. LAZY (CF-2).
133    ///
134    /// # Errors
135    ///
136    /// As [`HttpClient::new`].
137    pub fn from_config(
138        base_url: String,
139        auth: Arc<dyn HttpAuthProvider>,
140        http_config: HttpConfig,
141    ) -> Result<Self, HttpConnectorError> {
142        let mut headers = HeaderMap::new();
143        if let Ok(ua) = HeaderValue::from_str(&http_config.user_agent) {
144            headers.insert(reqwest::header::USER_AGENT, ua);
145        }
146        for (key, value) in &http_config.default_headers {
147            if let (Ok(name), Ok(val)) = (
148                HeaderName::try_from(key.as_str()),
149                HeaderValue::try_from(value.as_str()),
150            ) {
151                headers.insert(name, val);
152            }
153        }
154        // T-128-39a (Phase 128 security audit, W3 / review WR-02). reqwest's DEFAULT
155        // is to follow up to 10 redirects, which would carry a governed request to an
156        // endpoint `RequestPolicy` already refused — the exact bypass
157        // `pmcp-openapi-server`'s `dispatch.rs` hardens against. This constructor is
158        // `pub`, so a downstream consumer reaching for the one that honours
159        // `[backend.http]` would otherwise get the un-hardened client. Keep these two
160        // in the same shape; a redirect must be an explicit, policy-checked new request.
161        let client = reqwest::Client::builder()
162            .timeout(Duration::from_secs(http_config.timeout_seconds))
163            .redirect(reqwest::redirect::Policy::none())
164            .default_headers(headers)
165            .build()
166            .map_err(|_| HttpConnectorError::Backend("failed to build HTTP client".to_string()))?;
167        Self::with_config(client, base_url, auth, http_config)
168    }
169
170    /// Substitute path parameters into the operation path template.
171    ///
172    /// # Phase 128 D4 — the curated surface's path-injection floor
173    ///
174    /// Two passes, in this order, and the order is load-bearing:
175    ///
176    /// 1. every path parameter's value is rendered and then checked by
177    ///    `pmcp::server::schema_validation::validate_path_placeholder` against that
178    ///    parameter's declared rules (`Parameter::placeholder_rules`) — with
179    ///    NOTHING substituted yet, so a refusal on the second of two placeholders
180    ///    cannot leave a half-substituted path in existence anywhere;
181    /// 2. only once every value has passed are the replacements applied, and the
182    ///    COMPOSED result is then checked by
183    ///    `pmcp::server::schema_validation::validate_resolved_target` before the
184    ///    caller can dispatch it.
185    ///
186    /// Step 2 is not redundant with step 1. A composition belongs to no single
187    /// value, so only the composed check can see a segment that a template literal
188    /// and a passing value jointly push over the cap, traversal written into the
189    /// template itself, or a residual `{`/`}` left by a placeholder with no
190    /// supplied argument.
191    ///
192    /// ## The one narrowing: an operator-written `?` is permitted
193    ///
194    /// `validate_resolved_path` refuses a query separator anywhere, and core keeps
195    /// that strict rule for its other callers. Its sibling
196    /// `validate_resolved_target` — which `check_composed_path` calls — splits the
197    /// composed path at the FIRST `?` and applies the full, unmodified rule set to
198    /// each side, which exempts exactly that one separator and nothing else. It is safe
199    /// rather than a hole because step 1 already refuses `?` inside a substituted
200    /// value, in literal AND percent-encoded form with a decode-once pass — so a
201    /// `?` surviving into the composed string can only have come from the
202    /// `[[tools]]` `path` an operator authored, not from caller data. Refusing `..`
203    /// from that template catches a traversal bug; refusing `?` from it rejects
204    /// legitimate configuration. Same rule, different work. A SECOND `?`, a
205    /// dangling `?` with an empty query, a `#` fragment marker and anything else
206    /// stay refused, because the query portion faces the same rule.
207    ///
208    /// ## What counts as a placeholder here
209    ///
210    /// Substitution is textual, so a spec-derived operation's mid-segment
211    /// placeholder (`.../range(address='{address}')`) is substituted. A curated
212    /// `[[tools]]` `path`, by contrast, only ever reaches this function in the
213    /// whole-segment shape: a segment containing anything other than exactly one
214    /// `{name}` spanning the whole segment is refused at CONFIG time by
215    /// `crate::config::ServerConfig::validate`, because such a segment produces
216    /// either a parameter name no declaration can match or literal braces on the
217    /// wire.
218    ///
219    /// ## Without the `input-validation` feature
220    ///
221    /// Both checks are `#[cfg(feature = "input-validation")]`-gated and this
222    /// function behaves exactly as it did before Phase 128. `input-validation` is
223    /// in the toolkit's `default` feature set, so an unenforced build is an
224    /// explicit opt-out rather than an accident.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`HttpConnectorError::Backend`]:
229    ///
230    /// - via [`render_scalar`] when a path parameter value is a non-scalar
231    ///   (`Object`/`Array`) — such a value would otherwise be JSON-stringified
232    ///   into the URL (WR-03);
233    /// - when `validate_path_placeholder` refuses a rendered value (the character
234    ///   floor, the always-on length cap, or the declared `pattern`/`max_length`
235    ///   narrowing on top of them);
236    /// - when a declared path parameter has no supplied argument, naming that
237    ///   parameter and nothing else;
238    /// - when `validate_resolved_target` refuses the composed path on either side
239    ///   of an operator-written `?`.
240    ///
241    /// Every one of these messages names the declared parameter or the rule and
242    /// carries no byte of the rejected value and no fragment of the resolved path
243    /// (Pitfall 5, as [`render_scalar`] states it).
244    fn substitute_path(
245        operation: &Operation,
246        args: &serde_json::Map<String, serde_json::Value>,
247    ) -> Result<String, HttpConnectorError> {
248        // Pass 1 — render and CHECK every contribution. Nothing is applied yet.
249        let mut rendered: Vec<(String, String)> = Vec::new();
250        for param in operation.path_parameters() {
251            let Some(value) = args.get(&param.name) else {
252                refuse_missing_path_argument(&param.name)?;
253                continue;
254            };
255            let value_str = render_scalar(&param.name, value)?;
256            check_placeholder_value(param, &value_str)?;
257            rendered.push((format!("{{{}}}", param.name), value_str));
258        }
259        // Pass 2 — apply, then check the COMPOSED result.
260        let mut path = operation.path.clone();
261        for (placeholder, value_str) in &rendered {
262            path = path.replace(placeholder, value_str);
263        }
264        check_composed_path(&path)?;
265        Ok(path)
266    }
267
268    /// Render one query value: a scalar passes through; an array-of-scalars is
269    /// comma-joined (OpenAPI `form`/`explode:false` style); an object or an array
270    /// with any non-scalar member is rejected (each member is checked through
271    /// [`render_scalar`]).
272    ///
273    /// # Errors
274    ///
275    /// Returns [`HttpConnectorError::Backend`] naming `param_name` when `value`
276    /// (or any array member) is a non-scalar.
277    fn render_query_value(
278        param_name: &str,
279        value: &serde_json::Value,
280    ) -> Result<String, HttpConnectorError> {
281        if let serde_json::Value::Array(arr) = value {
282            // Comma-separate array members (OpenAPI `form`/`simple` style). A
283            // nested non-scalar member is rejected by render_scalar.
284            let mut csv = String::new();
285            for (i, member) in arr.iter().enumerate() {
286                if i > 0 {
287                    csv.push(',');
288                }
289                csv.push_str(&render_scalar(param_name, member)?);
290            }
291            Ok(csv)
292        } else {
293            render_scalar(param_name, value)
294        }
295    }
296
297    /// Build the query map from query-located params present in `args`.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`HttpConnectorError::Backend`] naming the offending parameter when
302    /// a query value is an object, or an array containing a non-scalar member
303    /// (WR-03). A scalar or an array-of-scalars behaves exactly as before.
304    fn build_query(
305        operation: &Operation,
306        args: &serde_json::Map<String, serde_json::Value>,
307    ) -> Result<HashMap<String, String>, HttpConnectorError> {
308        let mut query = HashMap::new();
309        for param in operation.query_parameters() {
310            if let Some(value) = args.get(&param.name) {
311                query.insert(
312                    param.name.clone(),
313                    Self::render_query_value(&param.name, value)?,
314                );
315            }
316        }
317        Ok(query)
318    }
319
320    /// Build the header map from header-located params present in `args`.
321    fn build_headers(
322        operation: &Operation,
323        args: &serde_json::Map<String, serde_json::Value>,
324    ) -> Result<HeaderMap, HttpConnectorError> {
325        let mut headers = HeaderMap::new();
326        for param in operation.header_parameters() {
327            if let Some(value) = args.get(&param.name) {
328                let name = HeaderName::try_from(param.name.as_str()).map_err(|_| {
329                    HttpConnectorError::InvalidHeader("invalid header name".to_string())
330                })?;
331                // Reject a non-scalar header value (naming the param) before it
332                // can be JSON-stringified into the header (WR-03).
333                let rendered = render_scalar(&param.name, value)?;
334                let val = HeaderValue::try_from(rendered).map_err(|_| {
335                    HttpConnectorError::InvalidHeader("invalid header value".to_string())
336                })?;
337                headers.insert(name, val);
338            }
339        }
340        Ok(headers)
341    }
342
343    /// Collect the request body: every arg that is not routed somewhere else.
344    ///
345    /// An arg belongs in the payload when it is either a declared
346    /// [`ParameterLocation::Body`] parameter — which is what `build_operation`
347    /// assigns to a `POST`/`PUT`/`PATCH` tool's non-path parameters — or an
348    /// UNDECLARED key, which only reaches here when the tool's schema was built
349    /// with `[server.validation] additional_properties = true`. A `Path`, `Query`
350    /// or `Header`-located parameter is withheld, because it already travels in the
351    /// URL or the headers.
352    ///
353    /// # Phase 128 CR-02
354    ///
355    /// The `Body`-located half is the fix. Before it, `build_operation` marked every
356    /// non-path declared parameter `Query`, so the `declared` exclusion below
357    /// withheld ALL of them and the only route to a payload was an undeclared key —
358    /// which `additionalProperties: false`, enforced for the first time in this
359    /// release, refuses. A curated mutating tool could accept a payload and send
360    /// none of it.
361    ///
362    /// # The reserved `body` key (WR-10)
363    ///
364    /// `"body"` is a reserved argument name: when present it becomes the ENTIRE
365    /// payload verbatim rather than one field of it. It is no longer also appended
366    /// to the query string, because on a body-bearing method a declared parameter
367    /// named `body` is now `Body`-located and `build_query` only reads
368    /// `Query`-located ones.
369    fn build_body(
370        operation: &Operation,
371        args: &serde_json::Map<String, serde_json::Value>,
372    ) -> Option<serde_json::Value> {
373        if !operation.has_request_body {
374            return None;
375        }
376        if let Some(body) = args.get("body") {
377            return Some(body.clone());
378        }
379        let routed_elsewhere: std::collections::HashSet<&str> = operation
380            .parameters
381            .iter()
382            .filter(|p| p.location != ParameterLocation::Body)
383            .map(|p| p.name.as_str())
384            .collect();
385        let body: serde_json::Map<String, serde_json::Value> = args
386            .iter()
387            .filter(|(k, _)| !routed_elsewhere.contains(k.as_str()))
388            .map(|(k, v)| (k.clone(), v.clone()))
389            .collect();
390        if body.is_empty() {
391            None
392        } else {
393            Some(serde_json::Value::Object(body))
394        }
395    }
396
397    fn convert_method(method: &str) -> Result<reqwest::Method, HttpConnectorError> {
398        match method.to_uppercase().as_str() {
399            "GET" => Ok(reqwest::Method::GET),
400            "POST" => Ok(reqwest::Method::POST),
401            "PUT" => Ok(reqwest::Method::PUT),
402            "PATCH" => Ok(reqwest::Method::PATCH),
403            "DELETE" => Ok(reqwest::Method::DELETE),
404            "HEAD" => Ok(reqwest::Method::HEAD),
405            "OPTIONS" => Ok(reqwest::Method::OPTIONS),
406            _ => Err(HttpConnectorError::Backend(
407                "unknown HTTP method".to_string(),
408            )),
409        }
410    }
411
412    /// Send the request, retrying on 5xx / connect / timeout with exponential backoff.
413    async fn send_with_retries(
414        &self,
415        request: reqwest::RequestBuilder,
416    ) -> Result<reqwest::Response, HttpConnectorError> {
417        let max_retries = self.http_config.retries;
418        let mut last_status: Option<u16> = None;
419        for attempt in 0..=max_retries {
420            if attempt > 0 {
421                let delay = self.http_config.retry_backoff_ms * (1u64 << (attempt - 1));
422                tokio::time::sleep(Duration::from_millis(delay)).await;
423            }
424            let Some(attempt_request) = request.try_clone() else {
425                return Err(HttpConnectorError::Request(
426                    "request body is not retryable".to_string(),
427                ));
428            };
429            match attempt_request.send().await {
430                Ok(response) => {
431                    let status = response.status();
432                    if status.is_server_error() && attempt < max_retries {
433                        last_status = Some(status.as_u16());
434                        continue;
435                    }
436                    return Ok(response);
437                },
438                Err(e) => {
439                    let retryable = e.is_connect() || e.is_timeout();
440                    if retryable && attempt < max_retries {
441                        continue;
442                    }
443                    // Redacted: never forward the reqwest error Display (echoes URL).
444                    return Err(HttpConnectorError::Request(
445                        "transport error contacting backend".to_string(),
446                    ));
447                },
448            }
449        }
450        Err(HttpConnectorError::Status {
451            status: last_status.unwrap_or(0),
452        })
453    }
454}
455
456/// Render a JSON scalar for use in a path / query / header position, REJECTING
457/// non-scalar values (WR-03 / GAP 4).
458///
459/// # The decided rule (uniform)
460///
461/// The `http::schema::Parameter` model carries NO OpenAPI `style` / `explode` /
462/// `type` hint, so there is no per-parameter serialization directive to honor;
463/// the rule must therefore be uniform across every path / query / header
464/// position:
465///
466/// - A scalar (`String`, `Number`, `Bool`, `Null`) renders to a bare string
467///   (`Null` → `"null"`, matching the `code_mode::HttpCodeExecutor::scalar_str`
468///   counterpart so the two HTTP surfaces stay consistent).
469/// - A query parameter that is an **array of scalars** is comma-joined by the
470///   caller ([`build_query`]); each member is rendered through this function so a
471///   nested non-scalar member is rejected.
472/// - An `Object`, an array containing any non-scalar member, or ANY non-scalar
473///   in path / header position is **rejected** with a typed error that names the
474///   parameter — it is NEVER JSON-stringified into the URL/header (which would
475///   leak literal `{`/`[`/`"` that then percent-encode into a silently-wrong
476///   request).
477///
478/// # Errors
479///
480/// Returns [`HttpConnectorError::Backend`] naming `param_name` when `value` is a
481/// non-scalar (`Object` or `Array`). Per the module's redaction discipline
482/// (Pitfall 5) the message names the PARAMETER ONLY — never the value.
483fn render_scalar(
484    param_name: &str,
485    value: &serde_json::Value,
486) -> Result<String, HttpConnectorError> {
487    match value {
488        serde_json::Value::String(s) => Ok(s.clone()),
489        serde_json::Value::Number(n) => Ok(n.to_string()),
490        serde_json::Value::Bool(b) => Ok(b.to_string()),
491        serde_json::Value::Null => Ok("null".to_string()),
492        // Object OR Array: non-scalar in a path/query/header position is rejected
493        // rather than silently JSON-stringified. Name the param ONLY (Pitfall 5).
494        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
495            Err(HttpConnectorError::Backend(format!(
496                "param '{param_name}' must be a scalar (non-scalar values are \
497                 not supported in path/query/header position)"
498            )))
499        },
500    }
501}
502
503// -----------------------------------------------------------------------------
504// Phase 128 D4 — the three checks `substitute_path` calls, each as a `cfg` PAIR.
505//
506// A written `cfg(not(...))` half rather than `#[cfg]` inside the function body:
507// the enforced and unenforced shapes are then both visible at a glance, and
508// `substitute_path` reads as one flow in either build. There is NO second copy of
509// any rule here — each enforced half calls the ONE core implementation in
510// `pmcp::server::schema_validation`, which is what keeps the curated surface and
511// the Code Mode surface on a single denylist (Q2).
512// -----------------------------------------------------------------------------
513
514/// Map a core placeholder refusal into this connector's error type.
515///
516/// `PlaceholderRefusal`'s own `Display` is value-free — it names the declared
517/// parameter and the declared expectation — so forwarding it verbatim keeps the two
518/// HTTP surfaces indistinguishable to a caller: they differ only in error TYPE
519/// (`HttpConnectorError::Backend` here, `ExecutionError::RuntimeError` on the Code
520/// Mode side), never in wording.
521#[cfg(feature = "input-validation")]
522fn refusal_to_backend_error(
523    refusal: &pmcp::server::schema_validation::PlaceholderRefusal,
524) -> HttpConnectorError {
525    HttpConnectorError::Backend(format!("{refusal}"))
526}
527
528/// Check ONE rendered path-parameter value against the D4 floor, the always-on
529/// cap, and this parameter's declared narrowing.
530///
531/// # Errors
532///
533/// [`HttpConnectorError::Backend`] carrying the core refusal, which names the
534/// parameter and never the value.
535#[cfg(feature = "input-validation")]
536fn check_placeholder_value(param: &Parameter, value_str: &str) -> Result<(), HttpConnectorError> {
537    pmcp::server::schema_validation::validate_path_placeholder(
538        &param.name,
539        value_str,
540        &param.placeholder_rules(),
541    )
542    .map_err(|refusal| refusal_to_backend_error(&refusal))
543}
544
545/// The `input-validation`-off half: the pre-Phase-128 behaviour, which applied no
546/// character check to a path-parameter value at all.
547#[cfg(not(feature = "input-validation"))]
548fn check_placeholder_value(_param: &Parameter, _value_str: &str) -> Result<(), HttpConnectorError> {
549    Ok(())
550}
551
552/// Check the COMPOSED path, exempting one operator-written `?`.
553///
554/// The narrowing itself lives in core as
555/// `pmcp::server::schema_validation::validate_resolved_target` — a SIBLING of
556/// `validate_resolved_path`, which keeps the strict `?`-anywhere rule for its
557/// other callers. It lives there rather than here because the Code Mode surface
558/// (`pmcp_code_mode::ResolvedPath::from_checked`) needs the identical rule, and
559/// the two previously held a copy each: the one part of the floor that could
560/// drift between them. Both sides of the first `?` face the full, unmodified rule
561/// set, so a second `?`, a fragment marker, traversal on either side, an over-cap
562/// query and an empty query portion all stay refused for free. See
563/// `HttpClient::substitute_path` for why exempting exactly that one byte is safe.
564///
565/// # Errors
566///
567/// [`HttpConnectorError::Backend`] carrying the core refusal, which names the rule
568/// and never any byte of the path.
569#[cfg(feature = "input-validation")]
570fn check_composed_path(path: &str) -> Result<(), HttpConnectorError> {
571    pmcp::server::schema_validation::validate_resolved_target(path)
572        .map_err(|refusal| refusal_to_backend_error(&refusal))
573}
574
575/// The `input-validation`-off half: no composed check, so a residual `{name}` from
576/// a path parameter with no supplied argument reaches the outbound URL exactly as
577/// it did before Phase 128.
578#[cfg(not(feature = "input-validation"))]
579fn check_composed_path(_path: &str) -> Result<(), HttpConnectorError> {
580    Ok(())
581}
582
583/// Refuse a declared path parameter that has no supplied argument.
584///
585/// Its own refusal rather than leaning on the composed check's residual-brace rule,
586/// for one reason: this is the only route that can name the PARAMETER. The composed
587/// refusal is param-agnostic by construction (`param: "path segment"`), because a
588/// composition belongs to no single parameter — so it would tell a caller that
589/// something in the path is wrong without saying which declaration to supply. This
590/// is a presence check, not a second copy of the character denylist.
591///
592/// D1's `required` keyword does not make this unreachable: it refuses only when the
593/// `[[tools.parameters]]` declaration says `required = true`, while
594/// `tools.rs::build_operation` marks every template path parameter required
595/// INDEPENDENTLY of any declaration — so the two can disagree, and measurably did.
596///
597/// # Errors
598///
599/// Always [`HttpConnectorError::Backend`], naming `param_name` and nothing else —
600/// never the template and never the partially-substituted path.
601#[cfg(feature = "input-validation")]
602fn refuse_missing_path_argument(param_name: &str) -> Result<(), HttpConnectorError> {
603    Err(HttpConnectorError::Backend(format!(
604        "param '{param_name}' is a declared path parameter and must be supplied"
605    )))
606}
607
608/// The `input-validation`-off half: the parameter is skipped, which is the
609/// pre-Phase-128 behaviour (the literal placeholder text stays in the path).
610#[cfg(not(feature = "input-validation"))]
611fn refuse_missing_path_argument(_param_name: &str) -> Result<(), HttpConnectorError> {
612    Ok(())
613}
614
615impl HttpClient {
616    /// Consult the registered E1 policy, if any, for one already-assembled
617    /// outbound request (Phase 128).
618    ///
619    /// Its own helper so the `execute` body keeps ONE added statement and stays
620    /// well under the cognitive-complexity 25 gate. Returns `Ok(())` immediately
621    /// when no policy is registered, which is the no-allocation empty case.
622    ///
623    /// The snapshot the policy sees is built HERE, AFTER that early return, so the
624    /// `policy == None` test exists exactly once. Building it at the call site
625    /// meant either paying for it on every request of every server — `query`'s keys
626    /// and values cloned, sorted, and the method uppercased, for the
627    /// overwhelmingly common no-policy case — or guarding the call site with a
628    /// SECOND copy of the same emptiness test, which then has to be kept in step
629    /// with this one. `query` is snapshotted SORTED so a policy sees a
630    /// deterministic order; the source is a `HashMap`.
631    ///
632    /// # Errors
633    ///
634    /// [`HttpConnectorError::PolicyRefused`] carrying the policy's OWN message.
635    async fn run_request_policy(
636        &self,
637        tool: &str,
638        method: &str,
639        path: &str,
640        query: &std::collections::HashMap<String, String>,
641        body: Option<&serde_json::Value>,
642    ) -> Result<(), HttpConnectorError> {
643        let Some(policy) = self.policy.as_ref() else {
644            return Ok(());
645        };
646        let mut sorted: Vec<(String, String)> =
647            query.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
648        sorted.sort();
649        let method = method.to_uppercase();
650        // A curated `tools/call` makes exactly ONE request, so a fresh id per request
651        // is the id per call. Minted here, after the no-policy early return, so a
652        // server with no policy pays nothing.
653        let call_id = crate::policy::next_call_id();
654        let req = crate::policy::OutboundRequest::new(tool, &method, path, &sorted, body)
655            .with_call_id(&call_id);
656        policy
657            .check(&req)
658            .await
659            .map_err(|refusal| HttpConnectorError::PolicyRefused(refusal.message().to_string()))
660    }
661
662    /// The shared `execute` body, carrying the MCP tool name (Phase 128 E1).
663    ///
664    /// [`HttpConnector::execute`] passes `""` (no tool to name) and
665    /// [`HttpConnector::execute_for_tool`] passes the synthesized tool's own
666    /// name, so there is ONE request path rather than two that can drift.
667    async fn execute_inner(
668        &self,
669        tool: &str,
670        operation: &Operation,
671        args: &serde_json::Value,
672    ) -> Result<serde_json::Value, HttpConnectorError> {
673        let empty = serde_json::Map::new();
674        let args_map = args.as_object().unwrap_or(&empty);
675
676        // Build URL via the shared join_url helper (explicit concat, never the
677        // url-crate RFC-3986 path merge) — preserves a stage prefix like /v1
678        // (Pitfall 2 / T-90-01-05).
679        let substituted = Self::substitute_path(operation, args_map)?;
680        let joined = join_url(self.base_url.as_str(), &substituted);
681        let mut url = url::Url::parse(&joined)
682            .map_err(|_| HttpConnectorError::Backend("constructed URL is invalid".to_string()))?;
683
684        let mut query = Self::build_query(operation, args_map)?;
685        let mut headers = Self::build_headers(operation, args_map)?;
686        let request_body = Self::build_body(operation, args_map);
687
688        // Phase 128 E1 / D-12 — the outbound-policy hook, and its position is
689        // load-bearing rather than incidental.
690        //
691        // It sits AFTER `join_url` because the policy must see the URL as it will
692        // be sent (resolved placeholders, stage prefix applied) rather than the
693        // `[[tools]]` template. It sits BEFORE `self.auth.apply` because that call
694        // is the first moment a credential exists in `headers` / `query`, and
695        // D-12's guarantee is that third-party policy code cannot observe one. A
696        // refusal therefore returns before auth AND before the send: nothing is
697        // authenticated and nothing leaves.
698        //
699        // The snapshot `query` becomes (sorted, so a policy sees a deterministic
700        // order) is built inside `run_request_policy`, AFTER its `policy == None`
701        // early return — so the no-policy case, which is the overwhelmingly common
702        // one, pays nothing here and the emptiness test is not duplicated at this
703        // call site. The snapshot carries no auth pair for the reason above: an
704        // API-key-in-query credential is contributed by the call below.
705        self.run_request_policy(
706            tool,
707            &operation.method,
708            &joined,
709            &query,
710            request_body.as_ref(),
711        )
712        .await?;
713
714        // Single-call tools have no per-request passthrough token (Plan 04/06 carry
715        // it through HttpCodeExecutor); pass None here.
716        self.auth.apply(&mut headers, &mut query, None).await?;
717
718        // Why: reqwest 0.13 gates `RequestBuilder::query` behind a `query` feature
719        // (verified in reqwest-0.13.2 request.rs:`#[cfg(feature = "query")]`). The
720        // toolkit deliberately does NOT enable that feature (Pitfall 4 / lean
721        // build), so query params are appended to the URL via `url`'s built-in,
722        // percent-encoding query-pair serializer instead.
723        if !query.is_empty() {
724            let mut pairs = url.query_pairs_mut();
725            for (key, value) in &query {
726                pairs.append_pair(key, value);
727            }
728            drop(pairs);
729        }
730
731        let method = Self::convert_method(&operation.method)?;
732        let mut request = self.client.request(method, url);
733        request = request.headers(headers);
734        if let Some(body) = request_body {
735            request = request.json(&body);
736        }
737
738        let response = self.send_with_retries(request).await?;
739        let status = response.status();
740        if !status.is_success() {
741            return Err(HttpConnectorError::Status {
742                status: status.as_u16(),
743            });
744        }
745        let body = response
746            .text()
747            .await
748            .map_err(|_| HttpConnectorError::Request("failed to read response body".to_string()))?;
749        if body.is_empty() {
750            return Ok(serde_json::Value::Null);
751        }
752        serde_json::from_str(&body).map_err(|_| {
753            HttpConnectorError::Backend("response body was not valid JSON".to_string())
754        })
755    }
756
757    /// A clone of this client carrying `policy`.
758    ///
759    /// Backs the [`HttpConnector::governed`] override — the route a policy takes
760    /// to a connector that has ALREADY been erased to `Arc<dyn HttpConnector>` by
761    /// the time the hooks value is in scope, which is exactly the situation
762    /// `pmcp-openapi-server`'s `build_server` is in.
763    fn cloned_with_policy(&self, policy: Arc<dyn crate::policy::RequestPolicy>) -> Self {
764        Self {
765            client: self.client.clone(),
766            base_url: self.base_url.clone(),
767            auth: Arc::clone(&self.auth),
768            http_config: self.http_config.clone(),
769            policy: Some(policy),
770        }
771    }
772}
773
774#[async_trait]
775impl HttpConnector for HttpClient {
776    async fn execute(
777        &self,
778        operation: &Operation,
779        args: &serde_json::Value,
780    ) -> Result<serde_json::Value, HttpConnectorError> {
781        // No tool to name: a caller driving the connector directly rather than
782        // through a synthesized handler.
783        self.execute_inner("", operation, args).await
784    }
785
786    async fn execute_for_tool(
787        &self,
788        tool: &str,
789        operation: &Operation,
790        args: &serde_json::Value,
791    ) -> Result<serde_json::Value, HttpConnectorError> {
792        self.execute_inner(tool, operation, args).await
793    }
794
795    fn has_request_policy(&self) -> bool {
796        self.policy.is_some()
797    }
798
799    fn governed(
800        &self,
801        policy: Arc<dyn crate::policy::RequestPolicy>,
802    ) -> Option<Arc<dyn HttpConnector>> {
803        Some(Arc::new(self.cloned_with_policy(policy)))
804    }
805
806    fn base_url(&self) -> &str {
807        self.base_url.as_str()
808    }
809}
810
811// -----------------------------------------------------------------------------
812// Phase 128 D4 test support + the two sibling test modules.
813//
814// `mod placeholder_floor` and `mod query_separator` are SIBLINGS of `mod tests`
815// at the `client` module level, not children of it. Both are still selected by
816// this plan's `--lib http::client` verify filter (a module prefix), and the names
817// mirror `pmcp-code-mode`'s `executor::query_separator` so the two surfaces'
818// boundary suites read alike. Being children of `client` is what gives them
819// access to the private `HttpClient::substitute_path`.
820// -----------------------------------------------------------------------------
821
822/// Fixtures shared by the two Phase 128 D4 test modules.
823#[cfg(all(test, feature = "input-validation"))]
824mod d4_support {
825    use super::{HttpClient, HttpConnectorError, Operation};
826    use crate::http::{Parameter, ParameterLocation};
827
828    /// A `GET` operation on `path` carrying `parameters`.
829    pub fn op(path: &str, parameters: Vec<Parameter>) -> Operation {
830        Operation {
831            method: "GET".to_string(),
832            path: path.to_string(),
833            parameters,
834            has_request_body: false,
835            base_url: None,
836        }
837    }
838
839    /// A required path parameter with no declared narrowing (floor + cap only).
840    pub fn path_param(name: &str) -> Parameter {
841        Parameter::new(name, ParameterLocation::Path, true)
842    }
843
844    /// Substitute `pairs` into `path`, treating every named key as a path
845    /// parameter with no declared narrowing.
846    pub fn substitute(
847        path: &str,
848        pairs: &[(&str, serde_json::Value)],
849    ) -> Result<String, HttpConnectorError> {
850        let parameters = pairs.iter().map(|(k, _)| path_param(k)).collect();
851        let mut args = serde_json::Map::new();
852        for (k, v) in pairs {
853            args.insert((*k).to_string(), v.clone());
854        }
855        HttpClient::substitute_path(&op(path, parameters), &args)
856    }
857
858    /// Substitute a single string `value` for `{name}` in `path`.
859    pub fn substitute_one(
860        path: &str,
861        name: &str,
862        value: &str,
863    ) -> Result<String, HttpConnectorError> {
864        substitute(
865            path,
866            &[(name, serde_json::Value::String(value.to_string()))],
867        )
868    }
869}
870
871/// The curated surface's D4 floor: every rendered placeholder value faces
872/// `validate_path_placeholder` before ANY substitution is applied, and the
873/// composed result faces `validate_resolved_target` before dispatch.
874#[cfg(all(test, feature = "input-validation"))]
875mod placeholder_floor {
876    use super::d4_support::{op, path_param, substitute, substitute_one};
877    use super::{HttpClient, HttpConnectorError};
878    use crate::http::{Parameter, ParameterLocation};
879    use pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH;
880
881    /// Assert a refusal names the parameter and carries no byte of the value and
882    /// no fragment of the resolved path.
883    fn assert_value_free(err: &HttpConnectorError, param: &str, value: &str, path_fragment: &str) {
884        assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
885        let rendered = err.to_string();
886        assert!(
887            rendered.contains(param),
888            "the refusal must name the declared parameter: {rendered}"
889        );
890        assert!(
891            !rendered.contains(value),
892            "the refusal must carry no byte of the value: {rendered}"
893        );
894        assert!(
895            !rendered.contains(path_fragment),
896            "the refusal must never contain the resolved path: {rendered}"
897        );
898    }
899
900    /// CR-01 row: a query separator inside a placeholder value.
901    #[test]
902    fn placeholder_floor_refuses_a_query_separator_in_a_value() {
903        let value = "current?string=x";
904        let err = substitute_one("/content/{version}/CUI", "version", value).unwrap_err();
905        assert_value_free(&err, "version", value, "/content/");
906    }
907
908    /// CR-01 row: parent-directory traversal inside a placeholder value.
909    #[test]
910    fn placeholder_floor_refuses_traversal_in_a_value() {
911        let value = "current/../../search/current";
912        let err = substitute_one("/content/{version}/CUI", "version", value).unwrap_err();
913        assert_value_free(&err, "version", value, "/content/");
914    }
915
916    /// Percent-encoded traversal in UPPER-case hex — the decode-once pass is what
917    /// has to catch it, not an enumerated denylist of spellings.
918    #[test]
919    fn placeholder_floor_refuses_upper_case_encoded_traversal() {
920        let err = substitute_one("/content/{version}/CUI", "version", "a%2E%2Eb").unwrap_err();
921        assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
922    }
923
924    /// Adjacency edge: a value that is EXACTLY a denied character. The check is a
925    /// character rule, not a substring-position heuristic.
926    #[test]
927    fn placeholder_floor_refuses_a_value_that_is_exactly_a_denied_character() {
928        let err = substitute_one("/content/{version}/CUI", "version", "?").unwrap_err();
929        assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
930    }
931
932    /// Encoding edge: a literal NUL byte, and separately its percent-encoded form.
933    #[test]
934    fn placeholder_floor_refuses_a_nul_byte_in_both_forms() {
935        assert!(substitute_one("/x/{v}", "v", "a\u{0}b").is_err());
936        assert!(substitute_one("/x/{v}", "v", "a%00b").is_err());
937    }
938
939    /// An empty value is refused — it would otherwise compose an empty segment.
940    #[test]
941    fn placeholder_floor_refuses_an_empty_value() {
942        assert!(substitute_one("/x/{v}", "v", "").is_err());
943    }
944
945    /// The always-on cap holds at exactly `PLACEHOLDER_MAX_LENGTH`.
946    #[test]
947    fn placeholder_floor_accepts_the_cap_and_refuses_one_more() {
948        let at_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH);
949        assert_eq!(
950            substitute_one("/x/{v}", "v", &at_cap).expect("at the cap"),
951            format!("/x/{at_cap}")
952        );
953        let over_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH + 1);
954        assert!(substitute_one("/x/{v}", "v", &over_cap).is_err());
955    }
956
957    /// A conforming value matching its DECLARED pattern is accepted and the path
958    /// is fully substituted.
959    #[test]
960    fn placeholder_floor_accepts_a_value_matching_its_declared_pattern() {
961        let parameters = vec![
962            Parameter::new("cui", ParameterLocation::Path, true).with_rules(
963                Some("^C[0-9]+$".to_string()),
964                Some(32),
965                false,
966            ),
967        ];
968        let mut args = serde_json::Map::new();
969        args.insert("cui".to_string(), serde_json::json!("C0018787"));
970        let resolved = HttpClient::substitute_path(&op("/CUI/{cui}/content", parameters), &args)
971            .expect("a conforming value must be accepted");
972        assert_eq!(resolved, "/CUI/C0018787/content");
973    }
974
975    /// D-10: the DECLARED pattern narrows on top of the floor.
976    #[test]
977    fn placeholder_floor_refuses_a_value_failing_its_declared_pattern() {
978        let parameters = vec![
979            Parameter::new("cui", ParameterLocation::Path, true).with_rules(
980                Some("^C[0-9]+$".to_string()),
981                None,
982                false,
983            ),
984        ];
985        let mut args = serde_json::Map::new();
986        args.insert("cui".to_string(), serde_json::json!("notacui"));
987        let err = HttpClient::substitute_path(&op("/CUI/{cui}", parameters), &args).unwrap_err();
988        assert!(err.to_string().contains("cui"), "{err}");
989        assert!(!err.to_string().contains("notacui"), "{err}");
990    }
991
992    /// Empty edge: a template with NO placeholders is returned unchanged and gains
993    /// zero new refusals. The composed check still runs, and passes.
994    #[test]
995    fn placeholder_floor_leaves_a_placeholder_free_template_untouched() {
996        let resolved = HttpClient::substitute_path(
997            &op("/Line/Mode/tube/Status", vec![]),
998            &serde_json::Map::new(),
999        )
1000        .expect("a placeholder-free template must be unaffected");
1001        assert_eq!(resolved, "/Line/Mode/tube/Status");
1002    }
1003
1004    /// Phase 128 CR-01 — a root operation is callable on the curated surface.
1005    ///
1006    /// `substitute_path` calls `check_composed_path` UNCONDITIONALLY, so before the
1007    /// core fix a spec declaring `paths: { "/": { get: … } }` — a health or index
1008    /// endpoint — failed every `tools/call` with `param 'path segment' must not be
1009    /// empty`. This is the caller-side row for the core exemption; the trailing-slash
1010    /// refusal it must not re-open is asserted immediately below.
1011    #[test]
1012    fn placeholder_floor_accepts_the_root_path_and_still_refuses_a_trailing_slash() {
1013        let resolved = HttpClient::substitute_path(&op("/", vec![]), &serde_json::Map::new())
1014            .expect("a `GET /` operation must be callable — the root is the shortest legal path");
1015        assert_eq!(resolved, "/");
1016
1017        // An empty tail placeholder composes to a trailing `/`, which stays refused.
1018        let err = substitute_one("/search/{v}", "v", "")
1019            .expect_err("an empty tail placeholder must stay refused");
1020        assert!(
1021            matches!(err, HttpConnectorError::Backend(_)),
1022            "the refusal is a Backend error naming the position: {err}"
1023        );
1024        assert!(
1025            HttpClient::substitute_path(&op("/search/", vec![]), &serde_json::Map::new()).is_err(),
1026            "a literal trailing slash in the template stays refused by decision"
1027        );
1028    }
1029
1030    /// A refusal on the SECOND of two placeholders aborts with no
1031    /// partially-substituted path in existence — the first value is rendered and
1032    /// checked but nothing is applied until every value has passed.
1033    #[test]
1034    fn placeholder_floor_refuses_the_second_of_two_placeholders_without_substituting() {
1035        let err = substitute(
1036            "/a/{first}/b/{second}",
1037            &[
1038                ("first", serde_json::json!("ok")),
1039                ("second", serde_json::json!("../escape")),
1040            ],
1041        )
1042        .unwrap_err();
1043        let rendered = err.to_string();
1044        assert!(rendered.contains("second"), "{rendered}");
1045        assert!(
1046            !rendered.contains("/a/ok/b/"),
1047            "no partially-substituted path may appear anywhere: {rendered}"
1048        );
1049    }
1050
1051    /// A path parameter ABSENT from `args` is refused, naming the parameter only —
1052    /// rather than leaving the literal `{name}` in the outbound URL.
1053    #[test]
1054    fn placeholder_floor_refuses_an_absent_path_argument() {
1055        let err = HttpClient::substitute_path(
1056            &op("/users/{id}/profile", vec![path_param("id")]),
1057            &serde_json::Map::new(),
1058        )
1059        .unwrap_err();
1060        let rendered = err.to_string();
1061        assert!(rendered.contains("id"), "{rendered}");
1062        assert!(
1063            !rendered.contains('{') && !rendered.contains('}'),
1064            "the refusal must not echo the template: {rendered}"
1065        );
1066        assert!(
1067            !rendered.contains("/users/"),
1068            "the refusal must not echo the path: {rendered}"
1069        );
1070    }
1071
1072    /// The COMPOSED check is the only mechanism that can see this: a template
1073    /// literal prefix plus a value that each pass on their own compose a segment
1074    /// over the cap. Spec-derived operations carry mid-segment placeholders, so
1075    /// this shape is reachable without any curated config.
1076    #[test]
1077    fn placeholder_floor_refuses_a_composed_segment_over_the_cap() {
1078        let prefix = "p".repeat(100);
1079        let value = "v".repeat(200);
1080        let err = substitute_one(&format!("/x/{prefix}{{id}}"), "id", &value).unwrap_err();
1081        assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1082    }
1083
1084    /// The composed check also refuses a residual `{`/`}` arriving from a template
1085    /// the curated config parser would not recognize — the spec-derived route that
1086    /// config validation cannot reach.
1087    #[test]
1088    fn placeholder_floor_refuses_a_residual_brace_from_an_unrecognized_template() {
1089        let err = HttpClient::substitute_path(&op("/x/{a}/y/{b}", vec![path_param("a")]), &{
1090            let mut args = serde_json::Map::new();
1091            args.insert("a".to_string(), serde_json::json!("ok"));
1092            args
1093        })
1094        .unwrap_err();
1095        assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1096    }
1097
1098    /// A template literal carrying traversal is refused by the composed check
1099    /// alone: no per-value check ever sees a literal, so this row isolates the
1100    /// composed mechanism.
1101    #[test]
1102    fn placeholder_floor_refuses_traversal_written_into_the_template_literal() {
1103        let err = HttpClient::substitute_path(&op("/a/../b", vec![]), &serde_json::Map::new())
1104            .unwrap_err();
1105        assert!(matches!(err, HttpConnectorError::Backend(_)), "{err}");
1106    }
1107}
1108
1109/// The `?` narrowing inherited from plan 05, mirrored on the CURATED surface and
1110/// pinned in BOTH directions.
1111///
1112/// A `[[tools]]` `path` is operator-authored configuration, exactly as a Code Mode
1113/// script's literal path text is operator-authored script text — so the same
1114/// asymmetry applies: refusing `..` from it catches a traversal bug, while refusing
1115/// `?` from it rejects legitimate authoring. `substitute_path` therefore splits the
1116/// composed path at the FIRST `?` and applies the full, unmodified rule set to each
1117/// side. Nine of the twelve rows below assert what did NOT change, because a
1118/// narrowing pinned only by accept-rows is indistinguishable from a deleted check.
1119#[cfg(all(test, feature = "input-validation"))]
1120mod query_separator {
1121    use super::d4_support::substitute_one;
1122    use super::{HttpClient, Operation};
1123    use pmcp::server::schema_validation::PLACEHOLDER_MAX_LENGTH;
1124
1125    /// Substitute nothing — a placeholder-free template, checked as composed.
1126    fn literal(path: &str) -> Result<String, super::HttpConnectorError> {
1127        HttpClient::substitute_path(
1128            &Operation {
1129                method: "GET".to_string(),
1130                path: path.to_string(),
1131                parameters: vec![],
1132                has_request_body: false,
1133                base_url: None,
1134            },
1135            &serde_json::Map::new(),
1136        )
1137    }
1138
1139    // ---- ACCEPTED: the separator an operator wrote into the config ----
1140
1141    /// The row the narrowing exists for: a `?` in a curated `[[tools]]` path.
1142    #[test]
1143    fn query_separator_accepts_an_author_written_query_string() {
1144        assert_eq!(
1145            literal("/Line/Mode/tube/Status?detail=true").expect("author query accepted"),
1146            "/Line/Mode/tube/Status?detail=true"
1147        );
1148    }
1149
1150    /// The change-request's own curated shape: an author query alongside a floored
1151    /// placeholder. Both mechanisms coexist on one path.
1152    #[test]
1153    fn query_separator_accepts_a_literal_query_alongside_a_floored_placeholder() {
1154        assert_eq!(
1155            substitute_one("/content/{version}/CUI?string=x", "version", "current")
1156                .expect("author query plus conforming placeholder accepted"),
1157            "/content/current/CUI?string=x"
1158        );
1159    }
1160
1161    /// The Graph-style `$select` projection shape in-tree consumers author.
1162    #[test]
1163    fn query_separator_accepts_a_graph_style_dollar_projection() {
1164        let resolved = literal(
1165            "/drives/D/items/I/workbook/worksheets/C/range(address='A2:D7')?$select=values",
1166        )
1167        .expect("a Graph $select projection must be accepted");
1168        assert!(resolved.ends_with("?$select=values"), "{resolved}");
1169    }
1170
1171    // ---- STILL REFUSED: everything the split does not relax ----
1172
1173    #[test]
1174    fn query_separator_still_refuses_traversal_in_the_path_portion() {
1175        assert!(
1176            literal("/a/../b?x=1").is_err(),
1177            "appending a query must not launder a traversal"
1178        );
1179    }
1180
1181    #[test]
1182    fn query_separator_still_refuses_traversal_in_the_query_portion() {
1183        let err = literal("/search?next=../../etc/passwd").unwrap_err();
1184        assert!(!err.to_string().contains("passwd"), "{err}");
1185    }
1186
1187    #[test]
1188    fn query_separator_still_refuses_a_control_byte_in_the_query_portion() {
1189        assert!(literal("/search?x=a%00b").is_err());
1190    }
1191
1192    #[test]
1193    fn query_separator_still_refuses_an_over_cap_query_portion() {
1194        let long = "z".repeat(PLACEHOLDER_MAX_LENGTH + 1);
1195        assert!(literal(&format!("/search?q={long}")).is_err());
1196    }
1197
1198    #[test]
1199    fn query_separator_still_refuses_a_second_question_mark() {
1200        assert!(
1201            literal("/search?a=1?b=2").is_err(),
1202            "only the FIRST `?` is split off; one exemption, not a licence"
1203        );
1204    }
1205
1206    #[test]
1207    fn query_separator_still_refuses_an_empty_query_portion() {
1208        assert!(
1209            literal("/search?").is_err(),
1210            "a dangling `?` is the same class as a trailing `/`"
1211        );
1212    }
1213
1214    #[test]
1215    fn query_separator_still_refuses_a_fragment_marker() {
1216        assert!(literal("/search#frag").is_err());
1217    }
1218
1219    /// THE row that proves the narrowing is not a hole: the template carries an
1220    /// author-written `?` (legal) AND a placeholder value carries an injected one
1221    /// (still refused by the per-value floor, which is the mechanism the narrowing
1222    /// relies on for its safety argument).
1223    #[test]
1224    fn query_separator_still_refuses_an_injected_separator_from_a_value() {
1225        let payload = "2026AA?string=x";
1226        let err = substitute_one("/search/{v}?detail=true", "v", payload).unwrap_err();
1227        let rendered = err.to_string();
1228        assert!(rendered.contains('v'), "{rendered}");
1229        assert!(
1230            !rendered.contains("2026AA") && !rendered.contains('?'),
1231            "the refusal must carry no byte of the value: {rendered}"
1232        );
1233    }
1234
1235    /// The second value route: an injected TRAVERSAL alongside an author query.
1236    #[test]
1237    fn query_separator_still_refuses_an_injected_traversal_from_a_value() {
1238        assert!(substitute_one("/search/{v}?detail=true", "v", "../../etc/passwd").is_err());
1239    }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244    use super::*;
1245    use crate::http::auth::NoAuth;
1246    use crate::http::{Parameter, ParameterLocation};
1247
1248    fn get_user_op() -> Operation {
1249        Operation {
1250            method: "GET".to_string(),
1251            path: "/users/{id}".to_string(),
1252            parameters: vec![
1253                Parameter::new("id", ParameterLocation::Path, true),
1254                Parameter::new("verbose", ParameterLocation::Query, false),
1255            ],
1256            has_request_body: false,
1257            base_url: None,
1258        }
1259    }
1260
1261    #[test]
1262    fn test_build_url_with_path_prefix() {
1263        // Regression: an API-Gateway stage prefix /v1 survives via join_url.
1264        let client = HttpClient::new(
1265            reqwest::Client::new(),
1266            "https://xxx.execute-api.eu-west-1.amazonaws.com/v1/".to_string(),
1267            Arc::new(NoAuth),
1268        )
1269        .unwrap();
1270        let op = get_user_op();
1271        let mut args = serde_json::Map::new();
1272        args.insert("id".to_string(), serde_json::json!("42"));
1273        let substituted = HttpClient::substitute_path(&op, &args).unwrap();
1274        let joined = join_url(client.base_url(), &substituted);
1275        assert_eq!(
1276            joined,
1277            "https://xxx.execute-api.eu-west-1.amazonaws.com/v1/users/42"
1278        );
1279    }
1280
1281    #[test]
1282    fn test_substitute_path_replaces_placeholder() {
1283        let op = get_user_op();
1284        let mut args = serde_json::Map::new();
1285        args.insert("id".to_string(), serde_json::json!(7));
1286        assert_eq!(HttpClient::substitute_path(&op, &args).unwrap(), "/users/7");
1287    }
1288
1289    #[test]
1290    fn test_build_query_skips_path_params() {
1291        let op = get_user_op();
1292        let mut args = serde_json::Map::new();
1293        args.insert("id".to_string(), serde_json::json!("42"));
1294        args.insert("verbose".to_string(), serde_json::json!(true));
1295        let query = HttpClient::build_query(&op, &args).unwrap();
1296        assert_eq!(query.get("verbose"), Some(&"true".to_string()));
1297        assert!(!query.contains_key("id"));
1298    }
1299
1300    // -- WR-03 / GAP 4: fallible scalar renderer (reject non-scalar params) -----
1301
1302    /// An array-of-scalars query param comma-joins (unchanged OpenAPI
1303    /// `form`/`explode:false` behavior).
1304    #[test]
1305    fn render_query_value_comma_joins_scalar_array() {
1306        let rendered =
1307            HttpClient::render_query_value("tags", &serde_json::json!(["a", 2, true])).unwrap();
1308        assert_eq!(rendered, "a,2,true");
1309    }
1310
1311    /// A scalar query param renders bare (unchanged).
1312    #[test]
1313    fn render_query_value_scalar_passthrough() {
1314        assert_eq!(
1315            HttpClient::render_query_value("q", &serde_json::json!("hi")).unwrap(),
1316            "hi"
1317        );
1318        assert_eq!(
1319            HttpClient::render_query_value("n", &serde_json::json!(7)).unwrap(),
1320            "7"
1321        );
1322    }
1323
1324    /// `render_scalar` renders Null as the bare string `"null"` (matches the
1325    /// code_mode `scalar_str` counterpart).
1326    #[test]
1327    fn render_scalar_null_is_bare_null() {
1328        assert_eq!(
1329            render_scalar("x", &serde_json::Value::Null).unwrap(),
1330            "null"
1331        );
1332    }
1333
1334    /// An OBJECT path param is rejected, naming the param; the error never echoes
1335    /// the value and never produces a JSON-stringified `{`/`[`/`"`.
1336    #[test]
1337    fn substitute_path_rejects_object_param() {
1338        let op = get_user_op();
1339        let mut args = serde_json::Map::new();
1340        args.insert("id".to_string(), serde_json::json!({"nested": "x"}));
1341        let err = HttpClient::substitute_path(&op, &args).unwrap_err();
1342        assert!(matches!(err, HttpConnectorError::Backend(_)));
1343        let rendered = err.to_string();
1344        assert!(
1345            rendered.contains("id"),
1346            "error must name the param: {rendered}"
1347        );
1348        for forbidden in ['{', '[', '"'] {
1349            assert!(
1350                !rendered.contains(forbidden),
1351                "must not echo JSON: {rendered}"
1352            );
1353        }
1354        // Pitfall 5: never echo the value.
1355        assert!(
1356            !rendered.contains("nested"),
1357            "must not echo the value: {rendered}"
1358        );
1359    }
1360
1361    /// An OBJECT query param is rejected, naming the param.
1362    #[test]
1363    fn build_query_rejects_object_param() {
1364        let op = get_user_op();
1365        let mut args = serde_json::Map::new();
1366        args.insert("verbose".to_string(), serde_json::json!({"k": "v"}));
1367        let err = HttpClient::build_query(&op, &args).unwrap_err();
1368        assert!(matches!(err, HttpConnectorError::Backend(_)));
1369        assert!(err.to_string().contains("verbose"));
1370    }
1371
1372    /// An array CONTAINING a non-scalar member is rejected (the scalar comma-join
1373    /// is preserved only for scalar-only arrays).
1374    #[test]
1375    fn render_query_value_rejects_array_with_object_member() {
1376        let err = HttpClient::render_query_value("tags", &serde_json::json!(["ok", {"bad": 1}]))
1377            .unwrap_err();
1378        assert!(matches!(err, HttpConnectorError::Backend(_)));
1379        assert!(err.to_string().contains("tags"));
1380    }
1381
1382    /// A non-scalar HEADER param is rejected, naming the param.
1383    #[test]
1384    fn build_headers_rejects_non_scalar_param() {
1385        let op = Operation {
1386            method: "GET".to_string(),
1387            path: "/x".to_string(),
1388            parameters: vec![Parameter::new("x-trace", ParameterLocation::Header, false)],
1389            has_request_body: false,
1390            base_url: None,
1391        };
1392        // An ARRAY in header position is non-scalar (arrays comma-join ONLY in
1393        // query position) and is rejected.
1394        let mut args = serde_json::Map::new();
1395        args.insert("x-trace".to_string(), serde_json::json!(["a", "b"]));
1396        let err = HttpClient::build_headers(&op, &args).unwrap_err();
1397        assert!(matches!(err, HttpConnectorError::Backend(_)));
1398        assert!(err.to_string().contains("x-trace"));
1399        // An OBJECT in header position is likewise rejected.
1400        let mut args2 = serde_json::Map::new();
1401        args2.insert("x-trace".to_string(), serde_json::json!({"k": "v"}));
1402        let err2 = HttpClient::build_headers(&op, &args2).unwrap_err();
1403        assert!(matches!(err2, HttpConnectorError::Backend(_)));
1404        assert!(err2.to_string().contains("x-trace"));
1405        // A scalar header value still succeeds.
1406        let mut args3 = serde_json::Map::new();
1407        args3.insert("x-trace".to_string(), serde_json::json!("abc"));
1408        let headers = HttpClient::build_headers(&op, &args3).unwrap();
1409        assert_eq!(headers.get("x-trace").unwrap(), "abc");
1410    }
1411
1412    #[test]
1413    fn test_new_is_lazy_and_rejects_bad_url() {
1414        // Lazy: a bad URL fails synchronously without any network (CF-2).
1415        let err = HttpClient::new(
1416            reqwest::Client::new(),
1417            "not a url".to_string(),
1418            Arc::new(NoAuth),
1419        )
1420        .err()
1421        .expect("bad URL should error");
1422        assert!(matches!(err, HttpConnectorError::Backend(_)));
1423        let rendered = err.to_string();
1424        assert!(!rendered.contains("not a url"), "must not echo the bad URL");
1425    }
1426
1427    #[tokio::test]
1428    async fn http_connector_get_returns_json() {
1429        use wiremock::matchers::{method, path};
1430        use wiremock::{Mock, MockServer, ResponseTemplate};
1431
1432        let server = MockServer::start().await;
1433        Mock::given(method("GET"))
1434            .and(path("/users/42"))
1435            .respond_with(
1436                ResponseTemplate::new(200)
1437                    .set_body_json(serde_json::json!({"id": 42, "name": "Ada"})),
1438            )
1439            .mount(&server)
1440            .await;
1441
1442        let client =
1443            HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1444        let op = get_user_op();
1445        let args = serde_json::json!({"id": "42"});
1446        let result = client.execute(&op, &args).await.unwrap();
1447        assert_eq!(result["id"], 42);
1448        assert_eq!(result["name"], "Ada");
1449    }
1450
1451    #[tokio::test]
1452    async fn http_connector_post_sends_body_and_auth() {
1453        use wiremock::matchers::{body_json, header, method, path};
1454        use wiremock::{Mock, MockServer, ResponseTemplate};
1455
1456        let server = MockServer::start().await;
1457        Mock::given(method("POST"))
1458            .and(path("/items"))
1459            .and(header("authorization", "Bearer tok"))
1460            .and(body_json(serde_json::json!({"name": "widget"})))
1461            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"ok": true})))
1462            .mount(&server)
1463            .await;
1464
1465        let auth = crate::http::auth::create_auth_provider(&crate::http::AuthConfig::Bearer {
1466            token: "tok".to_string(),
1467            required: true,
1468        })
1469        .unwrap();
1470        let client = HttpClient::new(reqwest::Client::new(), server.uri(), auth).unwrap();
1471        let op = Operation {
1472            method: "POST".to_string(),
1473            path: "/items".to_string(),
1474            parameters: vec![],
1475            has_request_body: true,
1476            base_url: None,
1477        };
1478        let args = serde_json::json!({"name": "widget"});
1479        let result = client.execute(&op, &args).await.unwrap();
1480        assert_eq!(result["ok"], true);
1481    }
1482
1483    /// Phase 128 CR-02 — a DECLARED `Body`-located parameter reaches the JSON
1484    /// payload, and does NOT also reach the query string.
1485    ///
1486    /// The row above proves only the UNDECLARED route (`parameters: vec![]`), which
1487    /// is the route `additionalProperties: false` closed. This one declares the
1488    /// parameters, which is the shape `build_operation` actually synthesizes.
1489    #[tokio::test]
1490    async fn http_connector_post_sends_declared_body_parameters_as_the_payload() {
1491        use wiremock::matchers::{body_json, method, path, query_param_is_missing};
1492        use wiremock::{Mock, MockServer, ResponseTemplate};
1493
1494        let server = MockServer::start().await;
1495        Mock::given(method("POST"))
1496            .and(path("/items"))
1497            .and(body_json(
1498                serde_json::json!({"name": "widget", "note": "free text"}),
1499            ))
1500            // The value must travel ONCE. Before CR-02 a declared parameter was
1501            // `Query`-located, so it was appended here instead.
1502            .and(query_param_is_missing("name"))
1503            .and(query_param_is_missing("note"))
1504            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"ok": true})))
1505            .mount(&server)
1506            .await;
1507
1508        let client =
1509            HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1510        let op = Operation {
1511            method: "POST".to_string(),
1512            path: "/items".to_string(),
1513            parameters: vec![
1514                Parameter::new("name", ParameterLocation::Body, true),
1515                Parameter::new("note", ParameterLocation::Body, false),
1516            ],
1517            has_request_body: true,
1518            base_url: None,
1519        };
1520        let args = serde_json::json!({"name": "widget", "note": "free text"});
1521        let result = client.execute(&op, &args).await.unwrap();
1522        assert_eq!(result["ok"], true);
1523    }
1524
1525    /// Phase 128 CR-02 — a `Query`-located parameter on a body-bearing method is
1526    /// still a query parameter and is still withheld from the payload, so the fix
1527    /// is a ROUTING change rather than "everything goes in the body now".
1528    #[test]
1529    fn build_body_withholds_a_query_located_parameter_on_a_post() {
1530        let op = Operation {
1531            method: "POST".to_string(),
1532            path: "/items".to_string(),
1533            parameters: vec![
1534                Parameter::new("dry_run", ParameterLocation::Query, false),
1535                Parameter::new("name", ParameterLocation::Body, true),
1536            ],
1537            has_request_body: true,
1538            base_url: None,
1539        };
1540        let args = serde_json::json!({"dry_run": "true", "name": "widget"})
1541            .as_object()
1542            .expect("object")
1543            .clone();
1544        let body = HttpClient::build_body(&op, &args).expect("a body is built");
1545        assert_eq!(body, serde_json::json!({"name": "widget"}));
1546        let query = HttpClient::build_query(&op, &args).expect("a query is built");
1547        assert_eq!(query.get("dry_run").map(String::as_str), Some("true"));
1548        assert!(
1549            !query.contains_key("name"),
1550            "a Body-located parameter must not reach the query string: {query:?}"
1551        );
1552    }
1553
1554    #[tokio::test]
1555    async fn http_connector_maps_non_2xx_to_status_without_url() {
1556        use wiremock::matchers::{method, path};
1557        use wiremock::{Mock, MockServer, ResponseTemplate};
1558
1559        let server = MockServer::start().await;
1560        Mock::given(method("GET"))
1561            .and(path("/users/42"))
1562            .respond_with(ResponseTemplate::new(404))
1563            .mount(&server)
1564            .await;
1565
1566        let client =
1567            HttpClient::new(reqwest::Client::new(), server.uri(), Arc::new(NoAuth)).unwrap();
1568        let op = get_user_op();
1569        let args = serde_json::json!({"id": "42"});
1570        let err = client.execute(&op, &args).await.unwrap_err();
1571        assert!(matches!(err, HttpConnectorError::Status { status: 404 }));
1572        let rendered = err.to_string();
1573        assert!(rendered.contains("404"));
1574        assert!(
1575            !rendered.contains("http://"),
1576            "status error must not echo the URL"
1577        );
1578    }
1579}
1580
1581// -----------------------------------------------------------------------------
1582// Phase 128 E1 — the curated surface's outbound-policy seam.
1583//
1584// A SIBLING of `mod tests` at the `client` module level, like `placeholder_floor`
1585// and `query_separator` above, so the `--lib http::client` filter selects it while
1586// it keeps access to the private `HttpClient` internals.
1587// -----------------------------------------------------------------------------
1588
1589/// The E1 hook on the curated single-call surface: it must run before auth and
1590/// before the send.
1591#[cfg(test)]
1592mod request_policy_seam {
1593    use super::{HttpClient, HttpConfig, HttpConnectorError};
1594    use crate::http::auth::HttpAuthProvider;
1595    use crate::http::{HttpConnector, Operation, Parameter, ParameterLocation};
1596    use crate::policy::{OutboundRequest, PolicyRefusal, RequestPolicy};
1597    use async_trait::async_trait;
1598    use reqwest::header::{HeaderMap, HeaderValue};
1599    use std::collections::HashMap;
1600    use std::sync::atomic::{AtomicUsize, Ordering};
1601    use std::sync::{Arc, Mutex};
1602
1603    /// An auth provider that RECORDS whether it was invoked, so a refusal test can
1604    /// prove the policy ran BEFORE auth rather than merely before the send.
1605    struct RecordingAuth {
1606        calls: Arc<AtomicUsize>,
1607    }
1608
1609    #[async_trait]
1610    impl HttpAuthProvider for RecordingAuth {
1611        async fn apply(
1612            &self,
1613            headers: &mut HeaderMap,
1614            _query: &mut HashMap<String, String>,
1615            _inbound_token: Option<&str>,
1616        ) -> Result<(), HttpConnectorError> {
1617            self.calls.fetch_add(1, Ordering::SeqCst);
1618            headers.insert("authorization", HeaderValue::from_static("Bearer tok"));
1619            Ok(())
1620        }
1621    }
1622
1623    /// One recorded `OutboundRequest`: `(tool, path, query, body)`.
1624    type Seen = Arc<Mutex<Vec<(String, String, Vec<(String, String)>, Option<String>)>>>;
1625
1626    /// Records every request it is shown, then allows or refuses.
1627    struct Recorder {
1628        seen: Seen,
1629        refuse: Option<&'static str>,
1630    }
1631
1632    #[async_trait]
1633    impl RequestPolicy for Recorder {
1634        async fn check(&self, req: &OutboundRequest<'_>) -> Result<(), PolicyRefusal> {
1635            self.seen.lock().expect("lock").push((
1636                req.tool.to_string(),
1637                req.path.to_string(),
1638                req.query.to_vec(),
1639                req.body.map(ToString::to_string),
1640            ));
1641            match self.refuse {
1642                Some(msg) => Err(PolicyRefusal::new(msg)),
1643                None => Ok(()),
1644            }
1645        }
1646    }
1647
1648    fn op() -> Operation {
1649        Operation {
1650            method: "GET".to_string(),
1651            path: "/users/{id}".to_string(),
1652            parameters: vec![
1653                Parameter::new("id", ParameterLocation::Path, true),
1654                Parameter::new("q", ParameterLocation::Query, false),
1655            ],
1656            has_request_body: false,
1657            base_url: None,
1658        }
1659    }
1660
1661    /// A client over `base_url` carrying `policy`, a recording auth provider, and
1662    /// ZERO retries (so a refusal test cannot be confused by a retry loop).
1663    fn client(
1664        base_url: String,
1665        policy: Option<Arc<dyn RequestPolicy>>,
1666    ) -> (HttpClient, Arc<AtomicUsize>) {
1667        let calls = Arc::new(AtomicUsize::new(0));
1668        let auth = Arc::new(RecordingAuth {
1669            calls: Arc::clone(&calls),
1670        });
1671        let cfg = HttpConfig {
1672            retries: 0,
1673            ..HttpConfig::default()
1674        };
1675        let c = HttpClient::with_config(reqwest::Client::new(), base_url, auth, cfg)
1676            .expect("client builds");
1677        let c = match policy {
1678            Some(p) => c.with_request_policy(p),
1679            None => c,
1680        };
1681        (c, calls)
1682    }
1683
1684    fn recorder(refuse: Option<&'static str>) -> (Arc<Recorder>, Seen) {
1685        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1686        (
1687            Arc::new(Recorder {
1688                seen: Arc::clone(&seen),
1689                refuse,
1690            }),
1691            seen,
1692        )
1693    }
1694
1695    #[tokio::test]
1696    async fn a_refusing_policy_stops_the_request_before_auth_and_before_the_send() {
1697        use wiremock::MockServer;
1698        // No mock is mounted: any request that escapes the policy 404s, so a
1699        // false GREEN here cannot masquerade as success.
1700        let server = MockServer::start().await;
1701        let (policy, _seen) = recorder(Some("refused by test policy"));
1702        let (client, auth_calls) = client(server.uri(), Some(policy));
1703
1704        let err = client
1705            .execute(&op(), &serde_json::json!({ "id": "42" }))
1706            .await
1707            .expect_err("the policy refuses");
1708
1709        assert_eq!(
1710            err.to_string(),
1711            "outbound request refused by policy: refused by test policy",
1712            "the refusal must carry the policy's own message"
1713        );
1714        assert_eq!(
1715            auth_calls.load(Ordering::SeqCst),
1716            0,
1717            "the auth provider must NOT have been invoked — the hook is before auth"
1718        );
1719        let requests = server
1720            .received_requests()
1721            .await
1722            .expect("wiremock records requests");
1723        assert!(requests.is_empty(), "a refusal must send nothing");
1724    }
1725
1726    #[tokio::test]
1727    async fn the_same_refused_call_twice_is_identical_and_sends_nothing() {
1728        use wiremock::MockServer;
1729        let server = MockServer::start().await;
1730        let (policy, _seen) = recorder(Some("refused by test policy"));
1731        let (client, _auth) = client(server.uri(), Some(policy));
1732
1733        let first = client
1734            .execute(&op(), &serde_json::json!({ "id": "42" }))
1735            .await
1736            .expect_err("refuses");
1737        let second = client
1738            .execute(&op(), &serde_json::json!({ "id": "42" }))
1739            .await
1740            .expect_err("refuses again");
1741        assert_eq!(first.to_string(), second.to_string());
1742        assert!(server
1743            .received_requests()
1744            .await
1745            .expect("recorded")
1746            .is_empty());
1747    }
1748
1749    #[tokio::test]
1750    async fn an_allowing_policy_lets_the_request_through_and_auth_is_applied() {
1751        use wiremock::matchers::{header, method, path};
1752        use wiremock::{Mock, MockServer, ResponseTemplate};
1753
1754        let server = MockServer::start().await;
1755        Mock::given(method("GET"))
1756            .and(path("/users/42"))
1757            .and(header("authorization", "Bearer tok"))
1758            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1759            .mount(&server)
1760            .await;
1761
1762        let (policy, _seen) = recorder(None);
1763        let (client, auth_calls) = client(server.uri(), Some(policy));
1764        let out = client
1765            .execute(&op(), &serde_json::json!({ "id": "42" }))
1766            .await
1767            .expect("allowed");
1768        assert_eq!(out["ok"], true);
1769        assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
1770        assert_eq!(server.received_requests().await.expect("recorded").len(), 1);
1771    }
1772
1773    #[tokio::test]
1774    async fn the_policy_sees_the_resolved_path_and_the_query_pairs() {
1775        use wiremock::matchers::{method, path};
1776        use wiremock::{Mock, MockServer, ResponseTemplate};
1777
1778        let server = MockServer::start().await;
1779        Mock::given(method("GET"))
1780            .and(path("/users/42"))
1781            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1782            .mount(&server)
1783            .await;
1784
1785        let (policy, seen) = recorder(None);
1786        let (client, _auth) = client(server.uri(), Some(policy));
1787        client
1788            .execute(&op(), &serde_json::json!({ "id": "42", "q": "hay" }))
1789            .await
1790            .expect("allowed");
1791
1792        let seen = seen.lock().expect("lock");
1793        assert_eq!(seen.len(), 1, "exactly one invocation per logical request");
1794        let (_tool, observed_path, query, body) = &seen[0];
1795        assert!(
1796            observed_path.ends_with("/users/42"),
1797            "the policy must see the SUBSTITUTED path, got {observed_path}"
1798        );
1799        assert!(
1800            !observed_path.contains('{'),
1801            "the policy must never see the template"
1802        );
1803        assert_eq!(query.as_slice(), &[("q".to_string(), "hay".to_string())]);
1804        assert!(body.is_none(), "a GET carries no body");
1805    }
1806
1807    #[tokio::test]
1808    async fn no_policy_behaves_exactly_as_before() {
1809        use wiremock::matchers::{method, path};
1810        use wiremock::{Mock, MockServer, ResponseTemplate};
1811
1812        let server = MockServer::start().await;
1813        Mock::given(method("GET"))
1814            .and(path("/users/42"))
1815            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1816            .mount(&server)
1817            .await;
1818
1819        let (client, auth_calls) = client(server.uri(), None);
1820        assert!(!client.has_request_policy());
1821        let out = client
1822            .execute(&op(), &serde_json::json!({ "id": "42" }))
1823            .await
1824            .expect("succeeds");
1825        assert_eq!(out["ok"], true);
1826        assert_eq!(auth_calls.load(Ordering::SeqCst), 1);
1827    }
1828
1829    #[tokio::test]
1830    async fn the_policy_is_told_which_tool_the_call_came_from() {
1831        use wiremock::matchers::{method, path};
1832        use wiremock::{Mock, MockServer, ResponseTemplate};
1833
1834        let server = MockServer::start().await;
1835        Mock::given(method("GET"))
1836            .and(path("/users/42"))
1837            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1838            .mount(&server)
1839            .await;
1840
1841        let (policy, seen) = recorder(None);
1842        let (client, _auth) = client(server.uri(), Some(policy));
1843        client
1844            .execute_for_tool("get_user", &op(), &serde_json::json!({ "id": "42" }))
1845            .await
1846            .expect("allowed");
1847
1848        let seen = seen.lock().expect("lock");
1849        assert_eq!(seen[0].0, "get_user");
1850    }
1851
1852    #[test]
1853    fn a_governed_connector_reports_its_policy_through_the_dyn_trait() {
1854        let (policy, _seen) = recorder(None);
1855        let (client, _auth) = client("https://example.test".to_string(), None);
1856        let bare: Arc<dyn HttpConnector> = Arc::new(client);
1857        assert!(
1858            !bare.has_request_policy(),
1859            "a bare connector carries no policy"
1860        );
1861        let governed = bare
1862            .governed(policy)
1863            .expect("HttpClient supports policy attachment");
1864        assert!(
1865            governed.has_request_policy(),
1866            "a registered policy must be observable on the dyn connector, or it could \
1867             look registered while never running"
1868        );
1869    }
1870}