Skip to main content

bynk_check/
test_env.rs

1//! #2134: a suite's configuration, `env { NAME = "value" … }`.
2//!
3//! A suite or case declares the variables and secrets its participants see.
4//! At `system` each participant's Worker `env` holds them, as Wrangler would
5//! put them there; at `unit`/`integration` they are what the `Secrets` test
6//! double answers. One map applies to every participant, and a case's `env`
7//! layers over its suite's, name by name.
8//!
9//! The checks here are tier-independent: a name given twice at one scope
10//! (`bynk.suite.duplicate_env`) and a value that is not a `String` literal
11//! (`bynk.suite.env_not_literal`). The `system`-only binding clash
12//! (`bynk.suite.env_binding_clash`) needs each Worker's binding names, which
13//! the emitter computes, so it is [`check_env_binding_clash`], called from
14//! there with them.
15
16use std::collections::{BTreeMap, HashMap};
17
18use bynk_syntax::ast::{Case, EnvClause, ExprKind, SuiteDecl};
19use bynk_syntax::error::CompileError;
20use bynk_syntax::span::Span;
21
22/// `bynk.suite.duplicate_env` and `bynk.suite.env_not_literal` for one suite
23/// declaration: its own `env` clauses as one scope, and each case's as
24/// another.
25pub fn check_env_clauses(decl: &SuiteDecl, errors: &mut Vec<CompileError>) {
26    check_scope(&decl.envs, "suite", errors);
27    for case in &decl.cases {
28        check_scope(&case.envs, "case", errors);
29    }
30}
31
32fn check_scope(envs: &[EnvClause], scope: &str, errors: &mut Vec<CompileError>) {
33    let mut seen: HashMap<&str, Span> = HashMap::new();
34    for entry in envs.iter().flat_map(|e| e.entries.iter()) {
35        let name = entry.name.name.as_str();
36        if let Some(prev) = seen.get(name) {
37            errors.push(
38                CompileError::new(
39                    "bynk.suite.duplicate_env",
40                    entry.name.span,
41                    format!("`{name}` is given more than once in this {scope}'s `env`"),
42                )
43                .with_label(*prev, "first given here")
44                .with_note(
45                    "each name has one value per scope; a case's `env` may override a name its suite gives",
46                ),
47            );
48        } else {
49            seen.insert(name, entry.name.span);
50        }
51        if !matches!(entry.value.kind, ExprKind::StrLit(_)) {
52            errors.push(
53                CompileError::new(
54                    "bynk.suite.env_not_literal",
55                    entry.value.span,
56                    format!("the value of `{name}` in an `env` clause is not a `String` literal"),
57                )
58                .with_note(
59                    "a Worker's variables and secrets are strings, so an `env` entry's value is written as one: `NAME = \"value\"`",
60                ),
61            );
62        }
63    }
64}
65
66/// The variables and secrets `case` runs with: its suite's `env` entries,
67/// overlaid by the case's own, name by name. Only literal values are kept;
68/// a non-literal is already `bynk.suite.env_not_literal`.
69pub fn case_env(decl: &SuiteDecl, case: &Case) -> BTreeMap<String, String> {
70    let mut env = BTreeMap::new();
71    for envs in [&decl.envs, &case.envs] {
72        for entry in envs.iter().flat_map(|e| e.entries.iter()) {
73            if let ExprKind::StrLit(v) = &entry.value.kind {
74                env.insert(entry.name.name.clone(), v.clone());
75            }
76        }
77    }
78    env
79}
80
81/// Whether `decl` gives any `env` entry, at the suite or on a case.
82pub fn has_env(decl: &SuiteDecl) -> bool {
83    !decl.envs.is_empty() || decl.cases.iter().any(|c| !c.envs.is_empty())
84}
85
86/// `bynk.suite.env_binding_clash`: at `system`, an `env` entry lands on each
87/// participant's Worker `env`, so a name one of them already binds (a Service
88/// Binding, KV namespace or Durable Object namespace), or a key the harness
89/// sets there itself (a call edge's key, a foreign caller's secret), is
90/// refused. `bindings` maps each such name to what holds it, as a phrase
91/// ("a binding of `shop.ledger`'s Worker").
92pub fn check_env_binding_clash(
93    decl: &SuiteDecl,
94    bindings: &BTreeMap<String, String>,
95    errors: &mut Vec<CompileError>,
96) {
97    let entries = decl
98        .envs
99        .iter()
100        .chain(decl.cases.iter().flat_map(|c| c.envs.iter()))
101        .flat_map(|e| e.entries.iter());
102    for entry in entries {
103        let name = &entry.name.name;
104        let Some(owner) = bindings.get(name) else {
105            continue;
106        };
107        errors.push(
108            CompileError::new(
109                "bynk.suite.env_binding_clash",
110                entry.name.span,
111                format!("`{name}` is {owner}, so it cannot be given in `env`"),
112            )
113            .with_note(
114                "at `system`, an `env` entry is put on every participant's Worker `env`, where it would clash with what is already there",
115            )
116            .with_note("rename the variable"),
117        );
118    }
119}
120
121/// #2138 (end-to-end testing track §3.7): a case run against a named target.
122/// A deployed target already runs with its own variables, so the runner can't
123/// apply the case's `env`; the case runs only if its `env` and the target's
124/// declared `vars` agree on every name either mentions. Refused
125/// (`bynk.test.remote_unsupported_env`), first match reported:
126/// - an entry naming one of the target's `secrets` — a secret comes from the
127///   environment, never from a suite;
128/// - an entry the target doesn't declare, or declares with another value;
129/// - a declared variable the case doesn't set, which would read as `None` in
130///   process, so the case wouldn't promote unchanged.
131pub fn remote_env_disagreement(
132    case_name: &str,
133    span: Span,
134    env: &BTreeMap<String, String>,
135    vars: &BTreeMap<String, String>,
136    secrets: &[String],
137) -> Option<CompileError> {
138    let refusal = |what: String, note: &str| {
139        Some(
140            CompileError::new(
141                "bynk.test.remote_unsupported_env",
142                span,
143                format!(
144                    "case `\"{case_name}\"` {what}, so it can't run against this target unchanged"
145                ),
146            )
147            .with_note(note.to_string()),
148        )
149    };
150    for (name, value) in env {
151        if secrets.contains(name) {
152            return refusal(
153                format!("sets the secret `{name}` in its `env`"),
154                "against a named target a secret comes from the environment, never from a suite; drop it from the case's `env` for this run",
155            );
156        }
157        match vars.get(name) {
158            None => {
159                return refusal(
160                    format!("sets `{name}` in its `env`, which the target doesn't declare"),
161                    "declare the value the target was deployed with under its `vars`, or run the case in process",
162                );
163            }
164            Some(deployed) if deployed != value => {
165                return refusal(
166                    format!(
167                        "sets `{name} = \"{value}\"`, but the target was deployed with `\"{deployed}\"`"
168                    ),
169                    "a suite written for one configuration must not run against another; run it in process, or against a target deployed as it expects",
170                );
171            }
172            Some(_) => {}
173        }
174    }
175    if let Some(name) = vars.keys().find(|n| !env.contains_key(*n)) {
176        return refusal(
177            format!("doesn't set `{name}`, which the target declares"),
178            "in process an unset name reads as `None`, so the target is a different environment; pin the value in the case's `env`",
179        );
180    }
181    None
182}
183
184#[cfg(test)]
185mod remote_env_tests {
186    use super::*;
187
188    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
189        pairs
190            .iter()
191            .map(|(k, v)| (k.to_string(), v.to_string()))
192            .collect()
193    }
194
195    fn check(env: &[(&str, &str)], vars: &[(&str, &str)], secrets: &[&str]) -> Option<String> {
196        let secrets: Vec<String> = secrets.iter().map(|s| s.to_string()).collect();
197        remote_env_disagreement("c", Span::default(), &map(env), &map(vars), &secrets)
198            .map(|e| e.message)
199    }
200
201    #[test]
202    fn agreeing_env_and_vars_run() {
203        assert_eq!(check(&[], &[], &[]), None);
204        assert_eq!(check(&[("P", "fake")], &[("P", "fake")], &["S"]), None);
205    }
206
207    #[test]
208    fn every_disagreement_is_refused() {
209        let listed = check(&[("S", "x")], &[], &["S"]).expect("a listed name is refused");
210        assert!(listed.contains("sets the secret `S`"), "{listed}");
211        let undeclared = check(&[("P", "fake")], &[], &[]).expect("undeclared is refused");
212        assert!(undeclared.contains("doesn't declare"), "{undeclared}");
213        let differs = check(&[("P", "fake")], &[("P", "real")], &[]).expect("differs");
214        assert!(differs.contains("deployed with `\"real\"`"), "{differs}");
215        let unset = check(&[], &[("P", "real")], &[]).expect("unset is refused");
216        assert!(unset.contains("doesn't set `P`"), "{unset}");
217    }
218}