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