Skip to main content

sendra_core/request/
resolve.rs

1//! The three "structured input becomes the final wire form" passes, run in
2//! this order: [`Request::resolve_auth`], [`Request::resolve_query`] and
3//! [`Request::resolve_body`]. Each returns a new [`Request`] with its own
4//! structured field(s) cleared and the plain wire-level field (`url`,
5//! `body`/headers, `Authorization` header) set instead — see each method's
6//! own doc comment for why the order among them and relative to the config
7//! and `pre_request` matters.
8
9use std::path::Path;
10
11use crate::error::SendraError;
12use crate::http::client::HttpClient;
13use crate::oauth::OAuthTokenCache;
14use crate::request::auth::{ApiKeyLocation, Auth};
15use crate::request::multipart::{encode_multipart, read_body_file};
16use crate::request::Request;
17
18impl Request {
19    /// Merge `query` onto `url`'s own query string, percent-encoded
20    /// properly, returning a request whose `url` is the final string that
21    /// goes on the wire and whose `query` is empty.
22    ///
23    /// Called right after [`resolve_auth`](Self::resolve_auth) — which, for
24    /// an `auth.api_key` in `query` form, has already appended its
25    /// `name`/`value` pair onto `query` so it merges through this exact
26    /// mechanism rather than a separate one — and before
27    /// [`resolve_body`](Self::resolve_body), the config, or a `pre_request`
28    /// script ever see the request. A `pre_request` script therefore sees
29    /// `query` parameters (including any from `auth.api_key`) already merged
30    /// into `request.url`, not a separate map, for consistency with
31    /// `resolve_body`'s "scripts see the final resolved form" precedent.
32    ///
33    /// A request with an empty `query` is returned with `url` untouched —
34    /// not even reparsed — so a `url`-only request behaves exactly as it
35    /// always has, including one whose `url` would not itself parse as a
36    /// valid [`reqwest::Url`] (which today is only ever caught by `reqwest`
37    /// itself, at send time).
38    ///
39    /// Uses [`reqwest::Url`]'s own query-pair APIs — already a dependency —
40    /// rather than string concatenation, so a value containing a space, `&`,
41    /// `=` or non-ASCII character is encoded correctly rather than however it
42    /// happened to be typed.
43    pub fn resolve_query(&self) -> Result<Request, SendraError> {
44        let mut resolved = self.clone();
45        if self.query.is_empty() {
46            return Ok(resolved);
47        }
48
49        let mut url =
50            reqwest::Url::parse(&self.url).map_err(|source| SendraError::InvalidRequest {
51                reason: format!("url `{}` is not valid: {source}", self.url),
52            })?;
53
54        // `query` wins: drop any existing pair under a name `query` also
55        // sets, then write the URL's surviving pairs back first so a key
56        // `query` says nothing about keeps its place ahead of the new ones.
57        let overridden: std::collections::HashSet<&str> =
58            self.query.iter().map(|(name, _)| name.as_str()).collect();
59        let kept: Vec<(String, String)> = url
60            .query_pairs()
61            .filter(|(name, _)| !overridden.contains(name.as_ref()))
62            .map(|(name, value)| (name.into_owned(), value.into_owned()))
63            .collect();
64
65        let mut pairs = url.query_pairs_mut();
66        pairs.clear();
67        for (name, value) in &kept {
68            pairs.append_pair(name, value);
69        }
70        for (name, value) in &self.query {
71            pairs.append_pair(name, value);
72        }
73        drop(pairs);
74
75        resolved.url = url.to_string();
76        resolved.query = Vec::new();
77        Ok(resolved)
78    }
79
80    /// Resolve whichever of `body`/`json`/`body_file`/`form`/`multipart` was
81    /// set into the final `body` string that goes on the wire, setting
82    /// `Content-Type` when the field implies one and the request has not
83    /// already set that header itself.
84    ///
85    /// Called once, after environment substitution and before the config is
86    /// applied or a `pre_request` script runs — so both see a plain `body`
87    /// string regardless of which field produced it, the same way they
88    /// already see a request whose `{{var}}`s have been resolved. `json`,
89    /// `body_file`, `form` and `multipart` are cleared on the way out; `body`
90    /// is the only body field left on the result.
91    ///
92    /// `base_dir` is where `body_file` and a multipart part's `path` resolve
93    /// relative to: **the directory containing the request's own YAML file**,
94    /// not the process's current working directory. A request file is
95    /// something a user can run from anywhere — `sendra run
96    /// requests/create-user.yaml` from a repository root — and `body_file:
97    /// ./payload.json` written inside `create-user.yaml` obviously means the
98    /// file beside it, not one resolved against whatever directory the
99    /// command happened to be typed from.
100    ///
101    /// `json`, `form` and `body_file`'s *path* were already substituted by
102    /// [`Environment::apply`](crate::Environment::apply) before this runs.
103    /// `body_file`'s *file content* is deliberately not substituted — it is
104    /// external content Sendra reads, not a value written in the request
105    /// file, and substitution has never reached outside the document; see the
106    /// [`environment`](crate::environment) module docs.
107    ///
108    /// File content — for `body_file` and a multipart file part alike — is
109    /// read as UTF-8 text; a file that is not valid UTF-8 is
110    /// [`SendraError::BodyFileIo`]. Sendra's bodies are text throughout, the
111    /// same way a [`Response`](crate::Response)'s is, and true binary uploads are out of scope
112    /// for this version.
113    pub fn resolve_body(&self, base_dir: &Path) -> Result<Request, SendraError> {
114        let mut resolved = self.clone();
115
116        if let Some(value) = &self.json {
117            let body = serde_json::to_string(value).expect("a serde_json::Value always serializes");
118            resolved.body = Some(body);
119            crate::config::insert_if_absent(
120                &mut resolved.headers,
121                "Content-Type",
122                "application/json",
123            );
124        } else if let Some(path) = &self.body_file {
125            resolved.body = Some(read_body_file(base_dir, path)?);
126        } else if !self.form.is_empty() {
127            let body = serde_urlencoded::to_string(&self.form)
128                .expect("a Vec<(String, String)> always encodes as x-www-form-urlencoded pairs");
129            resolved.body = Some(body);
130            crate::config::insert_if_absent(
131                &mut resolved.headers,
132                "Content-Type",
133                "application/x-www-form-urlencoded",
134            );
135        } else if !self.multipart.is_empty() {
136            let (body, content_type) = encode_multipart(&self.multipart, base_dir)?;
137            resolved.body = Some(body);
138            crate::config::insert_if_absent(&mut resolved.headers, "Content-Type", &content_type);
139        }
140
141        resolved.json = None;
142        resolved.body_file = None;
143        resolved.form = Vec::new();
144        resolved.multipart = Vec::new();
145
146        Ok(resolved)
147    }
148
149    /// Resolve `auth` into the header (`bearer`/`basic`/an `api_key` in
150    /// `header` form) or query parameter (an `api_key` in `query` form) that
151    /// goes on the wire, clearing `auth` on the way out.
152    ///
153    /// Called right after environment substitution and before
154    /// [`resolve_query`](Self::resolve_query), [`resolve_body`](Self::resolve_body), the
155    /// config, or a `pre_request` script ever see the request. It runs
156    /// *before* `resolve_query` specifically so that an `auth.api_key` in
157    /// `query` form can hand its `name`/`value` pair to `query` and let
158    /// `resolve_query` do the actual merging onto `url` — the same
159    /// percent-encoding and "the more structured source wins on a name
160    /// collision with the URL's own query string" rule an ordinary `query:`
161    /// entry gets, rather than a second, parallel implementation. A
162    /// `pre_request` script therefore sees a plain `Authorization` (or other)
163    /// header like any other, with no separate `request.auth` API — and, for
164    /// the `query` form, sees the parameter already merged into
165    /// `request.url` by the time `resolve_query` has also run.
166    ///
167    /// [`Request::validate`] has already rejected a request that sets `auth`
168    /// alongside an explicit header or query parameter of the same name it
169    /// would itself set, so this always adds the header/parameter rather than
170    /// needing [`config::insert_if_absent`](crate::config::insert_if_absent)'s
171    /// suppression rule.
172    pub fn resolve_auth(&self) -> Result<Request, SendraError> {
173        let mut resolved = self.clone();
174
175        if let Some(auth) = &self.auth {
176            match (&auth.bearer, &auth.basic, &auth.api_key, &auth.oauth) {
177                (Some(token), None, None, None) => {
178                    resolved
179                        .headers
180                        .push(("Authorization".to_string(), format!("Bearer {token}")));
181                }
182                (None, Some(basic), None, None) => {
183                    let credentials = format!("{}:{}", basic.user, basic.pass);
184                    let encoded = base64::Engine::encode(
185                        &base64::engine::general_purpose::STANDARD,
186                        credentials,
187                    );
188                    resolved
189                        .headers
190                        .push(("Authorization".to_string(), format!("Basic {encoded}")));
191                }
192                (None, None, Some(api_key), None) => match api_key.r#in {
193                    ApiKeyLocation::Header => {
194                        resolved
195                            .headers
196                            .push((api_key.name.clone(), api_key.value.clone()));
197                    }
198                    ApiKeyLocation::Query => {
199                        resolved
200                            .query
201                            .push((api_key.name.clone(), api_key.value.clone()));
202                    }
203                },
204                (None, None, None, Some(_)) => {
205                    // `resolve_oauth` collapses `auth.oauth` into `auth.bearer`
206                    // before this ever runs — see its doc comment. Reaching
207                    // this branch means that step was skipped, which is a
208                    // caller bug rather than a fact about the request, but it
209                    // still gets a typed error rather than the `unreachable!`
210                    // below, since `auth.oauth` is otherwise a value
211                    // `Request::validate` accepts.
212                    return Err(SendraError::InvalidRequest {
213                        reason: "auth.oauth must be resolved via Request::resolve_oauth before \
214                                 resolve_auth"
215                            .to_string(),
216                    });
217                }
218                // `validate` already rejected any other combination.
219                _ => unreachable!(
220                    "Request::validate enforces exactly one of bearer/basic/api_key/oauth"
221                ),
222            };
223        }
224        resolved.auth = None;
225
226        Ok(resolved)
227    }
228
229    /// Acquire an OAuth token for `auth.oauth`, collapsing it into the exact
230    /// `bearer` form [`resolve_auth`](Self::resolve_auth) already knows how
231    /// to turn into an `Authorization` header — so `oauth` is a *front end*
232    /// for the bearer case, not a second header-setting implementation. A
233    /// request whose `auth` is `None`, or whose `auth.oauth` is `None`, is
234    /// returned unchanged; there is nothing to acquire.
235    ///
236    /// Called once, before `resolve_auth`, from the request-resolution
237    /// pipeline in `sendra-cli` — the one step in that pipeline that needs
238    /// the shared [`HttpClient`] and an `.await`, since acquiring a token is
239    /// a real HTTP call to `token_url`. See [`crate::oauth`] for the cache
240    /// this reads and writes, the retry-vs-fail-fast decision for a broken
241    /// config, and the expiry margin.
242    pub async fn resolve_oauth(
243        &self,
244        client: &HttpClient,
245        cache: &OAuthTokenCache,
246    ) -> Result<Request, SendraError> {
247        let Some(oauth) = self.auth.as_ref().and_then(|auth| auth.oauth.as_ref()) else {
248            return Ok(self.clone());
249        };
250
251        let access_token = crate::oauth::acquire_token(oauth, client, cache).await?;
252
253        let mut resolved = self.clone();
254        resolved.auth = Some(Auth {
255            bearer: Some(access_token),
256            basic: None,
257            api_key: None,
258            oauth: None,
259        });
260        Ok(resolved)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::request::auth::OAuthGrantType;
268    use crate::SendraError;
269
270    /// A minimal request whose only body field is set from `field: value`
271    /// (already valid YAML for every shape these tests need — a scalar, a
272    /// block, a sequence).
273    fn request_with(field_and_value: &str) -> Request {
274        Request::from_yaml_str(&format!(
275            "method: POST\nurl: https://example.com\n{field_and_value}\n"
276        ))
277        .expect("the test request should parse")
278    }
279
280    #[test]
281    fn a_json_body_is_serialized_and_gets_the_default_content_type() {
282        let request = request_with("json:\n  name: ada\n  roles: [admin, user]\n");
283        let resolved = request
284            .resolve_body(Path::new("."))
285            .expect("no file to read");
286
287        let sent: serde_json::Value =
288            serde_json::from_str(resolved.body.as_deref().expect("a body was produced"))
289                .expect("the body is valid json");
290        assert_eq!(
291            sent,
292            serde_json::json!({"name": "ada", "roles": ["admin", "user"]})
293        );
294        assert_eq!(resolved.header("Content-Type"), Some("application/json"));
295        // The structured field is gone from the resolved request: the only
296        // body field left is the plain string a script or `send_prepared`
297        // reads.
298        assert!(resolved.json.is_none());
299    }
300
301    #[test]
302    fn a_json_bodys_explicit_content_type_is_not_clobbered() {
303        let request = request_with(
304            "headers:\n  Content-Type: application/vnd.example+json\njson:\n  ok: true\n",
305        );
306        let resolved = request
307            .resolve_body(Path::new("."))
308            .expect("no file to read");
309
310        assert_eq!(
311            resolved.header("Content-Type"),
312            Some("application/vnd.example+json"),
313            "an explicit content-type header must win over the automatic one"
314        );
315    }
316
317    #[test]
318    fn body_file_reads_relative_to_the_request_files_directory_not_the_cwd() {
319        let dir = tempfile::tempdir().unwrap();
320        std::fs::write(dir.path().join("payload.json"), r#"{"id":1}"#).unwrap();
321
322        let request = request_with("body_file: ./payload.json\n");
323        let resolved = request
324            .resolve_body(dir.path())
325            .expect("the file is beside the (hypothetical) request file");
326
327        assert_eq!(resolved.body.as_deref(), Some(r#"{"id":1}"#));
328        // `body_file` sets no content-type: Sendra cannot know what an
329        // arbitrary file holds, so the request's own `headers:` is
330        // responsible.
331        assert!(resolved.header("Content-Type").is_none());
332
333        // And resolving against a directory that does *not* hold the file —
334        // standing in for the process's cwd — fails, which is the point of
335        // the whole test: the path is relative to something specific, not
336        // wherever `sendra` happened to be run from.
337        let elsewhere = tempfile::tempdir().unwrap();
338        assert!(matches!(
339            request.resolve_body(elsewhere.path()),
340            Err(SendraError::BodyFileIo { .. })
341        ));
342    }
343
344    #[test]
345    fn a_non_utf8_body_file_is_a_typed_error_not_a_silent_corruption() {
346        let dir = tempfile::tempdir().unwrap();
347        std::fs::write(dir.path().join("payload.bin"), [0xff, 0xfe, 0x00, 0xff]).unwrap();
348
349        let request = request_with("body_file: ./payload.bin\n");
350        assert!(matches!(
351            request.resolve_body(dir.path()),
352            Err(SendraError::BodyFileIo { .. })
353        ));
354    }
355
356    #[test]
357    fn a_form_body_is_url_encoded_and_gets_the_default_content_type() {
358        let request = request_with("form:\n  username: ada lovelace\n  remember_me: \"true\"\n");
359        let resolved = request
360            .resolve_body(Path::new("."))
361            .expect("no file to read");
362
363        assert_eq!(
364            resolved.body.as_deref(),
365            Some("username=ada+lovelace&remember_me=true")
366        );
367        assert_eq!(
368            resolved.header("Content-Type"),
369            Some("application/x-www-form-urlencoded")
370        );
371        assert!(resolved.form.is_empty());
372    }
373
374    #[test]
375    fn a_multipart_body_encodes_a_text_part_and_a_file_part() {
376        let dir = tempfile::tempdir().unwrap();
377        std::fs::write(dir.path().join("cat.txt"), "meow").unwrap();
378
379        let request = request_with(
380            "multipart:\n  \
381             - name: description\n    value: a photo of my cat\n  \
382             - name: photo\n    path: ./cat.txt\n",
383        );
384        let resolved = request
385            .resolve_body(dir.path())
386            .expect("the file part reads fine");
387
388        let content_type = resolved
389            .header("Content-Type")
390            .expect("multipart sets its own content-type")
391            .to_string();
392        assert!(
393            content_type.starts_with("multipart/form-data; boundary="),
394            "got {content_type}"
395        );
396        let boundary = content_type
397            .strip_prefix("multipart/form-data; boundary=")
398            .unwrap();
399
400        let body = resolved.body.expect("a body was produced");
401        assert!(body.contains(&format!("--{boundary}\r\n")));
402        assert!(body.contains(
403            "Content-Disposition: form-data; name=\"description\"\r\n\r\na photo of my cat"
404        ));
405        assert!(body.contains(
406            "Content-Disposition: form-data; name=\"photo\"; filename=\"cat.txt\"\r\n\r\nmeow"
407        ));
408        assert!(body.trim_end().ends_with(&format!("--{boundary}--")));
409        assert!(resolved.multipart.is_empty());
410    }
411
412    #[test]
413    fn a_multipart_part_with_both_value_and_path_is_rejected_at_parse_time() {
414        let err = Request::from_yaml_str(
415            "method: POST\nurl: https://example.com\n\
416             multipart:\n  - name: photo\n    value: x\n    path: ./cat.jpg\n",
417        )
418        .expect_err("a part cannot be both text and a file");
419        assert!(
420            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("both `value` and `path`")),
421            "got {err:?}"
422        );
423    }
424
425    #[test]
426    fn a_multipart_part_with_neither_value_nor_path_is_rejected_at_parse_time() {
427        let err = Request::from_yaml_str(
428            "method: POST\nurl: https://example.com\nmultipart:\n  - name: photo\n",
429        )
430        .expect_err("a part needs exactly one of value/path");
431        assert!(
432            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("neither `value` nor `path`")),
433            "got {err:?}"
434        );
435    }
436
437    #[test]
438    fn a_request_naming_two_body_fields_is_rejected_at_parse_time() {
439        let err = Request::from_yaml_str(
440            "method: POST\nurl: https://example.com\nbody: '{}'\njson:\n  a: 1\n",
441        )
442        .expect_err("body and json together must be rejected");
443        assert!(
444            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("body") && reason.contains("json")),
445            "got {err:?}"
446        );
447    }
448
449    #[test]
450    fn two_body_fields_inside_a_collection_are_rejected_with_the_requests_context() {
451        let yaml = "\
452requests:
453  - name: Broken
454    method: POST
455    url: https://example.com
456    form:
457      a: '1'
458    body_file: ./x.json
459";
460        let err = crate::Document::from_yaml_str(yaml).expect_err("must be rejected");
461        match err {
462            SendraError::InvalidCollection { reason } => {
463                assert!(reason.contains("Broken"), "got {reason}");
464                assert!(reason.contains("form"), "got {reason}");
465                assert!(reason.contains("body_file"), "got {reason}");
466            }
467            other => panic!("expected InvalidCollection, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn a_plain_body_still_parses_and_resolves_unchanged() {
473        // The non-goal, pinned: a file written before this feature existed
474        // still works exactly as it did.
475        let request = request_with("body: '{\"name\": \"ada\"}'\n");
476        let resolved = request
477            .resolve_body(Path::new("."))
478            .expect("nothing to read");
479        assert_eq!(resolved.body.as_deref(), Some(r#"{"name": "ada"}"#));
480        assert!(resolved.header("Content-Type").is_none());
481    }
482
483    #[test]
484    fn a_request_with_no_body_field_at_all_resolves_to_no_body() {
485        let request = request_with("");
486        let resolved = request
487            .resolve_body(Path::new("."))
488            .expect("nothing to resolve");
489        assert!(resolved.body.is_none());
490    }
491
492    // --- query: as a map with real percent-encoding -------------------------
493
494    fn get_with(field_and_value: &str) -> Request {
495        Request::from_yaml_str(&format!(
496            "method: GET\nurl: https://example.com/search\n{field_and_value}\n"
497        ))
498        .expect("the test request should parse")
499    }
500
501    #[test]
502    fn a_request_with_no_query_field_leaves_the_url_untouched() {
503        // The non-goal, pinned: a url-only request is not even reparsed.
504        let request = get_with("");
505        let resolved = request.resolve_query().expect("nothing to resolve");
506        assert_eq!(resolved.url, "https://example.com/search");
507        assert!(resolved.query.is_empty());
508    }
509
510    #[test]
511    fn a_query_map_merges_onto_a_url_with_no_existing_query_string() {
512        let request = get_with("query:\n  a: '1'\n  b: '2'\n");
513        let resolved = request.resolve_query().expect("resolves");
514        assert_eq!(resolved.url, "https://example.com/search?a=1&b=2");
515        assert!(resolved.query.is_empty(), "cleared after resolution");
516    }
517
518    #[test]
519    fn a_query_map_is_appended_onto_a_url_that_already_has_a_query_string() {
520        let request = Request::from_yaml_str(
521            "method: GET\nurl: https://example.com/search?existing=1\nquery:\n  new: '2'\n",
522        )
523        .unwrap();
524        let resolved = request.resolve_query().expect("resolves");
525        assert_eq!(resolved.url, "https://example.com/search?existing=1&new=2");
526    }
527
528    #[test]
529    fn a_key_in_both_the_url_and_the_query_map_is_decided_by_the_query_map() {
530        // `query:` wins: the URL's own `a=from-url` is dropped, not sent
531        // alongside `a=from-query`.
532        let request = Request::from_yaml_str(
533            "method: GET\nurl: https://example.com/search?a=from-url&b=kept\nquery:\n  a: from-query\n",
534        )
535        .unwrap();
536        let resolved = request.resolve_query().expect("resolves");
537        let url = reqwest::Url::parse(&resolved.url).unwrap();
538        let pairs: Vec<(String, String)> = url
539            .query_pairs()
540            .map(|(k, v)| (k.into_owned(), v.into_owned()))
541            .collect();
542        assert_eq!(
543            pairs,
544            vec![
545                ("b".to_string(), "kept".to_string()),
546                ("a".to_string(), "from-query".to_string()),
547            ],
548            "got {pairs:?}"
549        );
550    }
551
552    #[test]
553    fn special_characters_are_percent_encoded_not_concatenated() {
554        let request = get_with("query:\n  q: 'coffee & tea, café'\n");
555        let resolved = request.resolve_query().expect("resolves");
556
557        // Read back through `Url` rather than asserting on the exact encoded
558        // string: what matters is that the server sees the value that was
559        // written, not which of several valid encodings was chosen.
560        let url = reqwest::Url::parse(&resolved.url).unwrap();
561        let (_, value) = url
562            .query_pairs()
563            .find(|(name, _)| name == "q")
564            .expect("q was sent");
565        assert_eq!(value, "coffee & tea, café");
566        // And the raw query string actually is encoded, not the literal text
567        // with a space and a non-ASCII character sitting in it.
568        assert!(!resolved.url.contains(' '));
569        assert!(resolved.url.is_ascii());
570    }
571
572    #[test]
573    fn a_repeated_query_key_is_written_as_a_list() {
574        let request = get_with("query:\n  tag:\n    - hot\n    - iced\n");
575        let resolved = request.resolve_query().expect("resolves");
576        let url = reqwest::Url::parse(&resolved.url).unwrap();
577        let tags: Vec<String> = url
578            .query_pairs()
579            .filter(|(name, _)| name == "tag")
580            .map(|(_, value)| value.into_owned())
581            .collect();
582        assert_eq!(tags, vec!["hot".to_string(), "iced".to_string()]);
583    }
584
585    #[test]
586    fn an_unquoted_number_query_value_is_coerced_to_its_string_form() {
587        let request = get_with("query:\n  limit: 10\n");
588        let resolved = request.resolve_query().expect("resolves");
589        assert_eq!(resolved.url, "https://example.com/search?limit=10");
590    }
591
592    #[test]
593    fn environment_substitution_reaches_query_values_and_list_entries() {
594        let request =
595            get_with("query:\n  tenant: '{{tenant}}'\n  tag:\n    - '{{tenant}}'\n    - iced\n");
596        let environment = crate::Environment::from_yaml_str("tenant: acme\n").unwrap();
597        let substituted = environment.apply(&request).expect("tenant is set");
598        assert_eq!(
599            substituted.query,
600            vec![
601                ("tenant".to_string(), "acme".to_string()),
602                ("tag".to_string(), "acme".to_string()),
603                ("tag".to_string(), "iced".to_string()),
604            ]
605        );
606    }
607
608    // --- auth: bearer and basic ---------------------------------------------
609
610    #[test]
611    fn auth_bearer_resolves_to_a_bearer_authorization_header() {
612        let request = request_with("auth:\n  bearer: my-token\n");
613        let resolved = request.resolve_auth().expect("resolves");
614        assert_eq!(resolved.header("Authorization"), Some("Bearer my-token"));
615        assert!(resolved.auth.is_none());
616    }
617
618    #[test]
619    fn auth_basic_resolves_to_a_base64_encoded_authorization_header() {
620        let request = request_with("auth:\n  basic:\n    user: ada\n    pass: s3cr3t\n");
621        let resolved = request.resolve_auth().expect("resolves");
622        // base64("ada:s3cr3t")
623        assert_eq!(
624            resolved.header("Authorization"),
625            Some("Basic YWRhOnMzY3IzdA==")
626        );
627        assert!(resolved.auth.is_none());
628    }
629
630    #[test]
631    fn a_request_with_no_auth_field_resolves_to_no_authorization_header() {
632        let request = request_with("");
633        let resolved = request.resolve_auth().expect("nothing to resolve");
634        assert!(resolved.header("Authorization").is_none());
635    }
636
637    #[test]
638    fn auth_naming_both_bearer_and_basic_is_rejected_at_parse_time() {
639        let err = Request::from_yaml_str(
640            "method: GET\nurl: https://example.com\nauth:\n  bearer: x\n  basic:\n    user: a\n    pass: b\n",
641        )
642        .expect_err("bearer and basic together must be rejected");
643        assert!(
644            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("basic")),
645            "got {err:?}"
646        );
647    }
648
649    #[test]
650    fn auth_naming_neither_bearer_nor_basic_is_rejected_at_parse_time() {
651        let err = Request::from_yaml_str("method: GET\nurl: https://example.com\nauth: {}\n")
652            .expect_err("an empty auth block must be rejected");
653        assert!(
654            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("basic")),
655            "got {err:?}"
656        );
657    }
658
659    #[test]
660    fn auth_alongside_an_explicit_authorization_header_is_rejected_at_parse_time() {
661        let err = Request::from_yaml_str(
662            "method: GET\nurl: https://example.com\nheaders:\n  Authorization: Bearer hand-written\nauth:\n  bearer: x\n",
663        )
664        .expect_err("auth and an explicit Authorization header together must be rejected");
665        assert!(
666            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("Authorization")),
667            "got {err:?}"
668        );
669    }
670
671    #[test]
672    fn the_authorization_collision_check_is_case_insensitive() {
673        let err = Request::from_yaml_str(
674            "method: GET\nurl: https://example.com\nheaders:\n  authorization: Bearer hand-written\nauth:\n  bearer: x\n",
675        )
676        .expect_err("a differently-cased Authorization header must still collide");
677        assert!(matches!(&err, SendraError::InvalidRequest { .. }));
678    }
679
680    #[test]
681    fn environment_substitution_reaches_bearer_and_basic_values() {
682        let request =
683            request_with("auth:\n  basic:\n    user: '{{username}}'\n    pass: '{{password}}'\n");
684        let environment =
685            crate::Environment::from_yaml_str("username: ada\npassword: s3cr3t\n").unwrap();
686        let substituted = environment.apply(&request).expect("both are set");
687        let auth = substituted.auth.expect("auth survives substitution");
688        let basic = auth.basic.expect("basic survives substitution");
689        assert_eq!(basic.user, "ada");
690        assert_eq!(basic.pass, "s3cr3t");
691    }
692
693    // --- auth: api_key -------------------------------------------------------
694
695    #[test]
696    fn auth_api_key_header_resolves_to_the_named_header() {
697        let request = request_with(
698            "auth:\n  api_key:\n    in: header\n    name: X-API-Key\n    value: s3cr3t\n",
699        );
700        let resolved = request.resolve_auth().expect("resolves");
701        assert_eq!(resolved.header("X-API-Key"), Some("s3cr3t"));
702        assert!(resolved.auth.is_none());
703    }
704
705    #[test]
706    fn auth_api_key_query_merges_through_resolve_query_not_a_parallel_path() {
707        let request =
708            get_with("auth:\n  api_key:\n    in: query\n    name: api_key\n    value: s3cr3t\n");
709        let resolved = request
710            .resolve_auth()
711            .and_then(|request| request.resolve_query())
712            .expect("resolves");
713        assert_eq!(resolved.url, "https://example.com/search?api_key=s3cr3t");
714        assert!(resolved.auth.is_none());
715        assert!(resolved.query.is_empty());
716    }
717
718    #[test]
719    fn auth_api_key_query_still_wins_over_an_existing_url_query_key_of_the_same_name() {
720        // Proves the api_key value flows through the exact same "query wins"
721        // precedence as an ordinary `query:` entry, not a separate rule.
722        let request = Request::from_yaml_str(
723            "method: GET\nurl: https://example.com/search?api_key=stale\nauth:\n  api_key:\n    in: query\n    name: api_key\n    value: fresh\n",
724        )
725        .unwrap();
726        let resolved = request
727            .resolve_auth()
728            .and_then(|request| request.resolve_query())
729            .expect("resolves");
730        assert_eq!(resolved.url, "https://example.com/search?api_key=fresh");
731    }
732
733    #[test]
734    fn a_request_with_no_auth_field_resolves_to_no_api_key_header_or_query_param() {
735        let request = get_with("");
736        let resolved = request
737            .resolve_auth()
738            .and_then(|request| request.resolve_query())
739            .expect("nothing to resolve");
740        assert_eq!(resolved.url, "https://example.com/search");
741    }
742
743    #[test]
744    fn auth_naming_bearer_and_api_key_is_rejected_at_parse_time() {
745        let err = Request::from_yaml_str(
746            "method: GET\nurl: https://example.com\nauth:\n  bearer: x\n  api_key:\n    in: header\n    name: X-API-Key\n    value: y\n",
747        )
748        .expect_err("bearer and api_key together must be rejected");
749        assert!(
750            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("api_key")),
751            "got {err:?}"
752        );
753    }
754
755    #[test]
756    fn auth_api_key_header_colliding_with_an_explicit_header_is_rejected_at_parse_time() {
757        let err = Request::from_yaml_str(
758            "method: GET\nurl: https://example.com\nheaders:\n  X-API-Key: hand-written\nauth:\n  api_key:\n    in: header\n    name: X-API-Key\n    value: y\n",
759        )
760        .expect_err("api_key and an explicit header of the same name together must be rejected");
761        assert!(
762            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("X-API-Key")),
763            "got {err:?}"
764        );
765    }
766
767    #[test]
768    fn the_api_key_header_collision_check_is_case_insensitive() {
769        let err = Request::from_yaml_str(
770            "method: GET\nurl: https://example.com\nheaders:\n  x-api-key: hand-written\nauth:\n  api_key:\n    in: header\n    name: X-API-Key\n    value: y\n",
771        )
772        .expect_err("a differently-cased header name must still collide");
773        assert!(matches!(&err, SendraError::InvalidRequest { .. }));
774    }
775
776    #[test]
777    fn auth_api_key_query_colliding_with_an_explicit_query_entry_is_rejected_at_parse_time() {
778        let err = Request::from_yaml_str(
779            "method: GET\nurl: https://example.com\nquery:\n  api_key: hand-written\nauth:\n  api_key:\n    in: query\n    name: api_key\n    value: y\n",
780        )
781        .expect_err("api_key and an explicit query entry of the same name together must be rejected");
782        assert!(
783            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("api_key")),
784            "got {err:?}"
785        );
786    }
787
788    #[test]
789    fn environment_substitution_reaches_api_key_name_and_value() {
790        let request = request_with(
791            "auth:\n  api_key:\n    in: header\n    name: '{{header_name}}'\n    value: '{{token}}'\n",
792        );
793        let environment =
794            crate::Environment::from_yaml_str("header_name: X-API-Key\ntoken: s3cr3t\n").unwrap();
795        let substituted = environment.apply(&request).expect("both are set");
796        let auth = substituted.auth.expect("auth survives substitution");
797        let api_key = auth.api_key.expect("api_key survives substitution");
798        assert_eq!(api_key.name, "X-API-Key");
799        assert_eq!(api_key.value, "s3cr3t");
800    }
801
802    // --- auth: oauth ----------------------------------------------------------
803
804    fn client() -> HttpClient {
805        crate::http::client::build_client(&crate::Config::default()).expect("a client builds")
806    }
807
808    fn token_server(body: &'static str) -> std::net::SocketAddr {
809        crate::test_support::start_route_server(vec![(
810            "/token",
811            format!(
812                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
813                body.len()
814            )
815            .into_bytes(),
816        )])
817    }
818
819    #[tokio::test]
820    async fn a_request_with_no_oauth_auth_is_unchanged_by_resolve_oauth() {
821        let request = request_with("auth:\n  bearer: unrelated\n");
822        let client = client();
823        let cache = OAuthTokenCache::new();
824        let resolved = request
825            .resolve_oauth(&client, &cache)
826            .await
827            .expect("nothing to acquire");
828        assert_eq!(resolved, request);
829
830        let no_auth = request_with("");
831        let resolved = no_auth
832            .resolve_oauth(&client, &cache)
833            .await
834            .expect("nothing to acquire");
835        assert_eq!(resolved, no_auth);
836    }
837
838    #[tokio::test]
839    async fn auth_oauth_resolves_via_resolve_oauth_then_resolve_auth_to_a_bearer_header() {
840        let addr = token_server(r#"{"access_token": "acquired-token"}"#);
841        let request = request_with(&format!(
842            "auth:\n  oauth:\n    grant_type: client_credentials\n    token_url: http://{addr}/token\n    client_id: id\n    client_secret: secret\n"
843        ));
844        let client = client();
845        let cache = OAuthTokenCache::new();
846
847        let resolved = request
848            .resolve_oauth(&client, &cache)
849            .await
850            .expect("the mock token endpoint answers");
851        assert_eq!(
852            resolved
853                .auth
854                .as_ref()
855                .and_then(|auth| auth.bearer.as_deref()),
856            Some("acquired-token"),
857            "resolve_oauth must collapse auth.oauth into auth.bearer"
858        );
859
860        let resolved = resolved.resolve_auth().expect("resolves");
861        assert_eq!(
862            resolved.header("Authorization"),
863            Some("Bearer acquired-token")
864        );
865        assert!(resolved.auth.is_none());
866    }
867
868    #[test]
869    fn calling_resolve_auth_directly_on_unresolved_oauth_is_a_typed_error_not_a_panic() {
870        let request = request_with(
871            "auth:\n  oauth:\n    grant_type: client_credentials\n    token_url: http://example.com/token\n    client_id: id\n    client_secret: secret\n",
872        );
873        let err = request
874            .resolve_auth()
875            .expect_err("oauth must be resolved via resolve_oauth first");
876        assert!(matches!(err, SendraError::InvalidRequest { .. }));
877    }
878
879    #[test]
880    fn oauth_password_grant_missing_username_is_rejected_at_parse_time() {
881        let err = Request::from_yaml_str(
882            "method: GET\nurl: https://example.com\nauth:\n  oauth:\n    grant_type: password\n    token_url: https://example.com/token\n    client_id: id\n    client_secret: secret\n    password: pw\n",
883        )
884        .expect_err("password grant needs username too");
885        assert!(
886            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("username") && reason.contains("password")),
887            "got {err:?}"
888        );
889    }
890
891    #[test]
892    fn oauth_client_credentials_needs_neither_username_nor_password() {
893        let request = Request::from_yaml_str(
894            "method: GET\nurl: https://example.com\nauth:\n  oauth:\n    grant_type: client_credentials\n    token_url: https://example.com/token\n    client_id: id\n    client_secret: secret\n",
895        )
896        .expect("client_credentials needs no username/password");
897        let oauth = request
898            .auth
899            .expect("auth survives parse")
900            .oauth
901            .expect("oauth is set");
902        assert_eq!(oauth.grant_type, OAuthGrantType::ClientCredentials);
903    }
904
905    #[test]
906    fn auth_naming_oauth_and_bearer_together_is_rejected_at_parse_time() {
907        let err = Request::from_yaml_str(
908            "method: GET\nurl: https://example.com\nauth:\n  bearer: x\n  oauth:\n    grant_type: client_credentials\n    token_url: https://example.com/token\n    client_id: id\n    client_secret: secret\n",
909        )
910        .expect_err("bearer and oauth together must be rejected");
911        assert!(
912            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("bearer") && reason.contains("oauth")),
913            "got {err:?}"
914        );
915    }
916
917    #[test]
918    fn auth_oauth_alongside_an_explicit_authorization_header_is_rejected_at_parse_time() {
919        let err = Request::from_yaml_str(
920            "method: GET\nurl: https://example.com\nheaders:\n  Authorization: Bearer hand-written\nauth:\n  oauth:\n    grant_type: client_credentials\n    token_url: https://example.com/token\n    client_id: id\n    client_secret: secret\n",
921        )
922        .expect_err("auth.oauth and an explicit Authorization header together must be rejected");
923        assert!(
924            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("Authorization"))
925        );
926    }
927
928    #[test]
929    fn environment_substitution_reaches_oauth_fields() {
930        let request = request_with(
931            "auth:\n  oauth:\n    grant_type: password\n    token_url: '{{token_url}}'\n    client_id: '{{client_id}}'\n    client_secret: '{{client_secret}}'\n    username: '{{username}}'\n    password: '{{password}}'\n    scope: '{{scope}}'\n",
932        );
933        let environment = crate::Environment::from_yaml_str(
934            "token_url: https://auth.example.com/token\nclient_id: id-value\nclient_secret: secret-value\nusername: ada\npassword: s3cr3t\nscope: read write\n",
935        )
936        .unwrap();
937        let substituted = environment.apply(&request).expect("every variable is set");
938        let oauth = substituted
939            .auth
940            .expect("auth survives substitution")
941            .oauth
942            .expect("oauth survives substitution");
943        assert_eq!(oauth.token_url, "https://auth.example.com/token");
944        assert_eq!(oauth.client_id, "id-value");
945        assert_eq!(oauth.client_secret.as_deref(), Some("secret-value"));
946        assert_eq!(oauth.username.as_deref(), Some("ada"));
947        assert_eq!(oauth.password.as_deref(), Some("s3cr3t"));
948        assert_eq!(oauth.scope.as_deref(), Some("read write"));
949    }
950}