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