Skip to main content

sendra_core/environment/
substitute.rs

1//! The recursive walk over the parsed domain model — [`Request`], its
2//! [`Assertions`], [`Collection`] and [`Document`] — that puts an
3//! [`Environment`]'s variables into place. See the module doc on
4//! [`environment`](super) for why this happens post-parse rather than as a
5//! find-and-replace over raw file text, and for the fields this walk
6//! deliberately leaves untouched.
7
8use std::collections::BTreeMap;
9
10use crate::assertions::{Assertions, NotAssertions};
11use crate::{
12    ApiKeyAuth, Auth, BasicAuth, Collection, Document, MultipartPart, OAuthAuth, Request,
13    SendraError,
14};
15
16use super::Environment;
17
18/// Delimiters for a `{{variable}}` reference in a request file.
19const TEMPLATE_OPEN: &str = "{{";
20const TEMPLATE_CLOSE: &str = "}}";
21
22impl Environment {
23    /// Substitute this environment into `request`, returning the request as it
24    /// will be sent.
25    ///
26    /// Every `{{name}}` in `url`, in each header name, in each header value, in
27    /// `body` and in the *values* of the `assertions` block is replaced. An
28    /// unknown name is [`VariableNotFound`](SendraError::VariableNotFound), and
29    /// a value whose `${VAR}` is not in the OS environment is
30    /// [`EnvVarNotSet`](SendraError::EnvVarNotSet) — never an empty string, and
31    /// never a half-substituted request. The whole request is built before it is
32    /// sent, so both failures land before any of its bytes go out.
33    ///
34    /// Headers are substituted in place, entry by entry, so order is preserved
35    /// exactly as written in the file. Two header names that were distinct in
36    /// the file can collide once substituted (`{{prefix}}-Key` and `X-Key`,
37    /// say) — that used to be an error back when headers were a map and a
38    /// silent collision would have dropped a value, but `Request.headers` is a
39    /// `Vec` that allows a name to repeat, so a post-substitution collision is
40    /// now exactly that: two headers of the same name, sent as written.
41    ///
42    /// Assertions are substituted for the same reason the rest of the file is:
43    /// what a staging response should say is exactly as environment-dependent
44    /// as what the request asks for, and `body_contains: '{{tenant}}'` would
45    /// otherwise compare against the literal braces. See
46    /// [`apply_assertions`](Self::apply_assertions) for the one line it draws.
47    pub fn apply(&self, request: &Request) -> Result<Request, SendraError> {
48        let mut headers = Vec::with_capacity(request.headers.len());
49        for (name, value) in &request.headers {
50            let name = self.expand_templates(name)?;
51            let value = self.expand_templates(value)?;
52            headers.push((name, value));
53        }
54
55        // `{{var}}` reaches query values (and list entries within them) the
56        // same way it reaches header values above — consistent with every
57        // other value field. Computed as a local, not inline in the struct
58        // literal below, because the `auth` field below needs it too — an
59        // environment-level default `auth.api_key` in `query` form has to be
60        // checked for a collision against these same substituted names.
61        let query: Vec<(String, String)> = request
62            .query
63            .iter()
64            .map(|(name, value)| Ok((self.expand_templates(name)?, self.expand_templates(value)?)))
65            .collect::<Result<_, SendraError>>()?;
66
67        // `auth` precedence: a request's own `auth:` fully replaces this
68        // environment's default — never merged — so the environment's
69        // `auth` is only even substituted when the request set none of its
70        // own. Either way `{{var}}` reaches every field (`bearer`, `basic`'s
71        // `user`/`pass`, `api_key`'s `name`/`value`) the same way it reaches
72        // every other value field.
73        let auth = match &request.auth {
74            Some(auth) => Some(self.substitute_auth(auth)?),
75            None => match &self.auth {
76                Some(auth) => {
77                    let auth = self.substitute_auth(auth)?;
78                    // `Request::validate` already checked a request's own
79                    // `auth:` against its own headers/query at parse time;
80                    // an environment's default cannot be checked until now,
81                    // once it is known which request (and its
82                    // already-substituted headers/query) it is being
83                    // applied to.
84                    if let Some(reason) = auth.collision_reason(&headers, &query) {
85                        return Err(SendraError::InvalidRequest { reason });
86                    }
87                    Some(auth)
88                }
89                None => None,
90            },
91        };
92
93        Ok(Request {
94            // `name` is left alone: it is what `sendra run <file> <name>`
95            // selects on, and a label that changed with the environment could
96            // not be typed on the command line.
97            name: request.name.clone(),
98            method: request.method,
99            url: self.expand_templates(&request.url)?,
100            headers,
101            query,
102            body: request
103                .body
104                .as_deref()
105                .map(|body| self.expand_templates(body))
106                .transpose()?,
107            // `{{var}}` reaches every string value here, the same way it
108            // reaches `body` above — a JSON body wanting a substituted field
109            // is exactly as ordinary a case as a substituted plain body.
110            // `expand_json` already exists for `assertions.json`; the rule is
111            // the same, values only, nothing about keys.
112            json: request
113                .json
114                .as_ref()
115                .map(|value| self.expand_json(value))
116                .transpose()?,
117            // The path itself is a value like any other and is substituted;
118            // what it points to is not. See `Request::resolve_body`, which is
119            // where that file is actually read, well after this runs.
120            body_file: request
121                .body_file
122                .as_deref()
123                .map(|path| self.expand_templates(path))
124                .transpose()?,
125            form: request
126                .form
127                .iter()
128                .map(|(name, value)| {
129                    Ok((self.expand_templates(name)?, self.expand_templates(value)?))
130                })
131                .collect::<Result<_, SendraError>>()?,
132            multipart: request
133                .multipart
134                .iter()
135                .map(|part| {
136                    Ok(MultipartPart {
137                        name: self.expand_templates(&part.name)?,
138                        value: part
139                            .value
140                            .as_deref()
141                            .map(|value| self.expand_templates(value))
142                            .transpose()?,
143                        // Same rule as `body_file`: the path is a value and is
144                        // substituted, the file it names is not.
145                        path: part
146                            .path
147                            .as_deref()
148                            .map(|path| self.expand_templates(path))
149                            .transpose()?,
150                    })
151                })
152                .collect::<Result<_, SendraError>>()?,
153            auth,
154            assertions: request
155                .assertions
156                .as_ref()
157                .map(|assertions| self.apply_assertions(assertions))
158                .transpose()?,
159            // **Script source is not substituted**, and this is the line that
160            // says so. A `{{var}}` inside a script stays those five characters.
161            //
162            // Substitution is textual, and the reason it is confined to values
163            // is that a value must never be able to change the structure of the
164            // document around it. A script is not a value, it is *code*: the
165            // failure mode is not a malformed URL but a variable's contents
166            // being parsed as program text, which is the same problem one level
167            // worse. A script that needs an environment value reads it off the
168            // request it is handed, which arrives fully substituted by the time
169            // it runs.
170            pre_request: request.pre_request.clone(),
171            post_request: request.post_request.clone(),
172            // **The `capture` block is not substituted either**, and for the
173            // rule `apply_assertions` already draws rather than the one above.
174            // A capture's value is a JSON path, which selects *which part of
175            // the response is being looked at* — exactly the role a JSON path
176            // plays in an assertion, where keys are left literal so `--env`
177            // cannot silently redirect a check onto a different field. Its key
178            // is a variable name, and a name that changed with the environment
179            // could not be written as `{{name}}` in the request that uses it,
180            // for the same reason a request's `name` is left alone.
181            capture: request.capture.clone(),
182            // Not substituted, for the same reason `capture` is not: `count`
183            // and `delay_ms` are plain numbers, not `{{var}}`-bearing string
184            // fields, so there is nothing here for this pass to expand.
185            retry: request.retry,
186        })
187    }
188
189    /// [`Environment::apply`] for an `assertions` block.
190    ///
191    /// **Values are substituted; keys are not.** A header name and a JSON path
192    /// select *what part of the response is being looked at*, and an object key
193    /// inside an expected value names a field the same way. An environment is
194    /// meant to change what a response is compared against — a tenant, an id, a
195    /// host — not to change which field is inspected, and a run where `--env`
196    /// silently redirected an assertion onto a different header would be very
197    /// hard to read back. Keeping keys literal also means substitution here can
198    /// never collapse two entries into one, so no assertion can go missing on
199    /// the way to being checked.
200    ///
201    /// `status`, `status_in` and `elapsed_ms_under` are numbers and have
202    /// nothing to substitute.
203    fn apply_assertions(&self, assertions: &Assertions) -> Result<Assertions, SendraError> {
204        Ok(Assertions {
205            status: assertions.status,
206            status_in: assertions.status_in.clone(),
207            headers: self.apply_assertion_headers(&assertions.headers)?,
208            body_contains: assertions
209                .body_contains
210                .as_deref()
211                .map(|body| self.expand_templates(body))
212                .transpose()?,
213            body_matches: assertions
214                .body_matches
215                .as_deref()
216                .map(|pattern| self.expand_templates(pattern))
217                .transpose()?,
218            elapsed_ms_under: assertions.elapsed_ms_under,
219            json: self.apply_assertion_json(&assertions.json)?,
220            not: assertions
221                .not
222                .as_ref()
223                .map(|not| self.apply_not_assertions(not))
224                .transpose()?,
225        })
226    }
227
228    /// [`apply_assertions`](Self::apply_assertions) for the `not:` block,
229    /// which carries the same substitutable fields.
230    fn apply_not_assertions(&self, not: &NotAssertions) -> Result<NotAssertions, SendraError> {
231        Ok(NotAssertions {
232            status: not.status,
233            status_in: not.status_in.clone(),
234            headers: self.apply_assertion_headers(&not.headers)?,
235            body_contains: not
236                .body_contains
237                .as_deref()
238                .map(|body| self.expand_templates(body))
239                .transpose()?,
240            body_matches: not
241                .body_matches
242                .as_deref()
243                .map(|pattern| self.expand_templates(pattern))
244                .transpose()?,
245            elapsed_ms_under: not.elapsed_ms_under,
246            json: self.apply_assertion_json(&not.json)?,
247        })
248    }
249
250    fn apply_assertion_headers(
251        &self,
252        headers: &BTreeMap<String, Option<String>>,
253    ) -> Result<BTreeMap<String, Option<String>>, SendraError> {
254        let mut expanded = BTreeMap::new();
255        for (name, expected) in headers {
256            let expected = expected
257                .as_deref()
258                .map(|value| self.expand_templates(value))
259                .transpose()?;
260            expanded.insert(name.clone(), expected);
261        }
262        Ok(expanded)
263    }
264
265    fn apply_assertion_json(
266        &self,
267        json: &BTreeMap<String, serde_json::Value>,
268    ) -> Result<BTreeMap<String, serde_json::Value>, SendraError> {
269        let mut expanded = BTreeMap::new();
270        for (path, expected) in json {
271            expanded.insert(path.clone(), self.expand_json(expected)?);
272        }
273        Ok(expanded)
274    }
275
276    /// Substitute into every string *value* of an expected JSON value,
277    /// including those nested in arrays and objects. Object keys are left alone,
278    /// per the rule on [`apply_assertions`](Self::apply_assertions); numbers,
279    /// booleans and null have no text to expand.
280    fn expand_json(&self, value: &serde_json::Value) -> Result<serde_json::Value, SendraError> {
281        use serde_json::Value;
282        Ok(match value {
283            Value::String(text) => Value::String(self.expand_templates(text)?),
284            Value::Array(items) => Value::Array(
285                items
286                    .iter()
287                    .map(|item| self.expand_json(item))
288                    .collect::<Result<_, _>>()?,
289            ),
290            Value::Object(fields) => Value::Object(
291                fields
292                    .iter()
293                    .map(|(key, value)| Ok((key.clone(), self.expand_json(value)?)))
294                    .collect::<Result<_, SendraError>>()?,
295            ),
296            other => other.clone(),
297        })
298    }
299
300    /// [`Environment::apply`] for every request in a collection, in file order.
301    ///
302    /// All or nothing: one request that cannot be substituted fails the whole
303    /// call. That suits a caller that wants a fully-resolved collection in hand,
304    /// which is what this returns. It is *not* what `sendra run` does with a
305    /// collection — there each request is substituted as it is reached, so a
306    /// broken one fails on its own and the requests around it are still sent, in
307    /// the same way a refused connection does not cancel its siblings. A caller
308    /// wanting those per-request outcomes calls [`Environment::apply`] in a loop
309    /// and keeps each `Result`.
310    ///
311    /// The collection's own `name` is left alone for the same reason a
312    /// request's is.
313    pub fn apply_collection(&self, collection: &Collection) -> Result<Collection, SendraError> {
314        Ok(Collection {
315            name: collection.name.clone(),
316            requests: collection
317                .requests
318                .iter()
319                .map(|request| self.apply(request))
320                .collect::<Result<_, _>>()?,
321        })
322    }
323
324    /// [`Environment::apply`] over whichever shape a file turned out to hold.
325    pub fn apply_document(&self, document: &Document) -> Result<Document, SendraError> {
326        Ok(match document {
327            Document::Single(request) => Document::Single(self.apply(request)?),
328            Document::Collection(collection) => {
329                Document::Collection(self.apply_collection(collection)?)
330            }
331        })
332    }
333
334    /// Replace every `{{name}}` in `text`.
335    fn expand_templates(&self, text: &str) -> Result<String, SendraError> {
336        super::expand(text, TEMPLATE_OPEN, TEMPLATE_CLOSE, |name| {
337            self.lookup(name)
338        })
339    }
340
341    /// `{{var}}` reaches a bearer token, basic user/pass, or an api_key's
342    /// name/value the same way it reaches every other value field — see the
343    /// note on `Request::auth`. Shared between substituting a request's own
344    /// `auth:` and this environment's default `auth:` ([`apply`](Self::apply)),
345    /// since both are the exact same [`Auth`] shape substituted against the
346    /// exact same environment.
347    fn substitute_auth(&self, auth: &Auth) -> Result<Auth, SendraError> {
348        Ok(Auth {
349            bearer: auth
350                .bearer
351                .as_deref()
352                .map(|token| self.expand_templates(token))
353                .transpose()?,
354            basic: auth
355                .basic
356                .as_ref()
357                .map(|basic| -> Result<BasicAuth, SendraError> {
358                    Ok(BasicAuth {
359                        user: self.expand_templates(&basic.user)?,
360                        pass: self.expand_templates(&basic.pass)?,
361                    })
362                })
363                .transpose()?,
364            api_key: auth
365                .api_key
366                .as_ref()
367                .map(|api_key| -> Result<ApiKeyAuth, SendraError> {
368                    Ok(ApiKeyAuth {
369                        r#in: api_key.r#in,
370                        name: self.expand_templates(&api_key.name)?,
371                        value: self.expand_templates(&api_key.value)?,
372                    })
373                })
374                .transpose()?,
375            oauth: auth
376                .oauth
377                .as_ref()
378                .map(|oauth| -> Result<OAuthAuth, SendraError> {
379                    Ok(OAuthAuth {
380                        grant_type: oauth.grant_type,
381                        token_url: self.expand_templates(&oauth.token_url)?,
382                        client_id: self.expand_templates(&oauth.client_id)?,
383                        client_secret: oauth
384                            .client_secret
385                            .as_deref()
386                            .map(|value| self.expand_templates(value))
387                            .transpose()?,
388                        scope: oauth
389                            .scope
390                            .as_deref()
391                            .map(|value| self.expand_templates(value))
392                            .transpose()?,
393                        username: oauth
394                            .username
395                            .as_deref()
396                            .map(|value| self.expand_templates(value))
397                            .transpose()?,
398                        password: oauth
399                            .password
400                            .as_deref()
401                            .map(|value| self.expand_templates(value))
402                            .transpose()?,
403                        authorization_url: oauth
404                            .authorization_url
405                            .as_deref()
406                            .map(|value| self.expand_templates(value))
407                            .transpose()?,
408                        redirect_uri: oauth
409                            .redirect_uri
410                            .as_deref()
411                            .map(|value| self.expand_templates(value))
412                            .transpose()?,
413                    })
414                })
415                .transpose()?,
416        })
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::super::test_helpers::environment;
423    use super::*;
424
425    use crate::Method;
426
427    /// A request touching all three substitutable places at once.
428    const TEMPLATED: &str = "\
429name: Templated
430method: POST
431url: '{{base_url}}/users/{{user_id}}'
432headers:
433  Authorization: 'Bearer {{api_key}}'
434  '{{header_name}}': fixed-value
435body: '{\"host\": \"{{base_url}}\"}'
436";
437
438    #[test]
439    fn substitutes_url_headers_and_body() {
440        let request = Request::from_yaml_str(TEMPLATED).unwrap();
441        let environment = environment(
442            &[
443                ("base_url", "https://staging.example.com"),
444                ("user_id", "42"),
445                ("api_key", "s3cret"),
446                ("header_name", "X-Tenant"),
447            ],
448            &[],
449        );
450
451        let applied = environment.apply(&request).expect("every variable is set");
452
453        assert_eq!(applied.url, "https://staging.example.com/users/42");
454        assert_eq!(applied.header("Authorization"), Some("Bearer s3cret"));
455        // Header *names* are substituted too, not just values.
456        assert_eq!(applied.header("X-Tenant"), Some("fixed-value"));
457        assert_eq!(
458            applied.body.as_deref(),
459            Some("{\"host\": \"https://staging.example.com\"}")
460        );
461        // The label is deliberately untouched: it is the run selector.
462        assert_eq!(applied.name.as_deref(), Some("Templated"));
463        assert_eq!(applied.method, Method::Post);
464    }
465
466    #[test]
467    fn substitution_reaches_json_form_and_multipart_values_but_not_a_body_files_content() {
468        let yaml = "\
469method: POST
470url: https://example.com
471";
472        // Three requests, one per structured body field, each with a
473        // `{{tenant}}` inside a *value*: a JSON/form/multipart body wanting a
474        // substituted field is exactly as ordinary as a substituted plain
475        // `body`.
476        let json_request = Request::from_yaml_str(&format!(
477            "{yaml}json:\n  tenant: '{{{{tenant}}}}'\n  nested:\n    id: '{{{{tenant}}}}'\n"
478        ))
479        .unwrap();
480        let form_request =
481            Request::from_yaml_str(&format!("{yaml}form:\n  tenant: '{{{{tenant}}}}'\n")).unwrap();
482        let multipart_request = Request::from_yaml_str(&format!(
483            "{yaml}multipart:\n  - name: '{{{{tenant}}}}'\n    value: '{{{{tenant}}}}'\n"
484        ))
485        .unwrap();
486
487        let environment = environment(&[("tenant", "acme")], &[]);
488
489        let applied_json = environment.apply(&json_request).expect("tenant is set");
490        assert_eq!(
491            applied_json.json,
492            Some(serde_json::json!({"tenant": "acme", "nested": {"id": "acme"}}))
493        );
494
495        let applied_form = environment.apply(&form_request).expect("tenant is set");
496        assert_eq!(
497            applied_form.form,
498            vec![("tenant".to_string(), "acme".to_string())]
499        );
500
501        let applied_multipart = environment
502            .apply(&multipart_request)
503            .expect("tenant is set");
504        assert_eq!(applied_multipart.multipart[0].name, "acme");
505        assert_eq!(
506            applied_multipart.multipart[0].value.as_deref(),
507            Some("acme")
508        );
509
510        // `body_file`'s *path* is a value like any other and is substituted...
511        let body_file_request =
512            Request::from_yaml_str(&format!("{yaml}body_file: './{{{{tenant}}}}.json'\n")).unwrap();
513        let applied_body_file = environment
514            .apply(&body_file_request)
515            .expect("tenant is set");
516        assert_eq!(applied_body_file.body_file.as_deref(), Some("./acme.json"));
517
518        // ...but what a `body_file` or multipart file *path points to* is
519        // never read here at all — substitution is a pass over the parsed
520        // document, and a file on disk is not part of it. A placeholder
521        // inside the file's actual content survives untouched all the way to
522        // `Request::resolve_body`, which reads the file only after this runs.
523        let dir = tempfile::tempdir().unwrap();
524        std::fs::write(dir.path().join("acme.json"), "{{tenant}}").unwrap();
525        let resolved = applied_body_file
526            .resolve_body(dir.path())
527            .expect("the file is there");
528        assert_eq!(
529            resolved.body.as_deref(),
530            Some("{{tenant}}"),
531            "a placeholder inside the file's content must not be substituted"
532        );
533    }
534
535    #[test]
536    fn substitution_preserves_header_order_including_a_repeated_name() {
537        let yaml = "\
538method: GET
539url: https://example.com
540headers:
541  Accept: application/json
542  X-Forwarded-For:
543    - '{{first}}'
544    - '{{second}}'
545  X-Tenant: '{{tenant}}'
546";
547        let request = Request::from_yaml_str(yaml).unwrap();
548        let environment = environment(
549            &[
550                ("first", "1.2.3.4"),
551                ("second", "5.6.7.8"),
552                ("tenant", "acme"),
553            ],
554            &[],
555        );
556
557        let applied = environment.apply(&request).expect("every variable is set");
558
559        assert_eq!(
560            applied.headers,
561            vec![
562                ("Accept".to_string(), "application/json".to_string()),
563                ("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
564                ("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
565                ("X-Tenant".to_string(), "acme".to_string()),
566            ],
567            "order among distinct names and among a repeated name must both survive"
568        );
569    }
570
571    /// A request whose every assertion carries a placeholder, in each of the
572    /// three places one can appear.
573    const TEMPLATED_ASSERTIONS: &str = "\
574method: GET
575url: '{{base_url}}/users/{{user_id}}'
576assertions:
577  status: 200
578  headers:
579    x-tenant: '{{tenant}}'
580    content-type:
581  body_contains: '{{tenant}}'
582  json:
583    $.id: '{{user_id}}'
584    $.nested:
585      tenant: '{{tenant}}'
586      tags: ['{{tenant}}', literal]
587";
588
589    #[test]
590    fn substitutes_the_values_in_an_assertions_block() {
591        let request = Request::from_yaml_str(TEMPLATED_ASSERTIONS).unwrap();
592        let environment = environment(
593            &[
594                ("base_url", "https://staging.example.com"),
595                ("user_id", "42"),
596                ("tenant", "acme"),
597            ],
598            &[],
599        );
600
601        let assertions = environment
602            .apply(&request)
603            .expect("every variable is set")
604            .assertions
605            .expect("the block survives substitution");
606
607        assert_eq!(assertions.status, Some(200));
608        assert_eq!(
609            assertions.headers.get("x-tenant"),
610            Some(&Some("acme".to_string()))
611        );
612        // A presence-only assertion has no value to substitute and stays one.
613        assert_eq!(assertions.headers.get("content-type"), Some(&None));
614        assert_eq!(assertions.body_contains.as_deref(), Some("acme"));
615        // Including strings nested inside an expected object or array.
616        assert_eq!(assertions.json["$.id"], serde_json::json!("42"));
617        assert_eq!(
618            assertions.json["$.nested"],
619            serde_json::json!({"tenant": "acme", "tags": ["acme", "literal"]})
620        );
621    }
622
623    #[test]
624    fn script_source_is_not_substituted() {
625        // The other line, one step further out than `assertion_keys_are_not_
626        // substituted`: an environment changes *values*, and a script is not a
627        // value, it is code. `{{secret}}` and `${SECRET}` inside a script are
628        // five and nine characters of Rhai source, not a placeholder.
629        //
630        // The reasoning is the one that made substitution value-only in the
631        // first place, one level worse: a value that could rewrite the document
632        // around it is a bug, and a value that could rewrite a *program* is the
633        // same bug where the document is executable. A script that needs an
634        // environment value reads it off the request it is handed, which by
635        // then has been fully substituted.
636        let request = Request::from_yaml_str(
637            "\
638method: GET
639url: 'https://example.com/{{tenant}}'
640pre_request: |
641  request.headers[\"X-Tenant\"] = \"{{tenant}}\";
642  request.headers[\"X-Secret\"] = \"${SECRET}\";
643post_request: |
644  if response.body != \"{{tenant}}\" { throw \"{{tenant}}\"; }
645",
646        )
647        .unwrap();
648        let environment = environment(&[("tenant", "acme")], &[]);
649
650        let applied = environment.apply(&request).unwrap();
651
652        // The url was substituted, so the environment is live and this is not
653        // passing by accident.
654        assert_eq!(applied.url, "https://example.com/acme");
655
656        // Both scripts came through byte for byte.
657        assert_eq!(applied.pre_request, request.pre_request);
658        assert_eq!(applied.post_request, request.post_request);
659
660        // `${SECRET}` is the case that would be loudest if this ever changed:
661        // nothing exports it, so a substituted script would have failed the
662        // whole request with `EnvVarNotSet` rather than quietly meaning
663        // something else.
664        assert!(applied
665            .pre_request
666            .as_deref()
667            .unwrap()
668            .contains("${SECRET}"));
669        assert!(applied
670            .pre_request
671            .as_deref()
672            .unwrap()
673            .contains("{{tenant}}"));
674    }
675
676    #[test]
677    fn assertion_keys_are_not_substituted() {
678        // The line drawn in `apply_assertions`: an environment changes what a
679        // response is compared against, never which part of it is inspected. A
680        // header name, a JSON path, and a key inside an expected object all
681        // stay exactly as written — including one that looks like a
682        // placeholder, which is then simply text that never matches.
683        let request = Request::from_yaml_str(
684            "\
685method: GET
686url: https://example.com
687assertions:
688  headers:
689    '{{header_name}}': fixed
690  json:
691    '$.{{field}}': 1
692    $.obj:
693      '{{key}}': 2
694",
695        )
696        .unwrap();
697        let environment = environment(
698            &[("header_name", "X-Tenant"), ("field", "id"), ("key", "k")],
699            &[],
700        );
701
702        let assertions = environment.apply(&request).unwrap().assertions.unwrap();
703
704        assert!(assertions.headers.contains_key("{{header_name}}"));
705        assert!(assertions.json.contains_key("$.{{field}}"));
706        assert_eq!(assertions.json["$.obj"], serde_json::json!({"{{key}}": 2}));
707    }
708
709    #[test]
710    fn a_missing_variable_in_an_assertion_fails_the_request_like_any_other() {
711        // Assertions are substituted on the way to the wire, so an unresolvable
712        // one is the same failure as an unresolvable URL: the request is never
713        // sent, rather than being sent and then checked against `{{nope}}`.
714        let request = Request::from_yaml_str(
715            "method: GET\nurl: https://example.com\nassertions:\n  body_contains: '{{nope}}'\n",
716        )
717        .unwrap();
718
719        let err = environment(&[("tenant", "acme")], &[])
720            .apply(&request)
721            .expect_err("`nope` is not defined");
722
723        assert!(
724            matches!(err, SendraError::VariableNotFound { .. }),
725            "got {err:?}"
726        );
727    }
728
729    #[test]
730    fn a_request_with_no_placeholders_is_unchanged() {
731        // Substitution has to be a no-op for every file written before this
732        // feature existed.
733        let request =
734            Request::from_yaml_str("method: GET\nurl: https://example.com/a\nbody: 'plain'\n")
735                .unwrap();
736        let applied = Environment::default().apply(&request).unwrap();
737        assert_eq!(applied, request);
738    }
739
740    #[test]
741    fn substitution_works_inside_a_collection() {
742        let yaml = "\
743name: Example API
744requests:
745  - name: List users
746    method: GET
747    url: '{{base_url}}/users'
748  - name: Create user
749    method: POST
750    url: '{{base_url}}/users'
751    headers:
752      Authorization: 'Bearer {{api_key}}'
753    body: '{\"name\": \"ada\"}'
754";
755        let document = Document::from_yaml_str(yaml).unwrap();
756        let environment = environment(
757            &[("base_url", "https://staging.example.com")],
758            &[("API_KEY", "s3cret")],
759        );
760        // `api_key` comes from the OS environment, through the file.
761        let environment = Environment {
762            variables: {
763                let mut variables = environment.variables.clone();
764                variables.insert("api_key".to_string(), "${API_KEY}".to_string());
765                variables
766            },
767            ..environment
768        };
769
770        let Document::Collection(applied) = environment.apply_document(&document).unwrap() else {
771            panic!("a collection must stay a collection");
772        };
773
774        // File order, and the collection's own name, survive the pass.
775        assert_eq!(applied.name.as_deref(), Some("Example API"));
776        assert_eq!(applied.names(), vec!["List users", "Create user"]);
777        assert_eq!(applied.requests[0].url, "https://staging.example.com/users");
778        assert_eq!(applied.requests[1].url, "https://staging.example.com/users");
779        assert_eq!(
780            applied.requests[1].header("Authorization"),
781            Some("Bearer s3cret")
782        );
783        // Untemplated fields are carried through untouched.
784        assert_eq!(
785            applied.requests[1].body.as_deref(),
786            Some("{\"name\": \"ada\"}")
787        );
788    }
789
790    #[test]
791    fn applying_to_a_whole_document_is_all_or_nothing() {
792        // `apply_document` substitutes a collection as a unit, so one bad
793        // variable fails the lot. That is this function's contract, not the
794        // CLI's behaviour: `sendra run` substitutes each request as it reaches
795        // it, so a broken request there fails alone and its siblings are still
796        // sent. Anything wanting per-request outcomes calls `apply` in a loop.
797        let yaml = "\
798requests:
799  - name: Fine
800    method: GET
801    url: '{{base_url}}/a'
802  - name: Broken
803    method: GET
804    url: '{{missing}}/b'
805";
806        let document = Document::from_yaml_str(yaml).unwrap();
807        let environment = environment(&[("base_url", "https://example.com")], &[]);
808
809        let err = environment
810            .apply_document(&document)
811            .expect_err("the second request references nothing");
812        assert!(
813            matches!(&err, SendraError::VariableNotFound { name, .. } if name == "missing"),
814            "got {err:?}"
815        );
816    }
817
818    #[test]
819    fn a_value_is_not_rescanned_for_placeholders() {
820        // Single pass by design: a value that happens to contain `{{...}}` is
821        // data, not a further reference to resolve.
822        let request = Request::from_yaml_str("method: GET\nurl: '{{a}}'\n").unwrap();
823        let environment = environment(&[("a", "literal-{{b}}"), ("b", "never-used")], &[]);
824
825        let applied = environment.apply(&request).unwrap();
826        assert_eq!(applied.url, "literal-{{b}}");
827    }
828
829    #[test]
830    fn whitespace_inside_a_placeholder_is_ignored() {
831        let request = Request::from_yaml_str("method: GET\nurl: '{{  base_url  }}/x'\n").unwrap();
832        let environment = environment(&[("base_url", "https://example.com")], &[]);
833        assert_eq!(
834            environment.apply(&request).unwrap().url,
835            "https://example.com/x"
836        );
837    }
838
839    #[test]
840    fn text_that_only_looks_like_a_placeholder_is_left_alone() {
841        // An unterminated `{{`, and an empty `{{}}`: both much likelier to be
842        // ordinary text (a JSON body, a templating language) than a typo, so
843        // neither is an error.
844        for url in ["https://example.com/{{unclosed", "https://example.com/{{}}"] {
845            let request = Request::from_yaml_str(&format!("method: GET\nurl: '{url}'\n")).unwrap();
846            let applied = Environment::default()
847                .apply(&request)
848                .unwrap_or_else(|e| panic!("{url} should not error: {e}"));
849            assert_eq!(applied.url, url);
850        }
851    }
852
853    #[test]
854    fn two_header_names_resolving_to_the_same_name_after_substitution_keeps_both() {
855        // Back when `Request.headers` was a map, two names colliding after
856        // substitution would silently drop one value, so this used to be a
857        // reported error. Now that a name is allowed to repeat, a
858        // post-substitution collision is just that: two headers under the
859        // same name, both sent — nothing is lost, so there is nothing to
860        // report.
861        let yaml = "\
862method: GET
863url: https://example.com
864headers:
865  '{{name}}': from-template
866  X-Key: from-literal
867";
868        let request = Request::from_yaml_str(yaml).unwrap();
869        let environment = environment(&[("name", "X-Key")], &[]);
870
871        let applied = environment
872            .apply(&request)
873            .expect("a post-substitution collision is legal, not an error");
874        assert_eq!(
875            applied.headers,
876            vec![
877                ("X-Key".to_string(), "from-template".to_string()),
878                ("X-Key".to_string(), "from-literal".to_string()),
879            ]
880        );
881    }
882
883    #[test]
884    fn the_capture_block_is_carried_through_substitution_untouched() {
885        // Same rule as an assertion's JSON path keys and a script's source: a
886        // path selects *which* part of the response is read, and `--env` must
887        // not be able to redirect it.
888        let request = Request::from_yaml_str(
889            "method: GET
890url: '{{base_url}}'
891capture:
892  token: '$.{{field}}'
893",
894        )
895        .unwrap();
896        let environment = environment(&[("base_url", "https://example.com")], &[]);
897
898        let applied = environment
899            .apply(&request)
900            .expect("`{{field}}` is inside the capture block, which is not substituted");
901        assert_eq!(
902            applied.capture, request.capture,
903            "the block goes through verbatim"
904        );
905        assert_eq!(
906            applied.capture.as_ref().unwrap().entries()["token"],
907            crate::CaptureSource::JsonPath("$.{{field}}".to_string())
908        );
909    }
910
911    // --- environment-level default `auth:` -----------------------------------
912
913    #[test]
914    fn environment_level_auth_applies_when_the_request_sets_none_of_its_own() {
915        let environment = Environment::from_yaml_str(
916            "base_url: https://example.com\nauth:\n  bearer: env-token\n",
917        )
918        .unwrap();
919        let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}'\n").unwrap();
920
921        let applied = environment.apply(&request).expect("resolves");
922        let auth = applied.auth.expect("the environment default was filled in");
923        assert_eq!(auth.bearer.as_deref(), Some("env-token"));
924    }
925
926    #[test]
927    fn a_requests_own_auth_fully_replaces_the_environments_default_not_merges() {
928        let environment = Environment::from_yaml_str("auth:\n  bearer: env-token\n").unwrap();
929        let request = Request::from_yaml_str(
930            "method: GET\nurl: https://example.com\nauth:\n  basic:\n    user: a\n    pass: b\n",
931        )
932        .unwrap();
933
934        let applied = environment.apply(&request).expect("resolves");
935        let auth = applied.auth.expect("the request's own auth survives");
936        // The request's own `basic` wins outright — not merged with the
937        // environment's `bearer` into some combination of both.
938        assert!(auth.bearer.is_none());
939        assert_eq!(auth.basic.map(|basic| basic.user), Some("a".to_string()));
940    }
941
942    #[test]
943    fn environment_level_auth_substitutes_against_its_own_environments_variables() {
944        let environment =
945            Environment::from_yaml_str("token: s3cret\nauth:\n  bearer: '{{token}}'\n").unwrap();
946        let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
947
948        let applied = environment.apply(&request).expect("`token` resolves");
949        assert_eq!(
950            applied.auth.and_then(|auth| auth.bearer),
951            Some("s3cret".to_string())
952        );
953    }
954
955    #[test]
956    fn environment_level_auth_header_colliding_with_an_explicit_header_is_rejected() {
957        let environment = Environment::from_yaml_str("auth:\n  bearer: env-token\n").unwrap();
958        let request = Request::from_yaml_str(
959            "method: GET\nurl: https://example.com\nheaders:\n  Authorization: hand-written\n",
960        )
961        .unwrap();
962
963        let err = environment
964            .apply(&request)
965            .expect_err("the environment default would collide with the explicit header");
966        assert!(
967            matches!(&err, SendraError::InvalidRequest { reason } if reason.contains("Authorization")),
968            "got {err:?}"
969        );
970    }
971
972    #[test]
973    fn environment_level_api_key_in_query_form_resolves_end_to_end() {
974        let environment = Environment::from_yaml_str(
975            "auth:\n  api_key:\n    in: query\n    name: api_key\n    value: s3cret\n",
976        )
977        .unwrap();
978        let request =
979            Request::from_yaml_str("method: GET\nurl: https://example.com/search\n").unwrap();
980
981        let resolved = environment
982            .apply(&request)
983            .and_then(|request| request.resolve_auth())
984            .and_then(|request| request.resolve_query())
985            .expect("resolves end to end");
986        assert_eq!(resolved.url, "https://example.com/search?api_key=s3cret");
987    }
988
989    #[test]
990    fn a_malformed_environment_level_auth_block_is_a_typed_error_at_parse_time() {
991        let err =
992            Environment::from_yaml_str("auth:\n  bearer: x\n  basic:\n    user: a\n    pass: b\n")
993                .expect_err("bearer and basic together must be rejected");
994        assert!(
995            matches!(&err, SendraError::InvalidEnvironment { reason, .. } if reason.contains("bearer") && reason.contains("basic")),
996            "got {err:?}"
997        );
998    }
999}