1use arc_swap::ArcSwap;
41use chrono::{DateTime, Utc};
42use serde_json::Value;
43use serde_json::json;
44use std::collections::{HashMap, HashSet};
45use std::fs;
46use std::path::{Path, PathBuf};
47use std::sync::{
48 Arc,
49 atomic::{AtomicU64, Ordering},
50};
51use std::time::{Duration, Instant};
52
53const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
55
56const FEATURE_SCHEMA_DEFS_JSON: &str = include_str!("feature-schema-defs.json");
58
59const SCHEMA_FILE_NAME: &str = "schema.json";
60const VALUES_FILE_NAME: &str = "values.json";
61
62const REFRESH_THRESHOLD: Duration = Duration::from_secs(5);
66
67pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
69
70pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
72
73pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
75
76pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
78
79fn should_suppress_missing_dir_errors() -> bool {
81 std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
82 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
83 .unwrap_or(false)
84}
85
86pub fn resolve_options_dir() -> PathBuf {
91 if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
92 return PathBuf::from(dir);
93 }
94
95 let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
96 if prod_path.exists() {
97 return prod_path;
98 }
99
100 PathBuf::from(LOCAL_OPTIONS_DIR)
101}
102
103pub type ValidationResult<T> = Result<T, ValidationError>;
105
106pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
108
109#[derive(Debug, thiserror::Error)]
111pub enum ValidationError {
112 #[error("Schema error in {file}: {message}")]
113 SchemaError { file: PathBuf, message: String },
114
115 #[error("Value error for {namespace}: {errors}")]
116 ValueError { namespace: String, errors: String },
117
118 #[error("Unknown namespace: {0}")]
119 UnknownNamespace(String),
120
121 #[error("Unknown option '{key}' in namespace '{namespace}'")]
122 UnknownOption { namespace: String, key: String },
123
124 #[error("Internal error: {0}")]
125 InternalError(String),
126
127 #[error("Failed to read file: {0}")]
128 FileRead(#[from] std::io::Error),
129
130 #[error("Failed to parse JSON: {0}")]
131 JSONParse(#[from] serde_json::Error),
132
133 #[error("{} validation error(s)", .0.len())]
134 ValidationErrors(Vec<ValidationError>),
135
136 #[error("Invalid {label} '{name}': {reason}")]
137 InvalidName {
138 label: String,
139 name: String,
140 reason: String,
141 },
142}
143
144pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
146 if let Some(c) = name
147 .chars()
148 .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
149 {
150 return Err(ValidationError::InvalidName {
151 label: label.to_string(),
152 name: name.to_string(),
153 reason: format!(
154 "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
155 c
156 ),
157 });
158 }
159 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
160 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
161 {
162 return Err(ValidationError::InvalidName {
163 label: label.to_string(),
164 name: name.to_string(),
165 reason: "must start and end with alphanumeric".to_string(),
166 });
167 }
168 Ok(())
169}
170
171#[derive(Debug, Clone)]
173pub struct OptionMetadata {
174 pub option_type: String,
175 pub property_schema: Value,
176 pub default: Value,
177}
178
179pub struct NamespaceSchema {
181 pub namespace: String,
182 pub options: HashMap<String, OptionMetadata>,
183 all_keys: HashSet<String>,
185 validator: jsonschema::Validator,
186}
187
188impl NamespaceSchema {
189 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
197 let output = self.validator.evaluate(values);
198 if output.flag().valid {
199 Ok(())
200 } else {
201 let errors: Vec<String> = output
202 .iter_errors()
203 .map(|e| {
204 format!(
205 "\n\t{} {}",
206 e.instance_location.as_str().trim_start_matches("/"),
207 e.error
208 )
209 })
210 .collect();
211 Err(ValidationError::ValueError {
212 namespace: self.namespace.clone(),
213 errors: errors.join(""),
214 })
215 }
216 }
217
218 pub fn get_default(&self, key: &str) -> Option<&Value> {
221 self.options.get(key).map(|meta| &meta.default)
222 }
223
224 pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
229 if !self.options.contains_key(key) {
230 return Err(ValidationError::UnknownOption {
231 namespace: self.namespace.clone(),
232 key: key.to_string(),
233 });
234 }
235 let test_obj = json!({ key: value });
236 self.validate_values(&test_obj)
237 }
238}
239
240pub struct SchemaRegistry {
242 schemas: HashMap<String, Arc<NamespaceSchema>>,
243}
244
245impl SchemaRegistry {
246 pub fn new() -> Self {
248 Self {
249 schemas: HashMap::new(),
250 }
251 }
252
253 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
263 let namespace_validator = Self::compile_namespace_validator()?;
264 let mut schemas_map = HashMap::new();
265
266 for entry in fs::read_dir(schemas_dir)? {
268 let entry = entry?;
269
270 if !entry.file_type()?.is_dir() {
271 continue;
272 }
273
274 let namespace =
275 entry
276 .file_name()
277 .into_string()
278 .map_err(|_| ValidationError::SchemaError {
279 file: entry.path(),
280 message: "Directory name contains invalid UTF-8".to_string(),
281 })?;
282
283 validate_k8s_name_component(&namespace, "namespace name")?;
284
285 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
286 let file = fs::File::open(&schema_file)?;
287 let schema_data: Value = serde_json::from_reader(file)?;
288
289 Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
290 let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
291 schemas_map.insert(namespace, schema);
292 }
293
294 Ok(Self {
295 schemas: schemas_map,
296 })
297 }
298
299 pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
305 let namespace_validator = Self::compile_namespace_validator()?;
306 let schema_file = Path::new("<embedded>");
307 let mut schemas_map = HashMap::new();
308
309 for (namespace, json) in schemas {
310 validate_k8s_name_component(namespace, "namespace name")?;
311
312 let schema_data: Value =
313 serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
314 file: schema_file.to_path_buf(),
315 message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
316 })?;
317
318 Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
319 let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
320 if schemas_map.insert(namespace.to_string(), schema).is_some() {
321 return Err(ValidationError::SchemaError {
322 file: schema_file.to_path_buf(),
323 message: format!("Duplicate namespace '{}'", namespace),
324 });
325 }
326 }
327
328 Ok(Self {
329 schemas: schemas_map,
330 })
331 }
332
333 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
335 let schema = self
336 .schemas
337 .get(namespace)
338 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
339
340 schema.validate_values(values)
341 }
342
343 fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
344 let namespace_schema_value: Value =
345 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
346 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
347 })?;
348 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
349 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
350 })
351 }
352
353 fn validate_with_namespace_schema(
355 schema_data: &Value,
356 path: &Path,
357 namespace_validator: &jsonschema::Validator,
358 ) -> ValidationResult<()> {
359 let output = namespace_validator.evaluate(schema_data);
360
361 if output.flag().valid {
362 Ok(())
363 } else {
364 let errors: Vec<String> = output
365 .iter_errors()
366 .map(|e| format!("Error: {}", e.error))
367 .collect();
368
369 Err(ValidationError::SchemaError {
370 file: path.to_path_buf(),
371 message: format!("Schema validation failed:\n{}", errors.join("\n")),
372 })
373 }
374 }
375
376 fn validate_default_type(
378 property_name: &str,
379 property_schema: &Value,
380 default_value: &Value,
381 path: &Path,
382 ) -> ValidationResult<()> {
383 jsonschema::validate(property_schema, default_value).map_err(|e| {
385 ValidationError::SchemaError {
386 file: path.to_path_buf(),
387 message: format!(
388 "Property '{}': default value does not match schema: {}",
389 property_name, e
390 ),
391 }
392 })?;
393
394 Ok(())
395 }
396
397 fn inject_object_constraints(schema: &mut Value) {
414 if let Some(obj) = schema.as_object_mut() {
415 if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
416 let required: Vec<Value> = props
417 .iter()
418 .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
419 .map(|(k, _)| Value::String(k.clone()))
420 .collect();
421 obj.insert("required".to_string(), Value::Array(required));
422 }
423 if !obj.contains_key("additionalProperties") {
424 obj.insert("additionalProperties".to_string(), json!(false));
425 }
426 }
427 }
428
429 fn parse_schema(
431 mut schema: Value,
432 namespace: &str,
433 path: &Path,
434 ) -> ValidationResult<Arc<NamespaceSchema>> {
435 if let Some(obj) = schema.as_object_mut() {
437 obj.insert("additionalProperties".to_string(), json!(false));
438 }
439
440 if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
443 for prop_value in properties.values_mut() {
444 let prop_type = prop_value
445 .get("type")
446 .and_then(|t| t.as_str())
447 .unwrap_or("");
448
449 if prop_type == "object" {
450 Self::inject_object_constraints(prop_value);
451 } else if prop_type == "array"
452 && let Some(items) = prop_value.get_mut("items")
453 {
454 let items_type = items.get("type").and_then(|t| t.as_str()).unwrap_or("");
455 if items_type == "object" {
456 Self::inject_object_constraints(items);
457 }
458 }
459 }
460 }
461
462 let mut options = HashMap::new();
464 let mut all_keys = HashSet::new();
465 let mut has_feature_keys = false;
466 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
467 for (prop_name, prop_value) in properties {
468 all_keys.insert(prop_name.clone());
469 if prop_name.starts_with("feature.") {
471 has_feature_keys = true;
472 }
473 if let (Some(prop_type), Some(default_value)) = (
474 prop_value.get("type").and_then(|t| t.as_str()),
475 prop_value.get("default"),
476 ) {
477 Self::validate_default_type(prop_name, prop_value, default_value, path)?;
478 options.insert(
479 prop_name.clone(),
480 OptionMetadata {
481 option_type: prop_type.to_string(),
482 property_schema: prop_value.clone(),
483 default: default_value.clone(),
484 },
485 );
486 }
487 }
488 }
489
490 if has_feature_keys {
493 let feature_defs: Value =
494 serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
495 ValidationError::InternalError(format!(
496 "Invalid feature-schema-defs JSON: {}",
497 e
498 ))
499 })?;
500
501 if let Some(obj) = schema.as_object_mut() {
502 obj.insert("definitions".to_string(), feature_defs);
503 }
504 }
505
506 let validator =
508 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
509 file: path.to_path_buf(),
510 message: format!("Failed to compile validator: {}", e),
511 })?;
512
513 Ok(Arc::new(NamespaceSchema {
514 namespace: namespace.to_string(),
515 options,
516 all_keys,
517 validator,
518 }))
519 }
520
521 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
523 self.schemas.get(namespace)
524 }
525
526 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
528 &self.schemas
529 }
530
531 pub fn load_values_json(
539 &self,
540 values_dir: &Path,
541 ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
542 let mut all_values = HashMap::new();
543 let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
544
545 for namespace in self.schemas.keys() {
546 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
547
548 if !values_file.exists() {
549 continue;
550 }
551
552 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
553
554 if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
556 generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
557 }
558
559 let values = parsed
560 .get("options")
561 .ok_or_else(|| ValidationError::ValueError {
562 namespace: namespace.clone(),
563 errors: "values.json must have an 'options' key".to_string(),
564 })?;
565
566 let values = self.strip_unknown_keys(namespace, values);
569
570 self.validate_values(namespace, &values)?;
571
572 if let Value::Object(obj) = values {
573 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
574 all_values.insert(namespace.clone(), ns_values);
575 }
576 }
577
578 Ok((all_values, generated_at_by_namespace))
579 }
580
581 pub fn strip_unknown_keys(&self, namespace: &str, values: &Value) -> Value {
584 let schema = match self.schemas.get(namespace) {
585 Some(s) => s,
586 None => return values.clone(),
587 };
588
589 let obj = match values.as_object() {
590 Some(obj) => obj,
591 None => return values.clone(),
592 };
593
594 let unknown_keys: Vec<&String> = obj
595 .keys()
596 .filter(|k| !schema.all_keys.contains(*k))
597 .collect();
598
599 if unknown_keys.is_empty() {
600 return values.clone();
601 }
602
603 for key in &unknown_keys {
604 eprintln!(
605 "sentry-options: Ignoring unknown option '{}' in namespace '{}'. \
606 This is expected during deployments when values are updated before schemas.",
607 key, namespace
608 );
609 }
610
611 let filtered: serde_json::Map<String, Value> = obj
612 .iter()
613 .filter(|(k, _)| schema.all_keys.contains(*k))
614 .map(|(k, v)| (k.clone(), v.clone()))
615 .collect();
616 Value::Object(filtered)
617 }
618}
619
620impl Default for SchemaRegistry {
621 fn default() -> Self {
622 Self::new()
623 }
624}
625
626pub type PropagationCallback = Box<dyn Fn(&str, f64) + Send + Sync>;
640
641pub struct ValuesStore {
642 registry: Arc<SchemaRegistry>,
643 values_dir: PathBuf,
644 values: ArcSwap<ValuesByNamespace>,
645 baseline: Instant,
646 last_refresh_offset_ns: AtomicU64,
647 refresh_threshold: Duration,
648 last_generated_at: ArcSwap<HashMap<String, String>>,
651 on_propagation: Option<PropagationCallback>,
653}
654
655impl ValuesStore {
656 pub fn new(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValidationResult<Self> {
658 Self::build(registry, values_dir, REFRESH_THRESHOLD, None)
659 }
660
661 pub fn with_propagation_callback(
664 registry: Arc<SchemaRegistry>,
665 values_dir: &Path,
666 callback: PropagationCallback,
667 ) -> ValidationResult<Self> {
668 Self::build(registry, values_dir, REFRESH_THRESHOLD, Some(callback))
669 }
670
671 fn build(
674 registry: Arc<SchemaRegistry>,
675 values_dir: &Path,
676 refresh_threshold: Duration,
677 on_propagation: Option<PropagationCallback>,
678 ) -> ValidationResult<Self> {
679 if !should_suppress_missing_dir_errors() && fs::metadata(values_dir).is_err() {
680 eprintln!("Values directory does not exist: {}", values_dir.display());
681 }
682
683 let baseline = Instant::now();
684 let (initial, generated_at_by_namespace) = registry.load_values_json(values_dir)?;
685 let last_refresh_offset_ns = AtomicU64::new(baseline.elapsed().as_nanos() as u64);
686
687 Ok(Self {
688 registry,
689 values_dir: values_dir.to_path_buf(),
690 values: ArcSwap::from_pointee(initial),
691 baseline,
692 last_refresh_offset_ns,
693 refresh_threshold,
694 last_generated_at: ArcSwap::from_pointee(generated_at_by_namespace),
695 on_propagation,
696 })
697 }
698
699 pub fn registry(&self) -> &Arc<SchemaRegistry> {
701 &self.registry
702 }
703
704 pub fn load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
707 self.maybe_refresh();
708 self.values.load()
709 }
710
711 fn maybe_refresh(&self) {
712 let now_ns = self.baseline.elapsed().as_nanos() as u64;
713 let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
714 let elapsed_ns = now_ns.saturating_sub(last_ns);
715 let threshold_ns = self.refresh_threshold.as_nanos() as u64;
716 let jitter_ns = if self.refresh_threshold.is_zero() {
719 0
720 } else {
721 stack_jitter_ns()
722 };
723
724 if elapsed_ns < threshold_ns.saturating_add(jitter_ns) {
725 return;
726 }
727
728 self.refresh(last_ns, now_ns);
729 }
730
731 fn refresh(&self, observed_last_ns: u64, now_ns: u64) {
732 let result = self.registry.load_values_json(&self.values_dir);
733
734 let new_generated_at = match result {
742 Ok((new_values, generated_at)) => {
743 self.values.store(Arc::new(new_values));
744 Some(generated_at)
745 }
746 Err(e) => {
747 eprintln!(
748 "Failed to reload values from {}: {}",
749 self.values_dir.display(),
750 e
751 );
752 None
753 }
754 };
755
756 let _ = self.last_refresh_offset_ns.compare_exchange(
761 observed_last_ns,
762 now_ns,
763 Ordering::AcqRel,
764 Ordering::Relaxed,
765 );
766
767 if let Some(new_generated_at) = new_generated_at {
771 let last = self.last_generated_at.load();
772 if *last.as_ref() != new_generated_at {
773 if let Some(callback) = &self.on_propagation {
774 let applied_at = Utc::now();
775 for (ns, ts) in &new_generated_at {
776 if last.get(ns).is_some_and(|old| old == ts) {
777 continue;
778 }
779 match propagation_delay_secs(&applied_at, ts) {
780 Some(delay) => {
781 if let Err(e) =
782 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
783 callback(ns, delay)
784 }))
785 {
786 eprintln!("Propagation callback panicked for {ns}: {:?}", e);
787 }
788 }
789 None => eprintln!("Bad generated_at for {ns}: {ts}"),
790 }
791 }
792 }
793 self.last_generated_at.store(Arc::new(new_generated_at));
794 }
795 }
796 }
797}
798
799fn propagation_delay_secs(now: &DateTime<Utc>, generated_at: &str) -> Option<f64> {
801 let generated = DateTime::parse_from_rfc3339(generated_at).ok()?;
802 let delay = (*now - generated.with_timezone(&Utc)).num_milliseconds() as f64 / 1000.0;
803 Some(delay.max(0.0))
804}
805
806fn stack_jitter_ns() -> u64 {
811 let local = 0u8;
812 let addr = &local as *const u8 as usize as u64;
813 addr.wrapping_mul(0x9E37_79B9_7F4A_7C15) % 1_000_000_000
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use std::sync::Mutex;
820 use tempfile::TempDir;
821
822 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
823 let schema_dir = temp_dir.path().join(namespace);
824 fs::create_dir_all(&schema_dir).unwrap();
825 let schema_file = schema_dir.join("schema.json");
826 fs::write(&schema_file, schema_json).unwrap();
827 schema_file
828 }
829
830 fn create_test_schema_with_values(
831 temp_dir: &TempDir,
832 namespace: &str,
833 schema_json: &str,
834 values_json: &str,
835 ) -> (PathBuf, PathBuf) {
836 let schemas_dir = temp_dir.path().join("schemas");
837 let values_dir = temp_dir.path().join("values");
838
839 let schema_dir = schemas_dir.join(namespace);
840 fs::create_dir_all(&schema_dir).unwrap();
841 let schema_file = schema_dir.join("schema.json");
842 fs::write(&schema_file, schema_json).unwrap();
843
844 let ns_values_dir = values_dir.join(namespace);
845 fs::create_dir_all(&ns_values_dir).unwrap();
846 let values_file = ns_values_dir.join("values.json");
847 fs::write(&values_file, values_json).unwrap();
848
849 (schemas_dir, values_dir)
850 }
851
852 #[test]
853 fn test_validate_k8s_name_component_valid() {
854 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
855 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
856 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
857 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
858 }
859
860 #[test]
861 fn test_validate_k8s_name_component_rejects_uppercase() {
862 let result = validate_k8s_name_component("MyService", "namespace");
863 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
864 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
865 }
866
867 #[test]
868 fn test_validate_k8s_name_component_rejects_underscore() {
869 let result = validate_k8s_name_component("my_service", "target");
870 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
871 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
872 }
873
874 #[test]
875 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
876 let result = validate_k8s_name_component("-service", "namespace");
877 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
878 assert!(
879 result
880 .unwrap_err()
881 .to_string()
882 .contains("start and end with alphanumeric")
883 );
884 }
885
886 #[test]
887 fn test_validate_k8s_name_component_rejects_trailing_dot() {
888 let result = validate_k8s_name_component("service.", "namespace");
889 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
890 assert!(
891 result
892 .unwrap_err()
893 .to_string()
894 .contains("start and end with alphanumeric")
895 );
896 }
897
898 #[test]
899 fn test_load_schema_valid() {
900 let temp_dir = TempDir::new().unwrap();
901 create_test_schema(
902 &temp_dir,
903 "test",
904 r#"{
905 "version": "1.0",
906 "type": "object",
907 "properties": {
908 "test-key": {
909 "type": "string",
910 "default": "test",
911 "description": "Test option"
912 }
913 }
914 }"#,
915 );
916
917 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
918 }
919
920 #[test]
921 fn test_load_schema_missing_version() {
922 let temp_dir = TempDir::new().unwrap();
923 create_test_schema(
924 &temp_dir,
925 "test",
926 r#"{
927 "type": "object",
928 "properties": {}
929 }"#,
930 );
931
932 let result = SchemaRegistry::from_directory(temp_dir.path());
933 assert!(result.is_err());
934 match result {
935 Err(ValidationError::SchemaError { message, .. }) => {
936 assert!(message.contains(
937 "Schema validation failed:
938Error: \"version\" is a required property"
939 ));
940 }
941 _ => panic!("Expected SchemaError for missing version"),
942 }
943 }
944
945 #[test]
946 fn test_unknown_namespace() {
947 let temp_dir = TempDir::new().unwrap();
948 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
949
950 let result = registry.validate_values("unknown", &json!({}));
951 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
952 }
953
954 #[test]
955 fn test_multiple_namespaces() {
956 let temp_dir = TempDir::new().unwrap();
957 create_test_schema(
958 &temp_dir,
959 "ns1",
960 r#"{
961 "version": "1.0",
962 "type": "object",
963 "properties": {
964 "opt1": {
965 "type": "string",
966 "default": "default1",
967 "description": "First option"
968 }
969 }
970 }"#,
971 );
972 create_test_schema(
973 &temp_dir,
974 "ns2",
975 r#"{
976 "version": "2.0",
977 "type": "object",
978 "properties": {
979 "opt2": {
980 "type": "integer",
981 "default": 42,
982 "description": "Second option"
983 }
984 }
985 }"#,
986 );
987
988 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
989 assert!(registry.schemas.contains_key("ns1"));
990 assert!(registry.schemas.contains_key("ns2"));
991 }
992
993 #[test]
994 fn test_invalid_default_type() {
995 let temp_dir = TempDir::new().unwrap();
996 create_test_schema(
997 &temp_dir,
998 "test",
999 r#"{
1000 "version": "1.0",
1001 "type": "object",
1002 "properties": {
1003 "bad-default": {
1004 "type": "integer",
1005 "default": "not-a-number",
1006 "description": "A bad default value"
1007 }
1008 }
1009 }"#,
1010 );
1011
1012 let result = SchemaRegistry::from_directory(temp_dir.path());
1013 assert!(result.is_err());
1014 match result {
1015 Err(ValidationError::SchemaError { message, .. }) => {
1016 assert!(
1017 message.contains("Property 'bad-default': default value does not match schema")
1018 );
1019 assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
1020 }
1021 _ => panic!("Expected SchemaError for invalid default type"),
1022 }
1023 }
1024
1025 #[test]
1026 fn test_extra_properties() {
1027 let temp_dir = TempDir::new().unwrap();
1028 create_test_schema(
1029 &temp_dir,
1030 "test",
1031 r#"{
1032 "version": "1.0",
1033 "type": "object",
1034 "properties": {
1035 "bad-property": {
1036 "type": "integer",
1037 "default": 0,
1038 "description": "Test property",
1039 "extra": "property"
1040 }
1041 }
1042 }"#,
1043 );
1044
1045 let result = SchemaRegistry::from_directory(temp_dir.path());
1046 assert!(result.is_err());
1047 match result {
1048 Err(ValidationError::SchemaError { message, .. }) => {
1049 assert!(
1050 message
1051 .contains("Additional properties are not allowed ('extra' was unexpected)")
1052 );
1053 }
1054 _ => panic!("Expected SchemaError for extra properties"),
1055 }
1056 }
1057
1058 #[test]
1059 fn test_missing_description() {
1060 let temp_dir = TempDir::new().unwrap();
1061 create_test_schema(
1062 &temp_dir,
1063 "test",
1064 r#"{
1065 "version": "1.0",
1066 "type": "object",
1067 "properties": {
1068 "missing-desc": {
1069 "type": "string",
1070 "default": "test"
1071 }
1072 }
1073 }"#,
1074 );
1075
1076 let result = SchemaRegistry::from_directory(temp_dir.path());
1077 assert!(result.is_err());
1078 match result {
1079 Err(ValidationError::SchemaError { message, .. }) => {
1080 assert!(message.contains("\"description\" is a required property"));
1081 }
1082 _ => panic!("Expected SchemaError for missing description"),
1083 }
1084 }
1085
1086 #[test]
1087 fn test_from_schemas_rejects_duplicate_namespace() {
1088 let schema_a = r#"{
1089 "version": "1.0",
1090 "type": "object",
1091 "properties": {
1092 "opt": {"type": "string", "default": "a", "description": "A"}
1093 }
1094 }"#;
1095 let schema_b = r#"{
1096 "version": "1.0",
1097 "type": "object",
1098 "properties": {
1099 "opt": {"type": "string", "default": "b", "description": "B"}
1100 }
1101 }"#;
1102
1103 let result = SchemaRegistry::from_schemas(&[("test", schema_a), ("test", schema_b)]);
1104 match result {
1105 Err(ValidationError::SchemaError { message, .. }) => {
1106 assert!(message.contains("Duplicate namespace 'test'"));
1107 }
1108 _ => panic!("Expected SchemaError for duplicate namespace"),
1109 }
1110 }
1111
1112 #[test]
1113 fn test_invalid_directory_structure() {
1114 let temp_dir = TempDir::new().unwrap();
1115 let schema_dir = temp_dir.path().join("missing-schema");
1117 fs::create_dir_all(&schema_dir).unwrap();
1118
1119 let result = SchemaRegistry::from_directory(temp_dir.path());
1120 assert!(result.is_err());
1121 match result {
1122 Err(ValidationError::FileRead(..)) => {
1123 }
1125 _ => panic!("Expected FileRead error for missing schema.json"),
1126 }
1127 }
1128
1129 #[test]
1130 fn test_get_default() {
1131 let temp_dir = TempDir::new().unwrap();
1132 create_test_schema(
1133 &temp_dir,
1134 "test",
1135 r#"{
1136 "version": "1.0",
1137 "type": "object",
1138 "properties": {
1139 "string_opt": {
1140 "type": "string",
1141 "default": "hello",
1142 "description": "A string option"
1143 },
1144 "int_opt": {
1145 "type": "integer",
1146 "default": 42,
1147 "description": "An integer option"
1148 }
1149 }
1150 }"#,
1151 );
1152
1153 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1154 let schema = registry.get("test").unwrap();
1155
1156 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1157 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1158 assert_eq!(schema.get_default("unknown"), None);
1159 }
1160
1161 #[test]
1162 fn test_validate_values_valid() {
1163 let temp_dir = TempDir::new().unwrap();
1164 create_test_schema(
1165 &temp_dir,
1166 "test",
1167 r#"{
1168 "version": "1.0",
1169 "type": "object",
1170 "properties": {
1171 "enabled": {
1172 "type": "boolean",
1173 "default": false,
1174 "description": "Enable feature"
1175 }
1176 }
1177 }"#,
1178 );
1179
1180 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1181 let result = registry.validate_values("test", &json!({"enabled": true}));
1182 assert!(result.is_ok());
1183 }
1184
1185 #[test]
1186 fn test_validate_values_invalid_type() {
1187 let temp_dir = TempDir::new().unwrap();
1188 create_test_schema(
1189 &temp_dir,
1190 "test",
1191 r#"{
1192 "version": "1.0",
1193 "type": "object",
1194 "properties": {
1195 "count": {
1196 "type": "integer",
1197 "default": 0,
1198 "description": "Count"
1199 }
1200 }
1201 }"#,
1202 );
1203
1204 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1205 let result = registry.validate_values("test", &json!({"count": "not a number"}));
1206 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1207 }
1208
1209 #[test]
1210 fn test_validate_values_unknown_option() {
1211 let temp_dir = TempDir::new().unwrap();
1212 create_test_schema(
1213 &temp_dir,
1214 "test",
1215 r#"{
1216 "version": "1.0",
1217 "type": "object",
1218 "properties": {
1219 "known_option": {
1220 "type": "string",
1221 "default": "default",
1222 "description": "A known option"
1223 }
1224 }
1225 }"#,
1226 );
1227
1228 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1229
1230 let result = registry.validate_values("test", &json!({"known_option": "value"}));
1232 assert!(result.is_ok());
1233
1234 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1236 assert!(result.is_err());
1237 match result {
1238 Err(ValidationError::ValueError { errors, .. }) => {
1239 assert!(errors.contains("Additional properties are not allowed"));
1240 }
1241 _ => panic!("Expected ValueError for unknown option"),
1242 }
1243 }
1244
1245 #[test]
1246 fn test_object_with_additional_properties() {
1247 let temp_dir = TempDir::new().unwrap();
1248 create_test_schema(
1249 &temp_dir,
1250 "test",
1251 r#"{
1252 "version": "1.0",
1253 "type": "object",
1254 "properties": {
1255 "scopes": {
1256 "type": "object",
1257 "additionalProperties": {"type": "string"},
1258 "default": {},
1259 "description": "A dynamic string-to-string map"
1260 }
1261 }
1262 }"#,
1263 );
1264
1265 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1266
1267 assert!(
1268 registry
1269 .validate_values("test", &json!({"scopes": {}}))
1270 .is_ok()
1271 );
1272 assert!(
1273 registry
1274 .validate_values(
1275 "test",
1276 &json!({"scopes": {"read": "true", "write": "false"}})
1277 )
1278 .is_ok()
1279 );
1280 assert!(matches!(
1281 registry.validate_values("test", &json!({"scopes": {"read": 42}})),
1282 Err(ValidationError::ValueError { .. })
1283 ));
1284 }
1285
1286 #[test]
1287 fn test_object_without_additional_properties_still_rejects_unknown_keys() {
1288 let temp_dir = TempDir::new().unwrap();
1291 create_test_schema(
1292 &temp_dir,
1293 "test",
1294 r#"{
1295 "version": "1.0",
1296 "type": "object",
1297 "properties": {
1298 "config": {
1299 "type": "object",
1300 "properties": {
1301 "host": {"type": "string"}
1302 },
1303 "default": {"host": "localhost"},
1304 "description": "Server config"
1305 }
1306 }
1307 }"#,
1308 );
1309
1310 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1311
1312 let result = registry.validate_values("test", &json!({"config": {"host": "example.com"}}));
1314 assert!(result.is_ok());
1315
1316 let result = registry.validate_values(
1318 "test",
1319 &json!({"config": {"host": "example.com", "unknown": "x"}}),
1320 );
1321 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1322 }
1323
1324 #[test]
1325 fn test_object_with_fixed_properties_and_additional_properties_enforces_required() {
1326 let temp_dir = TempDir::new().unwrap();
1329 create_test_schema(
1330 &temp_dir,
1331 "test",
1332 r#"{
1333 "version": "1.0",
1334 "type": "object",
1335 "properties": {
1336 "config": {
1337 "type": "object",
1338 "properties": {
1339 "name": {"type": "string"}
1340 },
1341 "additionalProperties": {"type": "string"},
1342 "default": {"name": "default"},
1343 "description": "Config with fixed and dynamic keys"
1344 }
1345 }
1346 }"#,
1347 );
1348
1349 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1350
1351 let result =
1353 registry.validate_values("test", &json!({"config": {"name": "x", "extra": "y"}}));
1354 assert!(result.is_ok());
1355
1356 let result = registry.validate_values("test", &json!({"config": {"extra": "y"}}));
1358 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1359 }
1360
1361 #[test]
1362 fn test_load_values_json_valid() {
1363 let temp_dir = TempDir::new().unwrap();
1364 let schemas_dir = temp_dir.path().join("schemas");
1365 let values_dir = temp_dir.path().join("values");
1366
1367 let schema_dir = schemas_dir.join("test");
1368 fs::create_dir_all(&schema_dir).unwrap();
1369 fs::write(
1370 schema_dir.join("schema.json"),
1371 r#"{
1372 "version": "1.0",
1373 "type": "object",
1374 "properties": {
1375 "enabled": {
1376 "type": "boolean",
1377 "default": false,
1378 "description": "Enable feature"
1379 },
1380 "name": {
1381 "type": "string",
1382 "default": "default",
1383 "description": "Name"
1384 },
1385 "count": {
1386 "type": "integer",
1387 "default": 0,
1388 "description": "Count"
1389 },
1390 "rate": {
1391 "type": "number",
1392 "default": 0.0,
1393 "description": "Rate"
1394 }
1395 }
1396 }"#,
1397 )
1398 .unwrap();
1399
1400 let test_values_dir = values_dir.join("test");
1401 fs::create_dir_all(&test_values_dir).unwrap();
1402 fs::write(
1403 test_values_dir.join("values.json"),
1404 r#"{
1405 "options": {
1406 "enabled": true,
1407 "name": "test-name",
1408 "count": 42,
1409 "rate": 0.75
1410 }
1411 }"#,
1412 )
1413 .unwrap();
1414
1415 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1416 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1417
1418 assert_eq!(values.len(), 1);
1419 assert_eq!(values["test"]["enabled"], json!(true));
1420 assert_eq!(values["test"]["name"], json!("test-name"));
1421 assert_eq!(values["test"]["count"], json!(42));
1422 assert_eq!(values["test"]["rate"], json!(0.75));
1423 assert!(generated_at_by_namespace.is_empty());
1424 }
1425
1426 #[test]
1427 fn test_load_values_json_nonexistent_dir() {
1428 let temp_dir = TempDir::new().unwrap();
1429 create_test_schema(
1430 &temp_dir,
1431 "test",
1432 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1433 );
1434
1435 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1436 let (values, generated_at_by_namespace) = registry
1437 .load_values_json(&temp_dir.path().join("nonexistent"))
1438 .unwrap();
1439
1440 assert!(values.is_empty());
1442 assert!(generated_at_by_namespace.is_empty());
1443 }
1444
1445 #[test]
1446 fn test_load_values_json_strips_unknown_keys() {
1447 let temp_dir = TempDir::new().unwrap();
1448 let schemas_dir = temp_dir.path().join("schemas");
1449 let values_dir = temp_dir.path().join("values");
1450
1451 let schema_dir = schemas_dir.join("test");
1452 fs::create_dir_all(&schema_dir).unwrap();
1453 fs::write(
1454 schema_dir.join("schema.json"),
1455 r#"{
1456 "version": "1.0",
1457 "type": "object",
1458 "properties": {
1459 "known-option": {
1460 "type": "string",
1461 "default": "default",
1462 "description": "A known option"
1463 }
1464 }
1465 }"#,
1466 )
1467 .unwrap();
1468
1469 let test_values_dir = values_dir.join("test");
1470 fs::create_dir_all(&test_values_dir).unwrap();
1471 fs::write(
1472 test_values_dir.join("values.json"),
1473 r#"{
1474 "options": {
1475 "known-option": "hello",
1476 "unknown-option": "should be stripped"
1477 }
1478 }"#,
1479 )
1480 .unwrap();
1481
1482 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1483 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1484
1485 assert_eq!(values["test"]["known-option"], json!("hello"));
1486 assert!(!values["test"].contains_key("unknown-option"));
1487 }
1488
1489 #[test]
1490 fn test_load_values_json_skips_missing_values_file() {
1491 let temp_dir = TempDir::new().unwrap();
1492 let schemas_dir = temp_dir.path().join("schemas");
1493 let values_dir = temp_dir.path().join("values");
1494
1495 let schema_dir1 = schemas_dir.join("with-values");
1497 fs::create_dir_all(&schema_dir1).unwrap();
1498 fs::write(
1499 schema_dir1.join("schema.json"),
1500 r#"{
1501 "version": "1.0",
1502 "type": "object",
1503 "properties": {
1504 "opt": {"type": "string", "default": "x", "description": "Opt"}
1505 }
1506 }"#,
1507 )
1508 .unwrap();
1509
1510 let schema_dir2 = schemas_dir.join("without-values");
1511 fs::create_dir_all(&schema_dir2).unwrap();
1512 fs::write(
1513 schema_dir2.join("schema.json"),
1514 r#"{
1515 "version": "1.0",
1516 "type": "object",
1517 "properties": {
1518 "opt": {"type": "string", "default": "x", "description": "Opt"}
1519 }
1520 }"#,
1521 )
1522 .unwrap();
1523
1524 let with_values_dir = values_dir.join("with-values");
1526 fs::create_dir_all(&with_values_dir).unwrap();
1527 fs::write(
1528 with_values_dir.join("values.json"),
1529 r#"{"options": {"opt": "y"}}"#,
1530 )
1531 .unwrap();
1532
1533 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1534 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1535
1536 assert_eq!(values.len(), 1);
1537 assert!(values.contains_key("with-values"));
1538 assert!(!values.contains_key("without-values"));
1539 }
1540
1541 #[test]
1542 fn test_load_values_json_extracts_generated_at() {
1543 let temp_dir = TempDir::new().unwrap();
1544 let schemas_dir = temp_dir.path().join("schemas");
1545 let values_dir = temp_dir.path().join("values");
1546
1547 let schema_dir = schemas_dir.join("test");
1548 fs::create_dir_all(&schema_dir).unwrap();
1549 fs::write(
1550 schema_dir.join("schema.json"),
1551 r#"{
1552 "version": "1.0",
1553 "type": "object",
1554 "properties": {
1555 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1556 }
1557 }"#,
1558 )
1559 .unwrap();
1560
1561 let test_values_dir = values_dir.join("test");
1562 fs::create_dir_all(&test_values_dir).unwrap();
1563 fs::write(
1564 test_values_dir.join("values.json"),
1565 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1566 )
1567 .unwrap();
1568
1569 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1570 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1571
1572 assert_eq!(values["test"]["enabled"], json!(true));
1573 assert_eq!(
1574 generated_at_by_namespace.get("test"),
1575 Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1576 );
1577 }
1578
1579 #[test]
1580 fn test_propagation_event_emitted_on_generated_at_change() {
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 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1594 }
1595 }"#,
1596 )
1597 .unwrap();
1598
1599 let test_values_dir = values_dir.join("test");
1600 fs::create_dir_all(&test_values_dir).unwrap();
1601 fs::write(
1602 test_values_dir.join("values.json"),
1603 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1604 )
1605 .unwrap();
1606
1607 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1608 let events_clone = events.clone();
1609
1610 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1611 let store = ValuesStore::build(
1612 registry,
1613 &values_dir,
1614 Duration::ZERO,
1615 Some(Box::new(move |ns, delay| {
1616 events_clone.lock().unwrap().push((ns.to_string(), delay));
1617 })),
1618 )
1619 .unwrap();
1620
1621 assert!(events.lock().unwrap().is_empty());
1623
1624 let _ = store.load();
1626 assert!(events.lock().unwrap().is_empty());
1627
1628 fs::write(
1630 test_values_dir.join("values.json"),
1631 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1632 )
1633 .unwrap();
1634
1635 let _ = store.load();
1636 let captured = events.lock().unwrap();
1637 assert_eq!(captured.len(), 1);
1638 assert_eq!(captured[0].0, "test");
1639 assert!(captured[0].1 > 0.0);
1640 }
1641
1642 #[test]
1643 fn test_propagation_event_not_emitted_without_callback() {
1644 let temp_dir = TempDir::new().unwrap();
1645 let schemas_dir = temp_dir.path().join("schemas");
1646 let values_dir = temp_dir.path().join("values");
1647
1648 let schema_dir = schemas_dir.join("test");
1649 fs::create_dir_all(&schema_dir).unwrap();
1650 fs::write(
1651 schema_dir.join("schema.json"),
1652 r#"{
1653 "version": "1.0",
1654 "type": "object",
1655 "properties": {
1656 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1657 }
1658 }"#,
1659 )
1660 .unwrap();
1661
1662 let test_values_dir = values_dir.join("test");
1663 fs::create_dir_all(&test_values_dir).unwrap();
1664 fs::write(
1665 test_values_dir.join("values.json"),
1666 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1667 )
1668 .unwrap();
1669
1670 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1671 let store = ValuesStore::build(registry, &values_dir, Duration::ZERO, None).unwrap();
1673 let _ = store.load();
1674 }
1676
1677 fn write_ns(base: &Path, namespace: &str, generated_at: &str) -> PathBuf {
1680 let schema_dir = base.join("schemas").join(namespace);
1681 fs::create_dir_all(&schema_dir).unwrap();
1682 fs::write(
1683 schema_dir.join("schema.json"),
1684 r#"{
1685 "version": "1.0",
1686 "type": "object",
1687 "properties": {
1688 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1689 }
1690 }"#,
1691 )
1692 .unwrap();
1693
1694 let ns_values_dir = base.join("values").join(namespace);
1695 fs::create_dir_all(&ns_values_dir).unwrap();
1696 let values_file = ns_values_dir.join("values.json");
1697 fs::write(
1698 &values_file,
1699 format!(r#"{{"options": {{"enabled": true}}, "generated_at": "{generated_at}"}}"#),
1700 )
1701 .unwrap();
1702 values_file
1703 }
1704
1705 #[test]
1706 fn test_propagation_delay_secs_parsing() {
1707 let now: DateTime<Utc> = "2024-01-21T19:00:00+00:00".parse().unwrap();
1708
1709 assert_eq!(
1711 propagation_delay_secs(&now, "2024-01-21T18:30:00+00:00"),
1712 Some(1800.0)
1713 );
1714 assert_eq!(
1716 propagation_delay_secs(&now, "2024-01-21T19:05:00+00:00"),
1717 Some(0.0)
1718 );
1719 assert_eq!(propagation_delay_secs(&now, "not-a-timestamp"), None);
1721 }
1722
1723 #[test]
1724 fn test_propagation_callback_panic_is_caught() {
1725 let temp_dir = TempDir::new().unwrap();
1726 let base = temp_dir.path();
1727 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
1728
1729 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1730 let store = ValuesStore::build(
1731 registry,
1732 &base.join("values"),
1733 Duration::ZERO,
1734 Some(Box::new(|_ns, _delay| panic!("callback boom"))),
1735 )
1736 .unwrap();
1737
1738 fs::write(
1740 &values_file,
1741 r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1742 )
1743 .unwrap();
1744 let _ = store.load();
1745
1746 assert_eq!(
1748 store.values.load().get("test").unwrap().get("enabled"),
1749 Some(&json!(false))
1750 );
1751 }
1752
1753 #[test]
1754 fn test_propagation_malformed_generated_at_does_not_emit() {
1755 let temp_dir = TempDir::new().unwrap();
1756 let base = temp_dir.path();
1757 let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
1758
1759 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1760 let events_clone = events.clone();
1761 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1762 let store = ValuesStore::build(
1763 registry,
1764 &base.join("values"),
1765 Duration::ZERO,
1766 Some(Box::new(move |ns, delay| {
1767 events_clone.lock().unwrap().push((ns.to_string(), delay));
1768 })),
1769 )
1770 .unwrap();
1771
1772 fs::write(
1775 &values_file,
1776 r#"{"options": {"enabled": true}, "generated_at": "garbage"}"#,
1777 )
1778 .unwrap();
1779 let _ = store.load();
1780 assert!(events.lock().unwrap().is_empty());
1781 }
1782
1783 #[test]
1784 fn test_propagation_only_changed_namespace_emits() {
1785 let temp_dir = TempDir::new().unwrap();
1786 let base = temp_dir.path();
1787 write_ns(base, "alpha", "2024-01-21T18:00:00+00:00");
1788 let beta_values = write_ns(base, "beta", "2024-01-21T18:00:00+00:00");
1789
1790 let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1791 let events_clone = events.clone();
1792 let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1793 let store = ValuesStore::build(
1794 registry,
1795 &base.join("values"),
1796 Duration::ZERO,
1797 Some(Box::new(move |ns, delay| {
1798 events_clone.lock().unwrap().push((ns.to_string(), delay));
1799 })),
1800 )
1801 .unwrap();
1802
1803 fs::write(
1805 &beta_values,
1806 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1807 )
1808 .unwrap();
1809 let _ = store.load();
1810
1811 let captured = events.lock().unwrap();
1812 assert_eq!(captured.len(), 1);
1813 assert_eq!(captured[0].0, "beta");
1814 }
1815
1816 #[test]
1817 fn test_load_values_json_rejects_wrong_type() {
1818 let temp_dir = TempDir::new().unwrap();
1819 let schemas_dir = temp_dir.path().join("schemas");
1820 let values_dir = temp_dir.path().join("values");
1821
1822 let schema_dir = schemas_dir.join("test");
1823 fs::create_dir_all(&schema_dir).unwrap();
1824 fs::write(
1825 schema_dir.join("schema.json"),
1826 r#"{
1827 "version": "1.0",
1828 "type": "object",
1829 "properties": {
1830 "count": {"type": "integer", "default": 0, "description": "Count"}
1831 }
1832 }"#,
1833 )
1834 .unwrap();
1835
1836 let test_values_dir = values_dir.join("test");
1837 fs::create_dir_all(&test_values_dir).unwrap();
1838 fs::write(
1839 test_values_dir.join("values.json"),
1840 r#"{"options": {"count": "not-a-number"}}"#,
1841 )
1842 .unwrap();
1843
1844 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1845 let result = registry.load_values_json(&values_dir);
1846
1847 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1848 }
1849
1850 mod feature_flag_tests {
1851 use super::*;
1852
1853 const FEATURE_SCHEMA: &str = r##"{
1854 "version": "1.0",
1855 "type": "object",
1856 "properties": {
1857 "feature.organizations:fury-mode": {
1858 "$ref": "#/definitions/Feature"
1859 }
1860 }
1861 }"##;
1862
1863 #[test]
1864 fn test_schema_with_valid_feature_flag() {
1865 let temp_dir = TempDir::new().unwrap();
1866 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1867 assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
1868 }
1869
1870 #[test]
1871 fn test_schema_with_feature_and_regular_option() {
1872 let temp_dir = TempDir::new().unwrap();
1873 create_test_schema(
1874 &temp_dir,
1875 "test",
1876 r##"{
1877 "version": "1.0",
1878 "type": "object",
1879 "properties": {
1880 "my-option": {
1881 "type": "string",
1882 "default": "hello",
1883 "description": "A regular option"
1884 },
1885 "feature.organizations:fury-mode": {
1886 "$ref": "#/definitions/Feature"
1887 }
1888 }
1889 }"##,
1890 );
1891 assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
1892 }
1893
1894 #[test]
1895 fn test_schema_with_invalid_feature_definition() {
1896 let temp_dir = TempDir::new().unwrap();
1897
1898 create_test_schema(
1900 &temp_dir,
1901 "test",
1902 r#"{
1903 "version": "1.0",
1904 "type": "object",
1905 "properties": {
1906 "feature.organizations:fury-mode": {
1907 "nope": "nope"
1908 }
1909 }
1910 }"#,
1911 );
1912 let result = SchemaRegistry::from_directory(temp_dir.path());
1913 assert!(result.is_err());
1914 }
1915
1916 #[test]
1917 fn test_validate_values_with_valid_feature_flag() {
1918 let temp_dir = TempDir::new().unwrap();
1919 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1920 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1921
1922 let result = registry.validate_values(
1923 "test",
1924 &json!({
1925 "feature.organizations:fury-mode": {
1926 "owner": {"team": "hybrid-cloud"},
1927 "segments": [],
1928 "created_at": "2024-01-01"
1929 }
1930 }),
1931 );
1932 assert!(result.is_ok());
1933 }
1934
1935 #[test]
1936 fn test_validate_values_with_feature_flag_missing_required_field_fails() {
1937 let temp_dir = TempDir::new().unwrap();
1938 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1939 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1940
1941 let result = registry.validate_values(
1943 "test",
1944 &json!({
1945 "feature.organizations:fury-mode": {
1946 "segments": [],
1947 "created_at": "2024-01-01"
1948 }
1949 }),
1950 );
1951 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1952 }
1953
1954 #[test]
1955 fn test_validate_values_with_feature_flag_invalid_owner_fails() {
1956 let temp_dir = TempDir::new().unwrap();
1957 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1958 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1959
1960 let result = registry.validate_values(
1962 "test",
1963 &json!({
1964 "feature.organizations:fury-mode": {
1965 "owner": {"email": "test@example.com"},
1966 "segments": [],
1967 "created_at": "2024-01-01"
1968 }
1969 }),
1970 );
1971 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1972 }
1973
1974 #[test]
1975 fn test_validate_values_feature_with_segments_and_conditions() {
1976 let temp_dir = TempDir::new().unwrap();
1977 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1978 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1979
1980 let result = registry.validate_values(
1981 "test",
1982 &json!({
1983 "feature.organizations:fury-mode": {
1984 "owner": {"team": "hybrid-cloud"},
1985 "enabled": true,
1986 "created_at": "2024-01-01T00:00:00",
1987 "segments": [
1988 {
1989 "name": "internal orgs",
1990 "rollout": 50,
1991 "conditions": [
1992 {
1993 "property": "organization_slug",
1994 "operator": "in",
1995 "value": ["sentry-test", "sentry"]
1996 }
1997 ]
1998 }
1999 ]
2000 }
2001 }),
2002 );
2003 assert!(result.is_ok());
2004 }
2005
2006 #[test]
2007 fn test_validate_values_feature_with_multiple_condition_operators() {
2008 let temp_dir = TempDir::new().unwrap();
2009 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2010 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2011
2012 let result = registry.validate_values(
2013 "test",
2014 &json!({
2015 "feature.organizations:fury-mode": {
2016 "owner": {"team": "hybrid-cloud"},
2017 "created_at": "2024-01-01",
2018 "segments": [
2019 {
2020 "name": "free accounts",
2021 "conditions": [
2022 {
2023 "property": "subscription_is_free",
2024 "operator": "equals",
2025 "value": true
2026 }
2027 ]
2028 }
2029 ]
2030 }
2031 }),
2032 );
2033 assert!(result.is_ok());
2034 }
2035
2036 #[test]
2037 fn test_validate_values_feature_with_invalid_condition_operator_fails() {
2038 let temp_dir = TempDir::new().unwrap();
2039 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2040 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2041
2042 let result = registry.validate_values(
2044 "test",
2045 &json!({
2046 "feature.organizations:fury-mode": {
2047 "owner": {"team": "hybrid-cloud"},
2048 "created_at": "2024-01-01",
2049 "segments": [
2050 {
2051 "name": "test segment",
2052 "conditions": [
2053 {
2054 "property": "some_prop",
2055 "operator": "invalid_operator",
2056 "value": "some_value"
2057 }
2058 ]
2059 }
2060 ]
2061 }
2062 }),
2063 );
2064 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2065 }
2066
2067 #[test]
2068 fn test_schema_feature_flag_not_in_options_map() {
2069 let temp_dir = TempDir::new().unwrap();
2071 create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2072 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2073 let schema = registry.get("test").unwrap();
2074
2075 assert!(
2076 schema
2077 .get_default("feature.organizations:fury-mode")
2078 .is_none()
2079 );
2080 }
2081
2082 #[test]
2083 fn test_validate_values_feature_and_regular_option_together() {
2084 let temp_dir = TempDir::new().unwrap();
2085 create_test_schema(
2086 &temp_dir,
2087 "test",
2088 r##"{
2089 "version": "1.0",
2090 "type": "object",
2091 "properties": {
2092 "my-option": {
2093 "type": "string",
2094 "default": "hello",
2095 "description": "A regular option"
2096 },
2097 "feature.organizations:fury-mode": {
2098 "$ref": "#/definitions/Feature"
2099 }
2100 }
2101 }"##,
2102 );
2103 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2104
2105 let result = registry.validate_values(
2107 "test",
2108 &json!({
2109 "my-option": "world",
2110 "feature.organizations:fury-mode": {
2111 "owner": {"team": "hybrid-cloud"},
2112 "segments": [],
2113 "created_at": "2024-01-01"
2114 }
2115 }),
2116 );
2117 assert!(result.is_ok());
2118 }
2119 }
2120
2121 mod store_tests {
2122 use super::*;
2123
2124 fn setup_store_test() -> (TempDir, PathBuf, PathBuf) {
2126 let temp_dir = TempDir::new().unwrap();
2127 let schemas_dir = temp_dir.path().join("schemas");
2128 let values_dir = temp_dir.path().join("values");
2129
2130 let ns1_schema = schemas_dir.join("ns1");
2131 fs::create_dir_all(&ns1_schema).unwrap();
2132 fs::write(
2133 ns1_schema.join("schema.json"),
2134 r#"{
2135 "version": "1.0",
2136 "type": "object",
2137 "properties": {
2138 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
2139 }
2140 }"#,
2141 )
2142 .unwrap();
2143
2144 let ns1_values = values_dir.join("ns1");
2145 fs::create_dir_all(&ns1_values).unwrap();
2146 fs::write(
2147 ns1_values.join("values.json"),
2148 r#"{"options": {"enabled": true}}"#,
2149 )
2150 .unwrap();
2151
2152 let ns2_schema = schemas_dir.join("ns2");
2153 fs::create_dir_all(&ns2_schema).unwrap();
2154 fs::write(
2155 ns2_schema.join("schema.json"),
2156 r#"{
2157 "version": "1.0",
2158 "type": "object",
2159 "properties": {
2160 "count": {"type": "integer", "default": 0, "description": "Count"}
2161 }
2162 }"#,
2163 )
2164 .unwrap();
2165
2166 let ns2_values = values_dir.join("ns2");
2167 fs::create_dir_all(&ns2_values).unwrap();
2168 fs::write(
2169 ns2_values.join("values.json"),
2170 r#"{"options": {"count": 42}}"#,
2171 )
2172 .unwrap();
2173
2174 (temp_dir, schemas_dir, values_dir)
2175 }
2176
2177 fn store_with_zero_threshold(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2178 let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2179 ValuesStore::build(registry, values_dir, Duration::ZERO, None).unwrap()
2180 }
2181
2182 #[test]
2183 fn test_initial_load_populates_values() {
2184 let (_temp, schemas_dir, values_dir) = setup_store_test();
2185 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2186 let store = ValuesStore::new(registry, &values_dir).unwrap();
2187
2188 let guard = store.load();
2189 assert_eq!(guard["ns1"]["enabled"], json!(true));
2190 assert_eq!(guard["ns2"]["count"], json!(42));
2191 }
2192
2193 #[test]
2194 fn test_read_within_threshold_serves_cached() {
2195 let (_temp, schemas_dir, values_dir) = setup_store_test();
2198 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2199 let store = ValuesStore::new(registry, &values_dir).unwrap();
2200
2201 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2203 fs::write(
2204 values_dir.join("ns1").join("values.json"),
2205 r#"{"options": {"enabled": false}}"#,
2206 )
2207 .unwrap();
2208
2209 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2211 }
2212
2213 #[test]
2214 fn test_read_after_threshold_refreshes() {
2215 let (_temp, schemas_dir, values_dir) = setup_store_test();
2216 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2217
2218 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2220 assert_eq!(store.load()["ns2"]["count"], json!(42));
2221
2222 fs::write(
2224 values_dir.join("ns1").join("values.json"),
2225 r#"{"options": {"enabled": false}}"#,
2226 )
2227 .unwrap();
2228 fs::write(
2229 values_dir.join("ns2").join("values.json"),
2230 r#"{"options": {"count": 100}}"#,
2231 )
2232 .unwrap();
2233
2234 let guard = store.load();
2236 assert_eq!(guard["ns1"]["enabled"], json!(false));
2237 assert_eq!(guard["ns2"]["count"], json!(100));
2238 }
2239
2240 #[test]
2241 fn test_refresh_failure_keeps_old_values() {
2242 let (_temp, schemas_dir, values_dir) = setup_store_test();
2243 let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2244
2245 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2246
2247 fs::write(
2249 values_dir.join("ns1").join("values.json"),
2250 r#"{"options": {"enabled": "not-a-boolean"}}"#,
2251 )
2252 .unwrap();
2253
2254 assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2256 }
2257
2258 #[test]
2259 fn test_concurrent_reads_observe_new_values() {
2260 use std::thread;
2261
2262 let (_temp, schemas_dir, values_dir) = setup_store_test();
2263 let store = Arc::new(store_with_zero_threshold(&schemas_dir, &values_dir));
2264
2265 assert_eq!(store.load()["ns2"]["count"], json!(42));
2267
2268 fs::write(
2269 values_dir.join("ns2").join("values.json"),
2270 r#"{"options": {"count": 7}}"#,
2271 )
2272 .unwrap();
2273
2274 let mut handles = Vec::new();
2275 for _ in 0..8 {
2276 let store = Arc::clone(&store);
2277 handles.push(thread::spawn(move || {
2278 let guard = store.load();
2279 guard["ns2"]["count"].clone()
2280 }));
2281 }
2282 for h in handles {
2283 assert_eq!(h.join().unwrap(), json!(7));
2284 }
2285 }
2286 }
2287 mod array_tests {
2288 use super::*;
2289
2290 #[test]
2291 fn test_basic_schema_validation() {
2292 let temp_dir = TempDir::new().unwrap();
2293 for (a_type, default) in [
2294 ("boolean", ""), ("boolean", "true"),
2296 ("integer", "1"),
2297 ("number", "1.2"),
2298 ("string", "\"wow\""),
2299 ] {
2300 create_test_schema(
2301 &temp_dir,
2302 "test",
2303 &format!(
2304 r#"{{
2305 "version": "1.0",
2306 "type": "object",
2307 "properties": {{
2308 "array-key": {{
2309 "type": "array",
2310 "items": {{"type": "{}"}},
2311 "default": [{}],
2312 "description": "Array option"
2313 }}
2314 }}
2315 }}"#,
2316 a_type, default
2317 ),
2318 );
2319
2320 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2321 }
2322 }
2323
2324 #[test]
2325 fn test_missing_items_object_rejection() {
2326 let temp_dir = TempDir::new().unwrap();
2327 create_test_schema(
2328 &temp_dir,
2329 "test",
2330 r#"{
2331 "version": "1.0",
2332 "type": "object",
2333 "properties": {
2334 "array-key": {
2335 "type": "array",
2336 "default": [1,2,3],
2337 "description": "Array option"
2338 }
2339 }
2340 }"#,
2341 );
2342
2343 let result = SchemaRegistry::from_directory(temp_dir.path());
2344 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2345 }
2346
2347 #[test]
2348 fn test_malformed_items_rejection() {
2349 let temp_dir = TempDir::new().unwrap();
2350 create_test_schema(
2351 &temp_dir,
2352 "test",
2353 r#"{
2354 "version": "1.0",
2355 "type": "object",
2356 "properties": {
2357 "array-key": {
2358 "type": "array",
2359 "items": {"type": ""},
2360 "default": [1,2,3],
2361 "description": "Array option"
2362 }
2363 }
2364 }"#,
2365 );
2366
2367 let result = SchemaRegistry::from_directory(temp_dir.path());
2368 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2369 }
2370
2371 #[test]
2372 fn test_schema_default_type_mismatch_rejection() {
2373 let temp_dir = TempDir::new().unwrap();
2374 create_test_schema(
2376 &temp_dir,
2377 "test",
2378 r#"{
2379 "version": "1.0",
2380 "type": "object",
2381 "properties": {
2382 "array-key": {
2383 "type": "array",
2384 "items": {"type": "integer"},
2385 "default": [1,2,3.3],
2386 "description": "Array option"
2387 }
2388 }
2389 }"#,
2390 );
2391
2392 let result = SchemaRegistry::from_directory(temp_dir.path());
2393 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2394 }
2395
2396 #[test]
2397 fn test_schema_default_heterogeneous_rejection() {
2398 let temp_dir = TempDir::new().unwrap();
2399 create_test_schema(
2400 &temp_dir,
2401 "test",
2402 r#"{
2403 "version": "1.0",
2404 "type": "object",
2405 "properties": {
2406 "array-key": {
2407 "type": "array",
2408 "items": {"type": "integer"},
2409 "default": [1,2,"uh oh!"],
2410 "description": "Array option"
2411 }
2412 }
2413 }"#,
2414 );
2415
2416 let result = SchemaRegistry::from_directory(temp_dir.path());
2417 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2418 }
2419
2420 #[test]
2421 fn test_load_values_valid() {
2422 let temp_dir = TempDir::new().unwrap();
2423 let (schemas_dir, values_dir) = create_test_schema_with_values(
2424 &temp_dir,
2425 "test",
2426 r#"{
2427 "version": "1.0",
2428 "type": "object",
2429 "properties": {
2430 "array-key": {
2431 "type": "array",
2432 "items": {"type": "integer"},
2433 "default": [1,2,3],
2434 "description": "Array option"
2435 }
2436 }
2437 }"#,
2438 r#"{
2439 "options": {
2440 "array-key": [4,5,6]
2441 }
2442 }"#,
2443 );
2444
2445 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2446 let (values, generated_at_by_namespace) =
2447 registry.load_values_json(&values_dir).unwrap();
2448
2449 assert_eq!(values.len(), 1);
2450 assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
2451 assert!(generated_at_by_namespace.is_empty());
2452 }
2453
2454 #[test]
2455 fn test_reject_values_not_an_array() {
2456 let temp_dir = TempDir::new().unwrap();
2457 let (schemas_dir, values_dir) = create_test_schema_with_values(
2458 &temp_dir,
2459 "test",
2460 r#"{
2461 "version": "1.0",
2462 "type": "object",
2463 "properties": {
2464 "array-key": {
2465 "type": "array",
2466 "items": {"type": "integer"},
2467 "default": [1,2,3],
2468 "description": "Array option"
2469 }
2470 }
2471 }"#,
2472 r#"{
2474 "options": {
2475 "array-key": "[]"
2476 }
2477 }"#,
2478 );
2479
2480 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2481 let result = registry.load_values_json(&values_dir);
2482
2483 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2484 }
2485
2486 #[test]
2487 fn test_reject_values_mismatch() {
2488 let temp_dir = TempDir::new().unwrap();
2489 let (schemas_dir, values_dir) = create_test_schema_with_values(
2490 &temp_dir,
2491 "test",
2492 r#"{
2493 "version": "1.0",
2494 "type": "object",
2495 "properties": {
2496 "array-key": {
2497 "type": "array",
2498 "items": {"type": "integer"},
2499 "default": [1,2,3],
2500 "description": "Array option"
2501 }
2502 }
2503 }"#,
2504 r#"{
2505 "options": {
2506 "array-key": ["a","b","c"]
2507 }
2508 }"#,
2509 );
2510
2511 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2512 let result = registry.load_values_json(&values_dir);
2513
2514 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2515 }
2516 }
2517
2518 mod object_tests {
2519 use super::*;
2520
2521 #[test]
2522 fn test_object_schema_loads() {
2523 let temp_dir = TempDir::new().unwrap();
2524 create_test_schema(
2525 &temp_dir,
2526 "test",
2527 r#"{
2528 "version": "1.0",
2529 "type": "object",
2530 "properties": {
2531 "config": {
2532 "type": "object",
2533 "properties": {
2534 "host": {"type": "string"},
2535 "port": {"type": "integer"},
2536 "rate": {"type": "number"},
2537 "enabled": {"type": "boolean"}
2538 },
2539 "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
2540 "description": "Service config"
2541 }
2542 }
2543 }"#,
2544 );
2545
2546 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2547 let schema = registry.get("test").unwrap();
2548 assert_eq!(schema.options["config"].option_type, "object");
2549 }
2550
2551 #[test]
2552 fn test_object_missing_properties_rejected() {
2553 let temp_dir = TempDir::new().unwrap();
2554 create_test_schema(
2555 &temp_dir,
2556 "test",
2557 r#"{
2558 "version": "1.0",
2559 "type": "object",
2560 "properties": {
2561 "config": {
2562 "type": "object",
2563 "default": {"host": "localhost"},
2564 "description": "Missing properties field"
2565 }
2566 }
2567 }"#,
2568 );
2569
2570 let result = SchemaRegistry::from_directory(temp_dir.path());
2571 assert!(result.is_err());
2572 }
2573
2574 #[test]
2575 fn test_object_default_wrong_type_rejected() {
2576 let temp_dir = TempDir::new().unwrap();
2577 create_test_schema(
2578 &temp_dir,
2579 "test",
2580 r#"{
2581 "version": "1.0",
2582 "type": "object",
2583 "properties": {
2584 "config": {
2585 "type": "object",
2586 "properties": {
2587 "host": {"type": "string"},
2588 "port": {"type": "integer"}
2589 },
2590 "default": {"host": "localhost", "port": "not-a-number"},
2591 "description": "Bad default"
2592 }
2593 }
2594 }"#,
2595 );
2596
2597 let result = SchemaRegistry::from_directory(temp_dir.path());
2598 assert!(result.is_err());
2599 }
2600
2601 #[test]
2602 fn test_object_default_missing_field_rejected() {
2603 let temp_dir = TempDir::new().unwrap();
2604 create_test_schema(
2605 &temp_dir,
2606 "test",
2607 r#"{
2608 "version": "1.0",
2609 "type": "object",
2610 "properties": {
2611 "config": {
2612 "type": "object",
2613 "properties": {
2614 "host": {"type": "string"},
2615 "port": {"type": "integer"}
2616 },
2617 "default": {"host": "localhost"},
2618 "description": "Missing port in default"
2619 }
2620 }
2621 }"#,
2622 );
2623
2624 let result = SchemaRegistry::from_directory(temp_dir.path());
2625 assert!(result.is_err());
2626 }
2627
2628 #[test]
2629 fn test_object_default_extra_field_rejected() {
2630 let temp_dir = TempDir::new().unwrap();
2631 create_test_schema(
2632 &temp_dir,
2633 "test",
2634 r#"{
2635 "version": "1.0",
2636 "type": "object",
2637 "properties": {
2638 "config": {
2639 "type": "object",
2640 "properties": {
2641 "host": {"type": "string"}
2642 },
2643 "default": {"host": "localhost", "extra": "field"},
2644 "description": "Extra field in default"
2645 }
2646 }
2647 }"#,
2648 );
2649
2650 let result = SchemaRegistry::from_directory(temp_dir.path());
2651 assert!(result.is_err());
2652 }
2653
2654 #[test]
2655 fn test_object_values_valid() {
2656 let temp_dir = TempDir::new().unwrap();
2657 let (schemas_dir, values_dir) = create_test_schema_with_values(
2658 &temp_dir,
2659 "test",
2660 r#"{
2661 "version": "1.0",
2662 "type": "object",
2663 "properties": {
2664 "config": {
2665 "type": "object",
2666 "properties": {
2667 "host": {"type": "string"},
2668 "port": {"type": "integer"}
2669 },
2670 "default": {"host": "localhost", "port": 8080},
2671 "description": "Service config"
2672 }
2673 }
2674 }"#,
2675 r#"{
2676 "options": {
2677 "config": {"host": "example.com", "port": 9090}
2678 }
2679 }"#,
2680 );
2681
2682 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2683 let result = registry.load_values_json(&values_dir);
2684 assert!(result.is_ok());
2685 }
2686
2687 #[test]
2688 fn test_object_values_wrong_field_type_rejected() {
2689 let temp_dir = TempDir::new().unwrap();
2690 let (schemas_dir, values_dir) = create_test_schema_with_values(
2691 &temp_dir,
2692 "test",
2693 r#"{
2694 "version": "1.0",
2695 "type": "object",
2696 "properties": {
2697 "config": {
2698 "type": "object",
2699 "properties": {
2700 "host": {"type": "string"},
2701 "port": {"type": "integer"}
2702 },
2703 "default": {"host": "localhost", "port": 8080},
2704 "description": "Service config"
2705 }
2706 }
2707 }"#,
2708 r#"{
2709 "options": {
2710 "config": {"host": "example.com", "port": "not-a-number"}
2711 }
2712 }"#,
2713 );
2714
2715 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2716 let result = registry.load_values_json(&values_dir);
2717 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2718 }
2719
2720 #[test]
2721 fn test_object_values_extra_field_rejected() {
2722 let temp_dir = TempDir::new().unwrap();
2723 let (schemas_dir, values_dir) = create_test_schema_with_values(
2724 &temp_dir,
2725 "test",
2726 r#"{
2727 "version": "1.0",
2728 "type": "object",
2729 "properties": {
2730 "config": {
2731 "type": "object",
2732 "properties": {
2733 "host": {"type": "string"}
2734 },
2735 "default": {"host": "localhost"},
2736 "description": "Service config"
2737 }
2738 }
2739 }"#,
2740 r#"{
2741 "options": {
2742 "config": {"host": "example.com", "extra": "field"}
2743 }
2744 }"#,
2745 );
2746
2747 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2748 let result = registry.load_values_json(&values_dir);
2749 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2750 }
2751
2752 #[test]
2753 fn test_object_values_missing_field_rejected() {
2754 let temp_dir = TempDir::new().unwrap();
2755 let (schemas_dir, values_dir) = create_test_schema_with_values(
2756 &temp_dir,
2757 "test",
2758 r#"{
2759 "version": "1.0",
2760 "type": "object",
2761 "properties": {
2762 "config": {
2763 "type": "object",
2764 "properties": {
2765 "host": {"type": "string"},
2766 "port": {"type": "integer"}
2767 },
2768 "default": {"host": "localhost", "port": 8080},
2769 "description": "Service config"
2770 }
2771 }
2772 }"#,
2773 r#"{
2774 "options": {
2775 "config": {"host": "example.com"}
2776 }
2777 }"#,
2778 );
2779
2780 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2781 let result = registry.load_values_json(&values_dir);
2782 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2783 }
2784
2785 #[test]
2788 fn test_array_of_objects_schema_loads() {
2789 let temp_dir = TempDir::new().unwrap();
2790 create_test_schema(
2791 &temp_dir,
2792 "test",
2793 r#"{
2794 "version": "1.0",
2795 "type": "object",
2796 "properties": {
2797 "endpoints": {
2798 "type": "array",
2799 "items": {
2800 "type": "object",
2801 "properties": {
2802 "url": {"type": "string"},
2803 "weight": {"type": "integer"}
2804 }
2805 },
2806 "default": [{"url": "https://a.example.com", "weight": 1}],
2807 "description": "Endpoints"
2808 }
2809 }
2810 }"#,
2811 );
2812
2813 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2814 let schema = registry.get("test").unwrap();
2815 assert_eq!(schema.options["endpoints"].option_type, "array");
2816 }
2817
2818 #[test]
2819 fn test_array_of_objects_empty_default() {
2820 let temp_dir = TempDir::new().unwrap();
2821 create_test_schema(
2822 &temp_dir,
2823 "test",
2824 r#"{
2825 "version": "1.0",
2826 "type": "object",
2827 "properties": {
2828 "endpoints": {
2829 "type": "array",
2830 "items": {
2831 "type": "object",
2832 "properties": {
2833 "url": {"type": "string"},
2834 "weight": {"type": "integer"}
2835 }
2836 },
2837 "default": [],
2838 "description": "Endpoints"
2839 }
2840 }
2841 }"#,
2842 );
2843
2844 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2845 assert!(registry.get("test").is_some());
2846 }
2847
2848 #[test]
2849 fn test_array_of_objects_default_wrong_field_type_rejected() {
2850 let temp_dir = TempDir::new().unwrap();
2851 create_test_schema(
2852 &temp_dir,
2853 "test",
2854 r#"{
2855 "version": "1.0",
2856 "type": "object",
2857 "properties": {
2858 "endpoints": {
2859 "type": "array",
2860 "items": {
2861 "type": "object",
2862 "properties": {
2863 "url": {"type": "string"},
2864 "weight": {"type": "integer"}
2865 }
2866 },
2867 "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
2868 "description": "Endpoints"
2869 }
2870 }
2871 }"#,
2872 );
2873
2874 let result = SchemaRegistry::from_directory(temp_dir.path());
2875 assert!(result.is_err());
2876 }
2877
2878 #[test]
2879 fn test_array_of_objects_missing_items_properties_rejected() {
2880 let temp_dir = TempDir::new().unwrap();
2881 create_test_schema(
2882 &temp_dir,
2883 "test",
2884 r#"{
2885 "version": "1.0",
2886 "type": "object",
2887 "properties": {
2888 "endpoints": {
2889 "type": "array",
2890 "items": {
2891 "type": "object"
2892 },
2893 "default": [],
2894 "description": "Missing properties in items"
2895 }
2896 }
2897 }"#,
2898 );
2899
2900 let result = SchemaRegistry::from_directory(temp_dir.path());
2901 assert!(result.is_err());
2902 }
2903
2904 #[test]
2905 fn test_array_of_objects_values_valid() {
2906 let temp_dir = TempDir::new().unwrap();
2907 let (schemas_dir, values_dir) = create_test_schema_with_values(
2908 &temp_dir,
2909 "test",
2910 r#"{
2911 "version": "1.0",
2912 "type": "object",
2913 "properties": {
2914 "endpoints": {
2915 "type": "array",
2916 "items": {
2917 "type": "object",
2918 "properties": {
2919 "url": {"type": "string"},
2920 "weight": {"type": "integer"}
2921 }
2922 },
2923 "default": [],
2924 "description": "Endpoints"
2925 }
2926 }
2927 }"#,
2928 r#"{
2929 "options": {
2930 "endpoints": [
2931 {"url": "https://a.example.com", "weight": 1},
2932 {"url": "https://b.example.com", "weight": 2}
2933 ]
2934 }
2935 }"#,
2936 );
2937
2938 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2939 let result = registry.load_values_json(&values_dir);
2940 assert!(result.is_ok());
2941 }
2942
2943 #[test]
2944 fn test_array_of_objects_values_wrong_item_shape_rejected() {
2945 let temp_dir = TempDir::new().unwrap();
2946 let (schemas_dir, values_dir) = create_test_schema_with_values(
2947 &temp_dir,
2948 "test",
2949 r#"{
2950 "version": "1.0",
2951 "type": "object",
2952 "properties": {
2953 "endpoints": {
2954 "type": "array",
2955 "items": {
2956 "type": "object",
2957 "properties": {
2958 "url": {"type": "string"},
2959 "weight": {"type": "integer"}
2960 }
2961 },
2962 "default": [],
2963 "description": "Endpoints"
2964 }
2965 }
2966 }"#,
2967 r#"{
2968 "options": {
2969 "endpoints": [
2970 {"url": "https://a.example.com", "weight": "not-a-number"}
2971 ]
2972 }
2973 }"#,
2974 );
2975
2976 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2977 let result = registry.load_values_json(&values_dir);
2978 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2979 }
2980
2981 #[test]
2982 fn test_array_of_objects_values_extra_field_rejected() {
2983 let temp_dir = TempDir::new().unwrap();
2984 let (schemas_dir, values_dir) = create_test_schema_with_values(
2985 &temp_dir,
2986 "test",
2987 r#"{
2988 "version": "1.0",
2989 "type": "object",
2990 "properties": {
2991 "endpoints": {
2992 "type": "array",
2993 "items": {
2994 "type": "object",
2995 "properties": {
2996 "url": {"type": "string"}
2997 }
2998 },
2999 "default": [],
3000 "description": "Endpoints"
3001 }
3002 }
3003 }"#,
3004 r#"{
3005 "options": {
3006 "endpoints": [
3007 {"url": "https://a.example.com", "extra": "field"}
3008 ]
3009 }
3010 }"#,
3011 );
3012
3013 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3014 let result = registry.load_values_json(&values_dir);
3015 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3016 }
3017
3018 #[test]
3019 fn test_array_of_objects_values_missing_field_rejected() {
3020 let temp_dir = TempDir::new().unwrap();
3021 let (schemas_dir, values_dir) = create_test_schema_with_values(
3022 &temp_dir,
3023 "test",
3024 r#"{
3025 "version": "1.0",
3026 "type": "object",
3027 "properties": {
3028 "endpoints": {
3029 "type": "array",
3030 "items": {
3031 "type": "object",
3032 "properties": {
3033 "url": {"type": "string"},
3034 "weight": {"type": "integer"}
3035 }
3036 },
3037 "default": [],
3038 "description": "Endpoints"
3039 }
3040 }
3041 }"#,
3042 r#"{
3043 "options": {
3044 "endpoints": [
3045 {"url": "https://a.example.com"}
3046 ]
3047 }
3048 }"#,
3049 );
3050
3051 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3052 let result = registry.load_values_json(&values_dir);
3053 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3054 }
3055
3056 #[test]
3059 fn test_object_optional_field_can_be_omitted_from_default() {
3060 let temp_dir = TempDir::new().unwrap();
3061 create_test_schema(
3062 &temp_dir,
3063 "test",
3064 r#"{
3065 "version": "1.0",
3066 "type": "object",
3067 "properties": {
3068 "config": {
3069 "type": "object",
3070 "properties": {
3071 "host": {"type": "string"},
3072 "debug": {"type": "boolean", "optional": true}
3073 },
3074 "default": {"host": "localhost"},
3075 "description": "Config with optional field"
3076 }
3077 }
3078 }"#,
3079 );
3080
3081 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3082 let schema = registry.get("test").unwrap();
3083 assert_eq!(schema.options["config"].option_type, "object");
3084 }
3085
3086 #[test]
3087 fn test_object_optional_field_can_be_included_in_default() {
3088 let temp_dir = TempDir::new().unwrap();
3089 create_test_schema(
3090 &temp_dir,
3091 "test",
3092 r#"{
3093 "version": "1.0",
3094 "type": "object",
3095 "properties": {
3096 "config": {
3097 "type": "object",
3098 "properties": {
3099 "host": {"type": "string"},
3100 "debug": {"type": "boolean", "optional": true}
3101 },
3102 "default": {"host": "localhost", "debug": true},
3103 "description": "Config with optional field included"
3104 }
3105 }
3106 }"#,
3107 );
3108
3109 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3110 assert!(registry.get("test").is_some());
3111 }
3112
3113 #[test]
3114 fn test_object_optional_field_wrong_type_rejected() {
3115 let temp_dir = TempDir::new().unwrap();
3116 create_test_schema(
3117 &temp_dir,
3118 "test",
3119 r#"{
3120 "version": "1.0",
3121 "type": "object",
3122 "properties": {
3123 "config": {
3124 "type": "object",
3125 "properties": {
3126 "host": {"type": "string"},
3127 "debug": {"type": "boolean", "optional": true}
3128 },
3129 "default": {"host": "localhost", "debug": "not-a-bool"},
3130 "description": "Optional field wrong type"
3131 }
3132 }
3133 }"#,
3134 );
3135
3136 let result = SchemaRegistry::from_directory(temp_dir.path());
3137 assert!(result.is_err());
3138 }
3139
3140 #[test]
3141 fn test_object_required_field_still_required_with_optional_present() {
3142 let temp_dir = TempDir::new().unwrap();
3143 create_test_schema(
3144 &temp_dir,
3145 "test",
3146 r#"{
3147 "version": "1.0",
3148 "type": "object",
3149 "properties": {
3150 "config": {
3151 "type": "object",
3152 "properties": {
3153 "host": {"type": "string"},
3154 "port": {"type": "integer"},
3155 "debug": {"type": "boolean", "optional": true}
3156 },
3157 "default": {"debug": true},
3158 "description": "Missing required fields"
3159 }
3160 }
3161 }"#,
3162 );
3163
3164 let result = SchemaRegistry::from_directory(temp_dir.path());
3165 assert!(result.is_err());
3166 }
3167
3168 #[test]
3169 fn test_object_optional_field_omitted_from_values() {
3170 let temp_dir = TempDir::new().unwrap();
3171 let (schemas_dir, values_dir) = create_test_schema_with_values(
3172 &temp_dir,
3173 "test",
3174 r#"{
3175 "version": "1.0",
3176 "type": "object",
3177 "properties": {
3178 "config": {
3179 "type": "object",
3180 "properties": {
3181 "host": {"type": "string"},
3182 "debug": {"type": "boolean", "optional": true}
3183 },
3184 "default": {"host": "localhost"},
3185 "description": "Config"
3186 }
3187 }
3188 }"#,
3189 r#"{
3190 "options": {
3191 "config": {"host": "example.com"}
3192 }
3193 }"#,
3194 );
3195
3196 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3197 let result = registry.load_values_json(&values_dir);
3198 assert!(result.is_ok());
3199 }
3200
3201 #[test]
3202 fn test_object_optional_field_included_in_values() {
3203 let temp_dir = TempDir::new().unwrap();
3204 let (schemas_dir, values_dir) = create_test_schema_with_values(
3205 &temp_dir,
3206 "test",
3207 r#"{
3208 "version": "1.0",
3209 "type": "object",
3210 "properties": {
3211 "config": {
3212 "type": "object",
3213 "properties": {
3214 "host": {"type": "string"},
3215 "debug": {"type": "boolean", "optional": true}
3216 },
3217 "default": {"host": "localhost"},
3218 "description": "Config"
3219 }
3220 }
3221 }"#,
3222 r#"{
3223 "options": {
3224 "config": {"host": "example.com", "debug": true}
3225 }
3226 }"#,
3227 );
3228
3229 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3230 let result = registry.load_values_json(&values_dir);
3231 assert!(result.is_ok());
3232 }
3233
3234 #[test]
3235 fn test_array_of_objects_optional_field_omitted() {
3236 let temp_dir = TempDir::new().unwrap();
3237 let (schemas_dir, values_dir) = create_test_schema_with_values(
3238 &temp_dir,
3239 "test",
3240 r#"{
3241 "version": "1.0",
3242 "type": "object",
3243 "properties": {
3244 "endpoints": {
3245 "type": "array",
3246 "items": {
3247 "type": "object",
3248 "properties": {
3249 "url": {"type": "string"},
3250 "weight": {"type": "integer", "optional": true}
3251 }
3252 },
3253 "default": [],
3254 "description": "Endpoints"
3255 }
3256 }
3257 }"#,
3258 r#"{
3259 "options": {
3260 "endpoints": [
3261 {"url": "https://a.example.com"},
3262 {"url": "https://b.example.com", "weight": 2}
3263 ]
3264 }
3265 }"#,
3266 );
3267
3268 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3269 let result = registry.load_values_json(&values_dir);
3270 assert!(result.is_ok());
3271 }
3272 }
3273}