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 #[test]
2302 fn test_validate_values_feature_with_experiment_mode() {
2303 let temp_dir = TempDir::new().unwrap();
2304 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2305 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2306
2307 let result = registry.validate_values(
2308 "test",
2309 &json!({
2310 "feature.organizations:fury-mode": {
2311 "owner": {"team": "hybrid-cloud"},
2312 "segments": [],
2313 "created_at": "2024-01-01",
2314 "experiment_mode": "simple"
2315 }
2316 }),
2317 );
2318 assert!(result.is_ok());
2319 }
2320
2321 #[test]
2322 fn test_validate_values_feature_with_invalid_experiment_mode_fails() {
2323 let temp_dir = TempDir::new().unwrap();
2324 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2325 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2326
2327 let result = registry.validate_values(
2328 "test",
2329 &json!({
2330 "feature.organizations:fury-mode": {
2331 "owner": {"team": "hybrid-cloud"},
2332 "segments": [],
2333 "created_at": "2024-01-01",
2334 "experiment_mode": "not-a-real-mode"
2335 }
2336 }),
2337 );
2338 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2339 }
2340
2341 #[test]
2342 fn test_validate_values_feature_without_experiment_mode_still_valid() {
2343 let temp_dir = TempDir::new().unwrap();
2344 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2345 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2346
2347 let result = registry.validate_values(
2348 "test",
2349 &json!({
2350 "feature.organizations:fury-mode": {
2351 "owner": {"team": "hybrid-cloud"},
2352 "segments": [],
2353 "created_at": "2024-01-01"
2354 }
2355 }),
2356 );
2357 assert!(result.is_ok());
2358 }
2359 }
2360
2361 mod store_tests {
2362 use super::*;
2363
2364 fn setup_store_test() -> (TempDir, PathBuf, PathBuf) {
2366 let temp_dir = TempDir::new().unwrap();
2367 let schemas_dir = temp_dir.path().join("schemas");
2368 let values_dir = temp_dir.path().join("values");
2369
2370 let ns1_schema = schemas_dir.join("ns1");
2371 fs::create_dir_all(&ns1_schema).unwrap();
2372 fs::write(
2373 ns1_schema.join("schema.json"),
2374 r#"{
2375 "version": "1.0",
2376 "type": "object",
2377 "properties": {
2378 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
2379 }
2380 }"#,
2381 )
2382 .unwrap();
2383
2384 let ns1_values = values_dir.join("ns1");
2385 fs::create_dir_all(&ns1_values).unwrap();
2386 fs::write(
2387 ns1_values.join("values.json"),
2388 r#"{"options": {"enabled": true}}"#,
2389 )
2390 .unwrap();
2391
2392 let ns2_schema = schemas_dir.join("ns2");
2393 fs::create_dir_all(&ns2_schema).unwrap();
2394 fs::write(
2395 ns2_schema.join("schema.json"),
2396 r#"{
2397 "version": "1.0",
2398 "type": "object",
2399 "properties": {
2400 "count": {"type": "integer", "default": 0, "description": "Count"}
2401 }
2402 }"#,
2403 )
2404 .unwrap();
2405
2406 let ns2_values = values_dir.join("ns2");
2407 fs::create_dir_all(&ns2_values).unwrap();
2408 fs::write(
2409 ns2_values.join("values.json"),
2410 r#"{"options": {"count": 42}}"#,
2411 )
2412 .unwrap();
2413
2414 (temp_dir, schemas_dir, values_dir)
2415 }
2416
2417 fn store_with_zero_threshold(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2418 let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2419 ValuesStore::builder(registry, values_dir)
2420 .with_refresh_threshold(Duration::ZERO)
2421 .build()
2422 .unwrap()
2423 }
2424
2425 fn store_without_refresh_on_read(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2426 let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2427 ValuesStore::builder(registry, values_dir)
2428 .with_refresh_threshold(None)
2429 .build()
2430 .unwrap()
2431 }
2432
2433 #[test]
2434 fn test_initial_load_populates_values() {
2435 let (_temp, schemas_dir, values_dir) = setup_store_test();
2436 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2437 let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2438
2439 let guard = store.load();
2440 assert_eq!(guard["ns1"]["enabled"], json!(true));
2441 assert_eq!(guard["ns2"]["count"], json!(42));
2442 }
2443
2444 #[test]
2445 fn test_read_within_threshold_serves_cached() {
2446 let (_temp, schemas_dir, values_dir) = setup_store_test();
2449 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2450 let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2451
2452 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2454 fs::write(
2455 values_dir.join("ns1").join("values.json"),
2456 r#"{"options": {"enabled": false}}"#,
2457 )
2458 .unwrap();
2459
2460 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2462 }
2463
2464 #[test]
2465 fn test_read_after_threshold_refreshes() {
2466 let (_temp, schemas_dir, values_dir) = setup_store_test();
2467 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2468
2469 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2471 assert_eq!(store.load()["ns2"]["count"], json!(42));
2472
2473 fs::write(
2475 values_dir.join("ns1").join("values.json"),
2476 r#"{"options": {"enabled": false}}"#,
2477 )
2478 .unwrap();
2479 fs::write(
2480 values_dir.join("ns2").join("values.json"),
2481 r#"{"options": {"count": 100}}"#,
2482 )
2483 .unwrap();
2484
2485 let guard = store.load();
2487 assert_eq!(guard["ns1"]["enabled"], json!(false));
2488 assert_eq!(guard["ns2"]["count"], json!(100));
2489 }
2490
2491 #[test]
2492 fn test_refresh_failure_keeps_old_values() {
2493 let (_temp, schemas_dir, values_dir) = setup_store_test();
2494 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2495
2496 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2497
2498 fs::write(
2500 values_dir.join("ns1").join("values.json"),
2501 r#"{"options": {"enabled": "not-a-boolean"}}"#,
2502 )
2503 .unwrap();
2504
2505 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2507 }
2508
2509 #[test]
2510 fn test_concurrent_reads_observe_new_values() {
2511 use std::thread;
2512
2513 let (_temp, schemas_dir, values_dir) = setup_store_test();
2514 let store = Arc::new(store_with_zero_threshold(&schemas_dir, &values_dir));
2515
2516 assert_eq!(store.load()["ns2"]["count"], json!(42));
2518
2519 fs::write(
2520 values_dir.join("ns2").join("values.json"),
2521 r#"{"options": {"count": 7}}"#,
2522 )
2523 .unwrap();
2524
2525 let mut handles = Vec::new();
2526 for _ in 0..8 {
2527 let store = Arc::clone(&store);
2528 handles.push(thread::spawn(move || {
2529 let guard = store.load();
2530 guard["ns2"]["count"].clone()
2531 }));
2532 }
2533 for h in handles {
2534 assert_eq!(h.join().unwrap(), json!(7));
2535 }
2536 }
2537
2538 #[test]
2539 fn test_refresh_returns_true_on_change_false_when_unchanged() {
2540 let (_temp, schemas_dir, values_dir) = setup_store_test();
2541 let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2542
2543 assert!(!store.refresh().unwrap());
2545
2546 fs::write(
2547 values_dir.join("ns1").join("values.json"),
2548 r#"{"options": {"enabled": false}}"#,
2549 )
2550 .unwrap();
2551 assert!(store.refresh().unwrap());
2552
2553 assert!(!store.refresh().unwrap());
2555 }
2556
2557 #[test]
2558 fn test_disabled_threshold_never_refreshes_on_read() {
2559 let (_temp, schemas_dir, values_dir) = setup_store_test();
2560 let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2561
2562 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2563 fs::write(
2564 values_dir.join("ns1").join("values.json"),
2565 r#"{"options": {"enabled": false}}"#,
2566 )
2567 .unwrap();
2568
2569 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2571
2572 assert!(store.refresh().unwrap());
2574 assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2575 }
2576
2577 #[test]
2578 fn test_refresh_error_keeps_snapshot_and_recovers() {
2579 let (_temp, schemas_dir, values_dir) = setup_store_test();
2580 let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2581 let ns1_values = values_dir.join("ns1").join("values.json");
2582
2583 fs::write(&ns1_values, "not json").unwrap();
2584 assert!(store.refresh().is_err());
2585 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2586
2587 fs::write(&ns1_values, r#"{"options": {"enabled": false}}"#).unwrap();
2588 assert!(store.refresh().unwrap());
2589 assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2590 }
2591
2592 #[test]
2593 fn test_refresh_fires_propagation_callback() {
2594 let temp_dir = TempDir::new().unwrap();
2595 let base = temp_dir.path();
2596 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2597
2598 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2599 let events_clone = events.clone();
2600 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2601 let store = ValuesStore::builder(registry, &base.join("values"))
2602 .with_refresh_threshold(None)
2603 .with_callback(Box::new(move |ns, delay| {
2604 events_clone.lock().unwrap().push((ns.to_string(), delay));
2605 }))
2606 .build()
2607 .unwrap();
2608
2609 fs::write(
2610 &values_file,
2611 r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2612 )
2613 .unwrap();
2614 assert!(store.refresh().unwrap());
2615
2616 let recorded = events.lock().unwrap();
2617 assert_eq!(recorded.len(), 1);
2618 assert_eq!(recorded[0].0, "test");
2619 }
2620
2621 #[test]
2622 fn test_refresh_resets_lazy_timer() {
2623 let (_temp, schemas_dir, values_dir) = setup_store_test();
2624 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2625 let store = ValuesStore::builder(registry, &values_dir)
2626 .with_refresh_threshold(Duration::from_secs(3600))
2627 .build()
2628 .unwrap();
2629
2630 assert!(!store.refresh().unwrap());
2631
2632 fs::write(
2635 values_dir.join("ns1").join("values.json"),
2636 r#"{"options": {"enabled": false}}"#,
2637 )
2638 .unwrap();
2639 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2640 }
2641
2642 #[test]
2643 fn test_refresh_runs_within_staleness_window() {
2644 let (_temp, schemas_dir, values_dir) = setup_store_test();
2645 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2646 let store = ValuesStore::builder(registry, &values_dir)
2647 .with_refresh_threshold(Duration::from_secs(3600))
2648 .build()
2649 .unwrap();
2650
2651 fs::write(
2654 values_dir.join("ns1").join("values.json"),
2655 r#"{"options": {"enabled": false}}"#,
2656 )
2657 .unwrap();
2658 assert!(store.refresh().unwrap());
2659 assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2660 }
2661 }
2662 mod array_tests {
2663 use super::*;
2664
2665 #[test]
2666 fn test_basic_schema_validation() {
2667 let temp_dir = TempDir::new().unwrap();
2668 for (a_type, default) in [
2669 ("boolean", ""), ("boolean", "true"),
2671 ("integer", "1"),
2672 ("number", "1.2"),
2673 ("string", "\"wow\""),
2674 ] {
2675 create_test_schema(
2676 &temp_dir,
2677 "test",
2678 &format!(
2679 r#"{{
2680 "version": "1.0",
2681 "type": "object",
2682 "properties": {{
2683 "array-key": {{
2684 "type": "array",
2685 "items": {{"type": "{}"}},
2686 "default": [{}],
2687 "description": "Array option"
2688 }}
2689 }}
2690 }}"#,
2691 a_type, default
2692 ),
2693 );
2694
2695 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2696 }
2697 }
2698
2699 #[test]
2700 fn test_missing_items_object_rejection() {
2701 let temp_dir = TempDir::new().unwrap();
2702 create_test_schema(
2703 &temp_dir,
2704 "test",
2705 r#"{
2706 "version": "1.0",
2707 "type": "object",
2708 "properties": {
2709 "array-key": {
2710 "type": "array",
2711 "default": [1,2,3],
2712 "description": "Array option"
2713 }
2714 }
2715 }"#,
2716 );
2717
2718 let result = SchemaRegistry::from_directory(temp_dir.path());
2719 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2720 }
2721
2722 #[test]
2723 fn test_malformed_items_rejection() {
2724 let temp_dir = TempDir::new().unwrap();
2725 create_test_schema(
2726 &temp_dir,
2727 "test",
2728 r#"{
2729 "version": "1.0",
2730 "type": "object",
2731 "properties": {
2732 "array-key": {
2733 "type": "array",
2734 "items": {"type": ""},
2735 "default": [1,2,3],
2736 "description": "Array option"
2737 }
2738 }
2739 }"#,
2740 );
2741
2742 let result = SchemaRegistry::from_directory(temp_dir.path());
2743 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2744 }
2745
2746 #[test]
2747 fn test_schema_default_type_mismatch_rejection() {
2748 let temp_dir = TempDir::new().unwrap();
2749 create_test_schema(
2751 &temp_dir,
2752 "test",
2753 r#"{
2754 "version": "1.0",
2755 "type": "object",
2756 "properties": {
2757 "array-key": {
2758 "type": "array",
2759 "items": {"type": "integer"},
2760 "default": [1,2,3.3],
2761 "description": "Array option"
2762 }
2763 }
2764 }"#,
2765 );
2766
2767 let result = SchemaRegistry::from_directory(temp_dir.path());
2768 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2769 }
2770
2771 #[test]
2772 fn test_schema_default_heterogeneous_rejection() {
2773 let temp_dir = TempDir::new().unwrap();
2774 create_test_schema(
2775 &temp_dir,
2776 "test",
2777 r#"{
2778 "version": "1.0",
2779 "type": "object",
2780 "properties": {
2781 "array-key": {
2782 "type": "array",
2783 "items": {"type": "integer"},
2784 "default": [1,2,"uh oh!"],
2785 "description": "Array option"
2786 }
2787 }
2788 }"#,
2789 );
2790
2791 let result = SchemaRegistry::from_directory(temp_dir.path());
2792 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2793 }
2794
2795 #[test]
2796 fn test_load_values_valid() {
2797 let temp_dir = TempDir::new().unwrap();
2798 let (schemas_dir, values_dir) = create_test_schema_with_values(
2799 &temp_dir,
2800 "test",
2801 r#"{
2802 "version": "1.0",
2803 "type": "object",
2804 "properties": {
2805 "array-key": {
2806 "type": "array",
2807 "items": {"type": "integer"},
2808 "default": [1,2,3],
2809 "description": "Array option"
2810 }
2811 }
2812 }"#,
2813 r#"{
2814 "options": {
2815 "array-key": [4,5,6]
2816 }
2817 }"#,
2818 );
2819
2820 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2821 let (values, generated_at_by_namespace) =
2822 registry.load_values_json(&values_dir).unwrap();
2823
2824 assert_eq!(values.len(), 1);
2825 assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
2826 assert!(generated_at_by_namespace.is_empty());
2827 }
2828
2829 #[test]
2830 fn test_reject_values_not_an_array() {
2831 let temp_dir = TempDir::new().unwrap();
2832 let (schemas_dir, values_dir) = create_test_schema_with_values(
2833 &temp_dir,
2834 "test",
2835 r#"{
2836 "version": "1.0",
2837 "type": "object",
2838 "properties": {
2839 "array-key": {
2840 "type": "array",
2841 "items": {"type": "integer"},
2842 "default": [1,2,3],
2843 "description": "Array option"
2844 }
2845 }
2846 }"#,
2847 r#"{
2849 "options": {
2850 "array-key": "[]"
2851 }
2852 }"#,
2853 );
2854
2855 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2856 let result = registry.load_values_json(&values_dir);
2857
2858 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2859 }
2860
2861 #[test]
2862 fn test_reject_values_mismatch() {
2863 let temp_dir = TempDir::new().unwrap();
2864 let (schemas_dir, values_dir) = create_test_schema_with_values(
2865 &temp_dir,
2866 "test",
2867 r#"{
2868 "version": "1.0",
2869 "type": "object",
2870 "properties": {
2871 "array-key": {
2872 "type": "array",
2873 "items": {"type": "integer"},
2874 "default": [1,2,3],
2875 "description": "Array option"
2876 }
2877 }
2878 }"#,
2879 r#"{
2880 "options": {
2881 "array-key": ["a","b","c"]
2882 }
2883 }"#,
2884 );
2885
2886 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2887 let result = registry.load_values_json(&values_dir);
2888
2889 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2890 }
2891 }
2892
2893 mod object_tests {
2894 use super::*;
2895
2896 #[test]
2897 fn test_object_schema_loads() {
2898 let temp_dir = TempDir::new().unwrap();
2899 create_test_schema(
2900 &temp_dir,
2901 "test",
2902 r#"{
2903 "version": "1.0",
2904 "type": "object",
2905 "properties": {
2906 "config": {
2907 "type": "object",
2908 "properties": {
2909 "host": {"type": "string"},
2910 "port": {"type": "integer"},
2911 "rate": {"type": "number"},
2912 "enabled": {"type": "boolean"}
2913 },
2914 "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
2915 "description": "Service config"
2916 }
2917 }
2918 }"#,
2919 );
2920
2921 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2922 let schema = registry.get("test").unwrap();
2923 assert_eq!(schema.options["config"].option_type, "object");
2924 }
2925
2926 #[test]
2927 fn test_object_missing_properties_rejected() {
2928 let temp_dir = TempDir::new().unwrap();
2929 create_test_schema(
2930 &temp_dir,
2931 "test",
2932 r#"{
2933 "version": "1.0",
2934 "type": "object",
2935 "properties": {
2936 "config": {
2937 "type": "object",
2938 "default": {"host": "localhost"},
2939 "description": "Missing properties field"
2940 }
2941 }
2942 }"#,
2943 );
2944
2945 let result = SchemaRegistry::from_directory(temp_dir.path());
2946 assert!(result.is_err());
2947 }
2948
2949 #[test]
2950 fn test_object_default_wrong_type_rejected() {
2951 let temp_dir = TempDir::new().unwrap();
2952 create_test_schema(
2953 &temp_dir,
2954 "test",
2955 r#"{
2956 "version": "1.0",
2957 "type": "object",
2958 "properties": {
2959 "config": {
2960 "type": "object",
2961 "properties": {
2962 "host": {"type": "string"},
2963 "port": {"type": "integer"}
2964 },
2965 "default": {"host": "localhost", "port": "not-a-number"},
2966 "description": "Bad default"
2967 }
2968 }
2969 }"#,
2970 );
2971
2972 let result = SchemaRegistry::from_directory(temp_dir.path());
2973 assert!(result.is_err());
2974 }
2975
2976 #[test]
2977 fn test_object_default_missing_field_rejected() {
2978 let temp_dir = TempDir::new().unwrap();
2979 create_test_schema(
2980 &temp_dir,
2981 "test",
2982 r#"{
2983 "version": "1.0",
2984 "type": "object",
2985 "properties": {
2986 "config": {
2987 "type": "object",
2988 "properties": {
2989 "host": {"type": "string"},
2990 "port": {"type": "integer"}
2991 },
2992 "default": {"host": "localhost"},
2993 "description": "Missing port in default"
2994 }
2995 }
2996 }"#,
2997 );
2998
2999 let result = SchemaRegistry::from_directory(temp_dir.path());
3000 assert!(result.is_err());
3001 }
3002
3003 #[test]
3004 fn test_object_default_extra_field_rejected() {
3005 let temp_dir = TempDir::new().unwrap();
3006 create_test_schema(
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 },
3018 "default": {"host": "localhost", "extra": "field"},
3019 "description": "Extra field in default"
3020 }
3021 }
3022 }"#,
3023 );
3024
3025 let result = SchemaRegistry::from_directory(temp_dir.path());
3026 assert!(result.is_err());
3027 }
3028
3029 #[test]
3030 fn test_object_values_valid() {
3031 let temp_dir = TempDir::new().unwrap();
3032 let (schemas_dir, values_dir) = create_test_schema_with_values(
3033 &temp_dir,
3034 "test",
3035 r#"{
3036 "version": "1.0",
3037 "type": "object",
3038 "properties": {
3039 "config": {
3040 "type": "object",
3041 "properties": {
3042 "host": {"type": "string"},
3043 "port": {"type": "integer"}
3044 },
3045 "default": {"host": "localhost", "port": 8080},
3046 "description": "Service config"
3047 }
3048 }
3049 }"#,
3050 r#"{
3051 "options": {
3052 "config": {"host": "example.com", "port": 9090}
3053 }
3054 }"#,
3055 );
3056
3057 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3058 let result = registry.load_values_json(&values_dir);
3059 assert!(result.is_ok());
3060 }
3061
3062 #[test]
3063 fn test_object_values_wrong_field_type_rejected() {
3064 let temp_dir = TempDir::new().unwrap();
3065 let (schemas_dir, values_dir) = create_test_schema_with_values(
3066 &temp_dir,
3067 "test",
3068 r#"{
3069 "version": "1.0",
3070 "type": "object",
3071 "properties": {
3072 "config": {
3073 "type": "object",
3074 "properties": {
3075 "host": {"type": "string"},
3076 "port": {"type": "integer"}
3077 },
3078 "default": {"host": "localhost", "port": 8080},
3079 "description": "Service config"
3080 }
3081 }
3082 }"#,
3083 r#"{
3084 "options": {
3085 "config": {"host": "example.com", "port": "not-a-number"}
3086 }
3087 }"#,
3088 );
3089
3090 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3091 let result = registry.load_values_json(&values_dir);
3092 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3093 }
3094
3095 #[test]
3096 fn test_object_values_extra_field_rejected() {
3097 let temp_dir = TempDir::new().unwrap();
3098 let (schemas_dir, values_dir) = create_test_schema_with_values(
3099 &temp_dir,
3100 "test",
3101 r#"{
3102 "version": "1.0",
3103 "type": "object",
3104 "properties": {
3105 "config": {
3106 "type": "object",
3107 "properties": {
3108 "host": {"type": "string"}
3109 },
3110 "default": {"host": "localhost"},
3111 "description": "Service config"
3112 }
3113 }
3114 }"#,
3115 r#"{
3116 "options": {
3117 "config": {"host": "example.com", "extra": "field"}
3118 }
3119 }"#,
3120 );
3121
3122 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3123 let result = registry.load_values_json(&values_dir);
3124 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3125 }
3126
3127 #[test]
3128 fn test_object_values_missing_field_rejected() {
3129 let temp_dir = TempDir::new().unwrap();
3130 let (schemas_dir, values_dir) = create_test_schema_with_values(
3131 &temp_dir,
3132 "test",
3133 r#"{
3134 "version": "1.0",
3135 "type": "object",
3136 "properties": {
3137 "config": {
3138 "type": "object",
3139 "properties": {
3140 "host": {"type": "string"},
3141 "port": {"type": "integer"}
3142 },
3143 "default": {"host": "localhost", "port": 8080},
3144 "description": "Service config"
3145 }
3146 }
3147 }"#,
3148 r#"{
3149 "options": {
3150 "config": {"host": "example.com"}
3151 }
3152 }"#,
3153 );
3154
3155 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3156 let result = registry.load_values_json(&values_dir);
3157 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3158 }
3159
3160 #[test]
3163 fn test_array_of_objects_schema_loads() {
3164 let temp_dir = TempDir::new().unwrap();
3165 create_test_schema(
3166 &temp_dir,
3167 "test",
3168 r#"{
3169 "version": "1.0",
3170 "type": "object",
3171 "properties": {
3172 "endpoints": {
3173 "type": "array",
3174 "items": {
3175 "type": "object",
3176 "properties": {
3177 "url": {"type": "string"},
3178 "weight": {"type": "integer"}
3179 }
3180 },
3181 "default": [{"url": "https://a.example.com", "weight": 1}],
3182 "description": "Endpoints"
3183 }
3184 }
3185 }"#,
3186 );
3187
3188 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3189 let schema = registry.get("test").unwrap();
3190 assert_eq!(schema.options["endpoints"].option_type, "array");
3191 }
3192
3193 #[test]
3194 fn test_array_of_objects_empty_default() {
3195 let temp_dir = TempDir::new().unwrap();
3196 create_test_schema(
3197 &temp_dir,
3198 "test",
3199 r#"{
3200 "version": "1.0",
3201 "type": "object",
3202 "properties": {
3203 "endpoints": {
3204 "type": "array",
3205 "items": {
3206 "type": "object",
3207 "properties": {
3208 "url": {"type": "string"},
3209 "weight": {"type": "integer"}
3210 }
3211 },
3212 "default": [],
3213 "description": "Endpoints"
3214 }
3215 }
3216 }"#,
3217 );
3218
3219 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3220 assert!(registry.get("test").is_some());
3221 }
3222
3223 #[test]
3224 fn test_array_of_objects_default_wrong_field_type_rejected() {
3225 let temp_dir = TempDir::new().unwrap();
3226 create_test_schema(
3227 &temp_dir,
3228 "test",
3229 r#"{
3230 "version": "1.0",
3231 "type": "object",
3232 "properties": {
3233 "endpoints": {
3234 "type": "array",
3235 "items": {
3236 "type": "object",
3237 "properties": {
3238 "url": {"type": "string"},
3239 "weight": {"type": "integer"}
3240 }
3241 },
3242 "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
3243 "description": "Endpoints"
3244 }
3245 }
3246 }"#,
3247 );
3248
3249 let result = SchemaRegistry::from_directory(temp_dir.path());
3250 assert!(result.is_err());
3251 }
3252
3253 #[test]
3254 fn test_array_of_objects_missing_items_properties_rejected() {
3255 let temp_dir = TempDir::new().unwrap();
3256 create_test_schema(
3257 &temp_dir,
3258 "test",
3259 r#"{
3260 "version": "1.0",
3261 "type": "object",
3262 "properties": {
3263 "endpoints": {
3264 "type": "array",
3265 "items": {
3266 "type": "object"
3267 },
3268 "default": [],
3269 "description": "Missing properties in items"
3270 }
3271 }
3272 }"#,
3273 );
3274
3275 let result = SchemaRegistry::from_directory(temp_dir.path());
3276 assert!(result.is_err());
3277 }
3278
3279 #[test]
3280 fn test_array_of_objects_values_valid() {
3281 let temp_dir = TempDir::new().unwrap();
3282 let (schemas_dir, values_dir) = create_test_schema_with_values(
3283 &temp_dir,
3284 "test",
3285 r#"{
3286 "version": "1.0",
3287 "type": "object",
3288 "properties": {
3289 "endpoints": {
3290 "type": "array",
3291 "items": {
3292 "type": "object",
3293 "properties": {
3294 "url": {"type": "string"},
3295 "weight": {"type": "integer"}
3296 }
3297 },
3298 "default": [],
3299 "description": "Endpoints"
3300 }
3301 }
3302 }"#,
3303 r#"{
3304 "options": {
3305 "endpoints": [
3306 {"url": "https://a.example.com", "weight": 1},
3307 {"url": "https://b.example.com", "weight": 2}
3308 ]
3309 }
3310 }"#,
3311 );
3312
3313 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3314 let result = registry.load_values_json(&values_dir);
3315 assert!(result.is_ok());
3316 }
3317
3318 #[test]
3319 fn test_array_of_objects_values_wrong_item_shape_rejected() {
3320 let temp_dir = TempDir::new().unwrap();
3321 let (schemas_dir, values_dir) = create_test_schema_with_values(
3322 &temp_dir,
3323 "test",
3324 r#"{
3325 "version": "1.0",
3326 "type": "object",
3327 "properties": {
3328 "endpoints": {
3329 "type": "array",
3330 "items": {
3331 "type": "object",
3332 "properties": {
3333 "url": {"type": "string"},
3334 "weight": {"type": "integer"}
3335 }
3336 },
3337 "default": [],
3338 "description": "Endpoints"
3339 }
3340 }
3341 }"#,
3342 r#"{
3343 "options": {
3344 "endpoints": [
3345 {"url": "https://a.example.com", "weight": "not-a-number"}
3346 ]
3347 }
3348 }"#,
3349 );
3350
3351 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3352 let result = registry.load_values_json(&values_dir);
3353 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3354 }
3355
3356 #[test]
3357 fn test_array_of_objects_values_extra_field_rejected() {
3358 let temp_dir = TempDir::new().unwrap();
3359 let (schemas_dir, values_dir) = create_test_schema_with_values(
3360 &temp_dir,
3361 "test",
3362 r#"{
3363 "version": "1.0",
3364 "type": "object",
3365 "properties": {
3366 "endpoints": {
3367 "type": "array",
3368 "items": {
3369 "type": "object",
3370 "properties": {
3371 "url": {"type": "string"}
3372 }
3373 },
3374 "default": [],
3375 "description": "Endpoints"
3376 }
3377 }
3378 }"#,
3379 r#"{
3380 "options": {
3381 "endpoints": [
3382 {"url": "https://a.example.com", "extra": "field"}
3383 ]
3384 }
3385 }"#,
3386 );
3387
3388 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3389 let result = registry.load_values_json(&values_dir);
3390 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3391 }
3392
3393 #[test]
3394 fn test_array_of_objects_values_missing_field_rejected() {
3395 let temp_dir = TempDir::new().unwrap();
3396 let (schemas_dir, values_dir) = create_test_schema_with_values(
3397 &temp_dir,
3398 "test",
3399 r#"{
3400 "version": "1.0",
3401 "type": "object",
3402 "properties": {
3403 "endpoints": {
3404 "type": "array",
3405 "items": {
3406 "type": "object",
3407 "properties": {
3408 "url": {"type": "string"},
3409 "weight": {"type": "integer"}
3410 }
3411 },
3412 "default": [],
3413 "description": "Endpoints"
3414 }
3415 }
3416 }"#,
3417 r#"{
3418 "options": {
3419 "endpoints": [
3420 {"url": "https://a.example.com"}
3421 ]
3422 }
3423 }"#,
3424 );
3425
3426 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3427 let result = registry.load_values_json(&values_dir);
3428 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3429 }
3430
3431 #[test]
3434 fn test_object_optional_field_can_be_omitted_from_default() {
3435 let temp_dir = TempDir::new().unwrap();
3436 create_test_schema(
3437 &temp_dir,
3438 "test",
3439 r#"{
3440 "version": "1.0",
3441 "type": "object",
3442 "properties": {
3443 "config": {
3444 "type": "object",
3445 "properties": {
3446 "host": {"type": "string"},
3447 "debug": {"type": "boolean", "optional": true}
3448 },
3449 "default": {"host": "localhost"},
3450 "description": "Config with optional field"
3451 }
3452 }
3453 }"#,
3454 );
3455
3456 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3457 let schema = registry.get("test").unwrap();
3458 assert_eq!(schema.options["config"].option_type, "object");
3459 }
3460
3461 #[test]
3462 fn test_object_optional_field_can_be_included_in_default() {
3463 let temp_dir = TempDir::new().unwrap();
3464 create_test_schema(
3465 &temp_dir,
3466 "test",
3467 r#"{
3468 "version": "1.0",
3469 "type": "object",
3470 "properties": {
3471 "config": {
3472 "type": "object",
3473 "properties": {
3474 "host": {"type": "string"},
3475 "debug": {"type": "boolean", "optional": true}
3476 },
3477 "default": {"host": "localhost", "debug": true},
3478 "description": "Config with optional field included"
3479 }
3480 }
3481 }"#,
3482 );
3483
3484 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3485 assert!(registry.get("test").is_some());
3486 }
3487
3488 #[test]
3489 fn test_object_optional_field_wrong_type_rejected() {
3490 let temp_dir = TempDir::new().unwrap();
3491 create_test_schema(
3492 &temp_dir,
3493 "test",
3494 r#"{
3495 "version": "1.0",
3496 "type": "object",
3497 "properties": {
3498 "config": {
3499 "type": "object",
3500 "properties": {
3501 "host": {"type": "string"},
3502 "debug": {"type": "boolean", "optional": true}
3503 },
3504 "default": {"host": "localhost", "debug": "not-a-bool"},
3505 "description": "Optional field wrong type"
3506 }
3507 }
3508 }"#,
3509 );
3510
3511 let result = SchemaRegistry::from_directory(temp_dir.path());
3512 assert!(result.is_err());
3513 }
3514
3515 #[test]
3516 fn test_object_required_field_still_required_with_optional_present() {
3517 let temp_dir = TempDir::new().unwrap();
3518 create_test_schema(
3519 &temp_dir,
3520 "test",
3521 r#"{
3522 "version": "1.0",
3523 "type": "object",
3524 "properties": {
3525 "config": {
3526 "type": "object",
3527 "properties": {
3528 "host": {"type": "string"},
3529 "port": {"type": "integer"},
3530 "debug": {"type": "boolean", "optional": true}
3531 },
3532 "default": {"debug": true},
3533 "description": "Missing required fields"
3534 }
3535 }
3536 }"#,
3537 );
3538
3539 let result = SchemaRegistry::from_directory(temp_dir.path());
3540 assert!(result.is_err());
3541 }
3542
3543 #[test]
3544 fn test_object_optional_field_omitted_from_values() {
3545 let temp_dir = TempDir::new().unwrap();
3546 let (schemas_dir, values_dir) = create_test_schema_with_values(
3547 &temp_dir,
3548 "test",
3549 r#"{
3550 "version": "1.0",
3551 "type": "object",
3552 "properties": {
3553 "config": {
3554 "type": "object",
3555 "properties": {
3556 "host": {"type": "string"},
3557 "debug": {"type": "boolean", "optional": true}
3558 },
3559 "default": {"host": "localhost"},
3560 "description": "Config"
3561 }
3562 }
3563 }"#,
3564 r#"{
3565 "options": {
3566 "config": {"host": "example.com"}
3567 }
3568 }"#,
3569 );
3570
3571 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3572 let result = registry.load_values_json(&values_dir);
3573 assert!(result.is_ok());
3574 }
3575
3576 #[test]
3577 fn test_object_optional_field_included_in_values() {
3578 let temp_dir = TempDir::new().unwrap();
3579 let (schemas_dir, values_dir) = create_test_schema_with_values(
3580 &temp_dir,
3581 "test",
3582 r#"{
3583 "version": "1.0",
3584 "type": "object",
3585 "properties": {
3586 "config": {
3587 "type": "object",
3588 "properties": {
3589 "host": {"type": "string"},
3590 "debug": {"type": "boolean", "optional": true}
3591 },
3592 "default": {"host": "localhost"},
3593 "description": "Config"
3594 }
3595 }
3596 }"#,
3597 r#"{
3598 "options": {
3599 "config": {"host": "example.com", "debug": true}
3600 }
3601 }"#,
3602 );
3603
3604 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3605 let result = registry.load_values_json(&values_dir);
3606 assert!(result.is_ok());
3607 }
3608
3609 #[test]
3610 fn test_array_of_objects_optional_field_omitted() {
3611 let temp_dir = TempDir::new().unwrap();
3612 let (schemas_dir, values_dir) = create_test_schema_with_values(
3613 &temp_dir,
3614 "test",
3615 r#"{
3616 "version": "1.0",
3617 "type": "object",
3618 "properties": {
3619 "endpoints": {
3620 "type": "array",
3621 "items": {
3622 "type": "object",
3623 "properties": {
3624 "url": {"type": "string"},
3625 "weight": {"type": "integer", "optional": true}
3626 }
3627 },
3628 "default": [],
3629 "description": "Endpoints"
3630 }
3631 }
3632 }"#,
3633 r#"{
3634 "options": {
3635 "endpoints": [
3636 {"url": "https://a.example.com"},
3637 {"url": "https://b.example.com", "weight": 2}
3638 ]
3639 }
3640 }"#,
3641 );
3642
3643 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3644 let result = registry.load_values_json(&values_dir);
3645 assert!(result.is_ok());
3646 }
3647 }
3648
3649 #[test]
3650 fn test_object_valued_map_validates_nested_values() {
3651 let registry = SchemaRegistry::from_schemas(&[(
3654 "test",
3655 r#"{
3656 "version": "1.0",
3657 "type": "object",
3658 "properties": {
3659 "org_tweaks": {
3660 "type": "object",
3661 "additionalProperties": {
3662 "type": "object",
3663 "additionalProperties": {"type": "integer"}
3664 },
3665 "default": {},
3666 "description": ""
3667 }
3668 }
3669 }"#,
3670 )])
3671 .unwrap();
3672
3673 assert!(
3674 registry
3675 .validate_values(
3676 "test",
3677 &json!({"org_tweaks": {"1": {"max_candidates": 20}}})
3678 )
3679 .is_ok()
3680 );
3681 assert!(
3682 registry
3683 .validate_values("test", &json!({"org_tweaks": {}}))
3684 .is_ok()
3685 );
3686 assert!(matches!(
3688 registry.validate_values(
3689 "test",
3690 &json!({"org_tweaks": {"1": {"max_candidates": "x"}}})
3691 ),
3692 Err(ValidationError::ValueError { .. })
3693 ));
3694 assert!(matches!(
3696 registry.validate_values("test", &json!({"org_tweaks": {"1": 20}})),
3697 Err(ValidationError::ValueError { .. })
3698 ));
3699 }
3700
3701 #[test]
3702 fn test_array_valued_map_validates_nested_values() {
3703 let registry = SchemaRegistry::from_schemas(&[(
3705 "test",
3706 r#"{
3707 "version": "1.0",
3708 "type": "object",
3709 "properties": {
3710 "webhook_logging": {
3711 "type": "object",
3712 "additionalProperties": {
3713 "type": "array",
3714 "items": {"type": "string"}
3715 },
3716 "default": {},
3717 "description": ""
3718 }
3719 }
3720 }"#,
3721 )])
3722 .unwrap();
3723
3724 assert!(
3725 registry
3726 .validate_values(
3727 "test",
3728 &json!({"webhook_logging": {"a": [], "b": ["x", "y"]}})
3729 )
3730 .is_ok()
3731 );
3732 assert!(matches!(
3734 registry.validate_values("test", &json!({"webhook_logging": {"a": [1]}})),
3735 Err(ValidationError::ValueError { .. })
3736 ));
3737 assert!(matches!(
3739 registry.validate_values("test", &json!({"webhook_logging": {"a": "x"}})),
3740 Err(ValidationError::ValueError { .. })
3741 ));
3742 }
3743
3744 #[test]
3745 fn test_nested_fixed_object_enforces_required_and_rejects_unknown() {
3746 let registry = SchemaRegistry::from_schemas(&[(
3749 "test",
3750 r#"{
3751 "version": "1.0",
3752 "type": "object",
3753 "properties": {
3754 "quotas": {
3755 "type": "object",
3756 "additionalProperties": {
3757 "type": "object",
3758 "properties": {
3759 "limit": {"type": "integer"},
3760 "window": {"type": "integer"}
3761 }
3762 },
3763 "default": {},
3764 "description": ""
3765 }
3766 }
3767 }"#,
3768 )])
3769 .unwrap();
3770
3771 assert!(
3772 registry
3773 .validate_values("test", &json!({"quotas": {"o": {"limit": 1, "window": 2}}}))
3774 .is_ok()
3775 );
3776 assert!(matches!(
3778 registry.validate_values("test", &json!({"quotas": {"o": {"limit": 1}}})),
3779 Err(ValidationError::ValueError { .. })
3780 ));
3781 assert!(matches!(
3783 registry.validate_values(
3784 "test",
3785 &json!({"quotas": {"o": {"limit": 1, "window": 2, "x": 3}}})
3786 ),
3787 Err(ValidationError::ValueError { .. })
3788 ));
3789 }
3790
3791 #[test]
3792 fn test_optional_marker_works_at_depth() {
3793 let registry = SchemaRegistry::from_schemas(&[(
3794 "test",
3795 r#"{
3796 "version": "1.0",
3797 "type": "object",
3798 "properties": {
3799 "items_map": {
3800 "type": "object",
3801 "additionalProperties": {
3802 "type": "object",
3803 "properties": {
3804 "url": {"type": "string"},
3805 "weight": {"type": "integer", "optional": true}
3806 }
3807 },
3808 "default": {},
3809 "description": ""
3810 }
3811 }
3812 }"#,
3813 )])
3814 .unwrap();
3815
3816 assert!(
3818 registry
3819 .validate_values("test", &json!({"items_map": {"a": {"url": "x"}}}))
3820 .is_ok()
3821 );
3822 assert!(matches!(
3824 registry.validate_values("test", &json!({"items_map": {"a": {"weight": 1}}})),
3825 Err(ValidationError::ValueError { .. })
3826 ));
3827 }
3828
3829 #[test]
3830 fn test_three_levels_of_nesting() {
3831 let registry = SchemaRegistry::from_schemas(&[(
3833 "test",
3834 r#"{
3835 "version": "1.0",
3836 "type": "object",
3837 "properties": {
3838 "deep": {
3839 "type": "object",
3840 "additionalProperties": {
3841 "type": "array",
3842 "items": {
3843 "type": "object",
3844 "additionalProperties": {"type": "integer"}
3845 }
3846 },
3847 "default": {},
3848 "description": ""
3849 }
3850 }
3851 }"#,
3852 )])
3853 .unwrap();
3854
3855 assert!(
3856 registry
3857 .validate_values("test", &json!({"deep": {"a": [{"x": 1}, {"y": 2}]}}))
3858 .is_ok()
3859 );
3860 assert!(matches!(
3861 registry.validate_values("test", &json!({"deep": {"a": [{"x": "no"}]}})),
3862 Err(ValidationError::ValueError { .. })
3863 ));
3864 }
3865
3866 #[test]
3867 fn test_meta_schema_rejects_malformed_nested_value_types() {
3868 assert!(matches!(
3870 SchemaRegistry::from_schemas(&[(
3871 "test",
3872 r#"{"version":"1.0","type":"object","properties":{
3873 "m":{"type":"object","additionalProperties":{"type":"object"},"default":{},"description":""}}}"#,
3874 )]),
3875 Err(ValidationError::SchemaError { .. })
3876 ));
3877 assert!(matches!(
3879 SchemaRegistry::from_schemas(&[(
3880 "test",
3881 r#"{"version":"1.0","type":"object","properties":{
3882 "m":{"type":"object","additionalProperties":{"type":"array"},"default":{},"description":""}}}"#,
3883 )]),
3884 Err(ValidationError::SchemaError { .. })
3885 ));
3886 assert!(matches!(
3888 SchemaRegistry::from_schemas(&[(
3889 "test",
3890 r#"{"version":"1.0","type":"object","properties":{
3891 "m":{"type":"object","additionalProperties":{"type":"string","bogus":1},"default":{},"description":""}}}"#,
3892 )]),
3893 Err(ValidationError::SchemaError { .. })
3894 ));
3895 }
3896
3897 #[test]
3898 fn test_pre_existing_shapes_still_validate() {
3899 let registry = SchemaRegistry::from_schemas(&[(
3901 "test",
3902 r#"{
3903 "version": "1.0",
3904 "type": "object",
3905 "properties": {
3906 "scopes": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}, "description": ""},
3907 "config": {"type": "object", "properties": {"host": {"type": "string"}}, "default": {"host": "h"}, "description": ""}
3908 }
3909 }"#,
3910 )])
3911 .unwrap();
3912
3913 assert!(
3914 registry
3915 .validate_values(
3916 "test",
3917 &json!({"scopes": {"r": "1"}, "config": {"host": "x"}})
3918 )
3919 .is_ok()
3920 );
3921 assert!(matches!(
3922 registry.validate_values("test", &json!({"config": {"host": "x", "extra": "y"}})),
3923 Err(ValidationError::ValueError { .. })
3924 ));
3925 }
3926}