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