gsm_testutil/
lib.rs

1use anyhow::{Context, Result, anyhow};
2use jsonschema::{Validator, validator_for};
3use once_cell::sync::Lazy;
4use serde::Serialize;
5use serde_json::Value;
6use std::collections::HashMap;
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12mod path_safety;
13
14#[cfg(feature = "e2e")]
15mod assertions;
16#[cfg(feature = "e2e")]
17pub mod e2e;
18#[cfg(feature = "e2e")]
19pub mod secrets;
20#[cfg(feature = "visual")]
21pub mod visual;
22
23fn workspace_root() -> PathBuf {
24    // workspace root is two levels up from this crate's manifest (libs/testutil)
25    Path::new(env!("CARGO_MANIFEST_DIR"))
26        .ancestors()
27        .nth(2)
28        .expect("workspace root")
29        .to_path_buf()
30}
31
32#[derive(Debug, Clone)]
33pub struct TestConfig {
34    pub platform: String,
35    pub env: Option<String>,
36    pub tenant: Option<String>,
37    pub team: Option<String>,
38    pub credentials: Option<Value>,
39    pub secret_uri: Option<String>,
40}
41
42impl TestConfig {
43    pub fn from_env_or_secrets(platform: &str) -> Result<Option<Self>> {
44        // Load .env files when present so local credentials become available in tests.
45        dotenvy::dotenv().ok();
46
47        let env = env::var("GREENTIC_ENV").ok();
48        let tenant = env::var("TENANT").ok();
49        let team = env::var("TEAM").ok();
50
51        if let Some(credentials) = load_seed_credentials(platform)? {
52            let secret_uri =
53                build_secret_uri(env.as_deref(), tenant.as_deref(), team.as_deref(), platform);
54            return Ok(Some(Self {
55                platform: platform.to_string(),
56                env,
57                tenant,
58                team,
59                credentials: Some(credentials),
60                secret_uri,
61            }));
62        }
63
64        Ok(None)
65    }
66}
67
68/// Load credentials from a greentic-secrets seed file when specified.
69/// Supported formats:
70/// - SeedDoc with `entries: [{ uri, value, ... }]`
71/// - Flat map `{ "uri": "...", "value": ... }` (single entry)
72///
73/// The seed file path is provided via `MESSAGING_SEED_FILE` (YAML or JSON).
74fn load_seed_credentials(platform: &str) -> Result<Option<Value>> {
75    let path = match env::var("MESSAGING_SEED_FILE") {
76        Ok(path) => PathBuf::from(path),
77        Err(_) => return Ok(None),
78    };
79    let absolute = absolute_path(path)?;
80    let content = fs::read_to_string(&absolute)
81        .with_context(|| format!("failed to read seed file {}", absolute.display()))?;
82    let value: Value = if absolute
83        .extension()
84        .and_then(|ext| ext.to_str())
85        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
86        .unwrap_or(false)
87    {
88        let yaml: serde_yaml_bw::Value = serde_yaml_bw::from_str(&content)
89            .with_context(|| format!("failed to parse yaml {}", absolute.display()))?;
90        serde_json::to_value(yaml)
91            .with_context(|| format!("failed to convert yaml {}", absolute.display()))?
92    } else {
93        serde_json::from_str(&content)
94            .with_context(|| format!("failed to parse json {}", absolute.display()))?
95    };
96
97    if let Some(entry) = value
98        .get("entries")
99        .and_then(|entries| entries.as_array())
100        .and_then(|entries| {
101            entries.iter().find(|entry| {
102                entry
103                    .get("uri")
104                    .and_then(|uri| uri.as_str())
105                    .map(|uri| uri.ends_with(&format!("{platform}.credentials.json")))
106                    .unwrap_or(false)
107            })
108        })
109        && let Some(val) = entry.get("value")
110    {
111        return Ok(Some(val.clone()));
112    }
113
114    if value
115        .get("uri")
116        .and_then(|uri| uri.as_str())
117        .map(|uri| uri.ends_with(&format!("{platform}.credentials.json")))
118        .unwrap_or(false)
119        && let Some(val) = value.get("value")
120    {
121        return Ok(Some(val.clone()));
122    }
123
124    Ok(None)
125}
126
127fn build_secret_uri(
128    env: Option<&str>,
129    tenant: Option<&str>,
130    team: Option<&str>,
131    platform: &str,
132) -> Option<String> {
133    let env = env?;
134    let tenant = tenant?;
135    let team = team.unwrap_or("default");
136    Some(format!(
137        "secrets://{env}/{tenant}/{team}/messaging/{platform}.credentials.json"
138    ))
139}
140
141pub fn load_card_value(path: &str) -> Result<Value> {
142    let absolute = absolute_path(path)?;
143    let content = fs::read_to_string(&absolute)
144        .with_context(|| format!("failed to read {}", absolute.display()))?;
145    let extension = absolute
146        .extension()
147        .and_then(|ext| ext.to_str())
148        .unwrap_or_default()
149        .to_ascii_lowercase();
150
151    match extension.as_str() {
152        "json" => serde_json::from_str(&content)
153            .with_context(|| format!("failed to parse json {}", absolute.display())),
154        "yaml" | "yml" => {
155            let yaml: serde_yaml_bw::Value = serde_yaml_bw::from_str(&content)
156                .with_context(|| format!("failed to parse yaml {}", absolute.display()))?;
157            serde_json::to_value(yaml)
158                .with_context(|| format!("failed to convert yaml {}", absolute.display()))
159        }
160        other => Err(anyhow!("unsupported fixture extension: {other}")),
161    }
162}
163
164fn absolute_path<P>(path: P) -> Result<PathBuf>
165where
166    P: AsRef<Path>,
167{
168    let root = workspace_root();
169    let relative = path.as_ref();
170    if relative.is_absolute() {
171        let canonical = relative
172            .canonicalize()
173            .with_context(|| format!("failed to canonicalize {}", relative.display()))?;
174        if !canonical.starts_with(&root) {
175            anyhow::bail!(
176                "absolute path escapes workspace root ({}): {}",
177                root.display(),
178                canonical.display()
179            );
180        }
181        return Ok(canonical);
182    }
183
184    path_safety::normalize_under_root(&root, relative)
185}
186
187pub fn assert_matches_schema<P>(schema_path: P, value: &Value) -> Result<()>
188where
189    P: AsRef<Path>,
190{
191    let compiled = load_compiled_schema(schema_path.as_ref())?;
192
193    let mut errors = compiled.iter_errors(value);
194    if let Some(first) = errors.next() {
195        let mut messages: Vec<String> = Vec::new();
196        messages.push(first.to_string());
197        for err in errors {
198            messages.push(err.to_string());
199        }
200        return Err(anyhow!("schema validation failed: {}", messages.join("; ")));
201    }
202
203    Ok(())
204}
205
206fn load_schema(path: &Path) -> Result<Value> {
207    let absolute = absolute_path(path)?;
208    let content = fs::read_to_string(&absolute)
209        .with_context(|| format!("failed to read {}", absolute.display()))?;
210    serde_json::from_str(&content)
211        .with_context(|| format!("failed to parse json {}", absolute.display()))
212}
213
214fn load_compiled_schema(path: &Path) -> Result<Arc<Validator>> {
215    static CACHE: Lazy<Mutex<HashMap<PathBuf, Arc<Validator>>>> =
216        Lazy::new(|| Mutex::new(HashMap::new()));
217
218    let absolute = absolute_path(path)?;
219
220    {
221        let cache = CACHE.lock().unwrap();
222        if let Some(schema) = cache.get(&absolute) {
223            return Ok(schema.clone());
224        }
225    }
226
227    let schema_value = load_schema(&absolute)?;
228    let compiled = validator_for(&schema_value)
229        .map_err(|err| anyhow!("failed to compile json schema: {err}"))?;
230    let compiled = Arc::new(compiled);
231
232    let mut cache = CACHE.lock().unwrap();
233    let entry = cache.entry(absolute).or_insert_with(|| compiled.clone());
234    Ok(entry.clone())
235}
236
237pub fn to_json_value<T>(value: &T) -> Result<Value>
238where
239    T: Serialize,
240{
241    serde_json::to_value(value).context("failed to convert to json value")
242}
243
244#[macro_export]
245macro_rules! skip_or_require {
246    ($expr:expr $(,)?) => {{
247        match $expr {
248            Ok(Some(value)) => value,
249            Ok(None) => {
250                eprintln!("skipping test: required secrets not available");
251                return;
252            }
253            Err(err) => panic!("failed to load test secrets: {err:?}"),
254        }
255    }};
256    ($expr:expr, $($msg:tt)+) => {{
257        match $expr {
258            Ok(Some(value)) => value,
259            Ok(None) => {
260                eprintln!("skipping test: {}", format!($($msg)+));
261                return;
262            }
263            Err(err) => panic!("failed to load test secrets: {err:?}"),
264        }
265    }};
266}
267
268#[macro_export]
269macro_rules! load_card {
270    ($path:expr $(,)?) => {{
271        $crate::load_card_value($path)
272            .unwrap_or_else(|err| panic!("failed to load card {}: {}", $path, err))
273    }};
274}
275
276#[macro_export]
277macro_rules! assert_snapshot_json {
278    ($name:expr, $value:expr $(,)?) => {{
279        let snapshot_value = $crate::to_json_value(&$value)
280            .unwrap_or_else(|err| panic!("failed to serialise snapshot {}: {}", $name, err));
281        insta::assert_json_snapshot!($name, snapshot_value);
282    }};
283}