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