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