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
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
68fn 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}