1use arc_swap::ArcSwap;
13use chrono::{DateTime, Utc};
14use serde_json::Value;
15use serde_json::json;
16use std::collections::{HashMap, HashSet};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{
20 Arc,
21 atomic::{AtomicU64, Ordering},
22};
23use std::time::{Duration, Instant, SystemTime};
24
25const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
27
28const FEATURE_SCHEMA_DEFS_JSON: &str = include_str!("feature-schema-defs.json");
30
31const SCHEMA_FILE_NAME: &str = "schema.json";
32const VALUES_FILE_NAME: &str = "values.json";
33
34const REFRESH_THRESHOLD: Duration = Duration::from_secs(5);
38
39pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
41
42pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
44
45pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
47
48pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
50
51fn should_suppress_missing_dir_errors() -> bool {
53 std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
54 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
55 .unwrap_or(false)
56}
57
58pub fn resolve_options_dir() -> PathBuf {
63 if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
64 return PathBuf::from(dir);
65 }
66
67 let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
68 if prod_path.exists() {
69 return prod_path;
70 }
71
72 PathBuf::from(LOCAL_OPTIONS_DIR)
73}
74
75pub type ValidationResult<T> = Result<T, ValidationError>;
77
78pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
80
81#[derive(Debug, thiserror::Error)]
83pub enum ValidationError {
84 #[error("Schema error in {file}: {message}")]
85 SchemaError { file: PathBuf, message: String },
86
87 #[error("Value error for {namespace}: {errors}")]
88 ValueError { namespace: String, errors: String },
89
90 #[error("Unknown namespace: {0}")]
91 UnknownNamespace(String),
92
93 #[error("Unknown option '{key}' in namespace '{namespace}'")]
94 UnknownOption { namespace: String, key: String },
95
96 #[error("Internal error: {0}")]
97 InternalError(String),
98
99 #[error("Failed to read file: {0}")]
100 FileRead(#[from] std::io::Error),
101
102 #[error("Failed to parse JSON: {0}")]
103 JSONParse(#[from] serde_json::Error),
104
105 #[error("{} validation error(s)", .0.len())]
106 ValidationErrors(Vec<ValidationError>),
107
108 #[error("Invalid {label} '{name}': {reason}")]
109 InvalidName {
110 label: String,
111 name: String,
112 reason: String,
113 },
114}
115
116pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
118 if let Some(c) = name
119 .chars()
120 .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
121 {
122 return Err(ValidationError::InvalidName {
123 label: label.to_string(),
124 name: name.to_string(),
125 reason: format!(
126 "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
127 c
128 ),
129 });
130 }
131 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
132 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
133 {
134 return Err(ValidationError::InvalidName {
135 label: label.to_string(),
136 name: name.to_string(),
137 reason: "must start and end with alphanumeric".to_string(),
138 });
139 }
140 Ok(())
141}
142
143#[derive(Debug, Clone)]
145pub struct OptionMetadata {
146 pub option_type: String,
147 pub property_schema: Value,
148 pub default: Value,
149}
150
151pub struct NamespaceSchema {
153 pub namespace: String,
154 pub options: HashMap<String, OptionMetadata>,
155 feature_keys: HashSet<String>,
158 all_keys: HashSet<String>,
160 validator: jsonschema::Validator,
161}
162
163impl NamespaceSchema {
164 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
172 let output = self.validator.evaluate(values);
173 if output.flag().valid {
174 Ok(())
175 } else {
176 let errors: Vec<String> = output
177 .iter_errors()
178 .map(|e| {
179 format!(
180 "\n\t{} {}",
181 e.instance_location.as_str().trim_start_matches("/"),
182 e.error
183 )
184 })
185 .collect();
186 Err(ValidationError::ValueError {
187 namespace: self.namespace.clone(),
188 errors: errors.join(""),
189 })
190 }
191 }
192
193 pub fn get_default(&self, key: &str) -> Option<&Value> {
196 self.options.get(key).map(|meta| &meta.default)
197 }
198
199 pub fn is_known_key(&self, key: &str) -> bool {
202 self.options.contains_key(key) || self.feature_keys.contains(key)
203 }
204
205 pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
210 if !self.is_known_key(key) {
211 return Err(ValidationError::UnknownOption {
212 namespace: self.namespace.clone(),
213 key: key.to_string(),
214 });
215 }
216 let test_obj = json!({ key: value });
217 self.validate_values(&test_obj)
218 }
219}
220
221pub struct SchemaRegistry {
223 schemas: HashMap<String, Arc<NamespaceSchema>>,
224}
225
226impl SchemaRegistry {
227 pub fn new() -> Self {
229 Self {
230 schemas: HashMap::new(),
231 }
232 }
233
234 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
244 let namespace_validator = Self::compile_namespace_validator()?;
245 let mut schemas_map = HashMap::new();
246
247 for entry in fs::read_dir(schemas_dir)? {
249 let entry = entry?;
250
251 if !entry.file_type()?.is_dir() {
252 continue;
253 }
254
255 let namespace =
256 entry
257 .file_name()
258 .into_string()
259 .map_err(|_| ValidationError::SchemaError {
260 file: entry.path(),
261 message: "Directory name contains invalid UTF-8".to_string(),
262 })?;
263
264 validate_k8s_name_component(&namespace, "namespace name")?;
265
266 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
267 let file = fs::File::open(&schema_file)?;
268 let schema_data: Value = serde_json::from_reader(file)?;
269
270 Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
271 let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
272 schemas_map.insert(namespace, schema);
273 }
274
275 Ok(Self {
276 schemas: schemas_map,
277 })
278 }
279
280 pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
286 let namespace_validator = Self::compile_namespace_validator()?;
287 let schema_file = Path::new("<embedded>");
288 let mut schemas_map = HashMap::new();
289
290 for (namespace, json) in schemas {
291 validate_k8s_name_component(namespace, "namespace name")?;
292
293 let schema_data: Value =
294 serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
295 file: schema_file.to_path_buf(),
296 message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
297 })?;
298
299 Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
300 let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
301 if schemas_map.insert(namespace.to_string(), schema).is_some() {
302 return Err(ValidationError::SchemaError {
303 file: schema_file.to_path_buf(),
304 message: format!("Duplicate namespace '{}'", namespace),
305 });
306 }
307 }
308
309 Ok(Self {
310 schemas: schemas_map,
311 })
312 }
313
314 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
316 let schema = self
317 .schemas
318 .get(namespace)
319 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
320
321 schema.validate_values(values)
322 }
323
324 fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
325 let namespace_schema_value: Value =
326 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
327 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
328 })?;
329 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
330 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
331 })
332 }
333
334 fn validate_with_namespace_schema(
336 schema_data: &Value,
337 path: &Path,
338 namespace_validator: &jsonschema::Validator,
339 ) -> ValidationResult<()> {
340 let output = namespace_validator.evaluate(schema_data);
341
342 if output.flag().valid {
343 Ok(())
344 } else {
345 let errors: Vec<String> = output
346 .iter_errors()
347 .map(|e| format!("Error: {}", e.error))
348 .collect();
349
350 Err(ValidationError::SchemaError {
351 file: path.to_path_buf(),
352 message: format!("Schema validation failed:\n{}", errors.join("\n")),
353 })
354 }
355 }
356
357 fn validate_default_type(
359 property_name: &str,
360 property_schema: &Value,
361 default_value: &Value,
362 path: &Path,
363 ) -> ValidationResult<()> {
364 jsonschema::validate(property_schema, default_value).map_err(|e| {
366 ValidationError::SchemaError {
367 file: path.to_path_buf(),
368 message: format!(
369 "Property '{}': default value does not match schema: {}",
370 property_name, e
371 ),
372 }
373 })?;
374
375 Ok(())
376 }
377
378 fn inject_object_constraints(schema: &mut Value) {
395 let Some(obj) = schema.as_object_mut() else {
396 return;
397 };
398
399 if obj.get("type").and_then(|t| t.as_str()) == Some("object") {
400 if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
404 let required: Vec<Value> = props
405 .iter()
406 .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
407 .map(|(k, _)| Value::String(k.clone()))
408 .collect();
409 obj.insert("required".to_string(), Value::Array(required));
410 }
411 if !obj.contains_key("additionalProperties") {
414 obj.insert("additionalProperties".to_string(), json!(false));
415 }
416 }
417
418 if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
421 for field in props.values_mut() {
422 Self::inject_object_constraints(field);
423 }
424 }
425 if let Some(additional) = obj.get_mut("additionalProperties")
426 && additional.is_object()
427 {
428 Self::inject_object_constraints(additional);
429 }
430 if let Some(items) = obj.get_mut("items") {
431 Self::inject_object_constraints(items);
432 }
433 }
434
435 fn parse_schema(
437 mut schema: Value,
438 namespace: &str,
439 path: &Path,
440 ) -> ValidationResult<Arc<NamespaceSchema>> {
441 if let Some(obj) = schema.as_object_mut() {
443 obj.insert("additionalProperties".to_string(), json!(false));
444 }
445
446 if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
449 for prop_value in properties.values_mut() {
450 Self::inject_object_constraints(prop_value);
451 }
452 }
453
454 let mut options = HashMap::new();
456 let mut feature_keys = HashSet::new();
457 let mut all_keys = HashSet::new();
458 let mut has_feature_keys = false;
459 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
460 for (prop_name, prop_value) in properties {
461 all_keys.insert(prop_name.clone());
462 if prop_name.starts_with("feature.") {
465 has_feature_keys = true;
466 feature_keys.insert(prop_name.clone());
467 }
468 if let (Some(prop_type), Some(default_value)) = (
469 prop_value.get("type").and_then(|t| t.as_str()),
470 prop_value.get("default"),
471 ) {
472 Self::validate_default_type(prop_name, prop_value, default_value, path)?;
473 options.insert(
474 prop_name.clone(),
475 OptionMetadata {
476 option_type: prop_type.to_string(),
477 property_schema: prop_value.clone(),
478 default: default_value.clone(),
479 },
480 );
481 }
482 }
483 }
484
485 if has_feature_keys {
488 let feature_defs: Value =
489 serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
490 ValidationError::InternalError(format!(
491 "Invalid feature-schema-defs JSON: {}",
492 e
493 ))
494 })?;
495
496 if let Some(obj) = schema.as_object_mut() {
497 obj.insert("definitions".to_string(), feature_defs);
498 }
499 }
500
501 let validator =
503 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
504 file: path.to_path_buf(),
505 message: format!("Failed to compile validator: {}", e),
506 })?;
507
508 Ok(Arc::new(NamespaceSchema {
509 namespace: namespace.to_string(),
510 options,
511 feature_keys,
512 all_keys,
513 validator,
514 }))
515 }
516
517 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
519 self.schemas.get(namespace)
520 }
521
522 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
524 &self.schemas
525 }
526
527 pub fn load_values_json(
535 &self,
536 values_dir: &Path,
537 ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
538 let mut all_values = HashMap::new();
539 let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
540
541 for namespace in self.schemas.keys() {
542 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
543
544 if !values_file.exists() {
545 continue;
546 }
547
548 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
549
550 if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
552 generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
553 }
554
555 let values = parsed
556 .get("options")
557 .ok_or_else(|| ValidationError::ValueError {
558 namespace: namespace.clone(),
559 errors: "values.json must have an 'options' key".to_string(),
560 })?;
561
562 let values = self.strip_unknown_keys(namespace, values);
565
566 self.validate_values(namespace, &values)?;
567
568 if let Value::Object(obj) = values {
569 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
570 all_values.insert(namespace.clone(), ns_values);
571 }
572 }
573
574 Ok((all_values, generated_at_by_namespace))
575 }
576
577 pub fn strip_unknown_keys(&self, namespace: &str, values: &Value) -> Value {
580 let schema = match self.schemas.get(namespace) {
581 Some(s) => s,
582 None => return values.clone(),
583 };
584
585 let obj = match values.as_object() {
586 Some(obj) => obj,
587 None => return values.clone(),
588 };
589
590 let unknown_keys: Vec<&String> = obj
591 .keys()
592 .filter(|k| !schema.all_keys.contains(*k))
593 .collect();
594
595 if unknown_keys.is_empty() {
596 return values.clone();
597 }
598
599 for key in &unknown_keys {
600 eprintln!(
601 "sentry-options: Ignoring unknown option '{}' in namespace '{}'. \
602 This is expected during deployments when values are updated before schemas.",
603 key, namespace
604 );
605 }
606
607 let filtered: serde_json::Map<String, Value> = obj
608 .iter()
609 .filter(|(k, _)| schema.all_keys.contains(*k))
610 .map(|(k, v)| (k.clone(), v.clone()))
611 .collect();
612 Value::Object(filtered)
613 }
614}
615
616impl Default for SchemaRegistry {
617 fn default() -> Self {
618 Self::new()
619 }
620}
621
622pub type PropagationCallback = Box<dyn Fn(&str, f64) + Send + Sync>;
626
627pub struct ValuesStore {
631 registry: Arc<SchemaRegistry>,
632 values_dir: PathBuf,
633 values: ArcSwap<ValuesByNamespace>,
634 baseline: Instant,
635 last_refresh_offset_ns: AtomicU64,
636 refresh_threshold: Duration,
637 last_generated_at: ArcSwap<HashMap<String, String>>,
640 on_propagation: Option<PropagationCallback>,
642 last_mtimes: ArcSwap<HashMap<String, SystemTime>>,
645}
646
647impl ValuesStore {
648 pub fn new(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValidationResult<Self> {
650 Self::build(registry, values_dir, REFRESH_THRESHOLD, None)
651 }
652
653 pub fn with_propagation_callback(
656 registry: Arc<SchemaRegistry>,
657 values_dir: &Path,
658 callback: PropagationCallback,
659 ) -> ValidationResult<Self> {
660 Self::build(registry, values_dir, REFRESH_THRESHOLD, Some(callback))
661 }
662
663 fn build(
666 registry: Arc<SchemaRegistry>,
667 values_dir: &Path,
668 refresh_threshold: Duration,
669 on_propagation: Option<PropagationCallback>,
670 ) -> ValidationResult<Self> {
671 if !should_suppress_missing_dir_errors() && fs::metadata(values_dir).is_err() {
672 eprintln!("Values directory does not exist: {}", values_dir.display());
673 }
674
675 let baseline = Instant::now();
676 let initial_mtimes = Self::collect_mtimes(®istry, values_dir);
677 let (initial, generated_at_by_namespace) = registry.load_values_json(values_dir)?;
678 let last_refresh_offset_ns = AtomicU64::new(baseline.elapsed().as_nanos() as u64);
679
680 Ok(Self {
681 registry,
682 values_dir: values_dir.to_path_buf(),
683 values: ArcSwap::from_pointee(initial),
684 baseline,
685 last_refresh_offset_ns,
686 refresh_threshold,
687 last_generated_at: ArcSwap::from_pointee(generated_at_by_namespace),
688 on_propagation,
689 last_mtimes: ArcSwap::from_pointee(initial_mtimes),
690 })
691 }
692
693 pub fn registry(&self) -> &Arc<SchemaRegistry> {
695 &self.registry
696 }
697
698 fn collect_mtimes(registry: &SchemaRegistry, values_dir: &Path) -> HashMap<String, SystemTime> {
701 let mut mtimes = HashMap::new();
702 for namespace in registry.schemas().keys() {
703 let path = values_dir.join(namespace).join(VALUES_FILE_NAME);
704 if let Ok(metadata) = fs::metadata(&path)
705 && let Ok(mtime) = metadata.modified()
706 {
707 mtimes.insert(namespace.clone(), mtime);
708 }
709 }
710 mtimes
711 }
712
713 pub fn load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
716 self.maybe_refresh();
717 self.values.load()
718 }
719
720 pub fn force_load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
724 let now_ns = self.baseline.elapsed().as_nanos() as u64;
725 let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
726 self.refresh(last_ns, now_ns);
727 self.values.load()
728 }
729
730 fn maybe_refresh(&self) {
731 let now_ns = self.baseline.elapsed().as_nanos() as u64;
732 let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
733 let elapsed_ns = now_ns.saturating_sub(last_ns);
734 let threshold_ns = self.refresh_threshold.as_nanos() as u64;
735 let jitter_ns = if self.refresh_threshold.is_zero() {
738 0
739 } else {
740 stack_jitter_ns()
741 };
742
743 if elapsed_ns < threshold_ns.saturating_add(jitter_ns) {
744 return;
745 }
746
747 self.refresh(last_ns, now_ns);
748 }
749
750 fn refresh(&self, observed_last_ns: u64, now_ns: u64) {
751 let current_mtimes = Self::collect_mtimes(&self.registry, &self.values_dir);
752 if *self.last_mtimes.load().as_ref() == current_mtimes {
753 let _ = self.last_refresh_offset_ns.compare_exchange(
754 observed_last_ns,
755 now_ns,
756 Ordering::AcqRel,
757 Ordering::Relaxed,
758 );
759 return;
760 }
761
762 let result = self.registry.load_values_json(&self.values_dir);
763
764 let new_generated_at = match result {
772 Ok((new_values, generated_at)) => {
773 self.values.store(Arc::new(new_values));
774 self.last_mtimes.store(Arc::new(current_mtimes));
775 Some(generated_at)
776 }
777 Err(e) => {
778 eprintln!(
779 "Failed to reload values from {}: {}",
780 self.values_dir.display(),
781 e
782 );
783 None
784 }
785 };
786
787 let _ = self.last_refresh_offset_ns.compare_exchange(
792 observed_last_ns,
793 now_ns,
794 Ordering::AcqRel,
795 Ordering::Relaxed,
796 );
797
798 if let Some(new_generated_at) = new_generated_at {
802 let last = self.last_generated_at.load();
803 if *last.as_ref() != new_generated_at {
804 if let Some(callback) = &self.on_propagation {
805 let applied_at = Utc::now();
806 for (ns, ts) in &new_generated_at {
807 if last.get(ns).is_some_and(|old| old == ts) {
808 continue;
809 }
810 match propagation_delay_secs(&applied_at, ts) {
811 Some(delay) => {
812 if let Err(e) =
813 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
814 callback(ns, delay)
815 }))
816 {
817 eprintln!("Propagation callback panicked for {ns}: {:?}", e);
818 }
819 }
820 None => eprintln!("Bad generated_at for {ns}: {ts}"),
821 }
822 }
823 }
824 self.last_generated_at.store(Arc::new(new_generated_at));
825 }
826 }
827 }
828}
829
830fn propagation_delay_secs(now: &DateTime<Utc>, generated_at: &str) -> Option<f64> {
832 let generated = DateTime::parse_from_rfc3339(generated_at).ok()?;
833 let delay = (*now - generated.with_timezone(&Utc)).num_milliseconds() as f64 / 1000.0;
834 Some(delay.max(0.0))
835}
836
837fn stack_jitter_ns() -> u64 {
842 let local = 0u8;
843 let addr = &local as *const u8 as usize as u64;
844 addr.wrapping_mul(0x9E37_79B9_7F4A_7C15) % 1_000_000_000
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use std::sync::Mutex;
851 use tempfile::TempDir;
852
853 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
854 let schema_dir = temp_dir.path().join(namespace);
855 fs::create_dir_all(&schema_dir).unwrap();
856 let schema_file = schema_dir.join("schema.json");
857 fs::write(&schema_file, schema_json).unwrap();
858 schema_file
859 }
860
861 fn create_test_schema_with_values(
862 temp_dir: &TempDir,
863 namespace: &str,
864 schema_json: &str,
865 values_json: &str,
866 ) -> (PathBuf, PathBuf) {
867 let schemas_dir = temp_dir.path().join("schemas");
868 let values_dir = temp_dir.path().join("values");
869
870 let schema_dir = schemas_dir.join(namespace);
871 fs::create_dir_all(&schema_dir).unwrap();
872 let schema_file = schema_dir.join("schema.json");
873 fs::write(&schema_file, schema_json).unwrap();
874
875 let ns_values_dir = values_dir.join(namespace);
876 fs::create_dir_all(&ns_values_dir).unwrap();
877 let values_file = ns_values_dir.join("values.json");
878 fs::write(&values_file, values_json).unwrap();
879
880 (schemas_dir, values_dir)
881 }
882
883 #[test]
884 fn test_validate_k8s_name_component_valid() {
885 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
886 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
887 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
888 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
889 }
890
891 #[test]
892 fn test_validate_k8s_name_component_rejects_uppercase() {
893 let result = validate_k8s_name_component("MyService", "namespace");
894 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
895 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
896 }
897
898 #[test]
899 fn test_validate_k8s_name_component_rejects_underscore() {
900 let result = validate_k8s_name_component("my_service", "target");
901 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
902 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
903 }
904
905 #[test]
906 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
907 let result = validate_k8s_name_component("-service", "namespace");
908 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
909 assert!(
910 result
911 .unwrap_err()
912 .to_string()
913 .contains("start and end with alphanumeric")
914 );
915 }
916
917 #[test]
918 fn test_validate_k8s_name_component_rejects_trailing_dot() {
919 let result = validate_k8s_name_component("service.", "namespace");
920 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
921 assert!(
922 result
923 .unwrap_err()
924 .to_string()
925 .contains("start and end with alphanumeric")
926 );
927 }
928
929 #[test]
930 fn test_load_schema_valid() {
931 let temp_dir = TempDir::new().unwrap();
932 create_test_schema(
933 &temp_dir,
934 "test",
935 r#"{
936 "version": "1.0",
937 "type": "object",
938 "properties": {
939 "test-key": {
940 "type": "string",
941 "default": "test",
942 "description": "Test option"
943 }
944 }
945 }"#,
946 );
947
948 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
949 }
950
951 #[test]
952 fn test_load_schema_missing_version() {
953 let temp_dir = TempDir::new().unwrap();
954 create_test_schema(
955 &temp_dir,
956 "test",
957 r#"{
958 "type": "object",
959 "properties": {}
960 }"#,
961 );
962
963 let result = SchemaRegistry::from_directory(temp_dir.path());
964 assert!(result.is_err());
965 match result {
966 Err(ValidationError::SchemaError { message, .. }) => {
967 assert!(message.contains(
968 "Schema validation failed:
969Error: \"version\" is a required property"
970 ));
971 }
972 _ => panic!("Expected SchemaError for missing version"),
973 }
974 }
975
976 #[test]
977 fn test_unknown_namespace() {
978 let temp_dir = TempDir::new().unwrap();
979 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
980
981 let result = registry.validate_values("unknown", &json!({}));
982 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
983 }
984
985 #[test]
986 fn test_multiple_namespaces() {
987 let temp_dir = TempDir::new().unwrap();
988 create_test_schema(
989 &temp_dir,
990 "ns1",
991 r#"{
992 "version": "1.0",
993 "type": "object",
994 "properties": {
995 "opt1": {
996 "type": "string",
997 "default": "default1",
998 "description": "First option"
999 }
1000 }
1001 }"#,
1002 );
1003 create_test_schema(
1004 &temp_dir,
1005 "ns2",
1006 r#"{
1007 "version": "2.0",
1008 "type": "object",
1009 "properties": {
1010 "opt2": {
1011 "type": "integer",
1012 "default": 42,
1013 "description": "Second option"
1014 }
1015 }
1016 }"#,
1017 );
1018
1019 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1020 assert!(registry.schemas.contains_key("ns1"));
1021 assert!(registry.schemas.contains_key("ns2"));
1022 }
1023
1024 #[test]
1025 fn test_invalid_default_type() {
1026 let temp_dir = TempDir::new().unwrap();
1027 create_test_schema(
1028 &temp_dir,
1029 "test",
1030 r#"{
1031 "version": "1.0",
1032 "type": "object",
1033 "properties": {
1034 "bad-default": {
1035 "type": "integer",
1036 "default": "not-a-number",
1037 "description": "A bad default value"
1038 }
1039 }
1040 }"#,
1041 );
1042
1043 let result = SchemaRegistry::from_directory(temp_dir.path());
1044 assert!(result.is_err());
1045 match result {
1046 Err(ValidationError::SchemaError { message, .. }) => {
1047 assert!(
1048 message.contains("Property 'bad-default': default value does not match schema")
1049 );
1050 assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
1051 }
1052 _ => panic!("Expected SchemaError for invalid default type"),
1053 }
1054 }
1055
1056 #[test]
1057 fn test_extra_properties() {
1058 let temp_dir = TempDir::new().unwrap();
1059 create_test_schema(
1060 &temp_dir,
1061 "test",
1062 r#"{
1063 "version": "1.0",
1064 "type": "object",
1065 "properties": {
1066 "bad-property": {
1067 "type": "integer",
1068 "default": 0,
1069 "description": "Test property",
1070 "extra": "property"
1071 }
1072 }
1073 }"#,
1074 );
1075
1076 let result = SchemaRegistry::from_directory(temp_dir.path());
1077 assert!(result.is_err());
1078 match result {
1079 Err(ValidationError::SchemaError { message, .. }) => {
1080 assert!(
1081 message
1082 .contains("Additional properties are not allowed ('extra' was unexpected)")
1083 );
1084 }
1085 _ => panic!("Expected SchemaError for extra properties"),
1086 }
1087 }
1088
1089 #[test]
1090 fn test_missing_description() {
1091 let temp_dir = TempDir::new().unwrap();
1092 create_test_schema(
1093 &temp_dir,
1094 "test",
1095 r#"{
1096 "version": "1.0",
1097 "type": "object",
1098 "properties": {
1099 "missing-desc": {
1100 "type": "string",
1101 "default": "test"
1102 }
1103 }
1104 }"#,
1105 );
1106
1107 let result = SchemaRegistry::from_directory(temp_dir.path());
1108 assert!(result.is_err());
1109 match result {
1110 Err(ValidationError::SchemaError { message, .. }) => {
1111 assert!(message.contains("\"description\" is a required property"));
1112 }
1113 _ => panic!("Expected SchemaError for missing description"),
1114 }
1115 }
1116
1117 #[test]
1118 fn test_from_schemas_rejects_duplicate_namespace() {
1119 let schema_a = r#"{
1120 "version": "1.0",
1121 "type": "object",
1122 "properties": {
1123 "opt": {"type": "string", "default": "a", "description": "A"}
1124 }
1125 }"#;
1126 let schema_b = r#"{
1127 "version": "1.0",
1128 "type": "object",
1129 "properties": {
1130 "opt": {"type": "string", "default": "b", "description": "B"}
1131 }
1132 }"#;
1133
1134 let result = SchemaRegistry::from_schemas(&[("test", schema_a), ("test", schema_b)]);
1135 match result {
1136 Err(ValidationError::SchemaError { message, .. }) => {
1137 assert!(message.contains("Duplicate namespace 'test'"));
1138 }
1139 _ => panic!("Expected SchemaError for duplicate namespace"),
1140 }
1141 }
1142
1143 #[test]
1144 fn test_invalid_directory_structure() {
1145 let temp_dir = TempDir::new().unwrap();
1146 let schema_dir = temp_dir.path().join("missing-schema");
1148 fs::create_dir_all(&schema_dir).unwrap();
1149
1150 let result = SchemaRegistry::from_directory(temp_dir.path());
1151 assert!(result.is_err());
1152 match result {
1153 Err(ValidationError::FileRead(..)) => {
1154 }
1156 _ => panic!("Expected FileRead error for missing schema.json"),
1157 }
1158 }
1159
1160 #[test]
1161 fn test_get_default() {
1162 let temp_dir = TempDir::new().unwrap();
1163 create_test_schema(
1164 &temp_dir,
1165 "test",
1166 r#"{
1167 "version": "1.0",
1168 "type": "object",
1169 "properties": {
1170 "string_opt": {
1171 "type": "string",
1172 "default": "hello",
1173 "description": "A string option"
1174 },
1175 "int_opt": {
1176 "type": "integer",
1177 "default": 42,
1178 "description": "An integer option"
1179 }
1180 }
1181 }"#,
1182 );
1183
1184 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1185 let schema = registry.get("test").unwrap();
1186
1187 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1188 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1189 assert_eq!(schema.get_default("unknown"), None);
1190 }
1191
1192 #[test]
1193 fn test_validate_values_valid() {
1194 let temp_dir = TempDir::new().unwrap();
1195 create_test_schema(
1196 &temp_dir,
1197 "test",
1198 r#"{
1199 "version": "1.0",
1200 "type": "object",
1201 "properties": {
1202 "enabled": {
1203 "type": "boolean",
1204 "default": false,
1205 "description": "Enable feature"
1206 }
1207 }
1208 }"#,
1209 );
1210
1211 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1212 let result = registry.validate_values("test", &json!({"enabled": true}));
1213 assert!(result.is_ok());
1214 }
1215
1216 #[test]
1217 fn test_validate_values_invalid_type() {
1218 let temp_dir = TempDir::new().unwrap();
1219 create_test_schema(
1220 &temp_dir,
1221 "test",
1222 r#"{
1223 "version": "1.0",
1224 "type": "object",
1225 "properties": {
1226 "count": {
1227 "type": "integer",
1228 "default": 0,
1229 "description": "Count"
1230 }
1231 }
1232 }"#,
1233 );
1234
1235 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1236 let result = registry.validate_values("test", &json!({"count": "not a number"}));
1237 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1238 }
1239
1240 #[test]
1241 fn test_validate_values_unknown_option() {
1242 let temp_dir = TempDir::new().unwrap();
1243 create_test_schema(
1244 &temp_dir,
1245 "test",
1246 r#"{
1247 "version": "1.0",
1248 "type": "object",
1249 "properties": {
1250 "known_option": {
1251 "type": "string",
1252 "default": "default",
1253 "description": "A known option"
1254 }
1255 }
1256 }"#,
1257 );
1258
1259 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1260
1261 let result = registry.validate_values("test", &json!({"known_option": "value"}));
1263 assert!(result.is_ok());
1264
1265 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1267 assert!(result.is_err());
1268 match result {
1269 Err(ValidationError::ValueError { errors, .. }) => {
1270 assert!(errors.contains("Additional properties are not allowed"));
1271 }
1272 _ => panic!("Expected ValueError for unknown option"),
1273 }
1274 }
1275
1276 #[test]
1277 fn test_object_with_additional_properties() {
1278 let temp_dir = TempDir::new().unwrap();
1279 create_test_schema(
1280 &temp_dir,
1281 "test",
1282 r#"{
1283 "version": "1.0",
1284 "type": "object",
1285 "properties": {
1286 "scopes": {
1287 "type": "object",
1288 "additionalProperties": {"type": "string"},
1289 "default": {},
1290 "description": "A dynamic string-to-string map"
1291 }
1292 }
1293 }"#,
1294 );
1295
1296 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1297
1298 assert!(
1299 registry
1300 .validate_values("test", &json!({"scopes": {}}))
1301 .is_ok()
1302 );
1303 assert!(
1304 registry
1305 .validate_values(
1306 "test",
1307 &json!({"scopes": {"read": "true", "write": "false"}})
1308 )
1309 .is_ok()
1310 );
1311 assert!(matches!(
1312 registry.validate_values("test", &json!({"scopes": {"read": 42}})),
1313 Err(ValidationError::ValueError { .. })
1314 ));
1315 }
1316
1317 #[test]
1318 fn test_object_without_additional_properties_still_rejects_unknown_keys() {
1319 let temp_dir = TempDir::new().unwrap();
1322 create_test_schema(
1323 &temp_dir,
1324 "test",
1325 r#"{
1326 "version": "1.0",
1327 "type": "object",
1328 "properties": {
1329 "config": {
1330 "type": "object",
1331 "properties": {
1332 "host": {"type": "string"}
1333 },
1334 "default": {"host": "localhost"},
1335 "description": "Server config"
1336 }
1337 }
1338 }"#,
1339 );
1340
1341 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1342
1343 let result = registry.validate_values("test", &json!({"config": {"host": "example.com"}}));
1345 assert!(result.is_ok());
1346
1347 let result = registry.validate_values(
1349 "test",
1350 &json!({"config": {"host": "example.com", "unknown": "x"}}),
1351 );
1352 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1353 }
1354
1355 #[test]
1356 fn test_object_with_fixed_properties_and_additional_properties_enforces_required() {
1357 let temp_dir = TempDir::new().unwrap();
1360 create_test_schema(
1361 &temp_dir,
1362 "test",
1363 r#"{
1364 "version": "1.0",
1365 "type": "object",
1366 "properties": {
1367 "config": {
1368 "type": "object",
1369 "properties": {
1370 "name": {"type": "string"}
1371 },
1372 "additionalProperties": {"type": "string"},
1373 "default": {"name": "default"},
1374 "description": "Config with fixed and dynamic keys"
1375 }
1376 }
1377 }"#,
1378 );
1379
1380 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1381
1382 let result =
1384 registry.validate_values("test", &json!({"config": {"name": "x", "extra": "y"}}));
1385 assert!(result.is_ok());
1386
1387 let result = registry.validate_values("test", &json!({"config": {"extra": "y"}}));
1389 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1390 }
1391
1392 #[test]
1393 fn test_load_values_json_valid() {
1394 let temp_dir = TempDir::new().unwrap();
1395 let schemas_dir = temp_dir.path().join("schemas");
1396 let values_dir = temp_dir.path().join("values");
1397
1398 let schema_dir = schemas_dir.join("test");
1399 fs::create_dir_all(&schema_dir).unwrap();
1400 fs::write(
1401 schema_dir.join("schema.json"),
1402 r#"{
1403 "version": "1.0",
1404 "type": "object",
1405 "properties": {
1406 "enabled": {
1407 "type": "boolean",
1408 "default": false,
1409 "description": "Enable feature"
1410 },
1411 "name": {
1412 "type": "string",
1413 "default": "default",
1414 "description": "Name"
1415 },
1416 "count": {
1417 "type": "integer",
1418 "default": 0,
1419 "description": "Count"
1420 },
1421 "rate": {
1422 "type": "number",
1423 "default": 0.0,
1424 "description": "Rate"
1425 }
1426 }
1427 }"#,
1428 )
1429 .unwrap();
1430
1431 let test_values_dir = values_dir.join("test");
1432 fs::create_dir_all(&test_values_dir).unwrap();
1433 fs::write(
1434 test_values_dir.join("values.json"),
1435 r#"{
1436 "options": {
1437 "enabled": true,
1438 "name": "test-name",
1439 "count": 42,
1440 "rate": 0.75
1441 }
1442 }"#,
1443 )
1444 .unwrap();
1445
1446 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1447 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1448
1449 assert_eq!(values.len(), 1);
1450 assert_eq!(values["test"]["enabled"], json!(true));
1451 assert_eq!(values["test"]["name"], json!("test-name"));
1452 assert_eq!(values["test"]["count"], json!(42));
1453 assert_eq!(values["test"]["rate"], json!(0.75));
1454 assert!(generated_at_by_namespace.is_empty());
1455 }
1456
1457 #[test]
1458 fn test_load_values_json_nonexistent_dir() {
1459 let temp_dir = TempDir::new().unwrap();
1460 create_test_schema(
1461 &temp_dir,
1462 "test",
1463 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1464 );
1465
1466 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1467 let (values, generated_at_by_namespace) = registry
1468 .load_values_json(&temp_dir.path().join("nonexistent"))
1469 .unwrap();
1470
1471 assert!(values.is_empty());
1473 assert!(generated_at_by_namespace.is_empty());
1474 }
1475
1476 #[test]
1477 fn test_load_values_json_strips_unknown_keys() {
1478 let temp_dir = TempDir::new().unwrap();
1479 let schemas_dir = temp_dir.path().join("schemas");
1480 let values_dir = temp_dir.path().join("values");
1481
1482 let schema_dir = schemas_dir.join("test");
1483 fs::create_dir_all(&schema_dir).unwrap();
1484 fs::write(
1485 schema_dir.join("schema.json"),
1486 r#"{
1487 "version": "1.0",
1488 "type": "object",
1489 "properties": {
1490 "known-option": {
1491 "type": "string",
1492 "default": "default",
1493 "description": "A known option"
1494 }
1495 }
1496 }"#,
1497 )
1498 .unwrap();
1499
1500 let test_values_dir = values_dir.join("test");
1501 fs::create_dir_all(&test_values_dir).unwrap();
1502 fs::write(
1503 test_values_dir.join("values.json"),
1504 r#"{
1505 "options": {
1506 "known-option": "hello",
1507 "unknown-option": "should be stripped"
1508 }
1509 }"#,
1510 )
1511 .unwrap();
1512
1513 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1514 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1515
1516 assert_eq!(values["test"]["known-option"], json!("hello"));
1517 assert!(!values["test"].contains_key("unknown-option"));
1518 }
1519
1520 #[test]
1521 fn test_load_values_json_skips_missing_values_file() {
1522 let temp_dir = TempDir::new().unwrap();
1523 let schemas_dir = temp_dir.path().join("schemas");
1524 let values_dir = temp_dir.path().join("values");
1525
1526 let schema_dir1 = schemas_dir.join("with-values");
1528 fs::create_dir_all(&schema_dir1).unwrap();
1529 fs::write(
1530 schema_dir1.join("schema.json"),
1531 r#"{
1532 "version": "1.0",
1533 "type": "object",
1534 "properties": {
1535 "opt": {"type": "string", "default": "x", "description": "Opt"}
1536 }
1537 }"#,
1538 )
1539 .unwrap();
1540
1541 let schema_dir2 = schemas_dir.join("without-values");
1542 fs::create_dir_all(&schema_dir2).unwrap();
1543 fs::write(
1544 schema_dir2.join("schema.json"),
1545 r#"{
1546 "version": "1.0",
1547 "type": "object",
1548 "properties": {
1549 "opt": {"type": "string", "default": "x", "description": "Opt"}
1550 }
1551 }"#,
1552 )
1553 .unwrap();
1554
1555 let with_values_dir = values_dir.join("with-values");
1557 fs::create_dir_all(&with_values_dir).unwrap();
1558 fs::write(
1559 with_values_dir.join("values.json"),
1560 r#"{"options": {"opt": "y"}}"#,
1561 )
1562 .unwrap();
1563
1564 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1565 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1566
1567 assert_eq!(values.len(), 1);
1568 assert!(values.contains_key("with-values"));
1569 assert!(!values.contains_key("without-values"));
1570 }
1571
1572 #[test]
1573 fn test_load_values_json_extracts_generated_at() {
1574 let temp_dir = TempDir::new().unwrap();
1575 let schemas_dir = temp_dir.path().join("schemas");
1576 let values_dir = temp_dir.path().join("values");
1577
1578 let schema_dir = schemas_dir.join("test");
1579 fs::create_dir_all(&schema_dir).unwrap();
1580 fs::write(
1581 schema_dir.join("schema.json"),
1582 r#"{
1583 "version": "1.0",
1584 "type": "object",
1585 "properties": {
1586 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1587 }
1588 }"#,
1589 )
1590 .unwrap();
1591
1592 let test_values_dir = values_dir.join("test");
1593 fs::create_dir_all(&test_values_dir).unwrap();
1594 fs::write(
1595 test_values_dir.join("values.json"),
1596 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1597 )
1598 .unwrap();
1599
1600 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1601 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1602
1603 assert_eq!(values["test"]["enabled"], json!(true));
1604 assert_eq!(
1605 generated_at_by_namespace.get("test"),
1606 Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1607 );
1608 }
1609
1610 #[test]
1611 fn test_propagation_event_emitted_on_generated_at_change() {
1612 let temp_dir = TempDir::new().unwrap();
1613 let schemas_dir = temp_dir.path().join("schemas");
1614 let values_dir = temp_dir.path().join("values");
1615
1616 let schema_dir = schemas_dir.join("test");
1617 fs::create_dir_all(&schema_dir).unwrap();
1618 fs::write(
1619 schema_dir.join("schema.json"),
1620 r#"{
1621 "version": "1.0",
1622 "type": "object",
1623 "properties": {
1624 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1625 }
1626 }"#,
1627 )
1628 .unwrap();
1629
1630 let test_values_dir = values_dir.join("test");
1631 fs::create_dir_all(&test_values_dir).unwrap();
1632 fs::write(
1633 test_values_dir.join("values.json"),
1634 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1635 )
1636 .unwrap();
1637
1638 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1639 let events_clone = events.clone();
1640
1641 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1642 let store = ValuesStore::build(
1643 registry,
1644 &values_dir,
1645 Duration::ZERO,
1646 Some(Box::new(move |ns, delay| {
1647 events_clone.lock().unwrap().push((ns.to_string(), delay));
1648 })),
1649 )
1650 .unwrap();
1651
1652 assert!(events.lock().unwrap().is_empty());
1654
1655 let _ = store.load();
1657 assert!(events.lock().unwrap().is_empty());
1658
1659 fs::write(
1661 test_values_dir.join("values.json"),
1662 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1663 )
1664 .unwrap();
1665
1666 let _ = store.load();
1667 let captured = events.lock().unwrap();
1668 assert_eq!(captured.len(), 1);
1669 assert_eq!(captured[0].0, "test");
1670 assert!(captured[0].1 > 0.0);
1671 }
1672
1673 #[test]
1674 fn test_propagation_event_not_emitted_without_callback() {
1675 let temp_dir = TempDir::new().unwrap();
1676 let schemas_dir = temp_dir.path().join("schemas");
1677 let values_dir = temp_dir.path().join("values");
1678
1679 let schema_dir = schemas_dir.join("test");
1680 fs::create_dir_all(&schema_dir).unwrap();
1681 fs::write(
1682 schema_dir.join("schema.json"),
1683 r#"{
1684 "version": "1.0",
1685 "type": "object",
1686 "properties": {
1687 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1688 }
1689 }"#,
1690 )
1691 .unwrap();
1692
1693 let test_values_dir = values_dir.join("test");
1694 fs::create_dir_all(&test_values_dir).unwrap();
1695 fs::write(
1696 test_values_dir.join("values.json"),
1697 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1698 )
1699 .unwrap();
1700
1701 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1702 let store = ValuesStore::build(registry, &values_dir, Duration::ZERO, None).unwrap();
1704 let _ = store.load();
1705 }
1707
1708 fn write_ns(base: &Path, namespace: &str, generated_at: &str) -> PathBuf {
1711 let schema_dir = base.join("schemas").join(namespace);
1712 fs::create_dir_all(&schema_dir).unwrap();
1713 fs::write(
1714 schema_dir.join("schema.json"),
1715 r#"{
1716 "version": "1.0",
1717 "type": "object",
1718 "properties": {
1719 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1720 }
1721 }"#,
1722 )
1723 .unwrap();
1724
1725 let ns_values_dir = base.join("values").join(namespace);
1726 fs::create_dir_all(&ns_values_dir).unwrap();
1727 let values_file = ns_values_dir.join("values.json");
1728 fs::write(
1729 &values_file,
1730 format!(r#"{{"options": {{"enabled": true}}, "generated_at": "{generated_at}"}}"#),
1731 )
1732 .unwrap();
1733 values_file
1734 }
1735
1736 #[test]
1737 fn test_propagation_delay_secs_parsing() {
1738 let now: DateTime<Utc> = "2024-01-21T19:00:00+00:00".parse().unwrap();
1739
1740 assert_eq!(
1742 propagation_delay_secs(&now, "2024-01-21T18:30:00+00:00"),
1743 Some(1800.0)
1744 );
1745 assert_eq!(
1747 propagation_delay_secs(&now, "2024-01-21T19:05:00+00:00"),
1748 Some(0.0)
1749 );
1750 assert_eq!(propagation_delay_secs(&now, "not-a-timestamp"), None);
1752 }
1753
1754 #[test]
1755 fn test_propagation_callback_panic_is_caught() {
1756 let temp_dir = TempDir::new().unwrap();
1757 let base = temp_dir.path();
1758 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
1759
1760 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1761 let store = ValuesStore::build(
1762 registry,
1763 &base.join("values"),
1764 Duration::ZERO,
1765 Some(Box::new(|_ns, _delay| panic!("callback boom"))),
1766 )
1767 .unwrap();
1768
1769 fs::write(
1771 &values_file,
1772 r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1773 )
1774 .unwrap();
1775 let _ = store.load();
1776
1777 assert_eq!(
1779 store.values.load().get("test").unwrap().get("enabled"),
1780 Some(&json!(false))
1781 );
1782 }
1783
1784 #[test]
1785 fn test_propagation_malformed_generated_at_does_not_emit() {
1786 let temp_dir = TempDir::new().unwrap();
1787 let base = temp_dir.path();
1788 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
1789
1790 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1791 let events_clone = events.clone();
1792 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1793 let store = ValuesStore::build(
1794 registry,
1795 &base.join("values"),
1796 Duration::ZERO,
1797 Some(Box::new(move |ns, delay| {
1798 events_clone.lock().unwrap().push((ns.to_string(), delay));
1799 })),
1800 )
1801 .unwrap();
1802
1803 fs::write(
1806 &values_file,
1807 r#"{"options": {"enabled": true}, "generated_at": "garbage"}"#,
1808 )
1809 .unwrap();
1810 let _ = store.load();
1811 assert!(events.lock().unwrap().is_empty());
1812 }
1813
1814 #[test]
1815 fn test_propagation_only_changed_namespace_emits() {
1816 let temp_dir = TempDir::new().unwrap();
1817 let base = temp_dir.path();
1818 write_ns(base, "alpha", "2024-01-21T18:00:00+00:00");
1819 let beta_values = write_ns(base, "beta", "2024-01-21T18:00:00+00:00");
1820
1821 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1822 let events_clone = events.clone();
1823 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1824 let store = ValuesStore::build(
1825 registry,
1826 &base.join("values"),
1827 Duration::ZERO,
1828 Some(Box::new(move |ns, delay| {
1829 events_clone.lock().unwrap().push((ns.to_string(), delay));
1830 })),
1831 )
1832 .unwrap();
1833
1834 fs::write(
1836 &beta_values,
1837 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1838 )
1839 .unwrap();
1840 let _ = store.load();
1841
1842 let captured = events.lock().unwrap();
1843 assert_eq!(captured.len(), 1);
1844 assert_eq!(captured[0].0, "beta");
1845 }
1846
1847 #[test]
1848 fn test_load_values_json_rejects_wrong_type() {
1849 let temp_dir = TempDir::new().unwrap();
1850 let schemas_dir = temp_dir.path().join("schemas");
1851 let values_dir = temp_dir.path().join("values");
1852
1853 let schema_dir = schemas_dir.join("test");
1854 fs::create_dir_all(&schema_dir).unwrap();
1855 fs::write(
1856 schema_dir.join("schema.json"),
1857 r#"{
1858 "version": "1.0",
1859 "type": "object",
1860 "properties": {
1861 "count": {"type": "integer", "default": 0, "description": "Count"}
1862 }
1863 }"#,
1864 )
1865 .unwrap();
1866
1867 let test_values_dir = values_dir.join("test");
1868 fs::create_dir_all(&test_values_dir).unwrap();
1869 fs::write(
1870 test_values_dir.join("values.json"),
1871 r#"{"options": {"count": "not-a-number"}}"#,
1872 )
1873 .unwrap();
1874
1875 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1876 let result = registry.load_values_json(&values_dir);
1877
1878 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1879 }
1880
1881 mod feature_flag_tests {
1882 use super::*;
1883
1884 const FEATURE_SCHEMA: &str = r##"{
1885 "version": "1.0",
1886 "type": "object",
1887 "properties": {
1888 "feature.organizations:fury-mode": {
1889 "$ref": "#/definitions/Feature"
1890 }
1891 }
1892 }"##;
1893
1894 #[test]
1895 fn test_schema_with_valid_feature_flag() {
1896 let temp_dir = TempDir::new().unwrap();
1897 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1898 assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
1899 }
1900
1901 #[test]
1902 fn test_schema_with_feature_and_regular_option() {
1903 let temp_dir = TempDir::new().unwrap();
1904 create_test_schema(
1905 &temp_dir,
1906 "test",
1907 r##"{
1908 "version": "1.0",
1909 "type": "object",
1910 "properties": {
1911 "my-option": {
1912 "type": "string",
1913 "default": "hello",
1914 "description": "A regular option"
1915 },
1916 "feature.organizations:fury-mode": {
1917 "$ref": "#/definitions/Feature"
1918 }
1919 }
1920 }"##,
1921 );
1922 assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
1923 }
1924
1925 #[test]
1926 fn test_schema_with_invalid_feature_definition() {
1927 let temp_dir = TempDir::new().unwrap();
1928
1929 create_test_schema(
1931 &temp_dir,
1932 "test",
1933 r#"{
1934 "version": "1.0",
1935 "type": "object",
1936 "properties": {
1937 "feature.organizations:fury-mode": {
1938 "nope": "nope"
1939 }
1940 }
1941 }"#,
1942 );
1943 let result = SchemaRegistry::from_directory(temp_dir.path());
1944 assert!(result.is_err());
1945 }
1946
1947 #[test]
1948 fn test_validate_values_with_valid_feature_flag() {
1949 let temp_dir = TempDir::new().unwrap();
1950 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1951 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1952
1953 let result = registry.validate_values(
1954 "test",
1955 &json!({
1956 "feature.organizations:fury-mode": {
1957 "owner": {"team": "hybrid-cloud"},
1958 "segments": [],
1959 "created_at": "2024-01-01"
1960 }
1961 }),
1962 );
1963 assert!(result.is_ok());
1964 }
1965
1966 #[test]
1967 fn test_validate_values_with_feature_flag_missing_required_field_fails() {
1968 let temp_dir = TempDir::new().unwrap();
1969 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1970 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1971
1972 let result = registry.validate_values(
1974 "test",
1975 &json!({
1976 "feature.organizations:fury-mode": {
1977 "segments": [],
1978 "created_at": "2024-01-01"
1979 }
1980 }),
1981 );
1982 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1983 }
1984
1985 #[test]
1986 fn test_validate_values_with_feature_flag_invalid_owner_fails() {
1987 let temp_dir = TempDir::new().unwrap();
1988 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1989 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1990
1991 let result = registry.validate_values(
1993 "test",
1994 &json!({
1995 "feature.organizations:fury-mode": {
1996 "owner": {"email": "test@example.com"},
1997 "segments": [],
1998 "created_at": "2024-01-01"
1999 }
2000 }),
2001 );
2002 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2003 }
2004
2005 #[test]
2006 fn test_validate_values_feature_with_segments_and_conditions() {
2007 let temp_dir = TempDir::new().unwrap();
2008 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2009 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2010
2011 let result = registry.validate_values(
2012 "test",
2013 &json!({
2014 "feature.organizations:fury-mode": {
2015 "owner": {"team": "hybrid-cloud"},
2016 "enabled": true,
2017 "created_at": "2024-01-01T00:00:00",
2018 "segments": [
2019 {
2020 "name": "internal orgs",
2021 "rollout": 50,
2022 "conditions": [
2023 {
2024 "property": "organization_slug",
2025 "operator": "in",
2026 "value": ["sentry-test", "sentry"]
2027 }
2028 ]
2029 }
2030 ]
2031 }
2032 }),
2033 );
2034 assert!(result.is_ok());
2035 }
2036
2037 #[test]
2038 fn test_validate_values_feature_with_multiple_condition_operators() {
2039 let temp_dir = TempDir::new().unwrap();
2040 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2041 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2042
2043 let result = registry.validate_values(
2044 "test",
2045 &json!({
2046 "feature.organizations:fury-mode": {
2047 "owner": {"team": "hybrid-cloud"},
2048 "created_at": "2024-01-01",
2049 "segments": [
2050 {
2051 "name": "free accounts",
2052 "conditions": [
2053 {
2054 "property": "subscription_is_free",
2055 "operator": "equals",
2056 "value": true
2057 }
2058 ]
2059 }
2060 ]
2061 }
2062 }),
2063 );
2064 assert!(result.is_ok());
2065 }
2066
2067 #[test]
2068 fn test_validate_values_feature_with_invalid_condition_operator_fails() {
2069 let temp_dir = TempDir::new().unwrap();
2070 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2071 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2072
2073 let result = registry.validate_values(
2075 "test",
2076 &json!({
2077 "feature.organizations:fury-mode": {
2078 "owner": {"team": "hybrid-cloud"},
2079 "created_at": "2024-01-01",
2080 "segments": [
2081 {
2082 "name": "test segment",
2083 "conditions": [
2084 {
2085 "property": "some_prop",
2086 "operator": "invalid_operator",
2087 "value": "some_value"
2088 }
2089 ]
2090 }
2091 ]
2092 }
2093 }),
2094 );
2095 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2096 }
2097
2098 #[test]
2099 fn test_schema_feature_flag_not_in_options_map() {
2100 let temp_dir = TempDir::new().unwrap();
2102 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2103 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2104 let schema = registry.get("test").unwrap();
2105
2106 assert!(
2107 schema
2108 .get_default("feature.organizations:fury-mode")
2109 .is_none()
2110 );
2111 }
2112
2113 #[test]
2114 fn test_validate_values_feature_and_regular_option_together() {
2115 let temp_dir = TempDir::new().unwrap();
2116 create_test_schema(
2117 &temp_dir,
2118 "test",
2119 r##"{
2120 "version": "1.0",
2121 "type": "object",
2122 "properties": {
2123 "my-option": {
2124 "type": "string",
2125 "default": "hello",
2126 "description": "A regular option"
2127 },
2128 "feature.organizations:fury-mode": {
2129 "$ref": "#/definitions/Feature"
2130 }
2131 }
2132 }"##,
2133 );
2134 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2135
2136 let result = registry.validate_values(
2138 "test",
2139 &json!({
2140 "my-option": "world",
2141 "feature.organizations:fury-mode": {
2142 "owner": {"team": "hybrid-cloud"},
2143 "segments": [],
2144 "created_at": "2024-01-01"
2145 }
2146 }),
2147 );
2148 assert!(result.is_ok());
2149 }
2150
2151 #[test]
2152 fn test_feature_flag_is_known_key() {
2153 let temp_dir = TempDir::new().unwrap();
2154 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2155 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2156 let schema = registry.get("test").unwrap();
2157 assert!(schema.is_known_key("feature.organizations:fury-mode"));
2159 assert!(
2161 !schema
2162 .options
2163 .contains_key("feature.organizations:fury-mode")
2164 );
2165 assert!(!schema.is_known_key("feature.organizations:does-not-exist"));
2166 }
2167
2168 #[test]
2169 fn test_validate_option_accepts_valid_feature() {
2170 let temp_dir = TempDir::new().unwrap();
2173 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2174 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2175 let schema = registry.get("test").unwrap();
2176
2177 let result = schema.validate_option(
2178 "feature.organizations:fury-mode",
2179 &json!({
2180 "owner": {"team": "hybrid-cloud"},
2181 "segments": [{"name": "all", "rollout": 100, "conditions": []}],
2182 "created_at": "2024-01-01"
2183 }),
2184 );
2185 assert!(result.is_ok());
2186 }
2187
2188 #[test]
2189 fn test_validate_option_rejects_malformed_feature() {
2190 let temp_dir = TempDir::new().unwrap();
2193 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2194 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2195 let schema = registry.get("test").unwrap();
2196
2197 let result =
2199 schema.validate_option("feature.organizations:fury-mode", &json!({"segments": []}));
2200 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2201 }
2202 }
2203
2204 mod store_tests {
2205 use super::*;
2206
2207 fn setup_store_test() -> (TempDir, PathBuf, PathBuf) {
2209 let temp_dir = TempDir::new().unwrap();
2210 let schemas_dir = temp_dir.path().join("schemas");
2211 let values_dir = temp_dir.path().join("values");
2212
2213 let ns1_schema = schemas_dir.join("ns1");
2214 fs::create_dir_all(&ns1_schema).unwrap();
2215 fs::write(
2216 ns1_schema.join("schema.json"),
2217 r#"{
2218 "version": "1.0",
2219 "type": "object",
2220 "properties": {
2221 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
2222 }
2223 }"#,
2224 )
2225 .unwrap();
2226
2227 let ns1_values = values_dir.join("ns1");
2228 fs::create_dir_all(&ns1_values).unwrap();
2229 fs::write(
2230 ns1_values.join("values.json"),
2231 r#"{"options": {"enabled": true}}"#,
2232 )
2233 .unwrap();
2234
2235 let ns2_schema = schemas_dir.join("ns2");
2236 fs::create_dir_all(&ns2_schema).unwrap();
2237 fs::write(
2238 ns2_schema.join("schema.json"),
2239 r#"{
2240 "version": "1.0",
2241 "type": "object",
2242 "properties": {
2243 "count": {"type": "integer", "default": 0, "description": "Count"}
2244 }
2245 }"#,
2246 )
2247 .unwrap();
2248
2249 let ns2_values = values_dir.join("ns2");
2250 fs::create_dir_all(&ns2_values).unwrap();
2251 fs::write(
2252 ns2_values.join("values.json"),
2253 r#"{"options": {"count": 42}}"#,
2254 )
2255 .unwrap();
2256
2257 (temp_dir, schemas_dir, values_dir)
2258 }
2259
2260 fn store_with_zero_threshold(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2261 let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2262 ValuesStore::build(registry, values_dir, Duration::ZERO, None).unwrap()
2263 }
2264
2265 #[test]
2266 fn test_initial_load_populates_values() {
2267 let (_temp, schemas_dir, values_dir) = setup_store_test();
2268 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2269 let store = ValuesStore::new(registry, &values_dir).unwrap();
2270
2271 let guard = store.load();
2272 assert_eq!(guard["ns1"]["enabled"], json!(true));
2273 assert_eq!(guard["ns2"]["count"], json!(42));
2274 }
2275
2276 #[test]
2277 fn test_read_within_threshold_serves_cached() {
2278 let (_temp, schemas_dir, values_dir) = setup_store_test();
2281 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2282 let store = ValuesStore::new(registry, &values_dir).unwrap();
2283
2284 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2286 fs::write(
2287 values_dir.join("ns1").join("values.json"),
2288 r#"{"options": {"enabled": false}}"#,
2289 )
2290 .unwrap();
2291
2292 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2294 }
2295
2296 #[test]
2297 fn test_read_after_threshold_refreshes() {
2298 let (_temp, schemas_dir, values_dir) = setup_store_test();
2299 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2300
2301 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2303 assert_eq!(store.load()["ns2"]["count"], json!(42));
2304
2305 fs::write(
2307 values_dir.join("ns1").join("values.json"),
2308 r#"{"options": {"enabled": false}}"#,
2309 )
2310 .unwrap();
2311 fs::write(
2312 values_dir.join("ns2").join("values.json"),
2313 r#"{"options": {"count": 100}}"#,
2314 )
2315 .unwrap();
2316
2317 let guard = store.load();
2319 assert_eq!(guard["ns1"]["enabled"], json!(false));
2320 assert_eq!(guard["ns2"]["count"], json!(100));
2321 }
2322
2323 #[test]
2324 fn test_refresh_failure_keeps_old_values() {
2325 let (_temp, schemas_dir, values_dir) = setup_store_test();
2326 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2327
2328 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2329
2330 fs::write(
2332 values_dir.join("ns1").join("values.json"),
2333 r#"{"options": {"enabled": "not-a-boolean"}}"#,
2334 )
2335 .unwrap();
2336
2337 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2339 }
2340
2341 #[test]
2342 fn test_concurrent_reads_observe_new_values() {
2343 use std::thread;
2344
2345 let (_temp, schemas_dir, values_dir) = setup_store_test();
2346 let store = Arc::new(store_with_zero_threshold(&schemas_dir, &values_dir));
2347
2348 assert_eq!(store.load()["ns2"]["count"], json!(42));
2350
2351 fs::write(
2352 values_dir.join("ns2").join("values.json"),
2353 r#"{"options": {"count": 7}}"#,
2354 )
2355 .unwrap();
2356
2357 let mut handles = Vec::new();
2358 for _ in 0..8 {
2359 let store = Arc::clone(&store);
2360 handles.push(thread::spawn(move || {
2361 let guard = store.load();
2362 guard["ns2"]["count"].clone()
2363 }));
2364 }
2365 for h in handles {
2366 assert_eq!(h.join().unwrap(), json!(7));
2367 }
2368 }
2369 }
2370 mod array_tests {
2371 use super::*;
2372
2373 #[test]
2374 fn test_basic_schema_validation() {
2375 let temp_dir = TempDir::new().unwrap();
2376 for (a_type, default) in [
2377 ("boolean", ""), ("boolean", "true"),
2379 ("integer", "1"),
2380 ("number", "1.2"),
2381 ("string", "\"wow\""),
2382 ] {
2383 create_test_schema(
2384 &temp_dir,
2385 "test",
2386 &format!(
2387 r#"{{
2388 "version": "1.0",
2389 "type": "object",
2390 "properties": {{
2391 "array-key": {{
2392 "type": "array",
2393 "items": {{"type": "{}"}},
2394 "default": [{}],
2395 "description": "Array option"
2396 }}
2397 }}
2398 }}"#,
2399 a_type, default
2400 ),
2401 );
2402
2403 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2404 }
2405 }
2406
2407 #[test]
2408 fn test_missing_items_object_rejection() {
2409 let temp_dir = TempDir::new().unwrap();
2410 create_test_schema(
2411 &temp_dir,
2412 "test",
2413 r#"{
2414 "version": "1.0",
2415 "type": "object",
2416 "properties": {
2417 "array-key": {
2418 "type": "array",
2419 "default": [1,2,3],
2420 "description": "Array option"
2421 }
2422 }
2423 }"#,
2424 );
2425
2426 let result = SchemaRegistry::from_directory(temp_dir.path());
2427 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2428 }
2429
2430 #[test]
2431 fn test_malformed_items_rejection() {
2432 let temp_dir = TempDir::new().unwrap();
2433 create_test_schema(
2434 &temp_dir,
2435 "test",
2436 r#"{
2437 "version": "1.0",
2438 "type": "object",
2439 "properties": {
2440 "array-key": {
2441 "type": "array",
2442 "items": {"type": ""},
2443 "default": [1,2,3],
2444 "description": "Array option"
2445 }
2446 }
2447 }"#,
2448 );
2449
2450 let result = SchemaRegistry::from_directory(temp_dir.path());
2451 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2452 }
2453
2454 #[test]
2455 fn test_schema_default_type_mismatch_rejection() {
2456 let temp_dir = TempDir::new().unwrap();
2457 create_test_schema(
2459 &temp_dir,
2460 "test",
2461 r#"{
2462 "version": "1.0",
2463 "type": "object",
2464 "properties": {
2465 "array-key": {
2466 "type": "array",
2467 "items": {"type": "integer"},
2468 "default": [1,2,3.3],
2469 "description": "Array option"
2470 }
2471 }
2472 }"#,
2473 );
2474
2475 let result = SchemaRegistry::from_directory(temp_dir.path());
2476 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2477 }
2478
2479 #[test]
2480 fn test_schema_default_heterogeneous_rejection() {
2481 let temp_dir = TempDir::new().unwrap();
2482 create_test_schema(
2483 &temp_dir,
2484 "test",
2485 r#"{
2486 "version": "1.0",
2487 "type": "object",
2488 "properties": {
2489 "array-key": {
2490 "type": "array",
2491 "items": {"type": "integer"},
2492 "default": [1,2,"uh oh!"],
2493 "description": "Array option"
2494 }
2495 }
2496 }"#,
2497 );
2498
2499 let result = SchemaRegistry::from_directory(temp_dir.path());
2500 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2501 }
2502
2503 #[test]
2504 fn test_load_values_valid() {
2505 let temp_dir = TempDir::new().unwrap();
2506 let (schemas_dir, values_dir) = create_test_schema_with_values(
2507 &temp_dir,
2508 "test",
2509 r#"{
2510 "version": "1.0",
2511 "type": "object",
2512 "properties": {
2513 "array-key": {
2514 "type": "array",
2515 "items": {"type": "integer"},
2516 "default": [1,2,3],
2517 "description": "Array option"
2518 }
2519 }
2520 }"#,
2521 r#"{
2522 "options": {
2523 "array-key": [4,5,6]
2524 }
2525 }"#,
2526 );
2527
2528 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2529 let (values, generated_at_by_namespace) =
2530 registry.load_values_json(&values_dir).unwrap();
2531
2532 assert_eq!(values.len(), 1);
2533 assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
2534 assert!(generated_at_by_namespace.is_empty());
2535 }
2536
2537 #[test]
2538 fn test_reject_values_not_an_array() {
2539 let temp_dir = TempDir::new().unwrap();
2540 let (schemas_dir, values_dir) = create_test_schema_with_values(
2541 &temp_dir,
2542 "test",
2543 r#"{
2544 "version": "1.0",
2545 "type": "object",
2546 "properties": {
2547 "array-key": {
2548 "type": "array",
2549 "items": {"type": "integer"},
2550 "default": [1,2,3],
2551 "description": "Array option"
2552 }
2553 }
2554 }"#,
2555 r#"{
2557 "options": {
2558 "array-key": "[]"
2559 }
2560 }"#,
2561 );
2562
2563 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2564 let result = registry.load_values_json(&values_dir);
2565
2566 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2567 }
2568
2569 #[test]
2570 fn test_reject_values_mismatch() {
2571 let temp_dir = TempDir::new().unwrap();
2572 let (schemas_dir, values_dir) = create_test_schema_with_values(
2573 &temp_dir,
2574 "test",
2575 r#"{
2576 "version": "1.0",
2577 "type": "object",
2578 "properties": {
2579 "array-key": {
2580 "type": "array",
2581 "items": {"type": "integer"},
2582 "default": [1,2,3],
2583 "description": "Array option"
2584 }
2585 }
2586 }"#,
2587 r#"{
2588 "options": {
2589 "array-key": ["a","b","c"]
2590 }
2591 }"#,
2592 );
2593
2594 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2595 let result = registry.load_values_json(&values_dir);
2596
2597 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2598 }
2599 }
2600
2601 mod object_tests {
2602 use super::*;
2603
2604 #[test]
2605 fn test_object_schema_loads() {
2606 let temp_dir = TempDir::new().unwrap();
2607 create_test_schema(
2608 &temp_dir,
2609 "test",
2610 r#"{
2611 "version": "1.0",
2612 "type": "object",
2613 "properties": {
2614 "config": {
2615 "type": "object",
2616 "properties": {
2617 "host": {"type": "string"},
2618 "port": {"type": "integer"},
2619 "rate": {"type": "number"},
2620 "enabled": {"type": "boolean"}
2621 },
2622 "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
2623 "description": "Service config"
2624 }
2625 }
2626 }"#,
2627 );
2628
2629 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2630 let schema = registry.get("test").unwrap();
2631 assert_eq!(schema.options["config"].option_type, "object");
2632 }
2633
2634 #[test]
2635 fn test_object_missing_properties_rejected() {
2636 let temp_dir = TempDir::new().unwrap();
2637 create_test_schema(
2638 &temp_dir,
2639 "test",
2640 r#"{
2641 "version": "1.0",
2642 "type": "object",
2643 "properties": {
2644 "config": {
2645 "type": "object",
2646 "default": {"host": "localhost"},
2647 "description": "Missing properties field"
2648 }
2649 }
2650 }"#,
2651 );
2652
2653 let result = SchemaRegistry::from_directory(temp_dir.path());
2654 assert!(result.is_err());
2655 }
2656
2657 #[test]
2658 fn test_object_default_wrong_type_rejected() {
2659 let temp_dir = TempDir::new().unwrap();
2660 create_test_schema(
2661 &temp_dir,
2662 "test",
2663 r#"{
2664 "version": "1.0",
2665 "type": "object",
2666 "properties": {
2667 "config": {
2668 "type": "object",
2669 "properties": {
2670 "host": {"type": "string"},
2671 "port": {"type": "integer"}
2672 },
2673 "default": {"host": "localhost", "port": "not-a-number"},
2674 "description": "Bad default"
2675 }
2676 }
2677 }"#,
2678 );
2679
2680 let result = SchemaRegistry::from_directory(temp_dir.path());
2681 assert!(result.is_err());
2682 }
2683
2684 #[test]
2685 fn test_object_default_missing_field_rejected() {
2686 let temp_dir = TempDir::new().unwrap();
2687 create_test_schema(
2688 &temp_dir,
2689 "test",
2690 r#"{
2691 "version": "1.0",
2692 "type": "object",
2693 "properties": {
2694 "config": {
2695 "type": "object",
2696 "properties": {
2697 "host": {"type": "string"},
2698 "port": {"type": "integer"}
2699 },
2700 "default": {"host": "localhost"},
2701 "description": "Missing port in default"
2702 }
2703 }
2704 }"#,
2705 );
2706
2707 let result = SchemaRegistry::from_directory(temp_dir.path());
2708 assert!(result.is_err());
2709 }
2710
2711 #[test]
2712 fn test_object_default_extra_field_rejected() {
2713 let temp_dir = TempDir::new().unwrap();
2714 create_test_schema(
2715 &temp_dir,
2716 "test",
2717 r#"{
2718 "version": "1.0",
2719 "type": "object",
2720 "properties": {
2721 "config": {
2722 "type": "object",
2723 "properties": {
2724 "host": {"type": "string"}
2725 },
2726 "default": {"host": "localhost", "extra": "field"},
2727 "description": "Extra field in default"
2728 }
2729 }
2730 }"#,
2731 );
2732
2733 let result = SchemaRegistry::from_directory(temp_dir.path());
2734 assert!(result.is_err());
2735 }
2736
2737 #[test]
2738 fn test_object_values_valid() {
2739 let temp_dir = TempDir::new().unwrap();
2740 let (schemas_dir, values_dir) = create_test_schema_with_values(
2741 &temp_dir,
2742 "test",
2743 r#"{
2744 "version": "1.0",
2745 "type": "object",
2746 "properties": {
2747 "config": {
2748 "type": "object",
2749 "properties": {
2750 "host": {"type": "string"},
2751 "port": {"type": "integer"}
2752 },
2753 "default": {"host": "localhost", "port": 8080},
2754 "description": "Service config"
2755 }
2756 }
2757 }"#,
2758 r#"{
2759 "options": {
2760 "config": {"host": "example.com", "port": 9090}
2761 }
2762 }"#,
2763 );
2764
2765 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2766 let result = registry.load_values_json(&values_dir);
2767 assert!(result.is_ok());
2768 }
2769
2770 #[test]
2771 fn test_object_values_wrong_field_type_rejected() {
2772 let temp_dir = TempDir::new().unwrap();
2773 let (schemas_dir, values_dir) = create_test_schema_with_values(
2774 &temp_dir,
2775 "test",
2776 r#"{
2777 "version": "1.0",
2778 "type": "object",
2779 "properties": {
2780 "config": {
2781 "type": "object",
2782 "properties": {
2783 "host": {"type": "string"},
2784 "port": {"type": "integer"}
2785 },
2786 "default": {"host": "localhost", "port": 8080},
2787 "description": "Service config"
2788 }
2789 }
2790 }"#,
2791 r#"{
2792 "options": {
2793 "config": {"host": "example.com", "port": "not-a-number"}
2794 }
2795 }"#,
2796 );
2797
2798 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2799 let result = registry.load_values_json(&values_dir);
2800 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2801 }
2802
2803 #[test]
2804 fn test_object_values_extra_field_rejected() {
2805 let temp_dir = TempDir::new().unwrap();
2806 let (schemas_dir, values_dir) = create_test_schema_with_values(
2807 &temp_dir,
2808 "test",
2809 r#"{
2810 "version": "1.0",
2811 "type": "object",
2812 "properties": {
2813 "config": {
2814 "type": "object",
2815 "properties": {
2816 "host": {"type": "string"}
2817 },
2818 "default": {"host": "localhost"},
2819 "description": "Service config"
2820 }
2821 }
2822 }"#,
2823 r#"{
2824 "options": {
2825 "config": {"host": "example.com", "extra": "field"}
2826 }
2827 }"#,
2828 );
2829
2830 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2831 let result = registry.load_values_json(&values_dir);
2832 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2833 }
2834
2835 #[test]
2836 fn test_object_values_missing_field_rejected() {
2837 let temp_dir = TempDir::new().unwrap();
2838 let (schemas_dir, values_dir) = create_test_schema_with_values(
2839 &temp_dir,
2840 "test",
2841 r#"{
2842 "version": "1.0",
2843 "type": "object",
2844 "properties": {
2845 "config": {
2846 "type": "object",
2847 "properties": {
2848 "host": {"type": "string"},
2849 "port": {"type": "integer"}
2850 },
2851 "default": {"host": "localhost", "port": 8080},
2852 "description": "Service config"
2853 }
2854 }
2855 }"#,
2856 r#"{
2857 "options": {
2858 "config": {"host": "example.com"}
2859 }
2860 }"#,
2861 );
2862
2863 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2864 let result = registry.load_values_json(&values_dir);
2865 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2866 }
2867
2868 #[test]
2871 fn test_array_of_objects_schema_loads() {
2872 let temp_dir = TempDir::new().unwrap();
2873 create_test_schema(
2874 &temp_dir,
2875 "test",
2876 r#"{
2877 "version": "1.0",
2878 "type": "object",
2879 "properties": {
2880 "endpoints": {
2881 "type": "array",
2882 "items": {
2883 "type": "object",
2884 "properties": {
2885 "url": {"type": "string"},
2886 "weight": {"type": "integer"}
2887 }
2888 },
2889 "default": [{"url": "https://a.example.com", "weight": 1}],
2890 "description": "Endpoints"
2891 }
2892 }
2893 }"#,
2894 );
2895
2896 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2897 let schema = registry.get("test").unwrap();
2898 assert_eq!(schema.options["endpoints"].option_type, "array");
2899 }
2900
2901 #[test]
2902 fn test_array_of_objects_empty_default() {
2903 let temp_dir = TempDir::new().unwrap();
2904 create_test_schema(
2905 &temp_dir,
2906 "test",
2907 r#"{
2908 "version": "1.0",
2909 "type": "object",
2910 "properties": {
2911 "endpoints": {
2912 "type": "array",
2913 "items": {
2914 "type": "object",
2915 "properties": {
2916 "url": {"type": "string"},
2917 "weight": {"type": "integer"}
2918 }
2919 },
2920 "default": [],
2921 "description": "Endpoints"
2922 }
2923 }
2924 }"#,
2925 );
2926
2927 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2928 assert!(registry.get("test").is_some());
2929 }
2930
2931 #[test]
2932 fn test_array_of_objects_default_wrong_field_type_rejected() {
2933 let temp_dir = TempDir::new().unwrap();
2934 create_test_schema(
2935 &temp_dir,
2936 "test",
2937 r#"{
2938 "version": "1.0",
2939 "type": "object",
2940 "properties": {
2941 "endpoints": {
2942 "type": "array",
2943 "items": {
2944 "type": "object",
2945 "properties": {
2946 "url": {"type": "string"},
2947 "weight": {"type": "integer"}
2948 }
2949 },
2950 "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
2951 "description": "Endpoints"
2952 }
2953 }
2954 }"#,
2955 );
2956
2957 let result = SchemaRegistry::from_directory(temp_dir.path());
2958 assert!(result.is_err());
2959 }
2960
2961 #[test]
2962 fn test_array_of_objects_missing_items_properties_rejected() {
2963 let temp_dir = TempDir::new().unwrap();
2964 create_test_schema(
2965 &temp_dir,
2966 "test",
2967 r#"{
2968 "version": "1.0",
2969 "type": "object",
2970 "properties": {
2971 "endpoints": {
2972 "type": "array",
2973 "items": {
2974 "type": "object"
2975 },
2976 "default": [],
2977 "description": "Missing properties in items"
2978 }
2979 }
2980 }"#,
2981 );
2982
2983 let result = SchemaRegistry::from_directory(temp_dir.path());
2984 assert!(result.is_err());
2985 }
2986
2987 #[test]
2988 fn test_array_of_objects_values_valid() {
2989 let temp_dir = TempDir::new().unwrap();
2990 let (schemas_dir, values_dir) = create_test_schema_with_values(
2991 &temp_dir,
2992 "test",
2993 r#"{
2994 "version": "1.0",
2995 "type": "object",
2996 "properties": {
2997 "endpoints": {
2998 "type": "array",
2999 "items": {
3000 "type": "object",
3001 "properties": {
3002 "url": {"type": "string"},
3003 "weight": {"type": "integer"}
3004 }
3005 },
3006 "default": [],
3007 "description": "Endpoints"
3008 }
3009 }
3010 }"#,
3011 r#"{
3012 "options": {
3013 "endpoints": [
3014 {"url": "https://a.example.com", "weight": 1},
3015 {"url": "https://b.example.com", "weight": 2}
3016 ]
3017 }
3018 }"#,
3019 );
3020
3021 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3022 let result = registry.load_values_json(&values_dir);
3023 assert!(result.is_ok());
3024 }
3025
3026 #[test]
3027 fn test_array_of_objects_values_wrong_item_shape_rejected() {
3028 let temp_dir = TempDir::new().unwrap();
3029 let (schemas_dir, values_dir) = create_test_schema_with_values(
3030 &temp_dir,
3031 "test",
3032 r#"{
3033 "version": "1.0",
3034 "type": "object",
3035 "properties": {
3036 "endpoints": {
3037 "type": "array",
3038 "items": {
3039 "type": "object",
3040 "properties": {
3041 "url": {"type": "string"},
3042 "weight": {"type": "integer"}
3043 }
3044 },
3045 "default": [],
3046 "description": "Endpoints"
3047 }
3048 }
3049 }"#,
3050 r#"{
3051 "options": {
3052 "endpoints": [
3053 {"url": "https://a.example.com", "weight": "not-a-number"}
3054 ]
3055 }
3056 }"#,
3057 );
3058
3059 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3060 let result = registry.load_values_json(&values_dir);
3061 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3062 }
3063
3064 #[test]
3065 fn test_array_of_objects_values_extra_field_rejected() {
3066 let temp_dir = TempDir::new().unwrap();
3067 let (schemas_dir, values_dir) = create_test_schema_with_values(
3068 &temp_dir,
3069 "test",
3070 r#"{
3071 "version": "1.0",
3072 "type": "object",
3073 "properties": {
3074 "endpoints": {
3075 "type": "array",
3076 "items": {
3077 "type": "object",
3078 "properties": {
3079 "url": {"type": "string"}
3080 }
3081 },
3082 "default": [],
3083 "description": "Endpoints"
3084 }
3085 }
3086 }"#,
3087 r#"{
3088 "options": {
3089 "endpoints": [
3090 {"url": "https://a.example.com", "extra": "field"}
3091 ]
3092 }
3093 }"#,
3094 );
3095
3096 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3097 let result = registry.load_values_json(&values_dir);
3098 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3099 }
3100
3101 #[test]
3102 fn test_array_of_objects_values_missing_field_rejected() {
3103 let temp_dir = TempDir::new().unwrap();
3104 let (schemas_dir, values_dir) = create_test_schema_with_values(
3105 &temp_dir,
3106 "test",
3107 r#"{
3108 "version": "1.0",
3109 "type": "object",
3110 "properties": {
3111 "endpoints": {
3112 "type": "array",
3113 "items": {
3114 "type": "object",
3115 "properties": {
3116 "url": {"type": "string"},
3117 "weight": {"type": "integer"}
3118 }
3119 },
3120 "default": [],
3121 "description": "Endpoints"
3122 }
3123 }
3124 }"#,
3125 r#"{
3126 "options": {
3127 "endpoints": [
3128 {"url": "https://a.example.com"}
3129 ]
3130 }
3131 }"#,
3132 );
3133
3134 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3135 let result = registry.load_values_json(&values_dir);
3136 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3137 }
3138
3139 #[test]
3142 fn test_object_optional_field_can_be_omitted_from_default() {
3143 let temp_dir = TempDir::new().unwrap();
3144 create_test_schema(
3145 &temp_dir,
3146 "test",
3147 r#"{
3148 "version": "1.0",
3149 "type": "object",
3150 "properties": {
3151 "config": {
3152 "type": "object",
3153 "properties": {
3154 "host": {"type": "string"},
3155 "debug": {"type": "boolean", "optional": true}
3156 },
3157 "default": {"host": "localhost"},
3158 "description": "Config with optional field"
3159 }
3160 }
3161 }"#,
3162 );
3163
3164 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3165 let schema = registry.get("test").unwrap();
3166 assert_eq!(schema.options["config"].option_type, "object");
3167 }
3168
3169 #[test]
3170 fn test_object_optional_field_can_be_included_in_default() {
3171 let temp_dir = TempDir::new().unwrap();
3172 create_test_schema(
3173 &temp_dir,
3174 "test",
3175 r#"{
3176 "version": "1.0",
3177 "type": "object",
3178 "properties": {
3179 "config": {
3180 "type": "object",
3181 "properties": {
3182 "host": {"type": "string"},
3183 "debug": {"type": "boolean", "optional": true}
3184 },
3185 "default": {"host": "localhost", "debug": true},
3186 "description": "Config with optional field included"
3187 }
3188 }
3189 }"#,
3190 );
3191
3192 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3193 assert!(registry.get("test").is_some());
3194 }
3195
3196 #[test]
3197 fn test_object_optional_field_wrong_type_rejected() {
3198 let temp_dir = TempDir::new().unwrap();
3199 create_test_schema(
3200 &temp_dir,
3201 "test",
3202 r#"{
3203 "version": "1.0",
3204 "type": "object",
3205 "properties": {
3206 "config": {
3207 "type": "object",
3208 "properties": {
3209 "host": {"type": "string"},
3210 "debug": {"type": "boolean", "optional": true}
3211 },
3212 "default": {"host": "localhost", "debug": "not-a-bool"},
3213 "description": "Optional field wrong type"
3214 }
3215 }
3216 }"#,
3217 );
3218
3219 let result = SchemaRegistry::from_directory(temp_dir.path());
3220 assert!(result.is_err());
3221 }
3222
3223 #[test]
3224 fn test_object_required_field_still_required_with_optional_present() {
3225 let temp_dir = TempDir::new().unwrap();
3226 create_test_schema(
3227 &temp_dir,
3228 "test",
3229 r#"{
3230 "version": "1.0",
3231 "type": "object",
3232 "properties": {
3233 "config": {
3234 "type": "object",
3235 "properties": {
3236 "host": {"type": "string"},
3237 "port": {"type": "integer"},
3238 "debug": {"type": "boolean", "optional": true}
3239 },
3240 "default": {"debug": true},
3241 "description": "Missing required fields"
3242 }
3243 }
3244 }"#,
3245 );
3246
3247 let result = SchemaRegistry::from_directory(temp_dir.path());
3248 assert!(result.is_err());
3249 }
3250
3251 #[test]
3252 fn test_object_optional_field_omitted_from_values() {
3253 let temp_dir = TempDir::new().unwrap();
3254 let (schemas_dir, values_dir) = create_test_schema_with_values(
3255 &temp_dir,
3256 "test",
3257 r#"{
3258 "version": "1.0",
3259 "type": "object",
3260 "properties": {
3261 "config": {
3262 "type": "object",
3263 "properties": {
3264 "host": {"type": "string"},
3265 "debug": {"type": "boolean", "optional": true}
3266 },
3267 "default": {"host": "localhost"},
3268 "description": "Config"
3269 }
3270 }
3271 }"#,
3272 r#"{
3273 "options": {
3274 "config": {"host": "example.com"}
3275 }
3276 }"#,
3277 );
3278
3279 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3280 let result = registry.load_values_json(&values_dir);
3281 assert!(result.is_ok());
3282 }
3283
3284 #[test]
3285 fn test_object_optional_field_included_in_values() {
3286 let temp_dir = TempDir::new().unwrap();
3287 let (schemas_dir, values_dir) = create_test_schema_with_values(
3288 &temp_dir,
3289 "test",
3290 r#"{
3291 "version": "1.0",
3292 "type": "object",
3293 "properties": {
3294 "config": {
3295 "type": "object",
3296 "properties": {
3297 "host": {"type": "string"},
3298 "debug": {"type": "boolean", "optional": true}
3299 },
3300 "default": {"host": "localhost"},
3301 "description": "Config"
3302 }
3303 }
3304 }"#,
3305 r#"{
3306 "options": {
3307 "config": {"host": "example.com", "debug": true}
3308 }
3309 }"#,
3310 );
3311
3312 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3313 let result = registry.load_values_json(&values_dir);
3314 assert!(result.is_ok());
3315 }
3316
3317 #[test]
3318 fn test_array_of_objects_optional_field_omitted() {
3319 let temp_dir = TempDir::new().unwrap();
3320 let (schemas_dir, values_dir) = create_test_schema_with_values(
3321 &temp_dir,
3322 "test",
3323 r#"{
3324 "version": "1.0",
3325 "type": "object",
3326 "properties": {
3327 "endpoints": {
3328 "type": "array",
3329 "items": {
3330 "type": "object",
3331 "properties": {
3332 "url": {"type": "string"},
3333 "weight": {"type": "integer", "optional": true}
3334 }
3335 },
3336 "default": [],
3337 "description": "Endpoints"
3338 }
3339 }
3340 }"#,
3341 r#"{
3342 "options": {
3343 "endpoints": [
3344 {"url": "https://a.example.com"},
3345 {"url": "https://b.example.com", "weight": 2}
3346 ]
3347 }
3348 }"#,
3349 );
3350
3351 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3352 let result = registry.load_values_json(&values_dir);
3353 assert!(result.is_ok());
3354 }
3355 }
3356
3357 #[test]
3358 fn test_object_valued_map_validates_nested_values() {
3359 let registry = SchemaRegistry::from_schemas(&[(
3362 "test",
3363 r#"{
3364 "version": "1.0",
3365 "type": "object",
3366 "properties": {
3367 "org_tweaks": {
3368 "type": "object",
3369 "additionalProperties": {
3370 "type": "object",
3371 "additionalProperties": {"type": "integer"}
3372 },
3373 "default": {},
3374 "description": ""
3375 }
3376 }
3377 }"#,
3378 )])
3379 .unwrap();
3380
3381 assert!(
3382 registry
3383 .validate_values(
3384 "test",
3385 &json!({"org_tweaks": {"1": {"max_candidates": 20}}})
3386 )
3387 .is_ok()
3388 );
3389 assert!(
3390 registry
3391 .validate_values("test", &json!({"org_tweaks": {}}))
3392 .is_ok()
3393 );
3394 assert!(matches!(
3396 registry.validate_values(
3397 "test",
3398 &json!({"org_tweaks": {"1": {"max_candidates": "x"}}})
3399 ),
3400 Err(ValidationError::ValueError { .. })
3401 ));
3402 assert!(matches!(
3404 registry.validate_values("test", &json!({"org_tweaks": {"1": 20}})),
3405 Err(ValidationError::ValueError { .. })
3406 ));
3407 }
3408
3409 #[test]
3410 fn test_array_valued_map_validates_nested_values() {
3411 let registry = SchemaRegistry::from_schemas(&[(
3413 "test",
3414 r#"{
3415 "version": "1.0",
3416 "type": "object",
3417 "properties": {
3418 "webhook_logging": {
3419 "type": "object",
3420 "additionalProperties": {
3421 "type": "array",
3422 "items": {"type": "string"}
3423 },
3424 "default": {},
3425 "description": ""
3426 }
3427 }
3428 }"#,
3429 )])
3430 .unwrap();
3431
3432 assert!(
3433 registry
3434 .validate_values(
3435 "test",
3436 &json!({"webhook_logging": {"a": [], "b": ["x", "y"]}})
3437 )
3438 .is_ok()
3439 );
3440 assert!(matches!(
3442 registry.validate_values("test", &json!({"webhook_logging": {"a": [1]}})),
3443 Err(ValidationError::ValueError { .. })
3444 ));
3445 assert!(matches!(
3447 registry.validate_values("test", &json!({"webhook_logging": {"a": "x"}})),
3448 Err(ValidationError::ValueError { .. })
3449 ));
3450 }
3451
3452 #[test]
3453 fn test_nested_fixed_object_enforces_required_and_rejects_unknown() {
3454 let registry = SchemaRegistry::from_schemas(&[(
3457 "test",
3458 r#"{
3459 "version": "1.0",
3460 "type": "object",
3461 "properties": {
3462 "quotas": {
3463 "type": "object",
3464 "additionalProperties": {
3465 "type": "object",
3466 "properties": {
3467 "limit": {"type": "integer"},
3468 "window": {"type": "integer"}
3469 }
3470 },
3471 "default": {},
3472 "description": ""
3473 }
3474 }
3475 }"#,
3476 )])
3477 .unwrap();
3478
3479 assert!(
3480 registry
3481 .validate_values("test", &json!({"quotas": {"o": {"limit": 1, "window": 2}}}))
3482 .is_ok()
3483 );
3484 assert!(matches!(
3486 registry.validate_values("test", &json!({"quotas": {"o": {"limit": 1}}})),
3487 Err(ValidationError::ValueError { .. })
3488 ));
3489 assert!(matches!(
3491 registry.validate_values(
3492 "test",
3493 &json!({"quotas": {"o": {"limit": 1, "window": 2, "x": 3}}})
3494 ),
3495 Err(ValidationError::ValueError { .. })
3496 ));
3497 }
3498
3499 #[test]
3500 fn test_optional_marker_works_at_depth() {
3501 let registry = SchemaRegistry::from_schemas(&[(
3502 "test",
3503 r#"{
3504 "version": "1.0",
3505 "type": "object",
3506 "properties": {
3507 "items_map": {
3508 "type": "object",
3509 "additionalProperties": {
3510 "type": "object",
3511 "properties": {
3512 "url": {"type": "string"},
3513 "weight": {"type": "integer", "optional": true}
3514 }
3515 },
3516 "default": {},
3517 "description": ""
3518 }
3519 }
3520 }"#,
3521 )])
3522 .unwrap();
3523
3524 assert!(
3526 registry
3527 .validate_values("test", &json!({"items_map": {"a": {"url": "x"}}}))
3528 .is_ok()
3529 );
3530 assert!(matches!(
3532 registry.validate_values("test", &json!({"items_map": {"a": {"weight": 1}}})),
3533 Err(ValidationError::ValueError { .. })
3534 ));
3535 }
3536
3537 #[test]
3538 fn test_three_levels_of_nesting() {
3539 let registry = SchemaRegistry::from_schemas(&[(
3541 "test",
3542 r#"{
3543 "version": "1.0",
3544 "type": "object",
3545 "properties": {
3546 "deep": {
3547 "type": "object",
3548 "additionalProperties": {
3549 "type": "array",
3550 "items": {
3551 "type": "object",
3552 "additionalProperties": {"type": "integer"}
3553 }
3554 },
3555 "default": {},
3556 "description": ""
3557 }
3558 }
3559 }"#,
3560 )])
3561 .unwrap();
3562
3563 assert!(
3564 registry
3565 .validate_values("test", &json!({"deep": {"a": [{"x": 1}, {"y": 2}]}}))
3566 .is_ok()
3567 );
3568 assert!(matches!(
3569 registry.validate_values("test", &json!({"deep": {"a": [{"x": "no"}]}})),
3570 Err(ValidationError::ValueError { .. })
3571 ));
3572 }
3573
3574 #[test]
3575 fn test_meta_schema_rejects_malformed_nested_value_types() {
3576 assert!(matches!(
3578 SchemaRegistry::from_schemas(&[(
3579 "test",
3580 r#"{"version":"1.0","type":"object","properties":{
3581 "m":{"type":"object","additionalProperties":{"type":"object"},"default":{},"description":""}}}"#,
3582 )]),
3583 Err(ValidationError::SchemaError { .. })
3584 ));
3585 assert!(matches!(
3587 SchemaRegistry::from_schemas(&[(
3588 "test",
3589 r#"{"version":"1.0","type":"object","properties":{
3590 "m":{"type":"object","additionalProperties":{"type":"array"},"default":{},"description":""}}}"#,
3591 )]),
3592 Err(ValidationError::SchemaError { .. })
3593 ));
3594 assert!(matches!(
3596 SchemaRegistry::from_schemas(&[(
3597 "test",
3598 r#"{"version":"1.0","type":"object","properties":{
3599 "m":{"type":"object","additionalProperties":{"type":"string","bogus":1},"default":{},"description":""}}}"#,
3600 )]),
3601 Err(ValidationError::SchemaError { .. })
3602 ));
3603 }
3604
3605 #[test]
3606 fn test_pre_existing_shapes_still_validate() {
3607 let registry = SchemaRegistry::from_schemas(&[(
3609 "test",
3610 r#"{
3611 "version": "1.0",
3612 "type": "object",
3613 "properties": {
3614 "scopes": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}, "description": ""},
3615 "config": {"type": "object", "properties": {"host": {"type": "string"}}, "default": {"host": "h"}, "description": ""}
3616 }
3617 }"#,
3618 )])
3619 .unwrap();
3620
3621 assert!(
3622 registry
3623 .validate_values(
3624 "test",
3625 &json!({"scopes": {"r": "1"}, "config": {"host": "x"}})
3626 )
3627 .is_ok()
3628 );
3629 assert!(matches!(
3630 registry.validate_values("test", &json!({"config": {"host": "x", "extra": "y"}})),
3631 Err(ValidationError::ValueError { .. })
3632 ));
3633 }
3634}