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