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