//! Schema-drift guard for services monitored by PoM. //! //! Background: PoM polls each target's `/api/health` and runs key-by-key //! assertions from `pom/deploy/pom-hetzner.toml` (`json_fields = { ... }`). //! If a producer changes the response shape without updating PoM — or vice //! versa — every snapshot becomes `Degraded` and an incident sits open //! until someone notices. The May-12 (MNW) and April-22 (MT) incidents //! each ran for weeks before discovery. //! //! This crate provides a single test helper that producer crates wire into //! their `#[cfg(test)]` blocks. Run alongside the health endpoint's pure //! body builder, it fails at PR time the moment the response shape stops //! satisfying PoM's expectations. //! //! # Usage //! //! ```ignore //! #[test] //! fn pom_hetzner_health_expectations_resolve() { //! let body = health_body(/* db_ok: */ true); //! pom_contract::assert_health_expectations_resolve( //! "../pom/deploy/pom-hetzner.toml", //! "mnw", //! &body, //! ); //! } //! ``` //! //! Paths are resolved relative to the calling crate's manifest directory //! at test time (`cargo test`'s CWD). The helper panics with a precise //! diagnostic listing every missing or mismatched field. use std::path::Path; /// Assert that every `json_fields` entry under `targets..health.expect` /// in the PoM config at `pom_config_path` resolves to its expected value when /// walked against `body`. /// /// Panics with a multi-line diagnostic on drift; returns silently on success. pub fn assert_health_expectations_resolve( pom_config_path: impl AsRef, target: &str, body: &serde_json::Value, ) { let path = pom_config_path.as_ref(); let raw = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); let cfg: toml::Value = raw .parse() .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display())); let json_fields = cfg .get("targets") .and_then(|t| t.get(target)) .and_then(|t| t.get("health")) .and_then(|h| h.get("expect")) .and_then(|e| e.get("json_fields")) .and_then(|f| f.as_table()) .unwrap_or_else(|| { panic!( "{} has no targets.{target}.health.expect.json_fields", path.display() ) }); let mut failures = Vec::new(); for (key, expected) in json_fields { let expected_str = expected .as_str() .map(|s| s.to_string()) .unwrap_or_else(|| expected.to_string()); match resolve_json_path(body, key) { None => failures.push(format!( " json field \"{key}\" missing from response (expected \"{expected_str}\")", )), Some(actual) => { let actual_str = match actual { serde_json::Value::String(s) => s.clone(), other => other.to_string(), }; if actual_str != expected_str { failures.push(format!( " json field \"{key}\": PoM expects \"{expected_str}\", response yields \"{actual_str}\"", )); } } } } if !failures.is_empty() { panic!( "PoM schema-drift detected for target \"{target}\" — {} expectation(s) no longer resolve:\n{}\n\nFix: either restore the missing field in the response builder or drop the assertion from `{}`.", failures.len(), failures.join("\n"), path.display(), ); } } /// Walk a dot-separated JSON path. Mirrors PoM's `resolve_json_path` exactly /// so this helper's path semantics match what runs against prod. fn resolve_json_path<'a>( value: &'a serde_json::Value, path: &str, ) -> Option<&'a serde_json::Value> { let mut current = value; for key in path.split('.') { current = current.get(key)?; } Some(current) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn write_config(dir: &std::path::Path, fields: &str) -> std::path::PathBuf { let path = dir.join("pom.toml"); let content = format!( "[targets.demo.health.expect]\nstatus_code = 200\njson_fields = {{ {fields} }}\n" ); std::fs::write(&path, content).unwrap(); path } fn tempdir() -> std::path::PathBuf { let p = std::env::temp_dir().join(format!( "pom-contract-test-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&p).unwrap(); p } #[test] fn passes_when_all_fields_resolve() { let dir = tempdir(); let cfg = write_config(&dir, r#""status" = "operational", "database" = "true""#); let body = json!({ "status": "operational", "database": true }); assert_health_expectations_resolve(&cfg, "demo", &body); } #[test] fn passes_with_nested_path() { let dir = tempdir(); let cfg = write_config(&dir, r#""status" = "operational", "checks.database" = "true""#); let body = json!({ "status": "operational", "checks": { "database": true } }); assert_health_expectations_resolve(&cfg, "demo", &body); } #[test] #[should_panic(expected = "json field \"checks.git_storage\" missing")] fn fails_when_field_missing() { let dir = tempdir(); let cfg = write_config( &dir, r#""status" = "operational", "checks.git_storage" = "true""#, ); let body = json!({ "status": "operational", "checks": { "database": true } }); assert_health_expectations_resolve(&cfg, "demo", &body); } #[test] #[should_panic(expected = "PoM expects \"true\", response yields \"false\"")] fn fails_when_value_mismatches() { let dir = tempdir(); let cfg = write_config(&dir, r#""status" = "operational", "database" = "true""#); let body = json!({ "status": "operational", "database": false }); assert_health_expectations_resolve(&cfg, "demo", &body); } #[test] #[should_panic(expected = "no targets.unknown.health.expect.json_fields")] fn fails_when_target_absent() { let dir = tempdir(); let cfg = write_config(&dir, r#""status" = "operational""#); let body = json!({ "status": "operational" }); assert_health_expectations_resolve(&cfg, "unknown", &body); } }