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 FEATURE_KEY_PREFIX: &str = "feature.";
36const FEATURE_KEY_PATTERN: &str = "^feature\\.";
38
39pub const DEFAULT_REFRESH_THRESHOLD: Duration = Duration::from_secs(5);
43
44pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
46
47pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
49
50pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
52
53pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
55
56fn should_suppress_missing_dir_errors() -> bool {
58 std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
59 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
60 .unwrap_or(false)
61}
62
63pub fn resolve_options_dir() -> PathBuf {
68 if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
69 return PathBuf::from(dir);
70 }
71
72 let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
73 if prod_path.exists() {
74 return prod_path;
75 }
76
77 PathBuf::from(LOCAL_OPTIONS_DIR)
78}
79
80pub type ValidationResult<T> = Result<T, ValidationError>;
82
83pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
85
86#[derive(Debug, thiserror::Error)]
88pub enum ValidationError {
89 #[error("Schema error in {file}: {message}")]
90 SchemaError { file: PathBuf, message: String },
91
92 #[error("Value error for {namespace}: {errors}")]
93 ValueError { namespace: String, errors: String },
94
95 #[error("Unknown namespace: {0}")]
96 UnknownNamespace(String),
97
98 #[error("Unknown option '{key}' in namespace '{namespace}'")]
99 UnknownOption { namespace: String, key: String },
100
101 #[error("Internal error: {0}")]
102 InternalError(String),
103
104 #[error("Failed to read file: {0}")]
105 FileRead(#[from] std::io::Error),
106
107 #[error("Failed to parse JSON: {0}")]
108 JSONParse(#[from] serde_json::Error),
109
110 #[error("{} validation error(s)", .0.len())]
111 ValidationErrors(Vec<ValidationError>),
112
113 #[error("Invalid {label} '{name}': {reason}")]
114 InvalidName {
115 label: String,
116 name: String,
117 reason: String,
118 },
119}
120
121pub const DNS1123_LABEL_MAX_LEN: usize = 63;
123
124pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
130 if name.len() > DNS1123_LABEL_MAX_LEN {
131 return Err(ValidationError::InvalidName {
132 label: label.to_string(),
133 name: name.to_string(),
134 reason: format!("must be at most {} characters", DNS1123_LABEL_MAX_LEN),
135 });
136 }
137 if let Some(c) = name
138 .chars()
139 .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-'))
140 {
141 return Err(ValidationError::InvalidName {
142 label: label.to_string(),
143 name: name.to_string(),
144 reason: format!(
145 "character '{}' not allowed. Use lowercase alphanumeric or '-'",
146 c
147 ),
148 });
149 }
150 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
151 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
152 {
153 return Err(ValidationError::InvalidName {
154 label: label.to_string(),
155 name: name.to_string(),
156 reason: "must start and end with alphanumeric".to_string(),
157 });
158 }
159 Ok(())
160}
161
162pub fn feature_property() -> Value {
166 json!({ "$ref": "#/definitions/Feature" })
167}
168
169#[derive(Debug, Clone)]
171pub struct OptionMetadata {
172 pub option_type: String,
173 pub property_schema: Value,
174 pub default: Value,
175}
176
177pub struct NamespaceSchema {
179 pub namespace: String,
180 pub options: HashMap<String, OptionMetadata>,
181 feature_keys: HashSet<String>,
184 all_keys: HashSet<String>,
186 open_features: bool,
190 validator: jsonschema::Validator,
191}
192
193impl NamespaceSchema {
194 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
202 let output = self.validator.evaluate(values);
203 if output.flag().valid {
204 Ok(())
205 } else {
206 let errors: Vec<String> = output
207 .iter_errors()
208 .map(|e| {
209 format!(
210 "\n\t{} {}",
211 e.instance_location.as_str().trim_start_matches("/"),
212 e.error
213 )
214 })
215 .collect();
216 Err(ValidationError::ValueError {
217 namespace: self.namespace.clone(),
218 errors: errors.join(""),
219 })
220 }
221 }
222
223 pub fn get_default(&self, key: &str) -> Option<&Value> {
226 self.options.get(key).map(|meta| &meta.default)
227 }
228
229 pub fn is_known_key(&self, key: &str) -> bool {
232 self.options.contains_key(key) || self.accepts_feature_key(key)
233 }
234
235 fn accepts_feature_key(&self, key: &str) -> bool {
238 self.feature_keys.contains(key)
239 || (self.open_features && key.starts_with(FEATURE_KEY_PREFIX))
240 }
241
242 pub fn accepts_key(&self, key: &str) -> bool {
246 self.all_keys.contains(key) || self.accepts_feature_key(key)
247 }
248
249 pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
254 if !self.is_known_key(key) {
255 return Err(ValidationError::UnknownOption {
256 namespace: self.namespace.clone(),
257 key: key.to_string(),
258 });
259 }
260 let test_obj = json!({ key: value });
261 self.validate_values(&test_obj)
262 }
263}
264
265pub struct SchemaRegistry {
267 schemas: HashMap<String, Arc<NamespaceSchema>>,
268}
269
270impl SchemaRegistry {
271 pub fn new() -> Self {
273 Self {
274 schemas: HashMap::new(),
275 }
276 }
277
278 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
288 let namespace_validator = Self::compile_namespace_validator()?;
289 let mut schemas_map = HashMap::new();
290
291 for entry in fs::read_dir(schemas_dir)? {
293 let entry = entry?;
294
295 if !entry.file_type()?.is_dir() {
296 continue;
297 }
298
299 let namespace =
300 entry
301 .file_name()
302 .into_string()
303 .map_err(|_| ValidationError::SchemaError {
304 file: entry.path(),
305 message: "Directory name contains invalid UTF-8".to_string(),
306 })?;
307
308 validate_k8s_name_component(&namespace, "namespace name")?;
309
310 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
311 let file = fs::File::open(&schema_file)?;
312 let schema_data: Value = serde_json::from_reader(file)?;
313
314 Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
315 let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
316 schemas_map.insert(namespace, schema);
317 }
318
319 Ok(Self {
320 schemas: schemas_map,
321 })
322 }
323
324 pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
330 let mut registry = Self::new();
331 registry.add_schemas(schemas)?;
332 Ok(registry)
333 }
334
335 pub fn add_schemas(&mut self, schemas: &[(&str, &str)]) -> ValidationResult<()> {
339 let namespace_validator = Self::compile_namespace_validator()?;
340 let schema_file = Path::new("<embedded>");
341
342 for (namespace, json) in schemas {
343 validate_k8s_name_component(namespace, "namespace name")?;
344
345 let schema_data: Value =
346 serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
347 file: schema_file.to_path_buf(),
348 message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
349 })?;
350
351 Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
352 let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
353 if self.schemas.insert(namespace.to_string(), schema).is_some() {
354 return Err(ValidationError::SchemaError {
355 file: schema_file.to_path_buf(),
356 message: format!("Duplicate namespace '{}'", namespace),
357 });
358 }
359 }
360
361 Ok(())
362 }
363
364 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
366 let schema = self
367 .schemas
368 .get(namespace)
369 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
370
371 schema.validate_values(values)
372 }
373
374 pub fn validate_option(
380 &self,
381 namespace: &str,
382 key: &str,
383 value: &Value,
384 ) -> ValidationResult<()> {
385 let schema = self
386 .schemas
387 .get(namespace)
388 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
389
390 schema.validate_option(key, value)
391 }
392
393 fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
394 let namespace_schema_value: Value =
395 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
396 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
397 })?;
398 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
399 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
400 })
401 }
402
403 fn validate_with_namespace_schema(
405 schema_data: &Value,
406 path: &Path,
407 namespace_validator: &jsonschema::Validator,
408 ) -> ValidationResult<()> {
409 let output = namespace_validator.evaluate(schema_data);
410
411 if output.flag().valid {
412 Ok(())
413 } else {
414 let errors: Vec<String> = output
415 .iter_errors()
416 .map(|e| format!("Error: {}", e.error))
417 .collect();
418
419 Err(ValidationError::SchemaError {
420 file: path.to_path_buf(),
421 message: format!("Schema validation failed:\n{}", errors.join("\n")),
422 })
423 }
424 }
425
426 fn validate_default_type(
428 property_name: &str,
429 property_schema: &Value,
430 default_value: &Value,
431 path: &Path,
432 ) -> ValidationResult<()> {
433 jsonschema::validate(property_schema, default_value).map_err(|e| {
435 ValidationError::SchemaError {
436 file: path.to_path_buf(),
437 message: format!(
438 "Property '{}': default value does not match schema: {}",
439 property_name, e
440 ),
441 }
442 })?;
443
444 Ok(())
445 }
446
447 fn inject_object_constraints(schema: &mut Value) {
464 let Some(obj) = schema.as_object_mut() else {
465 return;
466 };
467
468 if obj.get("type").and_then(|t| t.as_str()) == Some("object") {
469 if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
473 let required: Vec<Value> = props
474 .iter()
475 .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
476 .map(|(k, _)| Value::String(k.clone()))
477 .collect();
478 obj.insert("required".to_string(), Value::Array(required));
479 }
480 if !obj.contains_key("additionalProperties") {
483 obj.insert("additionalProperties".to_string(), json!(false));
484 }
485 }
486
487 if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
490 for field in props.values_mut() {
491 Self::inject_object_constraints(field);
492 }
493 }
494 if let Some(additional) = obj.get_mut("additionalProperties")
495 && additional.is_object()
496 {
497 Self::inject_object_constraints(additional);
498 }
499 if let Some(items) = obj.get_mut("items") {
500 Self::inject_object_constraints(items);
501 }
502 }
503
504 fn parse_schema(
506 mut schema: Value,
507 namespace: &str,
508 path: &Path,
509 ) -> ValidationResult<Arc<NamespaceSchema>> {
510 if let Some(obj) = schema.as_object_mut() {
512 obj.insert("additionalProperties".to_string(), json!(false));
513 }
514
515 if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
518 for prop_value in properties.values_mut() {
519 Self::inject_object_constraints(prop_value);
520 }
521 }
522
523 let mut options = HashMap::new();
525 let mut feature_keys = HashSet::new();
526 let mut all_keys = HashSet::new();
527 let open_features = schema
531 .get("patternProperties")
532 .and_then(|p| p.as_object())
533 .is_some_and(|p| p.contains_key(FEATURE_KEY_PATTERN));
534
535 let mut has_feature_keys = open_features;
536 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
537 for (prop_name, prop_value) in properties {
538 all_keys.insert(prop_name.clone());
539 if prop_name.starts_with(FEATURE_KEY_PREFIX) {
542 has_feature_keys = true;
543 feature_keys.insert(prop_name.clone());
544 }
545 if let (Some(prop_type), Some(default_value)) = (
546 prop_value.get("type").and_then(|t| t.as_str()),
547 prop_value.get("default"),
548 ) {
549 Self::validate_default_type(prop_name, prop_value, default_value, path)?;
550 options.insert(
551 prop_name.clone(),
552 OptionMetadata {
553 option_type: prop_type.to_string(),
554 property_schema: prop_value.clone(),
555 default: default_value.clone(),
556 },
557 );
558 }
559 }
560 }
561
562 if has_feature_keys {
565 let feature_defs: Value =
566 serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
567 ValidationError::InternalError(format!(
568 "Invalid feature-schema-defs JSON: {}",
569 e
570 ))
571 })?;
572
573 if let Some(obj) = schema.as_object_mut() {
574 obj.insert("definitions".to_string(), feature_defs);
575 }
576 }
577
578 let validator =
580 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
581 file: path.to_path_buf(),
582 message: format!("Failed to compile validator: {}", e),
583 })?;
584
585 Ok(Arc::new(NamespaceSchema {
586 namespace: namespace.to_string(),
587 options,
588 feature_keys,
589 all_keys,
590 open_features,
591 validator,
592 }))
593 }
594
595 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
597 self.schemas.get(namespace)
598 }
599
600 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
602 &self.schemas
603 }
604
605 pub fn load_values_json(
613 &self,
614 values_dir: &Path,
615 ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
616 let mut all_values = HashMap::new();
617 let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
618
619 for namespace in self.schemas.keys() {
620 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
621
622 if !values_file.exists() {
623 continue;
624 }
625
626 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
627
628 if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
630 generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
631 }
632
633 let values = parsed
634 .get("options")
635 .ok_or_else(|| ValidationError::ValueError {
636 namespace: namespace.clone(),
637 errors: "values.json must have an 'options' key".to_string(),
638 })?;
639
640 let values = self.strip_unknown_keys(namespace, values);
643
644 self.validate_values(namespace, &values)?;
645
646 if let Value::Object(obj) = values {
647 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
648 all_values.insert(namespace.clone(), ns_values);
649 }
650 }
651
652 Ok((all_values, generated_at_by_namespace))
653 }
654
655 pub fn strip_unknown_keys(&self, namespace: &str, values: &Value) -> Value {
658 let schema = match self.schemas.get(namespace) {
659 Some(s) => s,
660 None => return values.clone(),
661 };
662
663 let obj = match values.as_object() {
664 Some(obj) => obj,
665 None => return values.clone(),
666 };
667
668 let unknown_keys: Vec<&String> = obj.keys().filter(|k| !schema.accepts_key(k)).collect();
669
670 if unknown_keys.is_empty() {
671 return values.clone();
672 }
673
674 for key in &unknown_keys {
675 tracing::warn!(
676 key = %key,
677 namespace = %namespace,
678 "Ignoring unknown option. This is expected during deployments when \
679 values are updated before schemas.",
680 );
681 }
682
683 let filtered: serde_json::Map<String, Value> = obj
684 .iter()
685 .filter(|(k, _)| schema.accepts_key(k))
686 .map(|(k, v)| (k.clone(), v.clone()))
687 .collect();
688 Value::Object(filtered)
689 }
690}
691
692impl Default for SchemaRegistry {
693 fn default() -> Self {
694 Self::new()
695 }
696}
697
698pub type PropagationCallback = Box<dyn Fn(&str, f64) + Send + Sync>;
702
703pub struct ValuesStore {
707 registry: Arc<SchemaRegistry>,
708 values_dir: PathBuf,
709 values: ArcSwap<ValuesByNamespace>,
710 baseline: Instant,
711 last_refresh_offset_ns: AtomicU64,
712 refresh_threshold: Option<Duration>,
715 last_generated_at: ArcSwap<HashMap<String, String>>,
718 on_propagation: Option<PropagationCallback>,
720 last_mtimes: ArcSwap<HashMap<String, SystemTime>>,
723}
724
725pub struct ValuesStoreBuilder {
730 registry: Arc<SchemaRegistry>,
731 values_dir: PathBuf,
732 refresh_threshold: Option<Duration>,
733 on_propagation: Option<PropagationCallback>,
734}
735
736impl ValuesStoreBuilder {
737 pub fn with_refresh_threshold(mut self, threshold: impl Into<Option<Duration>>) -> Self {
743 self.refresh_threshold = threshold.into();
744 self
745 }
746
747 pub fn with_callback(mut self, callback: PropagationCallback) -> Self {
750 self.on_propagation = Some(callback);
751 self
752 }
753
754 pub fn build(self) -> ValidationResult<ValuesStore> {
756 let values_dir = &self.values_dir;
757 if !should_suppress_missing_dir_errors() && fs::metadata(values_dir).is_err() {
758 tracing::warn!(path = %values_dir.display(), "Values directory does not exist");
759 }
760
761 let baseline = Instant::now();
762 let initial_mtimes = ValuesStore::collect_mtimes(&self.registry, values_dir);
763 let (initial, generated_at_by_namespace) = self.registry.load_values_json(values_dir)?;
764 let last_refresh_offset_ns = AtomicU64::new(baseline.elapsed().as_nanos() as u64);
765
766 Ok(ValuesStore {
767 registry: self.registry,
768 values_dir: self.values_dir,
769 values: ArcSwap::from_pointee(initial),
770 baseline,
771 last_refresh_offset_ns,
772 refresh_threshold: self.refresh_threshold,
773 last_generated_at: ArcSwap::from_pointee(generated_at_by_namespace),
774 on_propagation: self.on_propagation,
775 last_mtimes: ArcSwap::from_pointee(initial_mtimes),
776 })
777 }
778}
779
780impl ValuesStore {
781 pub fn new(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValidationResult<Self> {
786 Self::builder(registry, values_dir).build()
787 }
788
789 #[deprecated(note = "use `ValuesStore::builder(...).with_callback(...)` instead")]
791 pub fn with_propagation_callback(
792 registry: Arc<SchemaRegistry>,
793 values_dir: &Path,
794 callback: PropagationCallback,
795 ) -> ValidationResult<Self> {
796 Self::builder(registry, values_dir)
797 .with_callback(callback)
798 .build()
799 }
800
801 pub fn builder(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValuesStoreBuilder {
804 ValuesStoreBuilder {
805 registry,
806 values_dir: values_dir.to_path_buf(),
807 refresh_threshold: Some(DEFAULT_REFRESH_THRESHOLD),
808 on_propagation: None,
809 }
810 }
811
812 pub fn registry(&self) -> &Arc<SchemaRegistry> {
814 &self.registry
815 }
816
817 fn collect_mtimes(registry: &SchemaRegistry, values_dir: &Path) -> HashMap<String, SystemTime> {
820 let mut mtimes = HashMap::new();
821 for namespace in registry.schemas().keys() {
822 let path = values_dir.join(namespace).join(VALUES_FILE_NAME);
823 if let Ok(metadata) = fs::metadata(&path)
824 && let Ok(mtime) = metadata.modified()
825 {
826 mtimes.insert(namespace.clone(), mtime);
827 }
828 }
829 mtimes
830 }
831
832 pub fn load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
835 self.maybe_refresh();
836 self.values.load()
837 }
838
839 pub fn force_load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
843 if let Err(e) = self.refresh() {
844 self.log_refresh_error(&e);
845 }
846 self.values.load()
847 }
848
849 pub fn refresh(&self) -> ValidationResult<bool> {
859 let now_ns = self.baseline.elapsed().as_nanos() as u64;
860 let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
861 self.refresh_at(last_ns, now_ns)
862 }
863
864 fn maybe_refresh(&self) {
865 let Some(threshold) = self.refresh_threshold else {
866 return;
867 };
868
869 let now_ns = self.baseline.elapsed().as_nanos() as u64;
870 let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
871 let elapsed_ns = now_ns.saturating_sub(last_ns);
872 let threshold_ns = threshold.as_nanos() as u64;
873 let jitter_ns = if threshold.is_zero() {
876 0
877 } else {
878 stack_jitter_ns()
879 };
880
881 if elapsed_ns < threshold_ns.saturating_add(jitter_ns) {
882 return;
883 }
884
885 if let Err(e) = self.refresh_at(last_ns, now_ns) {
886 self.log_refresh_error(&e);
887 }
888 }
889
890 fn log_refresh_error(&self, error: &ValidationError) {
891 tracing::error!(
892 path = %self.values_dir.display(),
893 error = %error,
894 "Failed to reload values",
895 );
896 }
897
898 fn refresh_at(&self, observed_last_ns: u64, now_ns: u64) -> ValidationResult<bool> {
899 let current_mtimes = Self::collect_mtimes(&self.registry, &self.values_dir);
900 if *self.last_mtimes.load().as_ref() == current_mtimes {
901 let _ = self.last_refresh_offset_ns.compare_exchange(
902 observed_last_ns,
903 now_ns,
904 Ordering::AcqRel,
905 Ordering::Relaxed,
906 );
907 return Ok(false);
908 }
909
910 let reload_start = Instant::now();
911 let result = self.registry.load_values_json(&self.values_dir);
912
913 let new_generated_at = match result {
921 Ok((new_values, generated_at)) => {
922 let elapsed_ms = reload_start.elapsed().as_millis();
923 for namespace in new_values.keys() {
924 tracing::info!(
925 path = %self.values_dir.display(),
926 namespace = %namespace,
927 elapsed_ms,
928 "Reloaded values",
929 );
930 }
931
932 self.values.store(Arc::new(new_values));
933 self.last_mtimes.store(Arc::new(current_mtimes));
934 Ok(generated_at)
935 }
936 Err(e) => Err(e),
937 };
938
939 let _ = self.last_refresh_offset_ns.compare_exchange(
944 observed_last_ns,
945 now_ns,
946 Ordering::AcqRel,
947 Ordering::Relaxed,
948 );
949
950 let new_generated_at = new_generated_at?;
951
952 let last = self.last_generated_at.load();
956 if *last.as_ref() != new_generated_at {
957 if let Some(callback) = &self.on_propagation {
958 let mtimes = self.last_mtimes.load();
959 for (ns, ts) in &new_generated_at {
960 if last.get(ns).is_some_and(|old| old == ts) {
961 continue;
962 }
963 let applied_at = mtimes
965 .get(ns)
966 .map(|m| DateTime::<Utc>::from(*m))
967 .unwrap_or_else(Utc::now);
968 match propagation_delay_secs(&applied_at, ts) {
969 Some(delay) => {
970 if let Err(e) =
971 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
972 callback(ns, delay)
973 }))
974 {
975 tracing::error!(
976 namespace = %ns,
977 error = ?e,
978 "Propagation callback panicked",
979 );
980 }
981 }
982 None => tracing::warn!(
983 namespace = %ns,
984 generated_at = %ts,
985 "Bad generated_at timestamp",
986 ),
987 }
988 }
989 }
990 self.last_generated_at.store(Arc::new(new_generated_at));
991 }
992
993 Ok(true)
994 }
995}
996
997fn propagation_delay_secs(now: &DateTime<Utc>, generated_at: &str) -> Option<f64> {
999 let generated = DateTime::parse_from_rfc3339(generated_at).ok()?;
1000 let delay = (*now - generated.with_timezone(&Utc)).num_milliseconds() as f64 / 1000.0;
1001 Some(delay.max(0.0))
1002}
1003
1004fn stack_jitter_ns() -> u64 {
1009 let local = 0u8;
1010 let addr = &local as *const u8 as usize as u64;
1011 addr.wrapping_mul(0x9E37_79B9_7F4A_7C15) % 1_000_000_000
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use std::sync::Mutex;
1018 use tempfile::TempDir;
1019
1020 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
1021 let schema_dir = temp_dir.path().join(namespace);
1022 fs::create_dir_all(&schema_dir).unwrap();
1023 let schema_file = schema_dir.join("schema.json");
1024 fs::write(&schema_file, schema_json).unwrap();
1025 schema_file
1026 }
1027
1028 fn create_test_schema_with_values(
1029 temp_dir: &TempDir,
1030 namespace: &str,
1031 schema_json: &str,
1032 values_json: &str,
1033 ) -> (PathBuf, PathBuf) {
1034 let schemas_dir = temp_dir.path().join("schemas");
1035 let values_dir = temp_dir.path().join("values");
1036
1037 let schema_dir = schemas_dir.join(namespace);
1038 fs::create_dir_all(&schema_dir).unwrap();
1039 let schema_file = schema_dir.join("schema.json");
1040 fs::write(&schema_file, schema_json).unwrap();
1041
1042 let ns_values_dir = values_dir.join(namespace);
1043 fs::create_dir_all(&ns_values_dir).unwrap();
1044 let values_file = ns_values_dir.join("values.json");
1045 fs::write(&values_file, values_json).unwrap();
1046
1047 (schemas_dir, values_dir)
1048 }
1049
1050 #[test]
1051 fn test_feature_property_matches_meta_schema() {
1052 let schema = json!({
1054 "version": "1.0",
1055 "type": "object",
1056 "properties": { "feature.red-bar": feature_property() },
1057 })
1058 .to_string();
1059 SchemaRegistry::from_schemas(&[("getsentry-features", &schema)]).unwrap();
1060 }
1061
1062 fn open_features_schema() -> String {
1064 json!({
1065 "version": "1.0",
1066 "type": "object",
1067 "patternProperties": { "^feature\\.": feature_property() },
1068 })
1069 .to_string()
1070 }
1071
1072 fn valid_feature_value() -> Value {
1073 json!({
1074 "created_at": "2026-01-01",
1075 "owner": {"team": "dev-infra"},
1076 "segments": [{"name": "all", "conditions": [], "rollout": 100}],
1077 })
1078 }
1079
1080 #[test]
1081 fn test_open_features_accepts_any_feature_key() {
1082 let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1083 let schema = registry.get("gs").unwrap();
1084
1085 assert!(schema.accepts_key("feature.organizations:never-enumerated"));
1088 assert!(schema.is_known_key("feature.organizations:never-enumerated"));
1089 registry
1090 .validate_values(
1091 "gs",
1092 &json!({"feature.organizations:never-enumerated": valid_feature_value()}),
1093 )
1094 .unwrap();
1095 }
1096
1097 #[test]
1098 fn test_open_features_still_validates_shape() {
1099 let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1100 let result = registry.validate_values(
1103 "gs",
1104 &json!({"feature.organizations:bad": {"created_at": "2026-01-01"}}),
1105 );
1106 assert!(result.is_err(), "expected a Feature shape violation");
1107 }
1108
1109 #[test]
1110 fn test_open_features_does_not_accept_non_feature_keys() {
1111 let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1112 let schema = registry.get("gs").unwrap();
1113
1114 assert!(!schema.accepts_key("some.option"));
1115 let stripped = registry.strip_unknown_keys(
1116 "gs",
1117 &json!({
1118 "feature.organizations:keep": valid_feature_value(),
1119 "some.option": 1,
1120 }),
1121 );
1122 let obj = stripped.as_object().unwrap();
1123 assert!(obj.contains_key("feature.organizations:keep"));
1124 assert!(!obj.contains_key("some.option"));
1125 }
1126
1127 #[test]
1128 fn test_enumerated_features_still_reject_undeclared_names() {
1129 let schema = json!({
1130 "version": "1.0",
1131 "type": "object",
1132 "properties": { "feature.organizations:declared": feature_property() },
1133 })
1134 .to_string();
1135 let registry = SchemaRegistry::from_schemas(&[("gs", &schema)]).unwrap();
1136 let schema = registry.get("gs").unwrap();
1137
1138 assert!(schema.accepts_key("feature.organizations:declared"));
1139 assert!(!schema.accepts_key("feature.organizations:undeclared"));
1140 }
1141
1142 #[test]
1143 fn test_validate_k8s_name_component_valid() {
1144 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
1145 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
1146 assert!(validate_k8s_name_component("a1-b2-c3", "namespace").is_ok());
1147 assert!(validate_k8s_name_component(&"a".repeat(63), "namespace").is_ok());
1148 }
1149
1150 #[test]
1151 fn test_validate_k8s_name_component_rejects_uppercase() {
1152 let result = validate_k8s_name_component("MyService", "namespace");
1153 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1154 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
1155 }
1156
1157 #[test]
1158 fn test_validate_k8s_name_component_rejects_underscore() {
1159 let result = validate_k8s_name_component("my_service", "target");
1160 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1161 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
1162 }
1163
1164 #[test]
1165 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
1166 let result = validate_k8s_name_component("-service", "namespace");
1167 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1168 assert!(
1169 result
1170 .unwrap_err()
1171 .to_string()
1172 .contains("start and end with alphanumeric")
1173 );
1174 }
1175
1176 #[test]
1177 fn test_validate_k8s_name_component_rejects_dot() {
1178 let result = validate_k8s_name_component("my.service", "namespace");
1179 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1180 assert!(result.unwrap_err().to_string().contains("'.' not allowed"));
1181 }
1182
1183 #[test]
1184 fn test_validate_k8s_name_component_rejects_too_long() {
1185 let result = validate_k8s_name_component(&"a".repeat(64), "namespace");
1186 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1187 assert!(result.unwrap_err().to_string().contains("at most 63"));
1188 }
1189
1190 #[test]
1191 fn test_load_schema_valid() {
1192 let temp_dir = TempDir::new().unwrap();
1193 create_test_schema(
1194 &temp_dir,
1195 "test",
1196 r#"{
1197 "version": "1.0",
1198 "type": "object",
1199 "properties": {
1200 "test-key": {
1201 "type": "string",
1202 "default": "test",
1203 "description": "Test option"
1204 }
1205 }
1206 }"#,
1207 );
1208
1209 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1210 }
1211
1212 #[test]
1213 fn test_load_schema_missing_version() {
1214 let temp_dir = TempDir::new().unwrap();
1215 create_test_schema(
1216 &temp_dir,
1217 "test",
1218 r#"{
1219 "type": "object",
1220 "properties": {}
1221 }"#,
1222 );
1223
1224 let result = SchemaRegistry::from_directory(temp_dir.path());
1225 assert!(result.is_err());
1226 match result {
1227 Err(ValidationError::SchemaError { message, .. }) => {
1228 assert!(message.contains(
1229 "Schema validation failed:
1230Error: \"version\" is a required property"
1231 ));
1232 }
1233 _ => panic!("Expected SchemaError for missing version"),
1234 }
1235 }
1236
1237 #[test]
1238 fn test_unknown_namespace() {
1239 let temp_dir = TempDir::new().unwrap();
1240 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1241
1242 let result = registry.validate_values("unknown", &json!({}));
1243 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
1244 }
1245
1246 #[test]
1247 fn test_multiple_namespaces() {
1248 let temp_dir = TempDir::new().unwrap();
1249 create_test_schema(
1250 &temp_dir,
1251 "ns1",
1252 r#"{
1253 "version": "1.0",
1254 "type": "object",
1255 "properties": {
1256 "opt1": {
1257 "type": "string",
1258 "default": "default1",
1259 "description": "First option"
1260 }
1261 }
1262 }"#,
1263 );
1264 create_test_schema(
1265 &temp_dir,
1266 "ns2",
1267 r#"{
1268 "version": "2.0",
1269 "type": "object",
1270 "properties": {
1271 "opt2": {
1272 "type": "integer",
1273 "default": 42,
1274 "description": "Second option"
1275 }
1276 }
1277 }"#,
1278 );
1279
1280 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1281 assert!(registry.schemas.contains_key("ns1"));
1282 assert!(registry.schemas.contains_key("ns2"));
1283 }
1284
1285 #[test]
1286 fn test_invalid_default_type() {
1287 let temp_dir = TempDir::new().unwrap();
1288 create_test_schema(
1289 &temp_dir,
1290 "test",
1291 r#"{
1292 "version": "1.0",
1293 "type": "object",
1294 "properties": {
1295 "bad-default": {
1296 "type": "integer",
1297 "default": "not-a-number",
1298 "description": "A bad default value"
1299 }
1300 }
1301 }"#,
1302 );
1303
1304 let result = SchemaRegistry::from_directory(temp_dir.path());
1305 assert!(result.is_err());
1306 match result {
1307 Err(ValidationError::SchemaError { message, .. }) => {
1308 assert!(
1309 message.contains("Property 'bad-default': default value does not match schema")
1310 );
1311 assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
1312 }
1313 _ => panic!("Expected SchemaError for invalid default type"),
1314 }
1315 }
1316
1317 #[test]
1318 fn test_extra_properties() {
1319 let temp_dir = TempDir::new().unwrap();
1320 create_test_schema(
1321 &temp_dir,
1322 "test",
1323 r#"{
1324 "version": "1.0",
1325 "type": "object",
1326 "properties": {
1327 "bad-property": {
1328 "type": "integer",
1329 "default": 0,
1330 "description": "Test property",
1331 "extra": "property"
1332 }
1333 }
1334 }"#,
1335 );
1336
1337 let result = SchemaRegistry::from_directory(temp_dir.path());
1338 assert!(result.is_err());
1339 match result {
1340 Err(ValidationError::SchemaError { message, .. }) => {
1341 assert!(
1342 message
1343 .contains("Additional properties are not allowed ('extra' was unexpected)")
1344 );
1345 }
1346 _ => panic!("Expected SchemaError for extra properties"),
1347 }
1348 }
1349
1350 #[test]
1351 fn test_missing_description() {
1352 let temp_dir = TempDir::new().unwrap();
1353 create_test_schema(
1354 &temp_dir,
1355 "test",
1356 r#"{
1357 "version": "1.0",
1358 "type": "object",
1359 "properties": {
1360 "missing-desc": {
1361 "type": "string",
1362 "default": "test"
1363 }
1364 }
1365 }"#,
1366 );
1367
1368 let result = SchemaRegistry::from_directory(temp_dir.path());
1369 assert!(result.is_err());
1370 match result {
1371 Err(ValidationError::SchemaError { message, .. }) => {
1372 assert!(message.contains("\"description\" is a required property"));
1373 }
1374 _ => panic!("Expected SchemaError for missing description"),
1375 }
1376 }
1377
1378 #[test]
1379 fn test_from_schemas_rejects_duplicate_namespace() {
1380 let schema_a = r#"{
1381 "version": "1.0",
1382 "type": "object",
1383 "properties": {
1384 "opt": {"type": "string", "default": "a", "description": "A"}
1385 }
1386 }"#;
1387 let schema_b = r#"{
1388 "version": "1.0",
1389 "type": "object",
1390 "properties": {
1391 "opt": {"type": "string", "default": "b", "description": "B"}
1392 }
1393 }"#;
1394
1395 let result = SchemaRegistry::from_schemas(&[("test", schema_a), ("test", schema_b)]);
1396 match result {
1397 Err(ValidationError::SchemaError { message, .. }) => {
1398 assert!(message.contains("Duplicate namespace 'test'"));
1399 }
1400 _ => panic!("Expected SchemaError for duplicate namespace"),
1401 }
1402 }
1403
1404 #[test]
1405 fn test_invalid_directory_structure() {
1406 let temp_dir = TempDir::new().unwrap();
1407 let schema_dir = temp_dir.path().join("missing-schema");
1409 fs::create_dir_all(&schema_dir).unwrap();
1410
1411 let result = SchemaRegistry::from_directory(temp_dir.path());
1412 assert!(result.is_err());
1413 match result {
1414 Err(ValidationError::FileRead(..)) => {
1415 }
1417 _ => panic!("Expected FileRead error for missing schema.json"),
1418 }
1419 }
1420
1421 #[test]
1422 fn test_get_default() {
1423 let temp_dir = TempDir::new().unwrap();
1424 create_test_schema(
1425 &temp_dir,
1426 "test",
1427 r#"{
1428 "version": "1.0",
1429 "type": "object",
1430 "properties": {
1431 "string_opt": {
1432 "type": "string",
1433 "default": "hello",
1434 "description": "A string option"
1435 },
1436 "int_opt": {
1437 "type": "integer",
1438 "default": 42,
1439 "description": "An integer option"
1440 }
1441 }
1442 }"#,
1443 );
1444
1445 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1446 let schema = registry.get("test").unwrap();
1447
1448 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1449 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1450 assert_eq!(schema.get_default("unknown"), None);
1451 }
1452
1453 #[test]
1454 fn test_validate_values_valid() {
1455 let temp_dir = TempDir::new().unwrap();
1456 create_test_schema(
1457 &temp_dir,
1458 "test",
1459 r#"{
1460 "version": "1.0",
1461 "type": "object",
1462 "properties": {
1463 "enabled": {
1464 "type": "boolean",
1465 "default": false,
1466 "description": "Enable feature"
1467 }
1468 }
1469 }"#,
1470 );
1471
1472 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1473 let result = registry.validate_values("test", &json!({"enabled": true}));
1474 assert!(result.is_ok());
1475 }
1476
1477 #[test]
1478 fn test_validate_values_invalid_type() {
1479 let temp_dir = TempDir::new().unwrap();
1480 create_test_schema(
1481 &temp_dir,
1482 "test",
1483 r#"{
1484 "version": "1.0",
1485 "type": "object",
1486 "properties": {
1487 "count": {
1488 "type": "integer",
1489 "default": 0,
1490 "description": "Count"
1491 }
1492 }
1493 }"#,
1494 );
1495
1496 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1497 let result = registry.validate_values("test", &json!({"count": "not a number"}));
1498 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1499 }
1500
1501 #[test]
1502 fn test_validate_values_unknown_option() {
1503 let temp_dir = TempDir::new().unwrap();
1504 create_test_schema(
1505 &temp_dir,
1506 "test",
1507 r#"{
1508 "version": "1.0",
1509 "type": "object",
1510 "properties": {
1511 "known_option": {
1512 "type": "string",
1513 "default": "default",
1514 "description": "A known option"
1515 }
1516 }
1517 }"#,
1518 );
1519
1520 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1521
1522 let result = registry.validate_values("test", &json!({"known_option": "value"}));
1524 assert!(result.is_ok());
1525
1526 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1528 assert!(result.is_err());
1529 match result {
1530 Err(ValidationError::ValueError { errors, .. }) => {
1531 assert!(errors.contains("Additional properties are not allowed"));
1532 }
1533 _ => panic!("Expected ValueError for unknown option"),
1534 }
1535 }
1536
1537 #[test]
1538 fn test_object_with_additional_properties() {
1539 let temp_dir = TempDir::new().unwrap();
1540 create_test_schema(
1541 &temp_dir,
1542 "test",
1543 r#"{
1544 "version": "1.0",
1545 "type": "object",
1546 "properties": {
1547 "scopes": {
1548 "type": "object",
1549 "additionalProperties": {"type": "string"},
1550 "default": {},
1551 "description": "A dynamic string-to-string map"
1552 }
1553 }
1554 }"#,
1555 );
1556
1557 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1558
1559 assert!(
1560 registry
1561 .validate_values("test", &json!({"scopes": {}}))
1562 .is_ok()
1563 );
1564 assert!(
1565 registry
1566 .validate_values(
1567 "test",
1568 &json!({"scopes": {"read": "true", "write": "false"}})
1569 )
1570 .is_ok()
1571 );
1572 assert!(matches!(
1573 registry.validate_values("test", &json!({"scopes": {"read": 42}})),
1574 Err(ValidationError::ValueError { .. })
1575 ));
1576 }
1577
1578 #[test]
1579 fn test_object_without_additional_properties_still_rejects_unknown_keys() {
1580 let temp_dir = TempDir::new().unwrap();
1583 create_test_schema(
1584 &temp_dir,
1585 "test",
1586 r#"{
1587 "version": "1.0",
1588 "type": "object",
1589 "properties": {
1590 "config": {
1591 "type": "object",
1592 "properties": {
1593 "host": {"type": "string"}
1594 },
1595 "default": {"host": "localhost"},
1596 "description": "Server config"
1597 }
1598 }
1599 }"#,
1600 );
1601
1602 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1603
1604 let result = registry.validate_values("test", &json!({"config": {"host": "example.com"}}));
1606 assert!(result.is_ok());
1607
1608 let result = registry.validate_values(
1610 "test",
1611 &json!({"config": {"host": "example.com", "unknown": "x"}}),
1612 );
1613 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1614 }
1615
1616 #[test]
1617 fn test_object_with_fixed_properties_and_additional_properties_enforces_required() {
1618 let temp_dir = TempDir::new().unwrap();
1621 create_test_schema(
1622 &temp_dir,
1623 "test",
1624 r#"{
1625 "version": "1.0",
1626 "type": "object",
1627 "properties": {
1628 "config": {
1629 "type": "object",
1630 "properties": {
1631 "name": {"type": "string"}
1632 },
1633 "additionalProperties": {"type": "string"},
1634 "default": {"name": "default"},
1635 "description": "Config with fixed and dynamic keys"
1636 }
1637 }
1638 }"#,
1639 );
1640
1641 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1642
1643 let result =
1645 registry.validate_values("test", &json!({"config": {"name": "x", "extra": "y"}}));
1646 assert!(result.is_ok());
1647
1648 let result = registry.validate_values("test", &json!({"config": {"extra": "y"}}));
1650 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1651 }
1652
1653 #[test]
1654 fn test_load_values_json_valid() {
1655 let temp_dir = TempDir::new().unwrap();
1656 let schemas_dir = temp_dir.path().join("schemas");
1657 let values_dir = temp_dir.path().join("values");
1658
1659 let schema_dir = schemas_dir.join("test");
1660 fs::create_dir_all(&schema_dir).unwrap();
1661 fs::write(
1662 schema_dir.join("schema.json"),
1663 r#"{
1664 "version": "1.0",
1665 "type": "object",
1666 "properties": {
1667 "enabled": {
1668 "type": "boolean",
1669 "default": false,
1670 "description": "Enable feature"
1671 },
1672 "name": {
1673 "type": "string",
1674 "default": "default",
1675 "description": "Name"
1676 },
1677 "count": {
1678 "type": "integer",
1679 "default": 0,
1680 "description": "Count"
1681 },
1682 "rate": {
1683 "type": "number",
1684 "default": 0.0,
1685 "description": "Rate"
1686 }
1687 }
1688 }"#,
1689 )
1690 .unwrap();
1691
1692 let test_values_dir = values_dir.join("test");
1693 fs::create_dir_all(&test_values_dir).unwrap();
1694 fs::write(
1695 test_values_dir.join("values.json"),
1696 r#"{
1697 "options": {
1698 "enabled": true,
1699 "name": "test-name",
1700 "count": 42,
1701 "rate": 0.75
1702 }
1703 }"#,
1704 )
1705 .unwrap();
1706
1707 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1708 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1709
1710 assert_eq!(values.len(), 1);
1711 assert_eq!(values["test"]["enabled"], json!(true));
1712 assert_eq!(values["test"]["name"], json!("test-name"));
1713 assert_eq!(values["test"]["count"], json!(42));
1714 assert_eq!(values["test"]["rate"], json!(0.75));
1715 assert!(generated_at_by_namespace.is_empty());
1716 }
1717
1718 #[test]
1719 fn test_load_values_json_nonexistent_dir() {
1720 let temp_dir = TempDir::new().unwrap();
1721 create_test_schema(
1722 &temp_dir,
1723 "test",
1724 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1725 );
1726
1727 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1728 let (values, generated_at_by_namespace) = registry
1729 .load_values_json(&temp_dir.path().join("nonexistent"))
1730 .unwrap();
1731
1732 assert!(values.is_empty());
1734 assert!(generated_at_by_namespace.is_empty());
1735 }
1736
1737 #[test]
1738 fn test_load_values_json_strips_unknown_keys() {
1739 let temp_dir = TempDir::new().unwrap();
1740 let schemas_dir = temp_dir.path().join("schemas");
1741 let values_dir = temp_dir.path().join("values");
1742
1743 let schema_dir = schemas_dir.join("test");
1744 fs::create_dir_all(&schema_dir).unwrap();
1745 fs::write(
1746 schema_dir.join("schema.json"),
1747 r#"{
1748 "version": "1.0",
1749 "type": "object",
1750 "properties": {
1751 "known-option": {
1752 "type": "string",
1753 "default": "default",
1754 "description": "A known option"
1755 }
1756 }
1757 }"#,
1758 )
1759 .unwrap();
1760
1761 let test_values_dir = values_dir.join("test");
1762 fs::create_dir_all(&test_values_dir).unwrap();
1763 fs::write(
1764 test_values_dir.join("values.json"),
1765 r#"{
1766 "options": {
1767 "known-option": "hello",
1768 "unknown-option": "should be stripped"
1769 }
1770 }"#,
1771 )
1772 .unwrap();
1773
1774 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1775 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1776
1777 assert_eq!(values["test"]["known-option"], json!("hello"));
1778 assert!(!values["test"].contains_key("unknown-option"));
1779 }
1780
1781 #[test]
1782 fn test_load_values_json_skips_missing_values_file() {
1783 let temp_dir = TempDir::new().unwrap();
1784 let schemas_dir = temp_dir.path().join("schemas");
1785 let values_dir = temp_dir.path().join("values");
1786
1787 let schema_dir1 = schemas_dir.join("with-values");
1789 fs::create_dir_all(&schema_dir1).unwrap();
1790 fs::write(
1791 schema_dir1.join("schema.json"),
1792 r#"{
1793 "version": "1.0",
1794 "type": "object",
1795 "properties": {
1796 "opt": {"type": "string", "default": "x", "description": "Opt"}
1797 }
1798 }"#,
1799 )
1800 .unwrap();
1801
1802 let schema_dir2 = schemas_dir.join("without-values");
1803 fs::create_dir_all(&schema_dir2).unwrap();
1804 fs::write(
1805 schema_dir2.join("schema.json"),
1806 r#"{
1807 "version": "1.0",
1808 "type": "object",
1809 "properties": {
1810 "opt": {"type": "string", "default": "x", "description": "Opt"}
1811 }
1812 }"#,
1813 )
1814 .unwrap();
1815
1816 let with_values_dir = values_dir.join("with-values");
1818 fs::create_dir_all(&with_values_dir).unwrap();
1819 fs::write(
1820 with_values_dir.join("values.json"),
1821 r#"{"options": {"opt": "y"}}"#,
1822 )
1823 .unwrap();
1824
1825 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1826 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1827
1828 assert_eq!(values.len(), 1);
1829 assert!(values.contains_key("with-values"));
1830 assert!(!values.contains_key("without-values"));
1831 }
1832
1833 #[test]
1834 fn test_load_values_json_extracts_generated_at() {
1835 let temp_dir = TempDir::new().unwrap();
1836 let schemas_dir = temp_dir.path().join("schemas");
1837 let values_dir = temp_dir.path().join("values");
1838
1839 let schema_dir = schemas_dir.join("test");
1840 fs::create_dir_all(&schema_dir).unwrap();
1841 fs::write(
1842 schema_dir.join("schema.json"),
1843 r#"{
1844 "version": "1.0",
1845 "type": "object",
1846 "properties": {
1847 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1848 }
1849 }"#,
1850 )
1851 .unwrap();
1852
1853 let test_values_dir = values_dir.join("test");
1854 fs::create_dir_all(&test_values_dir).unwrap();
1855 fs::write(
1856 test_values_dir.join("values.json"),
1857 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1858 )
1859 .unwrap();
1860
1861 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1862 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1863
1864 assert_eq!(values["test"]["enabled"], json!(true));
1865 assert_eq!(
1866 generated_at_by_namespace.get("test"),
1867 Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1868 );
1869 }
1870
1871 #[test]
1872 fn test_propagation_event_emitted_on_generated_at_change() {
1873 let temp_dir = TempDir::new().unwrap();
1874 let schemas_dir = temp_dir.path().join("schemas");
1875 let values_dir = temp_dir.path().join("values");
1876
1877 let schema_dir = schemas_dir.join("test");
1878 fs::create_dir_all(&schema_dir).unwrap();
1879 fs::write(
1880 schema_dir.join("schema.json"),
1881 r#"{
1882 "version": "1.0",
1883 "type": "object",
1884 "properties": {
1885 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1886 }
1887 }"#,
1888 )
1889 .unwrap();
1890
1891 let test_values_dir = values_dir.join("test");
1892 fs::create_dir_all(&test_values_dir).unwrap();
1893 fs::write(
1894 test_values_dir.join("values.json"),
1895 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1896 )
1897 .unwrap();
1898
1899 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1900 let events_clone = events.clone();
1901
1902 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1903 let store = ValuesStore::builder(registry, &values_dir)
1904 .with_refresh_threshold(Duration::ZERO)
1905 .with_callback(Box::new(move |ns, delay| {
1906 events_clone.lock().unwrap().push((ns.to_string(), delay));
1907 }))
1908 .build()
1909 .unwrap();
1910
1911 assert!(events.lock().unwrap().is_empty());
1913
1914 let _ = store.load();
1916 assert!(events.lock().unwrap().is_empty());
1917
1918 fs::write(
1920 test_values_dir.join("values.json"),
1921 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1922 )
1923 .unwrap();
1924
1925 let _ = store.load();
1926 let captured = events.lock().unwrap();
1927 assert_eq!(captured.len(), 1);
1928 assert_eq!(captured[0].0, "test");
1929 assert!(captured[0].1 > 0.0);
1930 }
1931
1932 #[test]
1933 fn test_propagation_event_not_emitted_without_callback() {
1934 let temp_dir = TempDir::new().unwrap();
1935 let schemas_dir = temp_dir.path().join("schemas");
1936 let values_dir = temp_dir.path().join("values");
1937
1938 let schema_dir = schemas_dir.join("test");
1939 fs::create_dir_all(&schema_dir).unwrap();
1940 fs::write(
1941 schema_dir.join("schema.json"),
1942 r#"{
1943 "version": "1.0",
1944 "type": "object",
1945 "properties": {
1946 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1947 }
1948 }"#,
1949 )
1950 .unwrap();
1951
1952 let test_values_dir = values_dir.join("test");
1953 fs::create_dir_all(&test_values_dir).unwrap();
1954 fs::write(
1955 test_values_dir.join("values.json"),
1956 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1957 )
1958 .unwrap();
1959
1960 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1961 let store = ValuesStore::builder(registry, &values_dir)
1963 .with_refresh_threshold(Duration::ZERO)
1964 .build()
1965 .unwrap();
1966 let _ = store.load();
1967 }
1969
1970 fn write_ns(base: &Path, namespace: &str, generated_at: &str) -> PathBuf {
1973 let schema_dir = base.join("schemas").join(namespace);
1974 fs::create_dir_all(&schema_dir).unwrap();
1975 fs::write(
1976 schema_dir.join("schema.json"),
1977 r#"{
1978 "version": "1.0",
1979 "type": "object",
1980 "properties": {
1981 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1982 }
1983 }"#,
1984 )
1985 .unwrap();
1986
1987 let ns_values_dir = base.join("values").join(namespace);
1988 fs::create_dir_all(&ns_values_dir).unwrap();
1989 let values_file = ns_values_dir.join("values.json");
1990 fs::write(
1991 &values_file,
1992 format!(r#"{{"options": {{"enabled": true}}, "generated_at": "{generated_at}"}}"#),
1993 )
1994 .unwrap();
1995 values_file
1996 }
1997
1998 #[test]
1999 fn test_propagation_delay_secs_parsing() {
2000 let now: DateTime<Utc> = "2024-01-21T19:00:00+00:00".parse().unwrap();
2001
2002 assert_eq!(
2004 propagation_delay_secs(&now, "2024-01-21T18:30:00+00:00"),
2005 Some(1800.0)
2006 );
2007 assert_eq!(
2009 propagation_delay_secs(&now, "2024-01-21T19:05:00+00:00"),
2010 Some(0.0)
2011 );
2012 assert_eq!(propagation_delay_secs(&now, "not-a-timestamp"), None);
2014 }
2015
2016 #[test]
2017 fn test_propagation_callback_panic_is_caught() {
2018 let temp_dir = TempDir::new().unwrap();
2019 let base = temp_dir.path();
2020 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2021
2022 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2023 let store = ValuesStore::builder(registry, &base.join("values"))
2024 .with_refresh_threshold(Duration::ZERO)
2025 .with_callback(Box::new(|_ns, _delay| panic!("callback boom")))
2026 .build()
2027 .unwrap();
2028
2029 fs::write(
2031 &values_file,
2032 r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2033 )
2034 .unwrap();
2035 let _ = store.load();
2036
2037 assert_eq!(
2039 store.values.load().get("test").unwrap().get("enabled"),
2040 Some(&json!(false))
2041 );
2042 }
2043
2044 #[test]
2045 fn test_propagation_malformed_generated_at_does_not_emit() {
2046 let temp_dir = TempDir::new().unwrap();
2047 let base = temp_dir.path();
2048 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2049
2050 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2051 let events_clone = events.clone();
2052 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2053 let store = ValuesStore::builder(registry, &base.join("values"))
2054 .with_refresh_threshold(Duration::ZERO)
2055 .with_callback(Box::new(move |ns, delay| {
2056 events_clone.lock().unwrap().push((ns.to_string(), delay));
2057 }))
2058 .build()
2059 .unwrap();
2060
2061 fs::write(
2064 &values_file,
2065 r#"{"options": {"enabled": true}, "generated_at": "garbage"}"#,
2066 )
2067 .unwrap();
2068 let _ = store.load();
2069 assert!(events.lock().unwrap().is_empty());
2070 }
2071
2072 #[test]
2073 fn test_propagation_delay_measured_to_file_mtime() {
2074 let temp_dir = TempDir::new().unwrap();
2075 let base = temp_dir.path();
2076 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2077
2078 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2079 let events_clone = events.clone();
2080 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2081 let store = ValuesStore::builder(registry, &base.join("values"))
2082 .with_refresh_threshold(Duration::ZERO)
2083 .with_callback(Box::new(move |ns, delay| {
2084 events_clone.lock().unwrap().push((ns.to_string(), delay));
2085 }))
2086 .build()
2087 .unwrap();
2088
2089 fs::write(
2090 &values_file,
2091 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2092 )
2093 .unwrap();
2094 let landed_at: DateTime<Utc> = "2024-01-21T19:00:42Z".parse().unwrap();
2095 fs::File::options()
2096 .write(true)
2097 .open(&values_file)
2098 .unwrap()
2099 .set_modified(landed_at.into())
2100 .unwrap();
2101 let _ = store.load();
2102
2103 let captured = events.lock().unwrap();
2104 assert_eq!(captured.len(), 1);
2105 assert_eq!(captured[0].1, 42.0);
2106 }
2107
2108 #[test]
2109 fn test_propagation_only_changed_namespace_emits() {
2110 let temp_dir = TempDir::new().unwrap();
2111 let base = temp_dir.path();
2112 write_ns(base, "alpha", "2024-01-21T18:00:00+00:00");
2113 let beta_values = write_ns(base, "beta", "2024-01-21T18:00:00+00:00");
2114
2115 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2116 let events_clone = events.clone();
2117 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2118 let store = ValuesStore::builder(registry, &base.join("values"))
2119 .with_refresh_threshold(Duration::ZERO)
2120 .with_callback(Box::new(move |ns, delay| {
2121 events_clone.lock().unwrap().push((ns.to_string(), delay));
2122 }))
2123 .build()
2124 .unwrap();
2125
2126 fs::write(
2128 &beta_values,
2129 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2130 )
2131 .unwrap();
2132 let _ = store.load();
2133
2134 let captured = events.lock().unwrap();
2135 assert_eq!(captured.len(), 1);
2136 assert_eq!(captured[0].0, "beta");
2137 }
2138
2139 #[test]
2140 fn test_load_values_json_rejects_wrong_type() {
2141 let temp_dir = TempDir::new().unwrap();
2142 let schemas_dir = temp_dir.path().join("schemas");
2143 let values_dir = temp_dir.path().join("values");
2144
2145 let schema_dir = schemas_dir.join("test");
2146 fs::create_dir_all(&schema_dir).unwrap();
2147 fs::write(
2148 schema_dir.join("schema.json"),
2149 r#"{
2150 "version": "1.0",
2151 "type": "object",
2152 "properties": {
2153 "count": {"type": "integer", "default": 0, "description": "Count"}
2154 }
2155 }"#,
2156 )
2157 .unwrap();
2158
2159 let test_values_dir = values_dir.join("test");
2160 fs::create_dir_all(&test_values_dir).unwrap();
2161 fs::write(
2162 test_values_dir.join("values.json"),
2163 r#"{"options": {"count": "not-a-number"}}"#,
2164 )
2165 .unwrap();
2166
2167 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2168 let result = registry.load_values_json(&values_dir);
2169
2170 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2171 }
2172
2173 mod feature_flag_tests {
2174 use super::*;
2175
2176 const FEATURE_SCHEMA: &str = r##"{
2177 "version": "1.0",
2178 "type": "object",
2179 "properties": {
2180 "feature.organizations:fury-mode": {
2181 "$ref": "#/definitions/Feature"
2182 }
2183 }
2184 }"##;
2185
2186 #[test]
2187 fn test_schema_with_valid_feature_flag() {
2188 let temp_dir = TempDir::new().unwrap();
2189 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2190 assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
2191 }
2192
2193 #[test]
2194 fn test_schema_with_feature_and_regular_option() {
2195 let temp_dir = TempDir::new().unwrap();
2196 create_test_schema(
2197 &temp_dir,
2198 "test",
2199 r##"{
2200 "version": "1.0",
2201 "type": "object",
2202 "properties": {
2203 "my-option": {
2204 "type": "string",
2205 "default": "hello",
2206 "description": "A regular option"
2207 },
2208 "feature.organizations:fury-mode": {
2209 "$ref": "#/definitions/Feature"
2210 }
2211 }
2212 }"##,
2213 );
2214 assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
2215 }
2216
2217 #[test]
2218 fn test_schema_with_invalid_feature_definition() {
2219 let temp_dir = TempDir::new().unwrap();
2220
2221 create_test_schema(
2223 &temp_dir,
2224 "test",
2225 r#"{
2226 "version": "1.0",
2227 "type": "object",
2228 "properties": {
2229 "feature.organizations:fury-mode": {
2230 "nope": "nope"
2231 }
2232 }
2233 }"#,
2234 );
2235 let result = SchemaRegistry::from_directory(temp_dir.path());
2236 assert!(result.is_err());
2237 }
2238
2239 #[test]
2240 fn test_validate_values_with_valid_feature_flag() {
2241 let temp_dir = TempDir::new().unwrap();
2242 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2243 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2244
2245 let result = registry.validate_values(
2246 "test",
2247 &json!({
2248 "feature.organizations:fury-mode": {
2249 "owner": {"team": "hybrid-cloud"},
2250 "segments": [],
2251 "created_at": "2024-01-01"
2252 }
2253 }),
2254 );
2255 assert!(result.is_ok());
2256 }
2257
2258 #[test]
2259 fn test_validate_values_with_feature_flag_missing_required_field_fails() {
2260 let temp_dir = TempDir::new().unwrap();
2261 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2262 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2263
2264 let result = registry.validate_values(
2266 "test",
2267 &json!({
2268 "feature.organizations:fury-mode": {
2269 "segments": [],
2270 "created_at": "2024-01-01"
2271 }
2272 }),
2273 );
2274 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2275 }
2276
2277 #[test]
2278 fn test_validate_values_with_feature_flag_invalid_owner_fails() {
2279 let temp_dir = TempDir::new().unwrap();
2280 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2281 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2282
2283 let result = registry.validate_values(
2285 "test",
2286 &json!({
2287 "feature.organizations:fury-mode": {
2288 "owner": {"email": "test@example.com"},
2289 "segments": [],
2290 "created_at": "2024-01-01"
2291 }
2292 }),
2293 );
2294 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2295 }
2296
2297 #[test]
2298 fn test_validate_values_feature_with_segments_and_conditions() {
2299 let temp_dir = TempDir::new().unwrap();
2300 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2301 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2302
2303 let result = registry.validate_values(
2304 "test",
2305 &json!({
2306 "feature.organizations:fury-mode": {
2307 "owner": {"team": "hybrid-cloud"},
2308 "enabled": true,
2309 "created_at": "2024-01-01T00:00:00",
2310 "segments": [
2311 {
2312 "name": "internal orgs",
2313 "rollout": 50,
2314 "conditions": [
2315 {
2316 "property": "organization_slug",
2317 "operator": "in",
2318 "value": ["sentry-test", "sentry"]
2319 }
2320 ]
2321 }
2322 ]
2323 }
2324 }),
2325 );
2326 assert!(result.is_ok());
2327 }
2328
2329 #[test]
2330 fn test_validate_values_feature_with_multiple_condition_operators() {
2331 let temp_dir = TempDir::new().unwrap();
2332 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2333 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2334
2335 let result = registry.validate_values(
2336 "test",
2337 &json!({
2338 "feature.organizations:fury-mode": {
2339 "owner": {"team": "hybrid-cloud"},
2340 "created_at": "2024-01-01",
2341 "segments": [
2342 {
2343 "name": "free accounts",
2344 "conditions": [
2345 {
2346 "property": "subscription_is_free",
2347 "operator": "equals",
2348 "value": true
2349 }
2350 ]
2351 }
2352 ]
2353 }
2354 }),
2355 );
2356 assert!(result.is_ok());
2357 }
2358
2359 #[test]
2360 fn test_validate_values_feature_with_invalid_condition_operator_fails() {
2361 let temp_dir = TempDir::new().unwrap();
2362 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2363 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2364
2365 let result = registry.validate_values(
2367 "test",
2368 &json!({
2369 "feature.organizations:fury-mode": {
2370 "owner": {"team": "hybrid-cloud"},
2371 "created_at": "2024-01-01",
2372 "segments": [
2373 {
2374 "name": "test segment",
2375 "conditions": [
2376 {
2377 "property": "some_prop",
2378 "operator": "invalid_operator",
2379 "value": "some_value"
2380 }
2381 ]
2382 }
2383 ]
2384 }
2385 }),
2386 );
2387 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2388 }
2389
2390 #[test]
2391 fn test_schema_feature_flag_not_in_options_map() {
2392 let temp_dir = TempDir::new().unwrap();
2394 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2395 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2396 let schema = registry.get("test").unwrap();
2397
2398 assert!(
2399 schema
2400 .get_default("feature.organizations:fury-mode")
2401 .is_none()
2402 );
2403 }
2404
2405 #[test]
2406 fn test_validate_values_feature_and_regular_option_together() {
2407 let temp_dir = TempDir::new().unwrap();
2408 create_test_schema(
2409 &temp_dir,
2410 "test",
2411 r##"{
2412 "version": "1.0",
2413 "type": "object",
2414 "properties": {
2415 "my-option": {
2416 "type": "string",
2417 "default": "hello",
2418 "description": "A regular option"
2419 },
2420 "feature.organizations:fury-mode": {
2421 "$ref": "#/definitions/Feature"
2422 }
2423 }
2424 }"##,
2425 );
2426 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2427
2428 let result = registry.validate_values(
2430 "test",
2431 &json!({
2432 "my-option": "world",
2433 "feature.organizations:fury-mode": {
2434 "owner": {"team": "hybrid-cloud"},
2435 "segments": [],
2436 "created_at": "2024-01-01"
2437 }
2438 }),
2439 );
2440 assert!(result.is_ok());
2441 }
2442
2443 #[test]
2444 fn test_feature_flag_is_known_key() {
2445 let temp_dir = TempDir::new().unwrap();
2446 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2447 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2448 let schema = registry.get("test").unwrap();
2449 assert!(schema.is_known_key("feature.organizations:fury-mode"));
2451 assert!(
2453 !schema
2454 .options
2455 .contains_key("feature.organizations:fury-mode")
2456 );
2457 assert!(!schema.is_known_key("feature.organizations:does-not-exist"));
2458 }
2459
2460 #[test]
2461 fn test_validate_option_accepts_valid_feature() {
2462 let temp_dir = TempDir::new().unwrap();
2465 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2466 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2467 let schema = registry.get("test").unwrap();
2468
2469 let result = schema.validate_option(
2470 "feature.organizations:fury-mode",
2471 &json!({
2472 "owner": {"team": "hybrid-cloud"},
2473 "segments": [{"name": "all", "rollout": 100, "conditions": []}],
2474 "created_at": "2024-01-01"
2475 }),
2476 );
2477 assert!(result.is_ok());
2478 }
2479
2480 #[test]
2481 fn test_validate_option_rejects_malformed_feature() {
2482 let temp_dir = TempDir::new().unwrap();
2485 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2486 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2487 let schema = registry.get("test").unwrap();
2488
2489 let result =
2491 schema.validate_option("feature.organizations:fury-mode", &json!({"segments": []}));
2492 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2493 }
2494
2495 #[test]
2496 fn test_validate_values_feature_with_experiment_mode() {
2497 let temp_dir = TempDir::new().unwrap();
2498 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2499 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2500
2501 let result = registry.validate_values(
2502 "test",
2503 &json!({
2504 "feature.organizations:fury-mode": {
2505 "owner": {"team": "hybrid-cloud"},
2506 "segments": [],
2507 "created_at": "2024-01-01",
2508 "experiment_mode": "simple"
2509 }
2510 }),
2511 );
2512 assert!(result.is_ok());
2513 }
2514
2515 #[test]
2516 fn test_validate_values_feature_with_invalid_experiment_mode_fails() {
2517 let temp_dir = TempDir::new().unwrap();
2518 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2519 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2520
2521 let result = registry.validate_values(
2522 "test",
2523 &json!({
2524 "feature.organizations:fury-mode": {
2525 "owner": {"team": "hybrid-cloud"},
2526 "segments": [],
2527 "created_at": "2024-01-01",
2528 "experiment_mode": "not-a-real-mode"
2529 }
2530 }),
2531 );
2532 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2533 }
2534
2535 #[test]
2536 fn test_validate_values_feature_without_experiment_mode_still_valid() {
2537 let temp_dir = TempDir::new().unwrap();
2538 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2539 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2540
2541 let result = registry.validate_values(
2542 "test",
2543 &json!({
2544 "feature.organizations:fury-mode": {
2545 "owner": {"team": "hybrid-cloud"},
2546 "segments": [],
2547 "created_at": "2024-01-01"
2548 }
2549 }),
2550 );
2551 assert!(result.is_ok());
2552 }
2553 }
2554
2555 mod store_tests {
2556 use super::*;
2557
2558 fn setup_store_test() -> (TempDir, PathBuf, PathBuf) {
2560 let temp_dir = TempDir::new().unwrap();
2561 let schemas_dir = temp_dir.path().join("schemas");
2562 let values_dir = temp_dir.path().join("values");
2563
2564 let ns1_schema = schemas_dir.join("ns1");
2565 fs::create_dir_all(&ns1_schema).unwrap();
2566 fs::write(
2567 ns1_schema.join("schema.json"),
2568 r#"{
2569 "version": "1.0",
2570 "type": "object",
2571 "properties": {
2572 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
2573 }
2574 }"#,
2575 )
2576 .unwrap();
2577
2578 let ns1_values = values_dir.join("ns1");
2579 fs::create_dir_all(&ns1_values).unwrap();
2580 fs::write(
2581 ns1_values.join("values.json"),
2582 r#"{"options": {"enabled": true}}"#,
2583 )
2584 .unwrap();
2585
2586 let ns2_schema = schemas_dir.join("ns2");
2587 fs::create_dir_all(&ns2_schema).unwrap();
2588 fs::write(
2589 ns2_schema.join("schema.json"),
2590 r#"{
2591 "version": "1.0",
2592 "type": "object",
2593 "properties": {
2594 "count": {"type": "integer", "default": 0, "description": "Count"}
2595 }
2596 }"#,
2597 )
2598 .unwrap();
2599
2600 let ns2_values = values_dir.join("ns2");
2601 fs::create_dir_all(&ns2_values).unwrap();
2602 fs::write(
2603 ns2_values.join("values.json"),
2604 r#"{"options": {"count": 42}}"#,
2605 )
2606 .unwrap();
2607
2608 (temp_dir, schemas_dir, values_dir)
2609 }
2610
2611 fn store_with_zero_threshold(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2612 let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2613 ValuesStore::builder(registry, values_dir)
2614 .with_refresh_threshold(Duration::ZERO)
2615 .build()
2616 .unwrap()
2617 }
2618
2619 fn store_without_refresh_on_read(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2620 let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2621 ValuesStore::builder(registry, values_dir)
2622 .with_refresh_threshold(None)
2623 .build()
2624 .unwrap()
2625 }
2626
2627 #[test]
2628 fn test_initial_load_populates_values() {
2629 let (_temp, schemas_dir, values_dir) = setup_store_test();
2630 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2631 let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2632
2633 let guard = store.load();
2634 assert_eq!(guard["ns1"]["enabled"], json!(true));
2635 assert_eq!(guard["ns2"]["count"], json!(42));
2636 }
2637
2638 #[test]
2639 fn test_read_within_threshold_serves_cached() {
2640 let (_temp, schemas_dir, values_dir) = setup_store_test();
2643 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2644 let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2645
2646 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2648 fs::write(
2649 values_dir.join("ns1").join("values.json"),
2650 r#"{"options": {"enabled": false}}"#,
2651 )
2652 .unwrap();
2653
2654 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2656 }
2657
2658 #[test]
2659 fn test_read_after_threshold_refreshes() {
2660 let (_temp, schemas_dir, values_dir) = setup_store_test();
2661 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2662
2663 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2665 assert_eq!(store.load()["ns2"]["count"], json!(42));
2666
2667 fs::write(
2669 values_dir.join("ns1").join("values.json"),
2670 r#"{"options": {"enabled": false}}"#,
2671 )
2672 .unwrap();
2673 fs::write(
2674 values_dir.join("ns2").join("values.json"),
2675 r#"{"options": {"count": 100}}"#,
2676 )
2677 .unwrap();
2678
2679 let guard = store.load();
2681 assert_eq!(guard["ns1"]["enabled"], json!(false));
2682 assert_eq!(guard["ns2"]["count"], json!(100));
2683 }
2684
2685 #[test]
2686 fn test_refresh_failure_keeps_old_values() {
2687 let (_temp, schemas_dir, values_dir) = setup_store_test();
2688 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2689
2690 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2691
2692 fs::write(
2694 values_dir.join("ns1").join("values.json"),
2695 r#"{"options": {"enabled": "not-a-boolean"}}"#,
2696 )
2697 .unwrap();
2698
2699 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2701 }
2702
2703 #[test]
2704 fn test_concurrent_reads_observe_new_values() {
2705 use std::thread;
2706
2707 let (_temp, schemas_dir, values_dir) = setup_store_test();
2708 let store = Arc::new(store_with_zero_threshold(&schemas_dir, &values_dir));
2709
2710 assert_eq!(store.load()["ns2"]["count"], json!(42));
2712
2713 fs::write(
2714 values_dir.join("ns2").join("values.json"),
2715 r#"{"options": {"count": 7}}"#,
2716 )
2717 .unwrap();
2718
2719 let mut handles = Vec::new();
2720 for _ in 0..8 {
2721 let store = Arc::clone(&store);
2722 handles.push(thread::spawn(move || {
2723 let guard = store.load();
2724 guard["ns2"]["count"].clone()
2725 }));
2726 }
2727 for h in handles {
2728 assert_eq!(h.join().unwrap(), json!(7));
2729 }
2730 }
2731
2732 #[test]
2733 fn test_refresh_returns_true_on_change_false_when_unchanged() {
2734 let (_temp, schemas_dir, values_dir) = setup_store_test();
2735 let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2736
2737 assert!(!store.refresh().unwrap());
2739
2740 fs::write(
2741 values_dir.join("ns1").join("values.json"),
2742 r#"{"options": {"enabled": false}}"#,
2743 )
2744 .unwrap();
2745 assert!(store.refresh().unwrap());
2746
2747 assert!(!store.refresh().unwrap());
2749 }
2750
2751 #[test]
2752 fn test_disabled_threshold_never_refreshes_on_read() {
2753 let (_temp, schemas_dir, values_dir) = setup_store_test();
2754 let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2755
2756 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2757 fs::write(
2758 values_dir.join("ns1").join("values.json"),
2759 r#"{"options": {"enabled": false}}"#,
2760 )
2761 .unwrap();
2762
2763 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2765
2766 assert!(store.refresh().unwrap());
2768 assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2769 }
2770
2771 #[test]
2772 fn test_refresh_error_keeps_snapshot_and_recovers() {
2773 let (_temp, schemas_dir, values_dir) = setup_store_test();
2774 let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2775 let ns1_values = values_dir.join("ns1").join("values.json");
2776
2777 fs::write(&ns1_values, "not json").unwrap();
2778 assert!(store.refresh().is_err());
2779 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2780
2781 fs::write(&ns1_values, r#"{"options": {"enabled": false}}"#).unwrap();
2782 assert!(store.refresh().unwrap());
2783 assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2784 }
2785
2786 #[test]
2787 fn test_refresh_fires_propagation_callback() {
2788 let temp_dir = TempDir::new().unwrap();
2789 let base = temp_dir.path();
2790 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2791
2792 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2793 let events_clone = events.clone();
2794 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2795 let store = ValuesStore::builder(registry, &base.join("values"))
2796 .with_refresh_threshold(None)
2797 .with_callback(Box::new(move |ns, delay| {
2798 events_clone.lock().unwrap().push((ns.to_string(), delay));
2799 }))
2800 .build()
2801 .unwrap();
2802
2803 fs::write(
2804 &values_file,
2805 r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2806 )
2807 .unwrap();
2808 assert!(store.refresh().unwrap());
2809
2810 let recorded = events.lock().unwrap();
2811 assert_eq!(recorded.len(), 1);
2812 assert_eq!(recorded[0].0, "test");
2813 }
2814
2815 #[test]
2816 fn test_refresh_resets_lazy_timer() {
2817 let (_temp, schemas_dir, values_dir) = setup_store_test();
2818 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2819 let store = ValuesStore::builder(registry, &values_dir)
2820 .with_refresh_threshold(Duration::from_secs(3600))
2821 .build()
2822 .unwrap();
2823
2824 assert!(!store.refresh().unwrap());
2825
2826 fs::write(
2829 values_dir.join("ns1").join("values.json"),
2830 r#"{"options": {"enabled": false}}"#,
2831 )
2832 .unwrap();
2833 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2834 }
2835
2836 #[test]
2837 fn test_refresh_runs_within_staleness_window() {
2838 let (_temp, schemas_dir, values_dir) = setup_store_test();
2839 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2840 let store = ValuesStore::builder(registry, &values_dir)
2841 .with_refresh_threshold(Duration::from_secs(3600))
2842 .build()
2843 .unwrap();
2844
2845 fs::write(
2848 values_dir.join("ns1").join("values.json"),
2849 r#"{"options": {"enabled": false}}"#,
2850 )
2851 .unwrap();
2852 assert!(store.refresh().unwrap());
2853 assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2854 }
2855 }
2856 mod array_tests {
2857 use super::*;
2858
2859 #[test]
2860 fn test_basic_schema_validation() {
2861 let temp_dir = TempDir::new().unwrap();
2862 for (a_type, default) in [
2863 ("boolean", ""), ("boolean", "true"),
2865 ("integer", "1"),
2866 ("number", "1.2"),
2867 ("string", "\"wow\""),
2868 ] {
2869 create_test_schema(
2870 &temp_dir,
2871 "test",
2872 &format!(
2873 r#"{{
2874 "version": "1.0",
2875 "type": "object",
2876 "properties": {{
2877 "array-key": {{
2878 "type": "array",
2879 "items": {{"type": "{}"}},
2880 "default": [{}],
2881 "description": "Array option"
2882 }}
2883 }}
2884 }}"#,
2885 a_type, default
2886 ),
2887 );
2888
2889 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2890 }
2891 }
2892
2893 #[test]
2894 fn test_missing_items_object_rejection() {
2895 let temp_dir = TempDir::new().unwrap();
2896 create_test_schema(
2897 &temp_dir,
2898 "test",
2899 r#"{
2900 "version": "1.0",
2901 "type": "object",
2902 "properties": {
2903 "array-key": {
2904 "type": "array",
2905 "default": [1,2,3],
2906 "description": "Array option"
2907 }
2908 }
2909 }"#,
2910 );
2911
2912 let result = SchemaRegistry::from_directory(temp_dir.path());
2913 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2914 }
2915
2916 #[test]
2917 fn test_malformed_items_rejection() {
2918 let temp_dir = TempDir::new().unwrap();
2919 create_test_schema(
2920 &temp_dir,
2921 "test",
2922 r#"{
2923 "version": "1.0",
2924 "type": "object",
2925 "properties": {
2926 "array-key": {
2927 "type": "array",
2928 "items": {"type": ""},
2929 "default": [1,2,3],
2930 "description": "Array option"
2931 }
2932 }
2933 }"#,
2934 );
2935
2936 let result = SchemaRegistry::from_directory(temp_dir.path());
2937 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2938 }
2939
2940 #[test]
2941 fn test_schema_default_type_mismatch_rejection() {
2942 let temp_dir = TempDir::new().unwrap();
2943 create_test_schema(
2945 &temp_dir,
2946 "test",
2947 r#"{
2948 "version": "1.0",
2949 "type": "object",
2950 "properties": {
2951 "array-key": {
2952 "type": "array",
2953 "items": {"type": "integer"},
2954 "default": [1,2,3.3],
2955 "description": "Array option"
2956 }
2957 }
2958 }"#,
2959 );
2960
2961 let result = SchemaRegistry::from_directory(temp_dir.path());
2962 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2963 }
2964
2965 #[test]
2966 fn test_schema_default_heterogeneous_rejection() {
2967 let temp_dir = TempDir::new().unwrap();
2968 create_test_schema(
2969 &temp_dir,
2970 "test",
2971 r#"{
2972 "version": "1.0",
2973 "type": "object",
2974 "properties": {
2975 "array-key": {
2976 "type": "array",
2977 "items": {"type": "integer"},
2978 "default": [1,2,"uh oh!"],
2979 "description": "Array option"
2980 }
2981 }
2982 }"#,
2983 );
2984
2985 let result = SchemaRegistry::from_directory(temp_dir.path());
2986 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2987 }
2988
2989 #[test]
2990 fn test_load_values_valid() {
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 "array-key": {
3000 "type": "array",
3001 "items": {"type": "integer"},
3002 "default": [1,2,3],
3003 "description": "Array option"
3004 }
3005 }
3006 }"#,
3007 r#"{
3008 "options": {
3009 "array-key": [4,5,6]
3010 }
3011 }"#,
3012 );
3013
3014 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3015 let (values, generated_at_by_namespace) =
3016 registry.load_values_json(&values_dir).unwrap();
3017
3018 assert_eq!(values.len(), 1);
3019 assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
3020 assert!(generated_at_by_namespace.is_empty());
3021 }
3022
3023 #[test]
3024 fn test_reject_values_not_an_array() {
3025 let temp_dir = TempDir::new().unwrap();
3026 let (schemas_dir, values_dir) = create_test_schema_with_values(
3027 &temp_dir,
3028 "test",
3029 r#"{
3030 "version": "1.0",
3031 "type": "object",
3032 "properties": {
3033 "array-key": {
3034 "type": "array",
3035 "items": {"type": "integer"},
3036 "default": [1,2,3],
3037 "description": "Array option"
3038 }
3039 }
3040 }"#,
3041 r#"{
3043 "options": {
3044 "array-key": "[]"
3045 }
3046 }"#,
3047 );
3048
3049 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3050 let result = registry.load_values_json(&values_dir);
3051
3052 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3053 }
3054
3055 #[test]
3056 fn test_reject_values_mismatch() {
3057 let temp_dir = TempDir::new().unwrap();
3058 let (schemas_dir, values_dir) = create_test_schema_with_values(
3059 &temp_dir,
3060 "test",
3061 r#"{
3062 "version": "1.0",
3063 "type": "object",
3064 "properties": {
3065 "array-key": {
3066 "type": "array",
3067 "items": {"type": "integer"},
3068 "default": [1,2,3],
3069 "description": "Array option"
3070 }
3071 }
3072 }"#,
3073 r#"{
3074 "options": {
3075 "array-key": ["a","b","c"]
3076 }
3077 }"#,
3078 );
3079
3080 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3081 let result = registry.load_values_json(&values_dir);
3082
3083 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3084 }
3085 }
3086
3087 mod object_tests {
3088 use super::*;
3089
3090 #[test]
3091 fn test_object_schema_loads() {
3092 let temp_dir = TempDir::new().unwrap();
3093 create_test_schema(
3094 &temp_dir,
3095 "test",
3096 r#"{
3097 "version": "1.0",
3098 "type": "object",
3099 "properties": {
3100 "config": {
3101 "type": "object",
3102 "properties": {
3103 "host": {"type": "string"},
3104 "port": {"type": "integer"},
3105 "rate": {"type": "number"},
3106 "enabled": {"type": "boolean"}
3107 },
3108 "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
3109 "description": "Service config"
3110 }
3111 }
3112 }"#,
3113 );
3114
3115 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3116 let schema = registry.get("test").unwrap();
3117 assert_eq!(schema.options["config"].option_type, "object");
3118 }
3119
3120 #[test]
3121 fn test_object_missing_properties_rejected() {
3122 let temp_dir = TempDir::new().unwrap();
3123 create_test_schema(
3124 &temp_dir,
3125 "test",
3126 r#"{
3127 "version": "1.0",
3128 "type": "object",
3129 "properties": {
3130 "config": {
3131 "type": "object",
3132 "default": {"host": "localhost"},
3133 "description": "Missing properties field"
3134 }
3135 }
3136 }"#,
3137 );
3138
3139 let result = SchemaRegistry::from_directory(temp_dir.path());
3140 assert!(result.is_err());
3141 }
3142
3143 #[test]
3144 fn test_object_default_wrong_type_rejected() {
3145 let temp_dir = TempDir::new().unwrap();
3146 create_test_schema(
3147 &temp_dir,
3148 "test",
3149 r#"{
3150 "version": "1.0",
3151 "type": "object",
3152 "properties": {
3153 "config": {
3154 "type": "object",
3155 "properties": {
3156 "host": {"type": "string"},
3157 "port": {"type": "integer"}
3158 },
3159 "default": {"host": "localhost", "port": "not-a-number"},
3160 "description": "Bad default"
3161 }
3162 }
3163 }"#,
3164 );
3165
3166 let result = SchemaRegistry::from_directory(temp_dir.path());
3167 assert!(result.is_err());
3168 }
3169
3170 #[test]
3171 fn test_object_default_missing_field_rejected() {
3172 let temp_dir = TempDir::new().unwrap();
3173 create_test_schema(
3174 &temp_dir,
3175 "test",
3176 r#"{
3177 "version": "1.0",
3178 "type": "object",
3179 "properties": {
3180 "config": {
3181 "type": "object",
3182 "properties": {
3183 "host": {"type": "string"},
3184 "port": {"type": "integer"}
3185 },
3186 "default": {"host": "localhost"},
3187 "description": "Missing port in default"
3188 }
3189 }
3190 }"#,
3191 );
3192
3193 let result = SchemaRegistry::from_directory(temp_dir.path());
3194 assert!(result.is_err());
3195 }
3196
3197 #[test]
3198 fn test_object_default_extra_field_rejected() {
3199 let temp_dir = TempDir::new().unwrap();
3200 create_test_schema(
3201 &temp_dir,
3202 "test",
3203 r#"{
3204 "version": "1.0",
3205 "type": "object",
3206 "properties": {
3207 "config": {
3208 "type": "object",
3209 "properties": {
3210 "host": {"type": "string"}
3211 },
3212 "default": {"host": "localhost", "extra": "field"},
3213 "description": "Extra field in default"
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_values_valid() {
3225 let temp_dir = TempDir::new().unwrap();
3226 let (schemas_dir, values_dir) = create_test_schema_with_values(
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 },
3239 "default": {"host": "localhost", "port": 8080},
3240 "description": "Service config"
3241 }
3242 }
3243 }"#,
3244 r#"{
3245 "options": {
3246 "config": {"host": "example.com", "port": 9090}
3247 }
3248 }"#,
3249 );
3250
3251 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3252 let result = registry.load_values_json(&values_dir);
3253 assert!(result.is_ok());
3254 }
3255
3256 #[test]
3257 fn test_object_values_wrong_field_type_rejected() {
3258 let temp_dir = TempDir::new().unwrap();
3259 let (schemas_dir, values_dir) = create_test_schema_with_values(
3260 &temp_dir,
3261 "test",
3262 r#"{
3263 "version": "1.0",
3264 "type": "object",
3265 "properties": {
3266 "config": {
3267 "type": "object",
3268 "properties": {
3269 "host": {"type": "string"},
3270 "port": {"type": "integer"}
3271 },
3272 "default": {"host": "localhost", "port": 8080},
3273 "description": "Service config"
3274 }
3275 }
3276 }"#,
3277 r#"{
3278 "options": {
3279 "config": {"host": "example.com", "port": "not-a-number"}
3280 }
3281 }"#,
3282 );
3283
3284 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3285 let result = registry.load_values_json(&values_dir);
3286 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3287 }
3288
3289 #[test]
3290 fn test_object_values_extra_field_rejected() {
3291 let temp_dir = TempDir::new().unwrap();
3292 let (schemas_dir, values_dir) = create_test_schema_with_values(
3293 &temp_dir,
3294 "test",
3295 r#"{
3296 "version": "1.0",
3297 "type": "object",
3298 "properties": {
3299 "config": {
3300 "type": "object",
3301 "properties": {
3302 "host": {"type": "string"}
3303 },
3304 "default": {"host": "localhost"},
3305 "description": "Service config"
3306 }
3307 }
3308 }"#,
3309 r#"{
3310 "options": {
3311 "config": {"host": "example.com", "extra": "field"}
3312 }
3313 }"#,
3314 );
3315
3316 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3317 let result = registry.load_values_json(&values_dir);
3318 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3319 }
3320
3321 #[test]
3322 fn test_object_values_missing_field_rejected() {
3323 let temp_dir = TempDir::new().unwrap();
3324 let (schemas_dir, values_dir) = create_test_schema_with_values(
3325 &temp_dir,
3326 "test",
3327 r#"{
3328 "version": "1.0",
3329 "type": "object",
3330 "properties": {
3331 "config": {
3332 "type": "object",
3333 "properties": {
3334 "host": {"type": "string"},
3335 "port": {"type": "integer"}
3336 },
3337 "default": {"host": "localhost", "port": 8080},
3338 "description": "Service config"
3339 }
3340 }
3341 }"#,
3342 r#"{
3343 "options": {
3344 "config": {"host": "example.com"}
3345 }
3346 }"#,
3347 );
3348
3349 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3350 let result = registry.load_values_json(&values_dir);
3351 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3352 }
3353
3354 #[test]
3357 fn test_array_of_objects_schema_loads() {
3358 let temp_dir = TempDir::new().unwrap();
3359 create_test_schema(
3360 &temp_dir,
3361 "test",
3362 r#"{
3363 "version": "1.0",
3364 "type": "object",
3365 "properties": {
3366 "endpoints": {
3367 "type": "array",
3368 "items": {
3369 "type": "object",
3370 "properties": {
3371 "url": {"type": "string"},
3372 "weight": {"type": "integer"}
3373 }
3374 },
3375 "default": [{"url": "https://a.example.com", "weight": 1}],
3376 "description": "Endpoints"
3377 }
3378 }
3379 }"#,
3380 );
3381
3382 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3383 let schema = registry.get("test").unwrap();
3384 assert_eq!(schema.options["endpoints"].option_type, "array");
3385 }
3386
3387 #[test]
3388 fn test_array_of_objects_empty_default() {
3389 let temp_dir = TempDir::new().unwrap();
3390 create_test_schema(
3391 &temp_dir,
3392 "test",
3393 r#"{
3394 "version": "1.0",
3395 "type": "object",
3396 "properties": {
3397 "endpoints": {
3398 "type": "array",
3399 "items": {
3400 "type": "object",
3401 "properties": {
3402 "url": {"type": "string"},
3403 "weight": {"type": "integer"}
3404 }
3405 },
3406 "default": [],
3407 "description": "Endpoints"
3408 }
3409 }
3410 }"#,
3411 );
3412
3413 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3414 assert!(registry.get("test").is_some());
3415 }
3416
3417 #[test]
3418 fn test_array_of_objects_default_wrong_field_type_rejected() {
3419 let temp_dir = TempDir::new().unwrap();
3420 create_test_schema(
3421 &temp_dir,
3422 "test",
3423 r#"{
3424 "version": "1.0",
3425 "type": "object",
3426 "properties": {
3427 "endpoints": {
3428 "type": "array",
3429 "items": {
3430 "type": "object",
3431 "properties": {
3432 "url": {"type": "string"},
3433 "weight": {"type": "integer"}
3434 }
3435 },
3436 "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
3437 "description": "Endpoints"
3438 }
3439 }
3440 }"#,
3441 );
3442
3443 let result = SchemaRegistry::from_directory(temp_dir.path());
3444 assert!(result.is_err());
3445 }
3446
3447 #[test]
3448 fn test_array_of_objects_missing_items_properties_rejected() {
3449 let temp_dir = TempDir::new().unwrap();
3450 create_test_schema(
3451 &temp_dir,
3452 "test",
3453 r#"{
3454 "version": "1.0",
3455 "type": "object",
3456 "properties": {
3457 "endpoints": {
3458 "type": "array",
3459 "items": {
3460 "type": "object"
3461 },
3462 "default": [],
3463 "description": "Missing properties in items"
3464 }
3465 }
3466 }"#,
3467 );
3468
3469 let result = SchemaRegistry::from_directory(temp_dir.path());
3470 assert!(result.is_err());
3471 }
3472
3473 #[test]
3474 fn test_array_of_objects_values_valid() {
3475 let temp_dir = TempDir::new().unwrap();
3476 let (schemas_dir, values_dir) = create_test_schema_with_values(
3477 &temp_dir,
3478 "test",
3479 r#"{
3480 "version": "1.0",
3481 "type": "object",
3482 "properties": {
3483 "endpoints": {
3484 "type": "array",
3485 "items": {
3486 "type": "object",
3487 "properties": {
3488 "url": {"type": "string"},
3489 "weight": {"type": "integer"}
3490 }
3491 },
3492 "default": [],
3493 "description": "Endpoints"
3494 }
3495 }
3496 }"#,
3497 r#"{
3498 "options": {
3499 "endpoints": [
3500 {"url": "https://a.example.com", "weight": 1},
3501 {"url": "https://b.example.com", "weight": 2}
3502 ]
3503 }
3504 }"#,
3505 );
3506
3507 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3508 let result = registry.load_values_json(&values_dir);
3509 assert!(result.is_ok());
3510 }
3511
3512 #[test]
3513 fn test_array_of_objects_values_wrong_item_shape_rejected() {
3514 let temp_dir = TempDir::new().unwrap();
3515 let (schemas_dir, values_dir) = create_test_schema_with_values(
3516 &temp_dir,
3517 "test",
3518 r#"{
3519 "version": "1.0",
3520 "type": "object",
3521 "properties": {
3522 "endpoints": {
3523 "type": "array",
3524 "items": {
3525 "type": "object",
3526 "properties": {
3527 "url": {"type": "string"},
3528 "weight": {"type": "integer"}
3529 }
3530 },
3531 "default": [],
3532 "description": "Endpoints"
3533 }
3534 }
3535 }"#,
3536 r#"{
3537 "options": {
3538 "endpoints": [
3539 {"url": "https://a.example.com", "weight": "not-a-number"}
3540 ]
3541 }
3542 }"#,
3543 );
3544
3545 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3546 let result = registry.load_values_json(&values_dir);
3547 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3548 }
3549
3550 #[test]
3551 fn test_array_of_objects_values_extra_field_rejected() {
3552 let temp_dir = TempDir::new().unwrap();
3553 let (schemas_dir, values_dir) = create_test_schema_with_values(
3554 &temp_dir,
3555 "test",
3556 r#"{
3557 "version": "1.0",
3558 "type": "object",
3559 "properties": {
3560 "endpoints": {
3561 "type": "array",
3562 "items": {
3563 "type": "object",
3564 "properties": {
3565 "url": {"type": "string"}
3566 }
3567 },
3568 "default": [],
3569 "description": "Endpoints"
3570 }
3571 }
3572 }"#,
3573 r#"{
3574 "options": {
3575 "endpoints": [
3576 {"url": "https://a.example.com", "extra": "field"}
3577 ]
3578 }
3579 }"#,
3580 );
3581
3582 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3583 let result = registry.load_values_json(&values_dir);
3584 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3585 }
3586
3587 #[test]
3588 fn test_array_of_objects_values_missing_field_rejected() {
3589 let temp_dir = TempDir::new().unwrap();
3590 let (schemas_dir, values_dir) = create_test_schema_with_values(
3591 &temp_dir,
3592 "test",
3593 r#"{
3594 "version": "1.0",
3595 "type": "object",
3596 "properties": {
3597 "endpoints": {
3598 "type": "array",
3599 "items": {
3600 "type": "object",
3601 "properties": {
3602 "url": {"type": "string"},
3603 "weight": {"type": "integer"}
3604 }
3605 },
3606 "default": [],
3607 "description": "Endpoints"
3608 }
3609 }
3610 }"#,
3611 r#"{
3612 "options": {
3613 "endpoints": [
3614 {"url": "https://a.example.com"}
3615 ]
3616 }
3617 }"#,
3618 );
3619
3620 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3621 let result = registry.load_values_json(&values_dir);
3622 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3623 }
3624
3625 #[test]
3628 fn test_object_optional_field_can_be_omitted_from_default() {
3629 let temp_dir = TempDir::new().unwrap();
3630 create_test_schema(
3631 &temp_dir,
3632 "test",
3633 r#"{
3634 "version": "1.0",
3635 "type": "object",
3636 "properties": {
3637 "config": {
3638 "type": "object",
3639 "properties": {
3640 "host": {"type": "string"},
3641 "debug": {"type": "boolean", "optional": true}
3642 },
3643 "default": {"host": "localhost"},
3644 "description": "Config with optional field"
3645 }
3646 }
3647 }"#,
3648 );
3649
3650 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3651 let schema = registry.get("test").unwrap();
3652 assert_eq!(schema.options["config"].option_type, "object");
3653 }
3654
3655 #[test]
3656 fn test_object_optional_field_can_be_included_in_default() {
3657 let temp_dir = TempDir::new().unwrap();
3658 create_test_schema(
3659 &temp_dir,
3660 "test",
3661 r#"{
3662 "version": "1.0",
3663 "type": "object",
3664 "properties": {
3665 "config": {
3666 "type": "object",
3667 "properties": {
3668 "host": {"type": "string"},
3669 "debug": {"type": "boolean", "optional": true}
3670 },
3671 "default": {"host": "localhost", "debug": true},
3672 "description": "Config with optional field included"
3673 }
3674 }
3675 }"#,
3676 );
3677
3678 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3679 assert!(registry.get("test").is_some());
3680 }
3681
3682 #[test]
3683 fn test_object_optional_field_wrong_type_rejected() {
3684 let temp_dir = TempDir::new().unwrap();
3685 create_test_schema(
3686 &temp_dir,
3687 "test",
3688 r#"{
3689 "version": "1.0",
3690 "type": "object",
3691 "properties": {
3692 "config": {
3693 "type": "object",
3694 "properties": {
3695 "host": {"type": "string"},
3696 "debug": {"type": "boolean", "optional": true}
3697 },
3698 "default": {"host": "localhost", "debug": "not-a-bool"},
3699 "description": "Optional field wrong type"
3700 }
3701 }
3702 }"#,
3703 );
3704
3705 let result = SchemaRegistry::from_directory(temp_dir.path());
3706 assert!(result.is_err());
3707 }
3708
3709 #[test]
3710 fn test_object_required_field_still_required_with_optional_present() {
3711 let temp_dir = TempDir::new().unwrap();
3712 create_test_schema(
3713 &temp_dir,
3714 "test",
3715 r#"{
3716 "version": "1.0",
3717 "type": "object",
3718 "properties": {
3719 "config": {
3720 "type": "object",
3721 "properties": {
3722 "host": {"type": "string"},
3723 "port": {"type": "integer"},
3724 "debug": {"type": "boolean", "optional": true}
3725 },
3726 "default": {"debug": true},
3727 "description": "Missing required fields"
3728 }
3729 }
3730 }"#,
3731 );
3732
3733 let result = SchemaRegistry::from_directory(temp_dir.path());
3734 assert!(result.is_err());
3735 }
3736
3737 #[test]
3738 fn test_object_optional_field_omitted_from_values() {
3739 let temp_dir = TempDir::new().unwrap();
3740 let (schemas_dir, values_dir) = create_test_schema_with_values(
3741 &temp_dir,
3742 "test",
3743 r#"{
3744 "version": "1.0",
3745 "type": "object",
3746 "properties": {
3747 "config": {
3748 "type": "object",
3749 "properties": {
3750 "host": {"type": "string"},
3751 "debug": {"type": "boolean", "optional": true}
3752 },
3753 "default": {"host": "localhost"},
3754 "description": "Config"
3755 }
3756 }
3757 }"#,
3758 r#"{
3759 "options": {
3760 "config": {"host": "example.com"}
3761 }
3762 }"#,
3763 );
3764
3765 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3766 let result = registry.load_values_json(&values_dir);
3767 assert!(result.is_ok());
3768 }
3769
3770 #[test]
3771 fn test_object_optional_field_included_in_values() {
3772 let temp_dir = TempDir::new().unwrap();
3773 let (schemas_dir, values_dir) = create_test_schema_with_values(
3774 &temp_dir,
3775 "test",
3776 r#"{
3777 "version": "1.0",
3778 "type": "object",
3779 "properties": {
3780 "config": {
3781 "type": "object",
3782 "properties": {
3783 "host": {"type": "string"},
3784 "debug": {"type": "boolean", "optional": true}
3785 },
3786 "default": {"host": "localhost"},
3787 "description": "Config"
3788 }
3789 }
3790 }"#,
3791 r#"{
3792 "options": {
3793 "config": {"host": "example.com", "debug": true}
3794 }
3795 }"#,
3796 );
3797
3798 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3799 let result = registry.load_values_json(&values_dir);
3800 assert!(result.is_ok());
3801 }
3802
3803 #[test]
3804 fn test_array_of_objects_optional_field_omitted() {
3805 let temp_dir = TempDir::new().unwrap();
3806 let (schemas_dir, values_dir) = create_test_schema_with_values(
3807 &temp_dir,
3808 "test",
3809 r#"{
3810 "version": "1.0",
3811 "type": "object",
3812 "properties": {
3813 "endpoints": {
3814 "type": "array",
3815 "items": {
3816 "type": "object",
3817 "properties": {
3818 "url": {"type": "string"},
3819 "weight": {"type": "integer", "optional": true}
3820 }
3821 },
3822 "default": [],
3823 "description": "Endpoints"
3824 }
3825 }
3826 }"#,
3827 r#"{
3828 "options": {
3829 "endpoints": [
3830 {"url": "https://a.example.com"},
3831 {"url": "https://b.example.com", "weight": 2}
3832 ]
3833 }
3834 }"#,
3835 );
3836
3837 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3838 let result = registry.load_values_json(&values_dir);
3839 assert!(result.is_ok());
3840 }
3841 }
3842
3843 #[test]
3844 fn test_object_valued_map_validates_nested_values() {
3845 let registry = SchemaRegistry::from_schemas(&[(
3848 "test",
3849 r#"{
3850 "version": "1.0",
3851 "type": "object",
3852 "properties": {
3853 "org_tweaks": {
3854 "type": "object",
3855 "additionalProperties": {
3856 "type": "object",
3857 "additionalProperties": {"type": "integer"}
3858 },
3859 "default": {},
3860 "description": ""
3861 }
3862 }
3863 }"#,
3864 )])
3865 .unwrap();
3866
3867 assert!(
3868 registry
3869 .validate_values(
3870 "test",
3871 &json!({"org_tweaks": {"1": {"max_candidates": 20}}})
3872 )
3873 .is_ok()
3874 );
3875 assert!(
3876 registry
3877 .validate_values("test", &json!({"org_tweaks": {}}))
3878 .is_ok()
3879 );
3880 assert!(matches!(
3882 registry.validate_values(
3883 "test",
3884 &json!({"org_tweaks": {"1": {"max_candidates": "x"}}})
3885 ),
3886 Err(ValidationError::ValueError { .. })
3887 ));
3888 assert!(matches!(
3890 registry.validate_values("test", &json!({"org_tweaks": {"1": 20}})),
3891 Err(ValidationError::ValueError { .. })
3892 ));
3893 }
3894
3895 #[test]
3896 fn test_array_valued_map_validates_nested_values() {
3897 let registry = SchemaRegistry::from_schemas(&[(
3899 "test",
3900 r#"{
3901 "version": "1.0",
3902 "type": "object",
3903 "properties": {
3904 "webhook_logging": {
3905 "type": "object",
3906 "additionalProperties": {
3907 "type": "array",
3908 "items": {"type": "string"}
3909 },
3910 "default": {},
3911 "description": ""
3912 }
3913 }
3914 }"#,
3915 )])
3916 .unwrap();
3917
3918 assert!(
3919 registry
3920 .validate_values(
3921 "test",
3922 &json!({"webhook_logging": {"a": [], "b": ["x", "y"]}})
3923 )
3924 .is_ok()
3925 );
3926 assert!(matches!(
3928 registry.validate_values("test", &json!({"webhook_logging": {"a": [1]}})),
3929 Err(ValidationError::ValueError { .. })
3930 ));
3931 assert!(matches!(
3933 registry.validate_values("test", &json!({"webhook_logging": {"a": "x"}})),
3934 Err(ValidationError::ValueError { .. })
3935 ));
3936 }
3937
3938 #[test]
3939 fn test_nested_fixed_object_enforces_required_and_rejects_unknown() {
3940 let registry = SchemaRegistry::from_schemas(&[(
3943 "test",
3944 r#"{
3945 "version": "1.0",
3946 "type": "object",
3947 "properties": {
3948 "quotas": {
3949 "type": "object",
3950 "additionalProperties": {
3951 "type": "object",
3952 "properties": {
3953 "limit": {"type": "integer"},
3954 "window": {"type": "integer"}
3955 }
3956 },
3957 "default": {},
3958 "description": ""
3959 }
3960 }
3961 }"#,
3962 )])
3963 .unwrap();
3964
3965 assert!(
3966 registry
3967 .validate_values("test", &json!({"quotas": {"o": {"limit": 1, "window": 2}}}))
3968 .is_ok()
3969 );
3970 assert!(matches!(
3972 registry.validate_values("test", &json!({"quotas": {"o": {"limit": 1}}})),
3973 Err(ValidationError::ValueError { .. })
3974 ));
3975 assert!(matches!(
3977 registry.validate_values(
3978 "test",
3979 &json!({"quotas": {"o": {"limit": 1, "window": 2, "x": 3}}})
3980 ),
3981 Err(ValidationError::ValueError { .. })
3982 ));
3983 }
3984
3985 #[test]
3986 fn test_optional_marker_works_at_depth() {
3987 let registry = SchemaRegistry::from_schemas(&[(
3988 "test",
3989 r#"{
3990 "version": "1.0",
3991 "type": "object",
3992 "properties": {
3993 "items_map": {
3994 "type": "object",
3995 "additionalProperties": {
3996 "type": "object",
3997 "properties": {
3998 "url": {"type": "string"},
3999 "weight": {"type": "integer", "optional": true}
4000 }
4001 },
4002 "default": {},
4003 "description": ""
4004 }
4005 }
4006 }"#,
4007 )])
4008 .unwrap();
4009
4010 assert!(
4012 registry
4013 .validate_values("test", &json!({"items_map": {"a": {"url": "x"}}}))
4014 .is_ok()
4015 );
4016 assert!(matches!(
4018 registry.validate_values("test", &json!({"items_map": {"a": {"weight": 1}}})),
4019 Err(ValidationError::ValueError { .. })
4020 ));
4021 }
4022
4023 #[test]
4024 fn test_three_levels_of_nesting() {
4025 let registry = SchemaRegistry::from_schemas(&[(
4027 "test",
4028 r#"{
4029 "version": "1.0",
4030 "type": "object",
4031 "properties": {
4032 "deep": {
4033 "type": "object",
4034 "additionalProperties": {
4035 "type": "array",
4036 "items": {
4037 "type": "object",
4038 "additionalProperties": {"type": "integer"}
4039 }
4040 },
4041 "default": {},
4042 "description": ""
4043 }
4044 }
4045 }"#,
4046 )])
4047 .unwrap();
4048
4049 assert!(
4050 registry
4051 .validate_values("test", &json!({"deep": {"a": [{"x": 1}, {"y": 2}]}}))
4052 .is_ok()
4053 );
4054 assert!(matches!(
4055 registry.validate_values("test", &json!({"deep": {"a": [{"x": "no"}]}})),
4056 Err(ValidationError::ValueError { .. })
4057 ));
4058 }
4059
4060 #[test]
4061 fn test_meta_schema_rejects_malformed_nested_value_types() {
4062 assert!(matches!(
4064 SchemaRegistry::from_schemas(&[(
4065 "test",
4066 r#"{"version":"1.0","type":"object","properties":{
4067 "m":{"type":"object","additionalProperties":{"type":"object"},"default":{},"description":""}}}"#,
4068 )]),
4069 Err(ValidationError::SchemaError { .. })
4070 ));
4071 assert!(matches!(
4073 SchemaRegistry::from_schemas(&[(
4074 "test",
4075 r#"{"version":"1.0","type":"object","properties":{
4076 "m":{"type":"object","additionalProperties":{"type":"array"},"default":{},"description":""}}}"#,
4077 )]),
4078 Err(ValidationError::SchemaError { .. })
4079 ));
4080 assert!(matches!(
4082 SchemaRegistry::from_schemas(&[(
4083 "test",
4084 r#"{"version":"1.0","type":"object","properties":{
4085 "m":{"type":"object","additionalProperties":{"type":"string","bogus":1},"default":{},"description":""}}}"#,
4086 )]),
4087 Err(ValidationError::SchemaError { .. })
4088 ));
4089 }
4090
4091 #[test]
4092 fn test_pre_existing_shapes_still_validate() {
4093 let registry = SchemaRegistry::from_schemas(&[(
4095 "test",
4096 r#"{
4097 "version": "1.0",
4098 "type": "object",
4099 "properties": {
4100 "scopes": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}, "description": ""},
4101 "config": {"type": "object", "properties": {"host": {"type": "string"}}, "default": {"host": "h"}, "description": ""}
4102 }
4103 }"#,
4104 )])
4105 .unwrap();
4106
4107 assert!(
4108 registry
4109 .validate_values(
4110 "test",
4111 &json!({"scopes": {"r": "1"}, "config": {"host": "x"}})
4112 )
4113 .is_ok()
4114 );
4115 assert!(matches!(
4116 registry.validate_values("test", &json!({"config": {"host": "x", "extra": "y"}})),
4117 Err(ValidationError::ValueError { .. })
4118 ));
4119 }
4120}