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