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 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 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 let primary = path_safety::normalize_under_root(
174 &root,
175 &base.join(format!("{platform}.credentials.json")),
176 )?;
177 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
202fn 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}