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