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