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        let upper = platform.to_ascii_uppercase();
51
52        if let Some(credentials) = load_seed_credentials(platform)? {
53            let secret_uri =
54                build_secret_uri(env.as_deref(), tenant.as_deref(), team.as_deref(), platform);
55            return Ok(Some(Self {
56                platform: platform.to_string(),
57                env,
58                tenant,
59                team,
60                credentials: Some(credentials),
61                secret_uri,
62            }));
63        }
64
65        let disable_env = env::var("MESSAGING_DISABLE_ENV")
66            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
67            .unwrap_or(false);
68        let disable_secrets_root = env::var("MESSAGING_DISABLE_SECRETS_ROOT")
69            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
70            .unwrap_or(false);
71
72        if !disable_env && let Some(credentials) = load_env_credentials(&upper)? {
73            eprintln!(
74                "warning: using env-based credentials for {platform}; prefer greentic-secrets seed files (set MESSAGING_DISABLE_ENV=1 to forbid)"
75            );
76            let secret_uri =
77                build_secret_uri(env.as_deref(), tenant.as_deref(), team.as_deref(), platform);
78            return Ok(Some(Self {
79                platform: platform.to_string(),
80                env,
81                tenant,
82                team,
83                credentials: Some(credentials),
84                secret_uri,
85            }));
86        }
87
88        if !disable_secrets_root
89            && let Some(credentials) = load_secret_credentials(
90                platform,
91                env.as_deref(),
92                tenant.as_deref(),
93                team.as_deref(),
94            )?
95        {
96            eprintln!(
97                "warning: using GREENTIC_SECRETS_DIR/SECRETS_ROOT fallback for {platform}; prefer greentic-secrets seed files (set MESSAGING_DISABLE_SECRETS_ROOT=1 to forbid)"
98            );
99            let secret_uri =
100                build_secret_uri(env.as_deref(), tenant.as_deref(), team.as_deref(), platform);
101            return Ok(Some(Self {
102                platform: platform.to_string(),
103                env,
104                tenant,
105                team,
106                credentials: Some(credentials),
107                secret_uri,
108            }));
109        }
110
111        if disable_env || disable_secrets_root {
112            eprintln!(
113                "info: env/SECRETS_ROOT fallbacks disabled via MESSAGING_DISABLE_ENV={} MESSAGING_DISABLE_SECRETS_ROOT={}",
114                disable_env, disable_secrets_root
115            );
116        }
117
118        Ok(None)
119    }
120}
121
122fn load_env_credentials(key: &str) -> Result<Option<Value>> {
123    let var = format!("MESSAGING_{key}_CREDENTIALS");
124    if let Ok(raw) = env::var(&var) {
125        if raw.trim().is_empty() {
126            return Ok(None);
127        }
128        let json = serde_json::from_str(&raw).with_context(|| format!("failed to parse {var}"))?;
129        return Ok(Some(json));
130    }
131
132    let path_var = format!("MESSAGING_{key}_CREDENTIALS_PATH");
133    if let Ok(path) = env::var(&path_var) {
134        let credentials_path = PathBuf::from(path);
135        let safe_path = absolute_path(credentials_path)?;
136        let content = fs::read_to_string(&safe_path)
137            .with_context(|| format!("failed to read {}", safe_path.display()))?;
138        let json = serde_json::from_str(&content)
139            .with_context(|| format!("failed to parse {}", safe_path.display()))?;
140        return Ok(Some(json));
141    }
142
143    Ok(None)
144}
145
146fn load_secret_credentials(
147    platform: &str,
148    env: Option<&str>,
149    tenant: Option<&str>,
150    team: Option<&str>,
151) -> Result<Option<Value>> {
152    let env = match env {
153        Some(value) => value,
154        None => return Ok(None),
155    };
156    let tenant = match tenant {
157        Some(value) => value,
158        None => return Ok(None),
159    };
160    let team = team.unwrap_or("default");
161
162    let root = match env::var("GREENTIC_SECRETS_DIR").or_else(|_| env::var("SECRETS_ROOT")) {
163        Ok(value) => value,
164        Err(_) => return Ok(None),
165    };
166
167    let root = PathBuf::from(root)
168        .canonicalize()
169        .with_context(|| "failed to canonicalize secrets root")?;
170    let base = Path::new(env).join(tenant).join(team).join("messaging");
171
172    // Preferred filename (post-migration): messaging/<platform>.credentials.json
173    let primary = path_safety::normalize_under_root(
174        &root,
175        &base.join(format!("{platform}.credentials.json")),
176    )?;
177    // Legacy filename (pre-migration): messaging/<platform>-<team>-credentials.json
178    let legacy = path_safety::normalize_under_root(
179        &root,
180        &base.join(format!("{platform}-{team}-credentials.json")),
181    )?;
182
183    let file = if primary.exists() {
184        primary
185    } else if legacy.exists() {
186        eprintln!(
187            "warning: using legacy secrets path {}; prefer messaging/<platform>.credentials.json",
188            legacy.display()
189        );
190        legacy
191    } else {
192        return Ok(None);
193    };
194
195    let content =
196        fs::read_to_string(&file).with_context(|| format!("failed to read {}", file.display()))?;
197    let json = serde_json::from_str(&content)
198        .with_context(|| format!("failed to parse {}", file.display()))?;
199    Ok(Some(json))
200}
201
202/// Load credentials from a greentic-secrets seed file when specified.
203/// Supported formats:
204/// - SeedDoc with `entries: [{ uri, value, ... }]`
205/// - Flat map `{ "uri": "...", "value": ... }` (single entry)
206///
207/// The seed file path is provided via `MESSAGING_SEED_FILE` (YAML or JSON).
208fn load_seed_credentials(platform: &str) -> Result<Option<Value>> {
209    let path = match env::var("MESSAGING_SEED_FILE") {
210        Ok(path) => PathBuf::from(path),
211        Err(_) => return Ok(None),
212    };
213    let absolute = absolute_path(path)?;
214    let content = fs::read_to_string(&absolute)
215        .with_context(|| format!("failed to read seed file {}", absolute.display()))?;
216    let value: Value = if absolute
217        .extension()
218        .and_then(|ext| ext.to_str())
219        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
220        .unwrap_or(false)
221    {
222        let yaml: serde_yaml_bw::Value = serde_yaml_bw::from_str(&content)
223            .with_context(|| format!("failed to parse yaml {}", absolute.display()))?;
224        serde_json::to_value(yaml)
225            .with_context(|| format!("failed to convert yaml {}", absolute.display()))?
226    } else {
227        serde_json::from_str(&content)
228            .with_context(|| format!("failed to parse json {}", absolute.display()))?
229    };
230
231    if let Some(entry) = value
232        .get("entries")
233        .and_then(|entries| entries.as_array())
234        .and_then(|entries| {
235            entries.iter().find(|entry| {
236                entry
237                    .get("uri")
238                    .and_then(|uri| uri.as_str())
239                    .map(|uri| uri.ends_with(&format!("{platform}.credentials.json")))
240                    .unwrap_or(false)
241            })
242        })
243        && let Some(val) = entry.get("value")
244    {
245        return Ok(Some(val.clone()));
246    }
247
248    if value
249        .get("uri")
250        .and_then(|uri| uri.as_str())
251        .map(|uri| uri.ends_with(&format!("{platform}.credentials.json")))
252        .unwrap_or(false)
253        && let Some(val) = value.get("value")
254    {
255        return Ok(Some(val.clone()));
256    }
257
258    Ok(None)
259}
260
261fn build_secret_uri(
262    env: Option<&str>,
263    tenant: Option<&str>,
264    team: Option<&str>,
265    platform: &str,
266) -> Option<String> {
267    let env = env?;
268    let tenant = tenant?;
269    let team = team.unwrap_or("default");
270    Some(format!(
271        "secrets://{env}/{tenant}/{team}/messaging/{platform}.credentials.json"
272    ))
273}
274
275pub fn load_card_value(path: &str) -> Result<Value> {
276    let absolute = absolute_path(path)?;
277    let content = fs::read_to_string(&absolute)
278        .with_context(|| format!("failed to read {}", absolute.display()))?;
279    let extension = absolute
280        .extension()
281        .and_then(|ext| ext.to_str())
282        .unwrap_or_default()
283        .to_ascii_lowercase();
284
285    match extension.as_str() {
286        "json" => serde_json::from_str(&content)
287            .with_context(|| format!("failed to parse json {}", absolute.display())),
288        "yaml" | "yml" => {
289            let yaml: serde_yaml_bw::Value = serde_yaml_bw::from_str(&content)
290                .with_context(|| format!("failed to parse yaml {}", absolute.display()))?;
291            serde_json::to_value(yaml)
292                .with_context(|| format!("failed to convert yaml {}", absolute.display()))
293        }
294        other => Err(anyhow!("unsupported fixture extension: {other}")),
295    }
296}
297
298fn absolute_path<P>(path: P) -> Result<PathBuf>
299where
300    P: AsRef<Path>,
301{
302    let root = workspace_root();
303    let relative = path.as_ref();
304    if relative.is_absolute() {
305        let canonical = relative
306            .canonicalize()
307            .with_context(|| format!("failed to canonicalize {}", relative.display()))?;
308        if !canonical.starts_with(&root) {
309            anyhow::bail!(
310                "absolute path escapes workspace root ({}): {}",
311                root.display(),
312                canonical.display()
313            );
314        }
315        return Ok(canonical);
316    }
317
318    path_safety::normalize_under_root(&root, relative)
319}
320
321pub fn assert_matches_schema<P>(schema_path: P, value: &Value) -> Result<()>
322where
323    P: AsRef<Path>,
324{
325    let compiled = load_compiled_schema(schema_path.as_ref())?;
326
327    let mut errors = compiled.iter_errors(value);
328    if let Some(first) = errors.next() {
329        let mut messages: Vec<String> = Vec::new();
330        messages.push(first.to_string());
331        for err in errors {
332            messages.push(err.to_string());
333        }
334        return Err(anyhow!("schema validation failed: {}", messages.join("; ")));
335    }
336
337    Ok(())
338}
339
340fn load_schema(path: &Path) -> Result<Value> {
341    let absolute = absolute_path(path)?;
342    let content = fs::read_to_string(&absolute)
343        .with_context(|| format!("failed to read {}", absolute.display()))?;
344    serde_json::from_str(&content)
345        .with_context(|| format!("failed to parse json {}", absolute.display()))
346}
347
348fn load_compiled_schema(path: &Path) -> Result<Arc<Validator>> {
349    static CACHE: Lazy<Mutex<HashMap<PathBuf, Arc<Validator>>>> =
350        Lazy::new(|| Mutex::new(HashMap::new()));
351
352    let absolute = absolute_path(path)?;
353
354    {
355        let cache = CACHE.lock().unwrap();
356        if let Some(schema) = cache.get(&absolute) {
357            return Ok(schema.clone());
358        }
359    }
360
361    let schema_value = load_schema(&absolute)?;
362    let compiled = validator_for(&schema_value)
363        .map_err(|err| anyhow!("failed to compile json schema: {err}"))?;
364    let compiled = Arc::new(compiled);
365
366    let mut cache = CACHE.lock().unwrap();
367    let entry = cache.entry(absolute).or_insert_with(|| compiled.clone());
368    Ok(entry.clone())
369}
370
371pub fn to_json_value<T>(value: &T) -> Result<Value>
372where
373    T: Serialize,
374{
375    serde_json::to_value(value).context("failed to convert to json value")
376}
377
378#[macro_export]
379macro_rules! skip_or_require {
380    ($expr:expr $(,)?) => {{
381        match $expr {
382            Ok(Some(value)) => value,
383            Ok(None) => {
384                eprintln!("skipping test: required secrets not available");
385                return;
386            }
387            Err(err) => panic!("failed to load test secrets: {err:?}"),
388        }
389    }};
390    ($expr:expr, $($msg:tt)+) => {{
391        match $expr {
392            Ok(Some(value)) => value,
393            Ok(None) => {
394                eprintln!("skipping test: {}", format!($($msg)+));
395                return;
396            }
397            Err(err) => panic!("failed to load test secrets: {err:?}"),
398        }
399    }};
400}
401
402#[macro_export]
403macro_rules! load_card {
404    ($path:expr $(,)?) => {{
405        $crate::load_card_value($path)
406            .unwrap_or_else(|err| panic!("failed to load card {}: {}", $path, err))
407    }};
408}
409
410#[macro_export]
411macro_rules! assert_snapshot_json {
412    ($name:expr, $value:expr $(,)?) => {{
413        let snapshot_value = $crate::to_json_value(&$value)
414            .unwrap_or_else(|err| panic!("failed to serialise snapshot {}: {}", $name, err));
415        insta::assert_json_snapshot!($name, snapshot_value);
416    }};
417}