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