Skip to main content

sentry_options_validation/
lib.rs

1//! Schema validation library for sentry-options.
2//!
3//! Schemas are loaded once into a [`SchemaRegistry`] and shared via `Arc`.
4//! Values are validated against schemas as complete objects.
5//!
6//! Values refresh lazily on read on the same thread. A read past
7//! the staleness window re-reads the values directory on the calling thread
8//! and publishes the new snapshot via `ArcSwap` (last-writer-wins). The full
9//! scheme is in `docs/architecture.md`; the memory-ordering invariants are
10//! documented inline in the `refresh` method below.
11
12use arc_swap::ArcSwap;
13use chrono::{DateTime, Utc};
14use serde_json::Value;
15use serde_json::json;
16use std::collections::{HashMap, HashSet};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{
20    Arc,
21    atomic::{AtomicU64, Ordering},
22};
23use std::time::{Duration, Instant, SystemTime};
24
25/// Embedded meta-schema for validating sentry-options schema files
26const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
27
28/// Embedded Feature type definitions for injecting into namespace schemas that contain feature flags
29const 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
34/// Prefix marking an option key as a feature flag.
35const FEATURE_KEY_PREFIX: &str = "feature.";
36/// Regex a schema uses to declare features by pattern instead of by name.
37const FEATURE_KEY_PATTERN: &str = "^feature\\.";
38
39/// Default minimum age of a cached values snapshot before a read triggers a
40/// refresh. A per-thread jitter of up to 1 s is added on top to spread the
41/// reload across threads.
42pub const DEFAULT_REFRESH_THRESHOLD: Duration = Duration::from_secs(5);
43
44/// Production path where options are deployed via config map
45pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
46
47/// Local fallback path for development
48pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
49
50/// Environment variable to override options directory
51pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
52
53/// Environment variable to suppress missing directory errors
54pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
55
56/// Check if missing directory errors should be suppressed
57fn should_suppress_missing_dir_errors() -> bool {
58    std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
59        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
60        .unwrap_or(false)
61}
62
63/// Resolve options directory using fallback chain:
64/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
65/// 2. `/etc/sentry-options` (if exists)
66/// 3. `sentry-options/` (local fallback)
67pub fn resolve_options_dir() -> PathBuf {
68    if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
69        return PathBuf::from(dir);
70    }
71
72    let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
73    if prod_path.exists() {
74        return prod_path;
75    }
76
77    PathBuf::from(LOCAL_OPTIONS_DIR)
78}
79
80/// Result type for validation operations
81pub type ValidationResult<T> = Result<T, ValidationError>;
82
83/// A map of option values keyed by their namespace
84pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
85
86/// Errors that can occur during schema and value validation
87#[derive(Debug, thiserror::Error)]
88pub enum ValidationError {
89    #[error("Schema error in {file}: {message}")]
90    SchemaError { file: PathBuf, message: String },
91
92    #[error("Value error for {namespace}: {errors}")]
93    ValueError { namespace: String, errors: String },
94
95    #[error("Unknown namespace: {0}")]
96    UnknownNamespace(String),
97
98    #[error("Unknown option '{key}' in namespace '{namespace}'")]
99    UnknownOption { namespace: String, key: String },
100
101    #[error("Internal error: {0}")]
102    InternalError(String),
103
104    #[error("Failed to read file: {0}")]
105    FileRead(#[from] std::io::Error),
106
107    #[error("Failed to parse JSON: {0}")]
108    JSONParse(#[from] serde_json::Error),
109
110    #[error("{} validation error(s)", .0.len())]
111    ValidationErrors(Vec<ValidationError>),
112
113    #[error("Invalid {label} '{name}': {reason}")]
114    InvalidName {
115        label: String,
116        name: String,
117        reason: String,
118    },
119}
120
121/// Maximum length of a K8s DNS-1123 label (RFC 1123).
122pub const DNS1123_LABEL_MAX_LEN: usize = 63;
123
124/// Validate a name component is a valid K8s DNS-1123 label: lowercase
125/// alphanumeric and '-', starts and ends alphanumeric, at most 63 chars.
126///
127/// '.' is allowed in the configmap name, but not in `volume.name`
128/// so we reject it.
129pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
130    if name.len() > DNS1123_LABEL_MAX_LEN {
131        return Err(ValidationError::InvalidName {
132            label: label.to_string(),
133            name: name.to_string(),
134            reason: format!("must be at most {} characters", DNS1123_LABEL_MAX_LEN),
135        });
136    }
137    if let Some(c) = name
138        .chars()
139        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-'))
140    {
141        return Err(ValidationError::InvalidName {
142            label: label.to_string(),
143            name: name.to_string(),
144            reason: format!(
145                "character '{}' not allowed. Use lowercase alphanumeric or '-'",
146                c
147            ),
148        });
149    }
150    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
151        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
152    {
153        return Err(ValidationError::InvalidName {
154            label: label.to_string(),
155            name: name.to_string(),
156            reason: "must start and end with alphanumeric".to_string(),
157        });
158    }
159    Ok(())
160}
161
162/// The value a namespace schema pairs with each `feature.<name>` key, i.e.
163/// `{"$ref": "#/definitions/Feature"}`. Splice this in when assembling a schema
164/// in memory rather than hand-rolling the ref the meta-schema requires.
165pub fn feature_property() -> Value {
166    json!({ "$ref": "#/definitions/Feature" })
167}
168
169/// Metadata for a single option in a namespace schema
170#[derive(Debug, Clone)]
171pub struct OptionMetadata {
172    pub option_type: String,
173    pub property_schema: Value,
174    pub default: Value,
175}
176
177/// Schema for a namespace, containing validator and option metadata
178pub struct NamespaceSchema {
179    pub namespace: String,
180    pub options: HashMap<String, OptionMetadata>,
181    /// `feature.`-prefixed keys. Kept separate from `options`
182    /// so they aren't involved in schema-evolution checks
183    feature_keys: HashSet<String>,
184    /// All property keys from the schema, including feature flags that aren't in `options`.
185    all_keys: HashSet<String>,
186    /// Set when the schema declares features by pattern rather than by name, i.e. any
187    /// `feature.`-prefixed key is accepted. The set of valid names then lives in the
188    /// consuming service's code, not here.
189    open_features: bool,
190    validator: jsonschema::Validator,
191}
192
193impl NamespaceSchema {
194    /// Validate an entire values object against this schema
195    ///
196    /// # Arguments
197    /// * `values` - JSON object containing option key-value pairs
198    ///
199    /// # Errors
200    /// Returns error if values don't match the schema
201    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
202        let output = self.validator.evaluate(values);
203        if output.flag().valid {
204            Ok(())
205        } else {
206            let errors: Vec<String> = output
207                .iter_errors()
208                .map(|e| {
209                    format!(
210                        "\n\t{} {}",
211                        e.instance_location.as_str().trim_start_matches("/"),
212                        e.error
213                    )
214                })
215                .collect();
216            Err(ValidationError::ValueError {
217                namespace: self.namespace.clone(),
218                errors: errors.join(""),
219            })
220        }
221    }
222
223    /// Get the default value for an option key.
224    /// Returns None if the key doesn't exist in the schema.
225    pub fn get_default(&self, key: &str) -> Option<&Value> {
226        self.options.get(key).map(|meta| &meta.default)
227    }
228
229    /// Whether `key` is a known key in this namespace, runtime
230    /// option or feature flag.
231    pub fn is_known_key(&self, key: &str) -> bool {
232        self.options.contains_key(key) || self.accepts_feature_key(key)
233    }
234
235    /// Whether `key` is a feature this namespace declares, either by name or by
236    /// pattern (see `open_features`).
237    fn accepts_feature_key(&self, key: &str) -> bool {
238        self.feature_keys.contains(key)
239            || (self.open_features && key.starts_with(FEATURE_KEY_PREFIX))
240    }
241
242    /// Whether a values key belongs to this namespace, i.e. survives loading rather
243    /// than being stripped as unknown. Unlike `is_known_key` this covers every
244    /// declared property, not just those with option metadata.
245    pub fn accepts_key(&self, key: &str) -> bool {
246        self.all_keys.contains(key) || self.accepts_feature_key(key)
247    }
248
249    /// Validate a single key-value pair against the schema.
250    ///
251    /// # Errors
252    /// Returns error if the key doesn't exist or the value doesn't match the expected type.
253    pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
254        if !self.is_known_key(key) {
255            return Err(ValidationError::UnknownOption {
256                namespace: self.namespace.clone(),
257                key: key.to_string(),
258            });
259        }
260        let test_obj = json!({ key: value });
261        self.validate_values(&test_obj)
262    }
263}
264
265/// Registry for loading and storing schemas
266pub struct SchemaRegistry {
267    schemas: HashMap<String, Arc<NamespaceSchema>>,
268}
269
270impl SchemaRegistry {
271    /// Create a new empty schema registry
272    pub fn new() -> Self {
273        Self {
274            schemas: HashMap::new(),
275        }
276    }
277
278    /// Load schemas from a directory and create a registry
279    ///
280    /// Expects directory structure: `schemas/{namespace}/schema.json`
281    ///
282    /// # Arguments
283    /// * `schemas_dir` - Path to directory containing namespace subdirectories
284    ///
285    /// # Errors
286    /// Returns error if directory doesn't exist or any schema is invalid
287    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
288        let namespace_validator = Self::compile_namespace_validator()?;
289        let mut schemas_map = HashMap::new();
290
291        // TODO: Parallelize the loading of schemas for the performance gainz
292        for entry in fs::read_dir(schemas_dir)? {
293            let entry = entry?;
294
295            if !entry.file_type()?.is_dir() {
296                continue;
297            }
298
299            let namespace =
300                entry
301                    .file_name()
302                    .into_string()
303                    .map_err(|_| ValidationError::SchemaError {
304                        file: entry.path(),
305                        message: "Directory name contains invalid UTF-8".to_string(),
306                    })?;
307
308            validate_k8s_name_component(&namespace, "namespace name")?;
309
310            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
311            let file = fs::File::open(&schema_file)?;
312            let schema_data: Value = serde_json::from_reader(file)?;
313
314            Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
315            let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
316            schemas_map.insert(namespace, schema);
317        }
318
319        Ok(Self {
320            schemas: schemas_map,
321        })
322    }
323
324    /// Build a registry from in-memory schema JSON strings.
325    ///
326    /// Each entry is a `(namespace, json)` pair. Applies the same validation
327    /// pipeline as `from_directory` (meta-schema check, constraint injection,
328    /// validator compilation) without reading from the filesystem.
329    pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
330        let mut registry = Self::new();
331        registry.add_schemas(schemas)?;
332        Ok(registry)
333    }
334
335    /// Add in-memory `(namespace, json)` schemas to whatever is already loaded.
336    /// Errors on an already-present namespace, so an in-memory schema can't
337    /// silently shadow one loaded from disk.
338    pub fn add_schemas(&mut self, schemas: &[(&str, &str)]) -> ValidationResult<()> {
339        let namespace_validator = Self::compile_namespace_validator()?;
340        let schema_file = Path::new("<embedded>");
341
342        for (namespace, json) in schemas {
343            validate_k8s_name_component(namespace, "namespace name")?;
344
345            let schema_data: Value =
346                serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
347                    file: schema_file.to_path_buf(),
348                    message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
349                })?;
350
351            Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
352            let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
353            if self.schemas.insert(namespace.to_string(), schema).is_some() {
354                return Err(ValidationError::SchemaError {
355                    file: schema_file.to_path_buf(),
356                    message: format!("Duplicate namespace '{}'", namespace),
357                });
358            }
359        }
360
361        Ok(())
362    }
363
364    /// Validate an entire values object for a namespace
365    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
366        let schema = self
367            .schemas
368            .get(namespace)
369            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
370
371        schema.validate_values(values)
372    }
373
374    /// Validate one option value against the named namespace schema.
375    ///
376    /// This is the narrow validation boundary for callers that write a single
377    /// option and do not have a complete values document. It uses the same
378    /// schema parsing and validation pipeline as [`validate_values`](Self::validate_values).
379    pub fn validate_option(
380        &self,
381        namespace: &str,
382        key: &str,
383        value: &Value,
384    ) -> ValidationResult<()> {
385        let schema = self
386            .schemas
387            .get(namespace)
388            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
389
390        schema.validate_option(key, value)
391    }
392
393    fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
394        let namespace_schema_value: Value =
395            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
396                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
397            })?;
398        jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
399            ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
400        })
401    }
402
403    /// Validate a schema against the namespace-schema
404    fn validate_with_namespace_schema(
405        schema_data: &Value,
406        path: &Path,
407        namespace_validator: &jsonschema::Validator,
408    ) -> ValidationResult<()> {
409        let output = namespace_validator.evaluate(schema_data);
410
411        if output.flag().valid {
412            Ok(())
413        } else {
414            let errors: Vec<String> = output
415                .iter_errors()
416                .map(|e| format!("Error: {}", e.error))
417                .collect();
418
419            Err(ValidationError::SchemaError {
420                file: path.to_path_buf(),
421                message: format!("Schema validation failed:\n{}", errors.join("\n")),
422            })
423        }
424    }
425
426    /// Validate that a default value matches its declared type using jsonschema
427    fn validate_default_type(
428        property_name: &str,
429        property_schema: &Value,
430        default_value: &Value,
431        path: &Path,
432    ) -> ValidationResult<()> {
433        // Validate the default value against the property schema
434        jsonschema::validate(property_schema, default_value).map_err(|e| {
435            ValidationError::SchemaError {
436                file: path.to_path_buf(),
437                message: format!(
438                    "Property '{}': default value does not match schema: {}",
439                    property_name, e
440                ),
441            }
442        })?;
443
444        Ok(())
445    }
446
447    /// Injects `required` (all non-optional field names) into an object-typed schema.
448    /// Also injects `additionalProperties: false` unless the schema already declares
449    /// `additionalProperties` explicitly — that signals a dynamic map where keys are
450    /// not known up front (e.g. `"additionalProperties": {"type": "string"}`).
451    /// e.g.
452    /// {
453    ///     "type": "object",
454    ///     "properties": {
455    ///       "host": { "type": "string" },
456    ///       "port": { "type": "integer" }
457    ///     },
458    ///     "required": ["host", "port"],                       <-- INJECTED
459    ///     "additionalProperties": false,                      <-- INJECTED
460    ///     "default": { "host": "localhost", "port": 8080 },
461    ///     "description": "..."
462    /// }
463    fn inject_object_constraints(schema: &mut Value) {
464        let Some(obj) = schema.as_object_mut() else {
465            return;
466        };
467
468        if obj.get("type").and_then(|t| t.as_str()) == Some("object") {
469            // Fixed-shape object: require every non-optional field. A dynamic map
470            // instead declares `additionalProperties` (a value schema) up front, so
471            // only inject `required` when there are declared `properties`.
472            if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
473                let required: Vec<Value> = props
474                    .iter()
475                    .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
476                    .map(|(k, _)| Value::String(k.clone()))
477                    .collect();
478                obj.insert("required".to_string(), Value::Array(required));
479            }
480            // Reject unknown keys unless the schema already declares an
481            // `additionalProperties` value type (i.e. it is a dynamic map).
482            if !obj.contains_key("additionalProperties") {
483                obj.insert("additionalProperties".to_string(), json!(false));
484            }
485        }
486
487        // Recurse so the same constraints apply at every level of a nested value
488        // schema: object-valued maps, arrays of objects, and nested fixed shapes.
489        if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
490            for field in props.values_mut() {
491                Self::inject_object_constraints(field);
492            }
493        }
494        if let Some(additional) = obj.get_mut("additionalProperties")
495            && additional.is_object()
496        {
497            Self::inject_object_constraints(additional);
498        }
499        if let Some(items) = obj.get_mut("items") {
500            Self::inject_object_constraints(items);
501        }
502    }
503
504    /// Parse a schema JSON into NamespaceSchema
505    fn parse_schema(
506        mut schema: Value,
507        namespace: &str,
508        path: &Path,
509    ) -> ValidationResult<Arc<NamespaceSchema>> {
510        // Inject additionalProperties: false to reject unknown options
511        if let Some(obj) = schema.as_object_mut() {
512            obj.insert("additionalProperties".to_string(), json!(false));
513        }
514
515        // Inject object constraints (required + additionalProperties) at every level
516        // of each option's value schema so jsonschema validates the full shape.
517        if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
518            for prop_value in properties.values_mut() {
519                Self::inject_object_constraints(prop_value);
520            }
521        }
522
523        // Extract option metadata and validate types.
524        let mut options = HashMap::new();
525        let mut feature_keys = HashSet::new();
526        let mut all_keys = HashSet::new();
527        // A `patternProperties` entry for the feature prefix declares features by
528        // pattern instead of listing every name, so any `feature.`-prefixed key is
529        // accepted and validated against the spliced-in Feature definition.
530        let open_features = schema
531            .get("patternProperties")
532            .and_then(|p| p.as_object())
533            .is_some_and(|p| p.contains_key(FEATURE_KEY_PATTERN));
534
535        let mut has_feature_keys = open_features;
536        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
537            for (prop_name, prop_value) in properties {
538                all_keys.insert(prop_name.clone());
539                // Detect feature flags so that we can augment the schema with defs.
540                // They're tracked separately from `options` (see NamespaceSchema).
541                if prop_name.starts_with(FEATURE_KEY_PREFIX) {
542                    has_feature_keys = true;
543                    feature_keys.insert(prop_name.clone());
544                }
545                if let (Some(prop_type), Some(default_value)) = (
546                    prop_value.get("type").and_then(|t| t.as_str()),
547                    prop_value.get("default"),
548                ) {
549                    Self::validate_default_type(prop_name, prop_value, default_value, path)?;
550                    options.insert(
551                        prop_name.clone(),
552                        OptionMetadata {
553                            option_type: prop_type.to_string(),
554                            property_schema: prop_value.clone(),
555                            default: default_value.clone(),
556                        },
557                    );
558                }
559            }
560        }
561
562        // If an options schema includes a feature flag, splice in the definitions
563        // so that values can be validated.
564        if has_feature_keys {
565            let feature_defs: Value =
566                serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
567                    ValidationError::InternalError(format!(
568                        "Invalid feature-schema-defs JSON: {}",
569                        e
570                    ))
571                })?;
572
573            if let Some(obj) = schema.as_object_mut() {
574                obj.insert("definitions".to_string(), feature_defs);
575            }
576        }
577
578        // Use the (potentially transformed) schema as the validator
579        let validator =
580            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
581                file: path.to_path_buf(),
582                message: format!("Failed to compile validator: {}", e),
583            })?;
584
585        Ok(Arc::new(NamespaceSchema {
586            namespace: namespace.to_string(),
587            options,
588            feature_keys,
589            all_keys,
590            open_features,
591            validator,
592        }))
593    }
594
595    /// Get a namespace schema by name
596    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
597        self.schemas.get(namespace)
598    }
599
600    /// Get all loaded schemas (for schema evolution validation)
601    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
602        &self.schemas
603    }
604
605    /// Load and validate JSON values from a directory.
606    /// Allows extra unknown option values to accommodate deployment race conditions
607    ///
608    /// Expects structure: `{values_dir}/{namespace}/values.json`
609    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
610    /// Skips namespaces without a values.json file.
611    /// Returns the values and a map of namespace -> `generated_at` timestamp.
612    pub fn load_values_json(
613        &self,
614        values_dir: &Path,
615    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
616        let mut all_values = HashMap::new();
617        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
618
619        for namespace in self.schemas.keys() {
620            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
621
622            if !values_file.exists() {
623                continue;
624            }
625
626            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
627
628            // Extract generated_at if present
629            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
630                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
631            }
632
633            let values = parsed
634                .get("options")
635                .ok_or_else(|| ValidationError::ValueError {
636                    namespace: namespace.clone(),
637                    errors: "values.json must have an 'options' key".to_string(),
638                })?;
639
640            // Strip unknown keys before validation to handle deployment race
641            // conditions where values are deployed before the schema update.
642            let values = self.strip_unknown_keys(namespace, values);
643
644            self.validate_values(namespace, &values)?;
645
646            if let Value::Object(obj) = values {
647                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
648                all_values.insert(namespace.clone(), ns_values);
649            }
650        }
651
652        Ok((all_values, generated_at_by_namespace))
653    }
654
655    /// Remove keys from values that are not defined in the namespace schema.
656    /// Logs a warning for each removed key. Returns the filtered values object.
657    pub fn strip_unknown_keys(&self, namespace: &str, values: &Value) -> Value {
658        let schema = match self.schemas.get(namespace) {
659            Some(s) => s,
660            None => return values.clone(),
661        };
662
663        let obj = match values.as_object() {
664            Some(obj) => obj,
665            None => return values.clone(),
666        };
667
668        let unknown_keys: Vec<&String> = obj.keys().filter(|k| !schema.accepts_key(k)).collect();
669
670        if unknown_keys.is_empty() {
671            return values.clone();
672        }
673
674        for key in &unknown_keys {
675            tracing::warn!(
676                key = %key,
677                namespace = %namespace,
678                "Ignoring unknown option. This is expected during deployments when \
679                 values are updated before schemas.",
680            );
681        }
682
683        let filtered: serde_json::Map<String, Value> = obj
684            .iter()
685            .filter(|(k, _)| schema.accepts_key(k))
686            .map(|(k, v)| (k.clone(), v.clone()))
687            .collect();
688        Value::Object(filtered)
689    }
690}
691
692impl Default for SchemaRegistry {
693    fn default() -> Self {
694        Self::new()
695    }
696}
697
698/// Callback invoked when a namespace's values are updated. Receives
699/// `(namespace, delay_secs)` — the propagation delay from ConfigMap generation
700/// to the app reading the new values.
701pub type PropagationCallback = Box<dyn Fn(&str, f64) + Send + Sync>;
702
703/// Validates and serves option values, reloading them lazily on read and
704/// serving the current snapshot lock-free via `ArcSwap`. See `refresh` for the
705/// staleness window and memory-ordering details.
706pub struct ValuesStore {
707    registry: Arc<SchemaRegistry>,
708    values_dir: PathBuf,
709    values: ArcSwap<ValuesByNamespace>,
710    baseline: Instant,
711    last_refresh_offset_ns: AtomicU64,
712    /// Staleness threshold for refresh-on-read; `None` disables it, so values
713    /// only change via [`refresh`](Self::refresh).
714    refresh_threshold: Option<Duration>,
715    /// Last known `generated_at` per namespace, used to detect value changes.
716    /// Uses ArcSwap for lock-free, fork-safe access.
717    last_generated_at: ArcSwap<HashMap<String, String>>,
718    /// Optional callback invoked when propagation delay is measured.
719    on_propagation: Option<PropagationCallback>,
720    /// Last known modification times of values files, used to skip
721    /// redundant reloads when nothing has changed on disk.
722    last_mtimes: ArcSwap<HashMap<String, SystemTime>>,
723}
724
725/// Builder for constructing a [`ValuesStore`].
726///
727/// Created via [`ValuesStore::builder`]. Configure optional parameters with
728/// the `with_*` methods, then finalize with [`build`](Self::build).
729pub struct ValuesStoreBuilder {
730    registry: Arc<SchemaRegistry>,
731    values_dir: PathBuf,
732    refresh_threshold: Option<Duration>,
733    on_propagation: Option<PropagationCallback>,
734}
735
736impl ValuesStoreBuilder {
737    /// Overrides the staleness threshold for refresh-on-read.
738    ///
739    /// Defaults to [`DEFAULT_REFRESH_THRESHOLD`]. Pass `None` to disable
740    /// refresh-on-read entirely; values then only change via
741    /// [`ValuesStore::refresh`]. A zero threshold refreshes on every read.
742    pub fn with_refresh_threshold(mut self, threshold: impl Into<Option<Duration>>) -> Self {
743        self.refresh_threshold = threshold.into();
744        self
745    }
746
747    /// Registers a callback that fires whenever new values are detected.
748    /// The callback receives `(namespace, delay_secs)` on each value change.
749    pub fn with_callback(mut self, callback: PropagationCallback) -> Self {
750        self.on_propagation = Some(callback);
751        self
752    }
753
754    /// Builds the store and performs the initial values load synchronously.
755    pub fn build(self) -> ValidationResult<ValuesStore> {
756        let values_dir = &self.values_dir;
757        if !should_suppress_missing_dir_errors() && fs::metadata(values_dir).is_err() {
758            tracing::warn!(path = %values_dir.display(), "Values directory does not exist");
759        }
760
761        let baseline = Instant::now();
762        let initial_mtimes = ValuesStore::collect_mtimes(&self.registry, values_dir);
763        let (initial, generated_at_by_namespace) = self.registry.load_values_json(values_dir)?;
764        let last_refresh_offset_ns = AtomicU64::new(baseline.elapsed().as_nanos() as u64);
765
766        Ok(ValuesStore {
767            registry: self.registry,
768            values_dir: self.values_dir,
769            values: ArcSwap::from_pointee(initial),
770            baseline,
771            last_refresh_offset_ns,
772            refresh_threshold: self.refresh_threshold,
773            last_generated_at: ArcSwap::from_pointee(generated_at_by_namespace),
774            on_propagation: self.on_propagation,
775            last_mtimes: ArcSwap::from_pointee(initial_mtimes),
776        })
777    }
778}
779
780impl ValuesStore {
781    /// Builds a store with the default configuration and performs the initial
782    /// values load synchronously.
783    ///
784    /// Use [`builder`](Self::builder) to override the configuration.
785    pub fn new(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValidationResult<Self> {
786        Self::builder(registry, values_dir).build()
787    }
788
789    /// Builds a store with a callback that fires whenever new values are detected.
790    #[deprecated(note = "use `ValuesStore::builder(...).with_callback(...)` instead")]
791    pub fn with_propagation_callback(
792        registry: Arc<SchemaRegistry>,
793        values_dir: &Path,
794        callback: PropagationCallback,
795    ) -> ValidationResult<Self> {
796        Self::builder(registry, values_dir)
797            .with_callback(callback)
798            .build()
799    }
800
801    /// Returns a builder for constructing a store from the given registry and
802    /// values directory.
803    pub fn builder(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValuesStoreBuilder {
804        ValuesStoreBuilder {
805            registry,
806            values_dir: values_dir.to_path_buf(),
807            refresh_threshold: Some(DEFAULT_REFRESH_THRESHOLD),
808            on_propagation: None,
809        }
810    }
811
812    /// The registry the store was constructed with.
813    pub fn registry(&self) -> &Arc<SchemaRegistry> {
814        &self.registry
815    }
816
817    /// Stat each `{values_dir}/{namespace}/values.json` and return a map of
818    /// namespace → modification time. Missing files are omitted.
819    fn collect_mtimes(registry: &SchemaRegistry, values_dir: &Path) -> HashMap<String, SystemTime> {
820        let mut mtimes = HashMap::new();
821        for namespace in registry.schemas().keys() {
822            let path = values_dir.join(namespace).join(VALUES_FILE_NAME);
823            if let Ok(metadata) = fs::metadata(&path)
824                && let Ok(mtime) = metadata.modified()
825            {
826                mtimes.insert(namespace.clone(), mtime);
827            }
828        }
829        mtimes
830    }
831
832    /// Returns a guard onto the current values snapshot, refreshing first if
833    /// the cached snapshot is older than `refresh_threshold + jitter`.
834    pub fn load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
835        self.maybe_refresh();
836        self.values.load()
837    }
838
839    /// Like [`load`](Self::load), but always refreshes, ignoring the staleness
840    /// threshold. The mtime check still applies, so unchanged files are not
841    /// re-read from disk.
842    pub fn force_load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
843        if let Err(e) = self.refresh() {
844            self.log_refresh_error(&e);
845        }
846        self.values.load()
847    }
848
849    /// Refreshes values from disk, ignoring the staleness threshold.
850    ///
851    /// Returns whether a new snapshot was published, i.e. the files changed
852    /// on disk (by mtime) and reloaded successfully. On error the previous
853    /// snapshot is retained. Propagation callbacks fire on the calling thread
854    /// before this returns.
855    ///
856    /// Any call resets the refresh-on-read timer, so calling this more often
857    /// than the threshold guarantees reads never refresh inline.
858    pub fn refresh(&self) -> ValidationResult<bool> {
859        let now_ns = self.baseline.elapsed().as_nanos() as u64;
860        let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
861        self.refresh_at(last_ns, now_ns)
862    }
863
864    fn maybe_refresh(&self) {
865        let Some(threshold) = self.refresh_threshold else {
866            return;
867        };
868
869        let now_ns = self.baseline.elapsed().as_nanos() as u64;
870        let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
871        let elapsed_ns = now_ns.saturating_sub(last_ns);
872        let threshold_ns = threshold.as_nanos() as u64;
873        // Skip jitter for a zero threshold so callers (chiefly tests) can
874        // force every read to refresh.
875        let jitter_ns = if threshold.is_zero() {
876            0
877        } else {
878            stack_jitter_ns()
879        };
880
881        if elapsed_ns < threshold_ns.saturating_add(jitter_ns) {
882            return;
883        }
884
885        if let Err(e) = self.refresh_at(last_ns, now_ns) {
886            self.log_refresh_error(&e);
887        }
888    }
889
890    fn log_refresh_error(&self, error: &ValidationError) {
891        tracing::error!(
892            path = %self.values_dir.display(),
893            error = %error,
894            "Failed to reload values",
895        );
896    }
897
898    fn refresh_at(&self, observed_last_ns: u64, now_ns: u64) -> ValidationResult<bool> {
899        let current_mtimes = Self::collect_mtimes(&self.registry, &self.values_dir);
900        if *self.last_mtimes.load().as_ref() == current_mtimes {
901            let _ = self.last_refresh_offset_ns.compare_exchange(
902                observed_last_ns,
903                now_ns,
904                Ordering::AcqRel,
905                Ordering::Relaxed,
906            );
907            return Ok(false);
908        }
909
910        let reload_start = Instant::now();
911        let result = self.registry.load_values_json(&self.values_dir);
912
913        // Publish the new snapshot before bumping the timestamp. The CAS below
914        // uses AcqRel (Release on success); a reader that Acquire-loads the
915        // bumped timestamp and decides the snapshot is fresh enough to skip
916        // refreshing is then guaranteed to observe this `ArcSwap::store`.
917        // Reversing the order opens a window where the timestamp says "fresh"
918        // but the ArcSwap still holds the previous snapshot on weakly ordered
919        // architectures.
920        let new_generated_at = match result {
921            Ok((new_values, generated_at)) => {
922                let elapsed_ms = reload_start.elapsed().as_millis();
923                for namespace in new_values.keys() {
924                    tracing::info!(
925                        path = %self.values_dir.display(),
926                        namespace = %namespace,
927                        elapsed_ms,
928                        "Reloaded values",
929                    );
930                }
931
932                self.values.store(Arc::new(new_values));
933                self.last_mtimes.store(Arc::new(current_mtimes));
934                Ok(generated_at)
935            }
936            Err(e) => Err(e),
937        };
938
939        // Bump the timestamp regardless of success. On failure, the bumped
940        // timestamp keeps subsequent reads from hammering the filesystem until
941        // the next threshold window. Losing the CAS just means another thread
942        // already bumped — our snapshot, if any, is still published.
943        let _ = self.last_refresh_offset_ns.compare_exchange(
944            observed_last_ns,
945            now_ns,
946            Ordering::AcqRel,
947            Ordering::Relaxed,
948        );
949
950        let new_generated_at = new_generated_at?;
951
952        // Fire the propagation callback after the CAS so other threads see a
953        // fresh timestamp and don't redundantly re-read from disk while a
954        // potentially slow callback (e.g. Python/GIL) executes.
955        let last = self.last_generated_at.load();
956        if *last.as_ref() != new_generated_at {
957            if let Some(callback) = &self.on_propagation {
958                let applied_at = Utc::now();
959                for (ns, ts) in &new_generated_at {
960                    if last.get(ns).is_some_and(|old| old == ts) {
961                        continue;
962                    }
963                    match propagation_delay_secs(&applied_at, ts) {
964                        Some(delay) => {
965                            if let Err(e) =
966                                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
967                                    callback(ns, delay)
968                                }))
969                            {
970                                tracing::error!(
971                                    namespace = %ns,
972                                    error = ?e,
973                                    "Propagation callback panicked",
974                                );
975                            }
976                        }
977                        None => tracing::warn!(
978                            namespace = %ns,
979                            generated_at = %ts,
980                            "Bad generated_at timestamp",
981                        ),
982                    }
983                }
984            }
985            self.last_generated_at.store(Arc::new(new_generated_at));
986        }
987
988        Ok(true)
989    }
990}
991
992/// Parse an RFC3339 timestamp and return the delay in seconds from then to `now`.
993fn propagation_delay_secs(now: &DateTime<Utc>, generated_at: &str) -> Option<f64> {
994    let generated = DateTime::parse_from_rfc3339(generated_at).ok()?;
995    let delay = (*now - generated.with_timezone(&Utc)).num_milliseconds() as f64 / 1000.0;
996    Some(delay.max(0.0))
997}
998
999/// Per-call jitter in `[0, 1s)` nanoseconds, derived from the address of a
1000/// stack local. Different threads have different stack bases, so the value
1001/// differs across threads; the same thread + call site stays roughly stable.
1002/// Cheap (~5 ns) and avoids `thread_local`.
1003fn stack_jitter_ns() -> u64 {
1004    let local = 0u8;
1005    let addr = &local as *const u8 as usize as u64;
1006    addr.wrapping_mul(0x9E37_79B9_7F4A_7C15) % 1_000_000_000
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012    use std::sync::Mutex;
1013    use tempfile::TempDir;
1014
1015    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
1016        let schema_dir = temp_dir.path().join(namespace);
1017        fs::create_dir_all(&schema_dir).unwrap();
1018        let schema_file = schema_dir.join("schema.json");
1019        fs::write(&schema_file, schema_json).unwrap();
1020        schema_file
1021    }
1022
1023    fn create_test_schema_with_values(
1024        temp_dir: &TempDir,
1025        namespace: &str,
1026        schema_json: &str,
1027        values_json: &str,
1028    ) -> (PathBuf, PathBuf) {
1029        let schemas_dir = temp_dir.path().join("schemas");
1030        let values_dir = temp_dir.path().join("values");
1031
1032        let schema_dir = schemas_dir.join(namespace);
1033        fs::create_dir_all(&schema_dir).unwrap();
1034        let schema_file = schema_dir.join("schema.json");
1035        fs::write(&schema_file, schema_json).unwrap();
1036
1037        let ns_values_dir = values_dir.join(namespace);
1038        fs::create_dir_all(&ns_values_dir).unwrap();
1039        let values_file = ns_values_dir.join("values.json");
1040        fs::write(&values_file, values_json).unwrap();
1041
1042        (schemas_dir, values_dir)
1043    }
1044
1045    #[test]
1046    fn test_feature_property_matches_meta_schema() {
1047        // Fails if the shape drifts from what the meta-schema requires.
1048        let schema = json!({
1049            "version": "1.0",
1050            "type": "object",
1051            "properties": { "feature.red-bar": feature_property() },
1052        })
1053        .to_string();
1054        SchemaRegistry::from_schemas(&[("getsentry-features", &schema)]).unwrap();
1055    }
1056
1057    /// A namespace declaring features by pattern rather than by name.
1058    fn open_features_schema() -> String {
1059        json!({
1060            "version": "1.0",
1061            "type": "object",
1062            "patternProperties": { "^feature\\.": feature_property() },
1063        })
1064        .to_string()
1065    }
1066
1067    fn valid_feature_value() -> Value {
1068        json!({
1069            "created_at": "2026-01-01",
1070            "owner": {"team": "dev-infra"},
1071            "segments": [{"name": "all", "conditions": [], "rollout": 100}],
1072        })
1073    }
1074
1075    #[test]
1076    fn test_open_features_accepts_any_feature_key() {
1077        let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1078        let schema = registry.get("gs").unwrap();
1079
1080        // Any feature name is declared by the pattern, so it survives loading and
1081        // validates against the spliced-in Feature definition.
1082        assert!(schema.accepts_key("feature.organizations:never-enumerated"));
1083        assert!(schema.is_known_key("feature.organizations:never-enumerated"));
1084        registry
1085            .validate_values(
1086                "gs",
1087                &json!({"feature.organizations:never-enumerated": valid_feature_value()}),
1088            )
1089            .unwrap();
1090    }
1091
1092    #[test]
1093    fn test_open_features_still_validates_shape() {
1094        let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1095        // `segments` is required by the Feature definition: an open namespace accepts
1096        // any feature *name*, not any feature *value*.
1097        let result = registry.validate_values(
1098            "gs",
1099            &json!({"feature.organizations:bad": {"created_at": "2026-01-01"}}),
1100        );
1101        assert!(result.is_err(), "expected a Feature shape violation");
1102    }
1103
1104    #[test]
1105    fn test_open_features_does_not_accept_non_feature_keys() {
1106        let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1107        let schema = registry.get("gs").unwrap();
1108
1109        assert!(!schema.accepts_key("some.option"));
1110        let stripped = registry.strip_unknown_keys(
1111            "gs",
1112            &json!({
1113                "feature.organizations:keep": valid_feature_value(),
1114                "some.option": 1,
1115            }),
1116        );
1117        let obj = stripped.as_object().unwrap();
1118        assert!(obj.contains_key("feature.organizations:keep"));
1119        assert!(!obj.contains_key("some.option"));
1120    }
1121
1122    #[test]
1123    fn test_enumerated_features_still_reject_undeclared_names() {
1124        let schema = json!({
1125            "version": "1.0",
1126            "type": "object",
1127            "properties": { "feature.organizations:declared": feature_property() },
1128        })
1129        .to_string();
1130        let registry = SchemaRegistry::from_schemas(&[("gs", &schema)]).unwrap();
1131        let schema = registry.get("gs").unwrap();
1132
1133        assert!(schema.accepts_key("feature.organizations:declared"));
1134        assert!(!schema.accepts_key("feature.organizations:undeclared"));
1135    }
1136
1137    #[test]
1138    fn test_validate_k8s_name_component_valid() {
1139        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
1140        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
1141        assert!(validate_k8s_name_component("a1-b2-c3", "namespace").is_ok());
1142        assert!(validate_k8s_name_component(&"a".repeat(63), "namespace").is_ok());
1143    }
1144
1145    #[test]
1146    fn test_validate_k8s_name_component_rejects_uppercase() {
1147        let result = validate_k8s_name_component("MyService", "namespace");
1148        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1149        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
1150    }
1151
1152    #[test]
1153    fn test_validate_k8s_name_component_rejects_underscore() {
1154        let result = validate_k8s_name_component("my_service", "target");
1155        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1156        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
1157    }
1158
1159    #[test]
1160    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
1161        let result = validate_k8s_name_component("-service", "namespace");
1162        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1163        assert!(
1164            result
1165                .unwrap_err()
1166                .to_string()
1167                .contains("start and end with alphanumeric")
1168        );
1169    }
1170
1171    #[test]
1172    fn test_validate_k8s_name_component_rejects_dot() {
1173        let result = validate_k8s_name_component("my.service", "namespace");
1174        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1175        assert!(result.unwrap_err().to_string().contains("'.' not allowed"));
1176    }
1177
1178    #[test]
1179    fn test_validate_k8s_name_component_rejects_too_long() {
1180        let result = validate_k8s_name_component(&"a".repeat(64), "namespace");
1181        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1182        assert!(result.unwrap_err().to_string().contains("at most 63"));
1183    }
1184
1185    #[test]
1186    fn test_load_schema_valid() {
1187        let temp_dir = TempDir::new().unwrap();
1188        create_test_schema(
1189            &temp_dir,
1190            "test",
1191            r#"{
1192                "version": "1.0",
1193                "type": "object",
1194                "properties": {
1195                    "test-key": {
1196                        "type": "string",
1197                        "default": "test",
1198                        "description": "Test option"
1199                    }
1200                }
1201            }"#,
1202        );
1203
1204        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1205    }
1206
1207    #[test]
1208    fn test_load_schema_missing_version() {
1209        let temp_dir = TempDir::new().unwrap();
1210        create_test_schema(
1211            &temp_dir,
1212            "test",
1213            r#"{
1214                "type": "object",
1215                "properties": {}
1216            }"#,
1217        );
1218
1219        let result = SchemaRegistry::from_directory(temp_dir.path());
1220        assert!(result.is_err());
1221        match result {
1222            Err(ValidationError::SchemaError { message, .. }) => {
1223                assert!(message.contains(
1224                    "Schema validation failed:
1225Error: \"version\" is a required property"
1226                ));
1227            }
1228            _ => panic!("Expected SchemaError for missing version"),
1229        }
1230    }
1231
1232    #[test]
1233    fn test_unknown_namespace() {
1234        let temp_dir = TempDir::new().unwrap();
1235        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1236
1237        let result = registry.validate_values("unknown", &json!({}));
1238        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
1239    }
1240
1241    #[test]
1242    fn test_multiple_namespaces() {
1243        let temp_dir = TempDir::new().unwrap();
1244        create_test_schema(
1245            &temp_dir,
1246            "ns1",
1247            r#"{
1248                "version": "1.0",
1249                "type": "object",
1250                "properties": {
1251                    "opt1": {
1252                        "type": "string",
1253                        "default": "default1",
1254                        "description": "First option"
1255                    }
1256                }
1257            }"#,
1258        );
1259        create_test_schema(
1260            &temp_dir,
1261            "ns2",
1262            r#"{
1263                "version": "2.0",
1264                "type": "object",
1265                "properties": {
1266                    "opt2": {
1267                        "type": "integer",
1268                        "default": 42,
1269                        "description": "Second option"
1270                    }
1271                }
1272            }"#,
1273        );
1274
1275        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1276        assert!(registry.schemas.contains_key("ns1"));
1277        assert!(registry.schemas.contains_key("ns2"));
1278    }
1279
1280    #[test]
1281    fn test_invalid_default_type() {
1282        let temp_dir = TempDir::new().unwrap();
1283        create_test_schema(
1284            &temp_dir,
1285            "test",
1286            r#"{
1287                "version": "1.0",
1288                "type": "object",
1289                "properties": {
1290                    "bad-default": {
1291                        "type": "integer",
1292                        "default": "not-a-number",
1293                        "description": "A bad default value"
1294                    }
1295                }
1296            }"#,
1297        );
1298
1299        let result = SchemaRegistry::from_directory(temp_dir.path());
1300        assert!(result.is_err());
1301        match result {
1302            Err(ValidationError::SchemaError { message, .. }) => {
1303                assert!(
1304                    message.contains("Property 'bad-default': default value does not match schema")
1305                );
1306                assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
1307            }
1308            _ => panic!("Expected SchemaError for invalid default type"),
1309        }
1310    }
1311
1312    #[test]
1313    fn test_extra_properties() {
1314        let temp_dir = TempDir::new().unwrap();
1315        create_test_schema(
1316            &temp_dir,
1317            "test",
1318            r#"{
1319                "version": "1.0",
1320                "type": "object",
1321                "properties": {
1322                    "bad-property": {
1323                        "type": "integer",
1324                        "default": 0,
1325                        "description": "Test property",
1326                        "extra": "property"
1327                    }
1328                }
1329            }"#,
1330        );
1331
1332        let result = SchemaRegistry::from_directory(temp_dir.path());
1333        assert!(result.is_err());
1334        match result {
1335            Err(ValidationError::SchemaError { message, .. }) => {
1336                assert!(
1337                    message
1338                        .contains("Additional properties are not allowed ('extra' was unexpected)")
1339                );
1340            }
1341            _ => panic!("Expected SchemaError for extra properties"),
1342        }
1343    }
1344
1345    #[test]
1346    fn test_missing_description() {
1347        let temp_dir = TempDir::new().unwrap();
1348        create_test_schema(
1349            &temp_dir,
1350            "test",
1351            r#"{
1352                "version": "1.0",
1353                "type": "object",
1354                "properties": {
1355                    "missing-desc": {
1356                        "type": "string",
1357                        "default": "test"
1358                    }
1359                }
1360            }"#,
1361        );
1362
1363        let result = SchemaRegistry::from_directory(temp_dir.path());
1364        assert!(result.is_err());
1365        match result {
1366            Err(ValidationError::SchemaError { message, .. }) => {
1367                assert!(message.contains("\"description\" is a required property"));
1368            }
1369            _ => panic!("Expected SchemaError for missing description"),
1370        }
1371    }
1372
1373    #[test]
1374    fn test_from_schemas_rejects_duplicate_namespace() {
1375        let schema_a = r#"{
1376            "version": "1.0",
1377            "type": "object",
1378            "properties": {
1379                "opt": {"type": "string", "default": "a", "description": "A"}
1380            }
1381        }"#;
1382        let schema_b = r#"{
1383            "version": "1.0",
1384            "type": "object",
1385            "properties": {
1386                "opt": {"type": "string", "default": "b", "description": "B"}
1387            }
1388        }"#;
1389
1390        let result = SchemaRegistry::from_schemas(&[("test", schema_a), ("test", schema_b)]);
1391        match result {
1392            Err(ValidationError::SchemaError { message, .. }) => {
1393                assert!(message.contains("Duplicate namespace 'test'"));
1394            }
1395            _ => panic!("Expected SchemaError for duplicate namespace"),
1396        }
1397    }
1398
1399    #[test]
1400    fn test_invalid_directory_structure() {
1401        let temp_dir = TempDir::new().unwrap();
1402        // Create a namespace directory without schema.json file
1403        let schema_dir = temp_dir.path().join("missing-schema");
1404        fs::create_dir_all(&schema_dir).unwrap();
1405
1406        let result = SchemaRegistry::from_directory(temp_dir.path());
1407        assert!(result.is_err());
1408        match result {
1409            Err(ValidationError::FileRead(..)) => {
1410                // Expected error when schema.json file is missing
1411            }
1412            _ => panic!("Expected FileRead error for missing schema.json"),
1413        }
1414    }
1415
1416    #[test]
1417    fn test_get_default() {
1418        let temp_dir = TempDir::new().unwrap();
1419        create_test_schema(
1420            &temp_dir,
1421            "test",
1422            r#"{
1423                "version": "1.0",
1424                "type": "object",
1425                "properties": {
1426                    "string_opt": {
1427                        "type": "string",
1428                        "default": "hello",
1429                        "description": "A string option"
1430                    },
1431                    "int_opt": {
1432                        "type": "integer",
1433                        "default": 42,
1434                        "description": "An integer option"
1435                    }
1436                }
1437            }"#,
1438        );
1439
1440        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1441        let schema = registry.get("test").unwrap();
1442
1443        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1444        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1445        assert_eq!(schema.get_default("unknown"), None);
1446    }
1447
1448    #[test]
1449    fn test_validate_values_valid() {
1450        let temp_dir = TempDir::new().unwrap();
1451        create_test_schema(
1452            &temp_dir,
1453            "test",
1454            r#"{
1455                "version": "1.0",
1456                "type": "object",
1457                "properties": {
1458                    "enabled": {
1459                        "type": "boolean",
1460                        "default": false,
1461                        "description": "Enable feature"
1462                    }
1463                }
1464            }"#,
1465        );
1466
1467        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1468        let result = registry.validate_values("test", &json!({"enabled": true}));
1469        assert!(result.is_ok());
1470    }
1471
1472    #[test]
1473    fn test_validate_values_invalid_type() {
1474        let temp_dir = TempDir::new().unwrap();
1475        create_test_schema(
1476            &temp_dir,
1477            "test",
1478            r#"{
1479                "version": "1.0",
1480                "type": "object",
1481                "properties": {
1482                    "count": {
1483                        "type": "integer",
1484                        "default": 0,
1485                        "description": "Count"
1486                    }
1487                }
1488            }"#,
1489        );
1490
1491        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1492        let result = registry.validate_values("test", &json!({"count": "not a number"}));
1493        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1494    }
1495
1496    #[test]
1497    fn test_validate_values_unknown_option() {
1498        let temp_dir = TempDir::new().unwrap();
1499        create_test_schema(
1500            &temp_dir,
1501            "test",
1502            r#"{
1503                "version": "1.0",
1504                "type": "object",
1505                "properties": {
1506                    "known_option": {
1507                        "type": "string",
1508                        "default": "default",
1509                        "description": "A known option"
1510                    }
1511                }
1512            }"#,
1513        );
1514
1515        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1516
1517        // Valid known option should pass
1518        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1519        assert!(result.is_ok());
1520
1521        // Unknown option should fail
1522        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1523        assert!(result.is_err());
1524        match result {
1525            Err(ValidationError::ValueError { errors, .. }) => {
1526                assert!(errors.contains("Additional properties are not allowed"));
1527            }
1528            _ => panic!("Expected ValueError for unknown option"),
1529        }
1530    }
1531
1532    #[test]
1533    fn test_object_with_additional_properties() {
1534        let temp_dir = TempDir::new().unwrap();
1535        create_test_schema(
1536            &temp_dir,
1537            "test",
1538            r#"{
1539                "version": "1.0",
1540                "type": "object",
1541                "properties": {
1542                    "scopes": {
1543                        "type": "object",
1544                        "additionalProperties": {"type": "string"},
1545                        "default": {},
1546                        "description": "A dynamic string-to-string map"
1547                    }
1548                }
1549            }"#,
1550        );
1551
1552        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1553
1554        assert!(
1555            registry
1556                .validate_values("test", &json!({"scopes": {}}))
1557                .is_ok()
1558        );
1559        assert!(
1560            registry
1561                .validate_values(
1562                    "test",
1563                    &json!({"scopes": {"read": "true", "write": "false"}})
1564                )
1565                .is_ok()
1566        );
1567        assert!(matches!(
1568            registry.validate_values("test", &json!({"scopes": {"read": 42}})),
1569            Err(ValidationError::ValueError { .. })
1570        ));
1571    }
1572
1573    #[test]
1574    fn test_object_without_additional_properties_still_rejects_unknown_keys() {
1575        // Structured object schemas (with properties, no additionalProperties) must
1576        // still reject unknown keys after the fix.
1577        let temp_dir = TempDir::new().unwrap();
1578        create_test_schema(
1579            &temp_dir,
1580            "test",
1581            r#"{
1582                "version": "1.0",
1583                "type": "object",
1584                "properties": {
1585                    "config": {
1586                        "type": "object",
1587                        "properties": {
1588                            "host": {"type": "string"}
1589                        },
1590                        "default": {"host": "localhost"},
1591                        "description": "Server config"
1592                    }
1593                }
1594            }"#,
1595        );
1596
1597        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1598
1599        // Known key is valid
1600        let result = registry.validate_values("test", &json!({"config": {"host": "example.com"}}));
1601        assert!(result.is_ok());
1602
1603        // Unknown key must fail
1604        let result = registry.validate_values(
1605            "test",
1606            &json!({"config": {"host": "example.com", "unknown": "x"}}),
1607        );
1608        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1609    }
1610
1611    #[test]
1612    fn test_object_with_fixed_properties_and_additional_properties_enforces_required() {
1613        // A schema that has both fixed properties and additionalProperties should still
1614        // enforce required on the declared fields.
1615        let temp_dir = TempDir::new().unwrap();
1616        create_test_schema(
1617            &temp_dir,
1618            "test",
1619            r#"{
1620                "version": "1.0",
1621                "type": "object",
1622                "properties": {
1623                    "config": {
1624                        "type": "object",
1625                        "properties": {
1626                            "name": {"type": "string"}
1627                        },
1628                        "additionalProperties": {"type": "string"},
1629                        "default": {"name": "default"},
1630                        "description": "Config with fixed and dynamic keys"
1631                    }
1632                }
1633            }"#,
1634        );
1635
1636        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1637
1638        // Fixed field present, extra dynamic keys allowed
1639        let result =
1640            registry.validate_values("test", &json!({"config": {"name": "x", "extra": "y"}}));
1641        assert!(result.is_ok());
1642
1643        // Missing required fixed field must fail
1644        let result = registry.validate_values("test", &json!({"config": {"extra": "y"}}));
1645        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1646    }
1647
1648    #[test]
1649    fn test_load_values_json_valid() {
1650        let temp_dir = TempDir::new().unwrap();
1651        let schemas_dir = temp_dir.path().join("schemas");
1652        let values_dir = temp_dir.path().join("values");
1653
1654        let schema_dir = schemas_dir.join("test");
1655        fs::create_dir_all(&schema_dir).unwrap();
1656        fs::write(
1657            schema_dir.join("schema.json"),
1658            r#"{
1659                "version": "1.0",
1660                "type": "object",
1661                "properties": {
1662                    "enabled": {
1663                        "type": "boolean",
1664                        "default": false,
1665                        "description": "Enable feature"
1666                    },
1667                    "name": {
1668                        "type": "string",
1669                        "default": "default",
1670                        "description": "Name"
1671                    },
1672                    "count": {
1673                        "type": "integer",
1674                        "default": 0,
1675                        "description": "Count"
1676                    },
1677                    "rate": {
1678                        "type": "number",
1679                        "default": 0.0,
1680                        "description": "Rate"
1681                    }
1682                }
1683            }"#,
1684        )
1685        .unwrap();
1686
1687        let test_values_dir = values_dir.join("test");
1688        fs::create_dir_all(&test_values_dir).unwrap();
1689        fs::write(
1690            test_values_dir.join("values.json"),
1691            r#"{
1692                "options": {
1693                    "enabled": true,
1694                    "name": "test-name",
1695                    "count": 42,
1696                    "rate": 0.75
1697                }
1698            }"#,
1699        )
1700        .unwrap();
1701
1702        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1703        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1704
1705        assert_eq!(values.len(), 1);
1706        assert_eq!(values["test"]["enabled"], json!(true));
1707        assert_eq!(values["test"]["name"], json!("test-name"));
1708        assert_eq!(values["test"]["count"], json!(42));
1709        assert_eq!(values["test"]["rate"], json!(0.75));
1710        assert!(generated_at_by_namespace.is_empty());
1711    }
1712
1713    #[test]
1714    fn test_load_values_json_nonexistent_dir() {
1715        let temp_dir = TempDir::new().unwrap();
1716        create_test_schema(
1717            &temp_dir,
1718            "test",
1719            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1720        );
1721
1722        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1723        let (values, generated_at_by_namespace) = registry
1724            .load_values_json(&temp_dir.path().join("nonexistent"))
1725            .unwrap();
1726
1727        // No values.json files found, returns empty
1728        assert!(values.is_empty());
1729        assert!(generated_at_by_namespace.is_empty());
1730    }
1731
1732    #[test]
1733    fn test_load_values_json_strips_unknown_keys() {
1734        let temp_dir = TempDir::new().unwrap();
1735        let schemas_dir = temp_dir.path().join("schemas");
1736        let values_dir = temp_dir.path().join("values");
1737
1738        let schema_dir = schemas_dir.join("test");
1739        fs::create_dir_all(&schema_dir).unwrap();
1740        fs::write(
1741            schema_dir.join("schema.json"),
1742            r#"{
1743                "version": "1.0",
1744                "type": "object",
1745                "properties": {
1746                    "known-option": {
1747                        "type": "string",
1748                        "default": "default",
1749                        "description": "A known option"
1750                    }
1751                }
1752            }"#,
1753        )
1754        .unwrap();
1755
1756        let test_values_dir = values_dir.join("test");
1757        fs::create_dir_all(&test_values_dir).unwrap();
1758        fs::write(
1759            test_values_dir.join("values.json"),
1760            r#"{
1761                "options": {
1762                    "known-option": "hello",
1763                    "unknown-option": "should be stripped"
1764                }
1765            }"#,
1766        )
1767        .unwrap();
1768
1769        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1770        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1771
1772        assert_eq!(values["test"]["known-option"], json!("hello"));
1773        assert!(!values["test"].contains_key("unknown-option"));
1774    }
1775
1776    #[test]
1777    fn test_load_values_json_skips_missing_values_file() {
1778        let temp_dir = TempDir::new().unwrap();
1779        let schemas_dir = temp_dir.path().join("schemas");
1780        let values_dir = temp_dir.path().join("values");
1781
1782        // Create two schemas
1783        let schema_dir1 = schemas_dir.join("with-values");
1784        fs::create_dir_all(&schema_dir1).unwrap();
1785        fs::write(
1786            schema_dir1.join("schema.json"),
1787            r#"{
1788                "version": "1.0",
1789                "type": "object",
1790                "properties": {
1791                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1792                }
1793            }"#,
1794        )
1795        .unwrap();
1796
1797        let schema_dir2 = schemas_dir.join("without-values");
1798        fs::create_dir_all(&schema_dir2).unwrap();
1799        fs::write(
1800            schema_dir2.join("schema.json"),
1801            r#"{
1802                "version": "1.0",
1803                "type": "object",
1804                "properties": {
1805                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1806                }
1807            }"#,
1808        )
1809        .unwrap();
1810
1811        // Only create values for one namespace
1812        let with_values_dir = values_dir.join("with-values");
1813        fs::create_dir_all(&with_values_dir).unwrap();
1814        fs::write(
1815            with_values_dir.join("values.json"),
1816            r#"{"options": {"opt": "y"}}"#,
1817        )
1818        .unwrap();
1819
1820        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1821        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1822
1823        assert_eq!(values.len(), 1);
1824        assert!(values.contains_key("with-values"));
1825        assert!(!values.contains_key("without-values"));
1826    }
1827
1828    #[test]
1829    fn test_load_values_json_extracts_generated_at() {
1830        let temp_dir = TempDir::new().unwrap();
1831        let schemas_dir = temp_dir.path().join("schemas");
1832        let values_dir = temp_dir.path().join("values");
1833
1834        let schema_dir = schemas_dir.join("test");
1835        fs::create_dir_all(&schema_dir).unwrap();
1836        fs::write(
1837            schema_dir.join("schema.json"),
1838            r#"{
1839                "version": "1.0",
1840                "type": "object",
1841                "properties": {
1842                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1843                }
1844            }"#,
1845        )
1846        .unwrap();
1847
1848        let test_values_dir = values_dir.join("test");
1849        fs::create_dir_all(&test_values_dir).unwrap();
1850        fs::write(
1851            test_values_dir.join("values.json"),
1852            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1853        )
1854        .unwrap();
1855
1856        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1857        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1858
1859        assert_eq!(values["test"]["enabled"], json!(true));
1860        assert_eq!(
1861            generated_at_by_namespace.get("test"),
1862            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1863        );
1864    }
1865
1866    #[test]
1867    fn test_propagation_event_emitted_on_generated_at_change() {
1868        let temp_dir = TempDir::new().unwrap();
1869        let schemas_dir = temp_dir.path().join("schemas");
1870        let values_dir = temp_dir.path().join("values");
1871
1872        let schema_dir = schemas_dir.join("test");
1873        fs::create_dir_all(&schema_dir).unwrap();
1874        fs::write(
1875            schema_dir.join("schema.json"),
1876            r#"{
1877                "version": "1.0",
1878                "type": "object",
1879                "properties": {
1880                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1881                }
1882            }"#,
1883        )
1884        .unwrap();
1885
1886        let test_values_dir = values_dir.join("test");
1887        fs::create_dir_all(&test_values_dir).unwrap();
1888        fs::write(
1889            test_values_dir.join("values.json"),
1890            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1891        )
1892        .unwrap();
1893
1894        let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1895        let events_clone = events.clone();
1896
1897        let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1898        let store = ValuesStore::builder(registry, &values_dir)
1899            .with_refresh_threshold(Duration::ZERO)
1900            .with_callback(Box::new(move |ns, delay| {
1901                events_clone.lock().unwrap().push((ns.to_string(), delay));
1902            }))
1903            .build()
1904            .unwrap();
1905
1906        // Initial load doesn't emit (generated_at set during construction).
1907        assert!(events.lock().unwrap().is_empty());
1908
1909        // Same generated_at — no event on reload.
1910        let _ = store.load();
1911        assert!(events.lock().unwrap().is_empty());
1912
1913        // Update the generated_at timestamp — should emit on next load.
1914        fs::write(
1915            test_values_dir.join("values.json"),
1916            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1917        )
1918        .unwrap();
1919
1920        let _ = store.load();
1921        let captured = events.lock().unwrap();
1922        assert_eq!(captured.len(), 1);
1923        assert_eq!(captured[0].0, "test");
1924        assert!(captured[0].1 > 0.0);
1925    }
1926
1927    #[test]
1928    fn test_propagation_event_not_emitted_without_callback() {
1929        let temp_dir = TempDir::new().unwrap();
1930        let schemas_dir = temp_dir.path().join("schemas");
1931        let values_dir = temp_dir.path().join("values");
1932
1933        let schema_dir = schemas_dir.join("test");
1934        fs::create_dir_all(&schema_dir).unwrap();
1935        fs::write(
1936            schema_dir.join("schema.json"),
1937            r#"{
1938                "version": "1.0",
1939                "type": "object",
1940                "properties": {
1941                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1942                }
1943            }"#,
1944        )
1945        .unwrap();
1946
1947        let test_values_dir = values_dir.join("test");
1948        fs::create_dir_all(&test_values_dir).unwrap();
1949        fs::write(
1950            test_values_dir.join("values.json"),
1951            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1952        )
1953        .unwrap();
1954
1955        let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1956        // No callback — should not panic or error.
1957        let store = ValuesStore::builder(registry, &values_dir)
1958            .with_refresh_threshold(Duration::ZERO)
1959            .build()
1960            .unwrap();
1961        let _ = store.load();
1962        // Just verify it doesn't crash.
1963    }
1964
1965    /// Write a boolean-only schema and a values file for `namespace` under
1966    /// `base`, returning the path of the written `values.json`.
1967    fn write_ns(base: &Path, namespace: &str, generated_at: &str) -> PathBuf {
1968        let schema_dir = base.join("schemas").join(namespace);
1969        fs::create_dir_all(&schema_dir).unwrap();
1970        fs::write(
1971            schema_dir.join("schema.json"),
1972            r#"{
1973                "version": "1.0",
1974                "type": "object",
1975                "properties": {
1976                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1977                }
1978            }"#,
1979        )
1980        .unwrap();
1981
1982        let ns_values_dir = base.join("values").join(namespace);
1983        fs::create_dir_all(&ns_values_dir).unwrap();
1984        let values_file = ns_values_dir.join("values.json");
1985        fs::write(
1986            &values_file,
1987            format!(r#"{{"options": {{"enabled": true}}, "generated_at": "{generated_at}"}}"#),
1988        )
1989        .unwrap();
1990        values_file
1991    }
1992
1993    #[test]
1994    fn test_propagation_delay_secs_parsing() {
1995        let now: DateTime<Utc> = "2024-01-21T19:00:00+00:00".parse().unwrap();
1996
1997        // Normal case: 30 minutes elapsed.
1998        assert_eq!(
1999            propagation_delay_secs(&now, "2024-01-21T18:30:00+00:00"),
2000            Some(1800.0)
2001        );
2002        // Clock skew (generated_at in the future) clamps to 0 rather than going negative.
2003        assert_eq!(
2004            propagation_delay_secs(&now, "2024-01-21T19:05:00+00:00"),
2005            Some(0.0)
2006        );
2007        // Malformed timestamp returns None.
2008        assert_eq!(propagation_delay_secs(&now, "not-a-timestamp"), None);
2009    }
2010
2011    #[test]
2012    fn test_propagation_callback_panic_is_caught() {
2013        let temp_dir = TempDir::new().unwrap();
2014        let base = temp_dir.path();
2015        let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2016
2017        let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2018        let store = ValuesStore::builder(registry, &base.join("values"))
2019            .with_refresh_threshold(Duration::ZERO)
2020            .with_callback(Box::new(|_ns, _delay| panic!("callback boom")))
2021            .build()
2022            .unwrap();
2023
2024        // Trigger a change so the panicking callback fires; load() must not unwind.
2025        fs::write(
2026            &values_file,
2027            r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2028        )
2029        .unwrap();
2030        let _ = store.load();
2031
2032        // The values refresh still applied despite the callback panic.
2033        assert_eq!(
2034            store.values.load().get("test").unwrap().get("enabled"),
2035            Some(&json!(false))
2036        );
2037    }
2038
2039    #[test]
2040    fn test_propagation_malformed_generated_at_does_not_emit() {
2041        let temp_dir = TempDir::new().unwrap();
2042        let base = temp_dir.path();
2043        let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2044
2045        let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2046        let events_clone = events.clone();
2047        let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2048        let store = ValuesStore::builder(registry, &base.join("values"))
2049            .with_refresh_threshold(Duration::ZERO)
2050            .with_callback(Box::new(move |ns, delay| {
2051                events_clone.lock().unwrap().push((ns.to_string(), delay));
2052            }))
2053            .build()
2054            .unwrap();
2055
2056        // Change generated_at to a malformed value: detected as a change, but
2057        // unparseable, so no event is emitted.
2058        fs::write(
2059            &values_file,
2060            r#"{"options": {"enabled": true}, "generated_at": "garbage"}"#,
2061        )
2062        .unwrap();
2063        let _ = store.load();
2064        assert!(events.lock().unwrap().is_empty());
2065    }
2066
2067    #[test]
2068    fn test_propagation_only_changed_namespace_emits() {
2069        let temp_dir = TempDir::new().unwrap();
2070        let base = temp_dir.path();
2071        write_ns(base, "alpha", "2024-01-21T18:00:00+00:00");
2072        let beta_values = write_ns(base, "beta", "2024-01-21T18:00:00+00:00");
2073
2074        let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2075        let events_clone = events.clone();
2076        let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2077        let store = ValuesStore::builder(registry, &base.join("values"))
2078            .with_refresh_threshold(Duration::ZERO)
2079            .with_callback(Box::new(move |ns, delay| {
2080                events_clone.lock().unwrap().push((ns.to_string(), delay));
2081            }))
2082            .build()
2083            .unwrap();
2084
2085        // Only beta's generated_at changes; alpha is untouched.
2086        fs::write(
2087            &beta_values,
2088            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2089        )
2090        .unwrap();
2091        let _ = store.load();
2092
2093        let captured = events.lock().unwrap();
2094        assert_eq!(captured.len(), 1);
2095        assert_eq!(captured[0].0, "beta");
2096    }
2097
2098    #[test]
2099    fn test_load_values_json_rejects_wrong_type() {
2100        let temp_dir = TempDir::new().unwrap();
2101        let schemas_dir = temp_dir.path().join("schemas");
2102        let values_dir = temp_dir.path().join("values");
2103
2104        let schema_dir = schemas_dir.join("test");
2105        fs::create_dir_all(&schema_dir).unwrap();
2106        fs::write(
2107            schema_dir.join("schema.json"),
2108            r#"{
2109                "version": "1.0",
2110                "type": "object",
2111                "properties": {
2112                    "count": {"type": "integer", "default": 0, "description": "Count"}
2113                }
2114            }"#,
2115        )
2116        .unwrap();
2117
2118        let test_values_dir = values_dir.join("test");
2119        fs::create_dir_all(&test_values_dir).unwrap();
2120        fs::write(
2121            test_values_dir.join("values.json"),
2122            r#"{"options": {"count": "not-a-number"}}"#,
2123        )
2124        .unwrap();
2125
2126        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2127        let result = registry.load_values_json(&values_dir);
2128
2129        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2130    }
2131
2132    mod feature_flag_tests {
2133        use super::*;
2134
2135        const FEATURE_SCHEMA: &str = r##"{
2136            "version": "1.0",
2137            "type": "object",
2138            "properties": {
2139                "feature.organizations:fury-mode": {
2140                  "$ref": "#/definitions/Feature"
2141                }
2142            }
2143        }"##;
2144
2145        #[test]
2146        fn test_schema_with_valid_feature_flag() {
2147            let temp_dir = TempDir::new().unwrap();
2148            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2149            assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
2150        }
2151
2152        #[test]
2153        fn test_schema_with_feature_and_regular_option() {
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                        "my-option": {
2163                            "type": "string",
2164                            "default": "hello",
2165                            "description": "A regular option"
2166                        },
2167                        "feature.organizations:fury-mode": {
2168                            "$ref": "#/definitions/Feature"
2169                        }
2170                    }
2171                }"##,
2172            );
2173            assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
2174        }
2175
2176        #[test]
2177        fn test_schema_with_invalid_feature_definition() {
2178            let temp_dir = TempDir::new().unwrap();
2179
2180            // namespace schema is invalid as feature flag is invalid.
2181            create_test_schema(
2182                &temp_dir,
2183                "test",
2184                r#"{
2185                    "version": "1.0",
2186                    "type": "object",
2187                    "properties": {
2188                        "feature.organizations:fury-mode": {
2189                            "nope": "nope"
2190                        }
2191                    }
2192                }"#,
2193            );
2194            let result = SchemaRegistry::from_directory(temp_dir.path());
2195            assert!(result.is_err());
2196        }
2197
2198        #[test]
2199        fn test_validate_values_with_valid_feature_flag() {
2200            let temp_dir = TempDir::new().unwrap();
2201            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2202            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2203
2204            let result = registry.validate_values(
2205                "test",
2206                &json!({
2207                    "feature.organizations:fury-mode": {
2208                        "owner": {"team": "hybrid-cloud"},
2209                        "segments": [],
2210                        "created_at": "2024-01-01"
2211                    }
2212                }),
2213            );
2214            assert!(result.is_ok());
2215        }
2216
2217        #[test]
2218        fn test_validate_values_with_feature_flag_missing_required_field_fails() {
2219            let temp_dir = TempDir::new().unwrap();
2220            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2221            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2222
2223            // Missing owner field
2224            let result = registry.validate_values(
2225                "test",
2226                &json!({
2227                    "feature.organizations:fury-mode": {
2228                        "segments": [],
2229                        "created_at": "2024-01-01"
2230                    }
2231                }),
2232            );
2233            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2234        }
2235
2236        #[test]
2237        fn test_validate_values_with_feature_flag_invalid_owner_fails() {
2238            let temp_dir = TempDir::new().unwrap();
2239            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2240            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2241
2242            // Owner missing required team field
2243            let result = registry.validate_values(
2244                "test",
2245                &json!({
2246                    "feature.organizations:fury-mode": {
2247                        "owner": {"email": "test@example.com"},
2248                        "segments": [],
2249                        "created_at": "2024-01-01"
2250                    }
2251                }),
2252            );
2253            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2254        }
2255
2256        #[test]
2257        fn test_validate_values_feature_with_segments_and_conditions() {
2258            let temp_dir = TempDir::new().unwrap();
2259            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2260            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2261
2262            let result = registry.validate_values(
2263                "test",
2264                &json!({
2265                    "feature.organizations:fury-mode": {
2266                        "owner": {"team": "hybrid-cloud"},
2267                        "enabled": true,
2268                        "created_at": "2024-01-01T00:00:00",
2269                        "segments": [
2270                            {
2271                                "name": "internal orgs",
2272                                "rollout": 50,
2273                                "conditions": [
2274                                    {
2275                                        "property": "organization_slug",
2276                                        "operator": "in",
2277                                        "value": ["sentry-test", "sentry"]
2278                                    }
2279                                ]
2280                            }
2281                        ]
2282                    }
2283                }),
2284            );
2285            assert!(result.is_ok());
2286        }
2287
2288        #[test]
2289        fn test_validate_values_feature_with_multiple_condition_operators() {
2290            let temp_dir = TempDir::new().unwrap();
2291            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2292            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2293
2294            let result = registry.validate_values(
2295                "test",
2296                &json!({
2297                    "feature.organizations:fury-mode": {
2298                        "owner": {"team": "hybrid-cloud"},
2299                        "created_at": "2024-01-01",
2300                        "segments": [
2301                            {
2302                                "name": "free accounts",
2303                                "conditions": [
2304                                    {
2305                                        "property": "subscription_is_free",
2306                                        "operator": "equals",
2307                                        "value": true
2308                                    }
2309                                ]
2310                            }
2311                        ]
2312                    }
2313                }),
2314            );
2315            assert!(result.is_ok());
2316        }
2317
2318        #[test]
2319        fn test_validate_values_feature_with_invalid_condition_operator_fails() {
2320            let temp_dir = TempDir::new().unwrap();
2321            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2322            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2323
2324            // Use an operator that doesn't exist
2325            let result = registry.validate_values(
2326                "test",
2327                &json!({
2328                    "feature.organizations:fury-mode": {
2329                        "owner": {"team": "hybrid-cloud"},
2330                        "created_at": "2024-01-01",
2331                        "segments": [
2332                            {
2333                                "name": "test segment",
2334                                "conditions": [
2335                                    {
2336                                        "property": "some_prop",
2337                                        "operator": "invalid_operator",
2338                                        "value": "some_value"
2339                                    }
2340                                ]
2341                            }
2342                        ]
2343                    }
2344                }),
2345            );
2346            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2347        }
2348
2349        #[test]
2350        fn test_schema_feature_flag_not_in_options_map() {
2351            // Feature flags are not added to default values
2352            let temp_dir = TempDir::new().unwrap();
2353            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2354            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2355            let schema = registry.get("test").unwrap();
2356
2357            assert!(
2358                schema
2359                    .get_default("feature.organizations:fury-mode")
2360                    .is_none()
2361            );
2362        }
2363
2364        #[test]
2365        fn test_validate_values_feature_and_regular_option_together() {
2366            let temp_dir = TempDir::new().unwrap();
2367            create_test_schema(
2368                &temp_dir,
2369                "test",
2370                r##"{
2371                    "version": "1.0",
2372                    "type": "object",
2373                    "properties": {
2374                        "my-option": {
2375                            "type": "string",
2376                            "default": "hello",
2377                            "description": "A regular option"
2378                        },
2379                        "feature.organizations:fury-mode": {
2380                            "$ref": "#/definitions/Feature"
2381                        }
2382                    }
2383                }"##,
2384            );
2385            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2386
2387            // Both options are valid
2388            let result = registry.validate_values(
2389                "test",
2390                &json!({
2391                    "my-option": "world",
2392                    "feature.organizations:fury-mode": {
2393                        "owner": {"team": "hybrid-cloud"},
2394                        "segments": [],
2395                        "created_at": "2024-01-01"
2396                    }
2397                }),
2398            );
2399            assert!(result.is_ok());
2400        }
2401
2402        #[test]
2403        fn test_feature_flag_is_known_key() {
2404            let temp_dir = TempDir::new().unwrap();
2405            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2406            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2407            let schema = registry.get("test").unwrap();
2408            // make sure it's known...
2409            assert!(schema.is_known_key("feature.organizations:fury-mode"));
2410            // ...but not a runtime option
2411            assert!(
2412                !schema
2413                    .options
2414                    .contains_key("feature.organizations:fury-mode")
2415            );
2416            assert!(!schema.is_known_key("feature.organizations:does-not-exist"));
2417        }
2418
2419        #[test]
2420        fn test_validate_option_accepts_valid_feature() {
2421            // Enables override_options(ns, {"feature.x": <Feature>}) with only the
2422            // schema present (no value in the store).
2423            let temp_dir = TempDir::new().unwrap();
2424            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2425            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2426            let schema = registry.get("test").unwrap();
2427
2428            let result = schema.validate_option(
2429                "feature.organizations:fury-mode",
2430                &json!({
2431                    "owner": {"team": "hybrid-cloud"},
2432                    "segments": [{"name": "all", "rollout": 100, "conditions": []}],
2433                    "created_at": "2024-01-01"
2434                }),
2435            );
2436            assert!(result.is_ok());
2437        }
2438
2439        #[test]
2440        fn test_validate_option_rejects_malformed_feature() {
2441            // A known feature key with a value that isn't a valid Feature must fail
2442            // validation (ValueError), not slip through as UnknownOption.
2443            let temp_dir = TempDir::new().unwrap();
2444            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2445            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2446            let schema = registry.get("test").unwrap();
2447
2448            // Missing required `owner` and `created_at`.
2449            let result =
2450                schema.validate_option("feature.organizations:fury-mode", &json!({"segments": []}));
2451            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2452        }
2453
2454        #[test]
2455        fn test_validate_values_feature_with_experiment_mode() {
2456            let temp_dir = TempDir::new().unwrap();
2457            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2458            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2459
2460            let result = registry.validate_values(
2461                "test",
2462                &json!({
2463                    "feature.organizations:fury-mode": {
2464                        "owner": {"team": "hybrid-cloud"},
2465                        "segments": [],
2466                        "created_at": "2024-01-01",
2467                        "experiment_mode": "simple"
2468                    }
2469                }),
2470            );
2471            assert!(result.is_ok());
2472        }
2473
2474        #[test]
2475        fn test_validate_values_feature_with_invalid_experiment_mode_fails() {
2476            let temp_dir = TempDir::new().unwrap();
2477            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2478            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2479
2480            let result = registry.validate_values(
2481                "test",
2482                &json!({
2483                    "feature.organizations:fury-mode": {
2484                        "owner": {"team": "hybrid-cloud"},
2485                        "segments": [],
2486                        "created_at": "2024-01-01",
2487                        "experiment_mode": "not-a-real-mode"
2488                    }
2489                }),
2490            );
2491            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2492        }
2493
2494        #[test]
2495        fn test_validate_values_feature_without_experiment_mode_still_valid() {
2496            let temp_dir = TempDir::new().unwrap();
2497            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2498            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2499
2500            let result = registry.validate_values(
2501                "test",
2502                &json!({
2503                    "feature.organizations:fury-mode": {
2504                        "owner": {"team": "hybrid-cloud"},
2505                        "segments": [],
2506                        "created_at": "2024-01-01"
2507                    }
2508                }),
2509            );
2510            assert!(result.is_ok());
2511        }
2512    }
2513
2514    mod store_tests {
2515        use super::*;
2516
2517        /// Creates schema and values files for two namespaces: ns1 and ns2.
2518        fn setup_store_test() -> (TempDir, PathBuf, PathBuf) {
2519            let temp_dir = TempDir::new().unwrap();
2520            let schemas_dir = temp_dir.path().join("schemas");
2521            let values_dir = temp_dir.path().join("values");
2522
2523            let ns1_schema = schemas_dir.join("ns1");
2524            fs::create_dir_all(&ns1_schema).unwrap();
2525            fs::write(
2526                ns1_schema.join("schema.json"),
2527                r#"{
2528                    "version": "1.0",
2529                    "type": "object",
2530                    "properties": {
2531                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
2532                    }
2533                }"#,
2534            )
2535            .unwrap();
2536
2537            let ns1_values = values_dir.join("ns1");
2538            fs::create_dir_all(&ns1_values).unwrap();
2539            fs::write(
2540                ns1_values.join("values.json"),
2541                r#"{"options": {"enabled": true}}"#,
2542            )
2543            .unwrap();
2544
2545            let ns2_schema = schemas_dir.join("ns2");
2546            fs::create_dir_all(&ns2_schema).unwrap();
2547            fs::write(
2548                ns2_schema.join("schema.json"),
2549                r#"{
2550                    "version": "1.0",
2551                    "type": "object",
2552                    "properties": {
2553                        "count": {"type": "integer", "default": 0, "description": "Count"}
2554                    }
2555                }"#,
2556            )
2557            .unwrap();
2558
2559            let ns2_values = values_dir.join("ns2");
2560            fs::create_dir_all(&ns2_values).unwrap();
2561            fs::write(
2562                ns2_values.join("values.json"),
2563                r#"{"options": {"count": 42}}"#,
2564            )
2565            .unwrap();
2566
2567            (temp_dir, schemas_dir, values_dir)
2568        }
2569
2570        fn store_with_zero_threshold(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2571            let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2572            ValuesStore::builder(registry, values_dir)
2573                .with_refresh_threshold(Duration::ZERO)
2574                .build()
2575                .unwrap()
2576        }
2577
2578        fn store_without_refresh_on_read(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2579            let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2580            ValuesStore::builder(registry, values_dir)
2581                .with_refresh_threshold(None)
2582                .build()
2583                .unwrap()
2584        }
2585
2586        #[test]
2587        fn test_initial_load_populates_values() {
2588            let (_temp, schemas_dir, values_dir) = setup_store_test();
2589            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2590            let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2591
2592            let guard = store.load();
2593            assert_eq!(guard["ns1"]["enabled"], json!(true));
2594            assert_eq!(guard["ns2"]["count"], json!(42));
2595        }
2596
2597        #[test]
2598        fn test_read_within_threshold_serves_cached() {
2599            // Default 5 s threshold: a modification followed by an immediate
2600            // read should still see the cached value.
2601            let (_temp, schemas_dir, values_dir) = setup_store_test();
2602            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2603            let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2604
2605            // Confirm initial value, then modify the file.
2606            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2607            fs::write(
2608                values_dir.join("ns1").join("values.json"),
2609                r#"{"options": {"enabled": false}}"#,
2610            )
2611            .unwrap();
2612
2613            // Within the threshold window, the cached value is still served.
2614            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2615        }
2616
2617        #[test]
2618        fn test_read_after_threshold_refreshes() {
2619            let (_temp, schemas_dir, values_dir) = setup_store_test();
2620            let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2621
2622            // Initial values.
2623            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2624            assert_eq!(store.load()["ns2"]["count"], json!(42));
2625
2626            // Modify both namespaces.
2627            fs::write(
2628                values_dir.join("ns1").join("values.json"),
2629                r#"{"options": {"enabled": false}}"#,
2630            )
2631            .unwrap();
2632            fs::write(
2633                values_dir.join("ns2").join("values.json"),
2634                r#"{"options": {"count": 100}}"#,
2635            )
2636            .unwrap();
2637
2638            // With a zero threshold, the next read refreshes.
2639            let guard = store.load();
2640            assert_eq!(guard["ns1"]["enabled"], json!(false));
2641            assert_eq!(guard["ns2"]["count"], json!(100));
2642        }
2643
2644        #[test]
2645        fn test_refresh_failure_keeps_old_values() {
2646            let (_temp, schemas_dir, values_dir) = setup_store_test();
2647            let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2648
2649            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2650
2651            // Replace ns1 values with a type-incompatible payload.
2652            fs::write(
2653                values_dir.join("ns1").join("values.json"),
2654                r#"{"options": {"enabled": "not-a-boolean"}}"#,
2655            )
2656            .unwrap();
2657
2658            // Refresh attempt fails; old value still served.
2659            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2660        }
2661
2662        #[test]
2663        fn test_concurrent_reads_observe_new_values() {
2664            use std::thread;
2665
2666            let (_temp, schemas_dir, values_dir) = setup_store_test();
2667            let store = Arc::new(store_with_zero_threshold(&schemas_dir, &values_dir));
2668
2669            // Prime: every thread sees the initial value.
2670            assert_eq!(store.load()["ns2"]["count"], json!(42));
2671
2672            fs::write(
2673                values_dir.join("ns2").join("values.json"),
2674                r#"{"options": {"count": 7}}"#,
2675            )
2676            .unwrap();
2677
2678            let mut handles = Vec::new();
2679            for _ in 0..8 {
2680                let store = Arc::clone(&store);
2681                handles.push(thread::spawn(move || {
2682                    let guard = store.load();
2683                    guard["ns2"]["count"].clone()
2684                }));
2685            }
2686            for h in handles {
2687                assert_eq!(h.join().unwrap(), json!(7));
2688            }
2689        }
2690
2691        #[test]
2692        fn test_refresh_returns_true_on_change_false_when_unchanged() {
2693            let (_temp, schemas_dir, values_dir) = setup_store_test();
2694            let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2695
2696            // Nothing changed on disk since construction.
2697            assert!(!store.refresh().unwrap());
2698
2699            fs::write(
2700                values_dir.join("ns1").join("values.json"),
2701                r#"{"options": {"enabled": false}}"#,
2702            )
2703            .unwrap();
2704            assert!(store.refresh().unwrap());
2705
2706            // Unchanged again after the reload.
2707            assert!(!store.refresh().unwrap());
2708        }
2709
2710        #[test]
2711        fn test_disabled_threshold_never_refreshes_on_read() {
2712            let (_temp, schemas_dir, values_dir) = setup_store_test();
2713            let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2714
2715            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2716            fs::write(
2717                values_dir.join("ns1").join("values.json"),
2718                r#"{"options": {"enabled": false}}"#,
2719            )
2720            .unwrap();
2721
2722            // Reads never refresh with a disabled threshold.
2723            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2724
2725            // A manual refresh picks up the change.
2726            assert!(store.refresh().unwrap());
2727            assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2728        }
2729
2730        #[test]
2731        fn test_refresh_error_keeps_snapshot_and_recovers() {
2732            let (_temp, schemas_dir, values_dir) = setup_store_test();
2733            let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2734            let ns1_values = values_dir.join("ns1").join("values.json");
2735
2736            fs::write(&ns1_values, "not json").unwrap();
2737            assert!(store.refresh().is_err());
2738            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2739
2740            fs::write(&ns1_values, r#"{"options": {"enabled": false}}"#).unwrap();
2741            assert!(store.refresh().unwrap());
2742            assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2743        }
2744
2745        #[test]
2746        fn test_refresh_fires_propagation_callback() {
2747            let temp_dir = TempDir::new().unwrap();
2748            let base = temp_dir.path();
2749            let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2750
2751            let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2752            let events_clone = events.clone();
2753            let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2754            let store = ValuesStore::builder(registry, &base.join("values"))
2755                .with_refresh_threshold(None)
2756                .with_callback(Box::new(move |ns, delay| {
2757                    events_clone.lock().unwrap().push((ns.to_string(), delay));
2758                }))
2759                .build()
2760                .unwrap();
2761
2762            fs::write(
2763                &values_file,
2764                r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2765            )
2766            .unwrap();
2767            assert!(store.refresh().unwrap());
2768
2769            let recorded = events.lock().unwrap();
2770            assert_eq!(recorded.len(), 1);
2771            assert_eq!(recorded[0].0, "test");
2772        }
2773
2774        #[test]
2775        fn test_refresh_resets_lazy_timer() {
2776            let (_temp, schemas_dir, values_dir) = setup_store_test();
2777            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2778            let store = ValuesStore::builder(registry, &values_dir)
2779                .with_refresh_threshold(Duration::from_secs(3600))
2780                .build()
2781                .unwrap();
2782
2783            assert!(!store.refresh().unwrap());
2784
2785            // A rewrite within the (freshly reset) window is not picked up by
2786            // reads — the manual refresh preempted the lazy refresh.
2787            fs::write(
2788                values_dir.join("ns1").join("values.json"),
2789                r#"{"options": {"enabled": false}}"#,
2790            )
2791            .unwrap();
2792            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2793        }
2794
2795        #[test]
2796        fn test_refresh_runs_within_staleness_window() {
2797            let (_temp, schemas_dir, values_dir) = setup_store_test();
2798            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2799            let store = ValuesStore::builder(registry, &values_dir)
2800                .with_refresh_threshold(Duration::from_secs(3600))
2801                .build()
2802                .unwrap();
2803
2804            // Immediately after construction, well within the staleness
2805            // window, a manual refresh still reloads changed files.
2806            fs::write(
2807                values_dir.join("ns1").join("values.json"),
2808                r#"{"options": {"enabled": false}}"#,
2809            )
2810            .unwrap();
2811            assert!(store.refresh().unwrap());
2812            assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2813        }
2814    }
2815    mod array_tests {
2816        use super::*;
2817
2818        #[test]
2819        fn test_basic_schema_validation() {
2820            let temp_dir = TempDir::new().unwrap();
2821            for (a_type, default) in [
2822                ("boolean", ""), // empty array test
2823                ("boolean", "true"),
2824                ("integer", "1"),
2825                ("number", "1.2"),
2826                ("string", "\"wow\""),
2827            ] {
2828                create_test_schema(
2829                    &temp_dir,
2830                    "test",
2831                    &format!(
2832                        r#"{{
2833                        "version": "1.0",
2834                        "type": "object",
2835                        "properties": {{
2836                            "array-key": {{
2837                                "type": "array",
2838                                "items": {{"type": "{}"}},
2839                                "default": [{}],
2840                                "description": "Array option"
2841                                }}
2842                            }}
2843                        }}"#,
2844                        a_type, default
2845                    ),
2846                );
2847
2848                SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2849            }
2850        }
2851
2852        #[test]
2853        fn test_missing_items_object_rejection() {
2854            let temp_dir = TempDir::new().unwrap();
2855            create_test_schema(
2856                &temp_dir,
2857                "test",
2858                r#"{
2859                    "version": "1.0",
2860                    "type": "object",
2861                    "properties": {
2862                        "array-key": {
2863                            "type": "array",
2864                            "default": [1,2,3],
2865                            "description": "Array option"
2866                        }
2867                    }
2868                }"#,
2869            );
2870
2871            let result = SchemaRegistry::from_directory(temp_dir.path());
2872            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2873        }
2874
2875        #[test]
2876        fn test_malformed_items_rejection() {
2877            let temp_dir = TempDir::new().unwrap();
2878            create_test_schema(
2879                &temp_dir,
2880                "test",
2881                r#"{
2882                    "version": "1.0",
2883                    "type": "object",
2884                    "properties": {
2885                        "array-key": {
2886                            "type": "array",
2887                            "items": {"type": ""},
2888                            "default": [1,2,3],
2889                            "description": "Array option"
2890                        }
2891                    }
2892                }"#,
2893            );
2894
2895            let result = SchemaRegistry::from_directory(temp_dir.path());
2896            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2897        }
2898
2899        #[test]
2900        fn test_schema_default_type_mismatch_rejection() {
2901            let temp_dir = TempDir::new().unwrap();
2902            // also tests real number rejection when type is integer
2903            create_test_schema(
2904                &temp_dir,
2905                "test",
2906                r#"{
2907                    "version": "1.0",
2908                    "type": "object",
2909                    "properties": {
2910                        "array-key": {
2911                            "type": "array",
2912                            "items": {"type": "integer"},
2913                            "default": [1,2,3.3],
2914                            "description": "Array option"
2915                        }
2916                    }
2917                }"#,
2918            );
2919
2920            let result = SchemaRegistry::from_directory(temp_dir.path());
2921            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2922        }
2923
2924        #[test]
2925        fn test_schema_default_heterogeneous_rejection() {
2926            let temp_dir = TempDir::new().unwrap();
2927            create_test_schema(
2928                &temp_dir,
2929                "test",
2930                r#"{
2931                    "version": "1.0",
2932                    "type": "object",
2933                    "properties": {
2934                        "array-key": {
2935                            "type": "array",
2936                            "items": {"type": "integer"},
2937                            "default": [1,2,"uh oh!"],
2938                            "description": "Array option"
2939                        }
2940                    }
2941                }"#,
2942            );
2943
2944            let result = SchemaRegistry::from_directory(temp_dir.path());
2945            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2946        }
2947
2948        #[test]
2949        fn test_load_values_valid() {
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                        "array-key": {
2959                            "type": "array",
2960                            "items": {"type": "integer"},
2961                            "default": [1,2,3],
2962                            "description": "Array option"
2963                        }
2964                    }
2965                }"#,
2966                r#"{
2967                    "options": {
2968                        "array-key": [4,5,6]
2969                    }
2970                }"#,
2971            );
2972
2973            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2974            let (values, generated_at_by_namespace) =
2975                registry.load_values_json(&values_dir).unwrap();
2976
2977            assert_eq!(values.len(), 1);
2978            assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
2979            assert!(generated_at_by_namespace.is_empty());
2980        }
2981
2982        #[test]
2983        fn test_reject_values_not_an_array() {
2984            let temp_dir = TempDir::new().unwrap();
2985            let (schemas_dir, values_dir) = create_test_schema_with_values(
2986                &temp_dir,
2987                "test",
2988                r#"{
2989                    "version": "1.0",
2990                    "type": "object",
2991                    "properties": {
2992                        "array-key": {
2993                            "type": "array",
2994                            "items": {"type": "integer"},
2995                            "default": [1,2,3],
2996                            "description": "Array option"
2997                        }
2998                    }
2999                }"#,
3000                // sneaky! not an array
3001                r#"{
3002                    "options": {
3003                        "array-key": "[]"
3004                    }
3005                }"#,
3006            );
3007
3008            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3009            let result = registry.load_values_json(&values_dir);
3010
3011            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3012        }
3013
3014        #[test]
3015        fn test_reject_values_mismatch() {
3016            let temp_dir = TempDir::new().unwrap();
3017            let (schemas_dir, values_dir) = create_test_schema_with_values(
3018                &temp_dir,
3019                "test",
3020                r#"{
3021                    "version": "1.0",
3022                    "type": "object",
3023                    "properties": {
3024                        "array-key": {
3025                            "type": "array",
3026                            "items": {"type": "integer"},
3027                            "default": [1,2,3],
3028                            "description": "Array option"
3029                        }
3030                    }
3031                }"#,
3032                r#"{
3033                    "options": {
3034                        "array-key": ["a","b","c"]
3035                    }
3036                }"#,
3037            );
3038
3039            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3040            let result = registry.load_values_json(&values_dir);
3041
3042            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3043        }
3044    }
3045
3046    mod object_tests {
3047        use super::*;
3048
3049        #[test]
3050        fn test_object_schema_loads() {
3051            let temp_dir = TempDir::new().unwrap();
3052            create_test_schema(
3053                &temp_dir,
3054                "test",
3055                r#"{
3056                    "version": "1.0",
3057                    "type": "object",
3058                    "properties": {
3059                        "config": {
3060                            "type": "object",
3061                            "properties": {
3062                                "host": {"type": "string"},
3063                                "port": {"type": "integer"},
3064                                "rate": {"type": "number"},
3065                                "enabled": {"type": "boolean"}
3066                            },
3067                            "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
3068                            "description": "Service config"
3069                        }
3070                    }
3071                }"#,
3072            );
3073
3074            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3075            let schema = registry.get("test").unwrap();
3076            assert_eq!(schema.options["config"].option_type, "object");
3077        }
3078
3079        #[test]
3080        fn test_object_missing_properties_rejected() {
3081            let temp_dir = TempDir::new().unwrap();
3082            create_test_schema(
3083                &temp_dir,
3084                "test",
3085                r#"{
3086                    "version": "1.0",
3087                    "type": "object",
3088                    "properties": {
3089                        "config": {
3090                            "type": "object",
3091                            "default": {"host": "localhost"},
3092                            "description": "Missing properties field"
3093                        }
3094                    }
3095                }"#,
3096            );
3097
3098            let result = SchemaRegistry::from_directory(temp_dir.path());
3099            assert!(result.is_err());
3100        }
3101
3102        #[test]
3103        fn test_object_default_wrong_type_rejected() {
3104            let temp_dir = TempDir::new().unwrap();
3105            create_test_schema(
3106                &temp_dir,
3107                "test",
3108                r#"{
3109                    "version": "1.0",
3110                    "type": "object",
3111                    "properties": {
3112                        "config": {
3113                            "type": "object",
3114                            "properties": {
3115                                "host": {"type": "string"},
3116                                "port": {"type": "integer"}
3117                            },
3118                            "default": {"host": "localhost", "port": "not-a-number"},
3119                            "description": "Bad default"
3120                        }
3121                    }
3122                }"#,
3123            );
3124
3125            let result = SchemaRegistry::from_directory(temp_dir.path());
3126            assert!(result.is_err());
3127        }
3128
3129        #[test]
3130        fn test_object_default_missing_field_rejected() {
3131            let temp_dir = TempDir::new().unwrap();
3132            create_test_schema(
3133                &temp_dir,
3134                "test",
3135                r#"{
3136                    "version": "1.0",
3137                    "type": "object",
3138                    "properties": {
3139                        "config": {
3140                            "type": "object",
3141                            "properties": {
3142                                "host": {"type": "string"},
3143                                "port": {"type": "integer"}
3144                            },
3145                            "default": {"host": "localhost"},
3146                            "description": "Missing port in default"
3147                        }
3148                    }
3149                }"#,
3150            );
3151
3152            let result = SchemaRegistry::from_directory(temp_dir.path());
3153            assert!(result.is_err());
3154        }
3155
3156        #[test]
3157        fn test_object_default_extra_field_rejected() {
3158            let temp_dir = TempDir::new().unwrap();
3159            create_test_schema(
3160                &temp_dir,
3161                "test",
3162                r#"{
3163                    "version": "1.0",
3164                    "type": "object",
3165                    "properties": {
3166                        "config": {
3167                            "type": "object",
3168                            "properties": {
3169                                "host": {"type": "string"}
3170                            },
3171                            "default": {"host": "localhost", "extra": "field"},
3172                            "description": "Extra field in default"
3173                        }
3174                    }
3175                }"#,
3176            );
3177
3178            let result = SchemaRegistry::from_directory(temp_dir.path());
3179            assert!(result.is_err());
3180        }
3181
3182        #[test]
3183        fn test_object_values_valid() {
3184            let temp_dir = TempDir::new().unwrap();
3185            let (schemas_dir, values_dir) = create_test_schema_with_values(
3186                &temp_dir,
3187                "test",
3188                r#"{
3189                    "version": "1.0",
3190                    "type": "object",
3191                    "properties": {
3192                        "config": {
3193                            "type": "object",
3194                            "properties": {
3195                                "host": {"type": "string"},
3196                                "port": {"type": "integer"}
3197                            },
3198                            "default": {"host": "localhost", "port": 8080},
3199                            "description": "Service config"
3200                        }
3201                    }
3202                }"#,
3203                r#"{
3204                    "options": {
3205                        "config": {"host": "example.com", "port": 9090}
3206                    }
3207                }"#,
3208            );
3209
3210            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3211            let result = registry.load_values_json(&values_dir);
3212            assert!(result.is_ok());
3213        }
3214
3215        #[test]
3216        fn test_object_values_wrong_field_type_rejected() {
3217            let temp_dir = TempDir::new().unwrap();
3218            let (schemas_dir, values_dir) = create_test_schema_with_values(
3219                &temp_dir,
3220                "test",
3221                r#"{
3222                    "version": "1.0",
3223                    "type": "object",
3224                    "properties": {
3225                        "config": {
3226                            "type": "object",
3227                            "properties": {
3228                                "host": {"type": "string"},
3229                                "port": {"type": "integer"}
3230                            },
3231                            "default": {"host": "localhost", "port": 8080},
3232                            "description": "Service config"
3233                        }
3234                    }
3235                }"#,
3236                r#"{
3237                    "options": {
3238                        "config": {"host": "example.com", "port": "not-a-number"}
3239                    }
3240                }"#,
3241            );
3242
3243            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3244            let result = registry.load_values_json(&values_dir);
3245            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3246        }
3247
3248        #[test]
3249        fn test_object_values_extra_field_rejected() {
3250            let temp_dir = TempDir::new().unwrap();
3251            let (schemas_dir, values_dir) = create_test_schema_with_values(
3252                &temp_dir,
3253                "test",
3254                r#"{
3255                    "version": "1.0",
3256                    "type": "object",
3257                    "properties": {
3258                        "config": {
3259                            "type": "object",
3260                            "properties": {
3261                                "host": {"type": "string"}
3262                            },
3263                            "default": {"host": "localhost"},
3264                            "description": "Service config"
3265                        }
3266                    }
3267                }"#,
3268                r#"{
3269                    "options": {
3270                        "config": {"host": "example.com", "extra": "field"}
3271                    }
3272                }"#,
3273            );
3274
3275            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3276            let result = registry.load_values_json(&values_dir);
3277            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3278        }
3279
3280        #[test]
3281        fn test_object_values_missing_field_rejected() {
3282            let temp_dir = TempDir::new().unwrap();
3283            let (schemas_dir, values_dir) = create_test_schema_with_values(
3284                &temp_dir,
3285                "test",
3286                r#"{
3287                    "version": "1.0",
3288                    "type": "object",
3289                    "properties": {
3290                        "config": {
3291                            "type": "object",
3292                            "properties": {
3293                                "host": {"type": "string"},
3294                                "port": {"type": "integer"}
3295                            },
3296                            "default": {"host": "localhost", "port": 8080},
3297                            "description": "Service config"
3298                        }
3299                    }
3300                }"#,
3301                r#"{
3302                    "options": {
3303                        "config": {"host": "example.com"}
3304                    }
3305                }"#,
3306            );
3307
3308            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3309            let result = registry.load_values_json(&values_dir);
3310            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3311        }
3312
3313        // Array of objects tests
3314
3315        #[test]
3316        fn test_array_of_objects_schema_loads() {
3317            let temp_dir = TempDir::new().unwrap();
3318            create_test_schema(
3319                &temp_dir,
3320                "test",
3321                r#"{
3322                    "version": "1.0",
3323                    "type": "object",
3324                    "properties": {
3325                        "endpoints": {
3326                            "type": "array",
3327                            "items": {
3328                                "type": "object",
3329                                "properties": {
3330                                    "url": {"type": "string"},
3331                                    "weight": {"type": "integer"}
3332                                }
3333                            },
3334                            "default": [{"url": "https://a.example.com", "weight": 1}],
3335                            "description": "Endpoints"
3336                        }
3337                    }
3338                }"#,
3339            );
3340
3341            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3342            let schema = registry.get("test").unwrap();
3343            assert_eq!(schema.options["endpoints"].option_type, "array");
3344        }
3345
3346        #[test]
3347        fn test_array_of_objects_empty_default() {
3348            let temp_dir = TempDir::new().unwrap();
3349            create_test_schema(
3350                &temp_dir,
3351                "test",
3352                r#"{
3353                    "version": "1.0",
3354                    "type": "object",
3355                    "properties": {
3356                        "endpoints": {
3357                            "type": "array",
3358                            "items": {
3359                                "type": "object",
3360                                "properties": {
3361                                    "url": {"type": "string"},
3362                                    "weight": {"type": "integer"}
3363                                }
3364                            },
3365                            "default": [],
3366                            "description": "Endpoints"
3367                        }
3368                    }
3369                }"#,
3370            );
3371
3372            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3373            assert!(registry.get("test").is_some());
3374        }
3375
3376        #[test]
3377        fn test_array_of_objects_default_wrong_field_type_rejected() {
3378            let temp_dir = TempDir::new().unwrap();
3379            create_test_schema(
3380                &temp_dir,
3381                "test",
3382                r#"{
3383                    "version": "1.0",
3384                    "type": "object",
3385                    "properties": {
3386                        "endpoints": {
3387                            "type": "array",
3388                            "items": {
3389                                "type": "object",
3390                                "properties": {
3391                                    "url": {"type": "string"},
3392                                    "weight": {"type": "integer"}
3393                                }
3394                            },
3395                            "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
3396                            "description": "Endpoints"
3397                        }
3398                    }
3399                }"#,
3400            );
3401
3402            let result = SchemaRegistry::from_directory(temp_dir.path());
3403            assert!(result.is_err());
3404        }
3405
3406        #[test]
3407        fn test_array_of_objects_missing_items_properties_rejected() {
3408            let temp_dir = TempDir::new().unwrap();
3409            create_test_schema(
3410                &temp_dir,
3411                "test",
3412                r#"{
3413                    "version": "1.0",
3414                    "type": "object",
3415                    "properties": {
3416                        "endpoints": {
3417                            "type": "array",
3418                            "items": {
3419                                "type": "object"
3420                            },
3421                            "default": [],
3422                            "description": "Missing properties in items"
3423                        }
3424                    }
3425                }"#,
3426            );
3427
3428            let result = SchemaRegistry::from_directory(temp_dir.path());
3429            assert!(result.is_err());
3430        }
3431
3432        #[test]
3433        fn test_array_of_objects_values_valid() {
3434            let temp_dir = TempDir::new().unwrap();
3435            let (schemas_dir, values_dir) = create_test_schema_with_values(
3436                &temp_dir,
3437                "test",
3438                r#"{
3439                    "version": "1.0",
3440                    "type": "object",
3441                    "properties": {
3442                        "endpoints": {
3443                            "type": "array",
3444                            "items": {
3445                                "type": "object",
3446                                "properties": {
3447                                    "url": {"type": "string"},
3448                                    "weight": {"type": "integer"}
3449                                }
3450                            },
3451                            "default": [],
3452                            "description": "Endpoints"
3453                        }
3454                    }
3455                }"#,
3456                r#"{
3457                    "options": {
3458                        "endpoints": [
3459                            {"url": "https://a.example.com", "weight": 1},
3460                            {"url": "https://b.example.com", "weight": 2}
3461                        ]
3462                    }
3463                }"#,
3464            );
3465
3466            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3467            let result = registry.load_values_json(&values_dir);
3468            assert!(result.is_ok());
3469        }
3470
3471        #[test]
3472        fn test_array_of_objects_values_wrong_item_shape_rejected() {
3473            let temp_dir = TempDir::new().unwrap();
3474            let (schemas_dir, values_dir) = create_test_schema_with_values(
3475                &temp_dir,
3476                "test",
3477                r#"{
3478                    "version": "1.0",
3479                    "type": "object",
3480                    "properties": {
3481                        "endpoints": {
3482                            "type": "array",
3483                            "items": {
3484                                "type": "object",
3485                                "properties": {
3486                                    "url": {"type": "string"},
3487                                    "weight": {"type": "integer"}
3488                                }
3489                            },
3490                            "default": [],
3491                            "description": "Endpoints"
3492                        }
3493                    }
3494                }"#,
3495                r#"{
3496                    "options": {
3497                        "endpoints": [
3498                            {"url": "https://a.example.com", "weight": "not-a-number"}
3499                        ]
3500                    }
3501                }"#,
3502            );
3503
3504            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3505            let result = registry.load_values_json(&values_dir);
3506            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3507        }
3508
3509        #[test]
3510        fn test_array_of_objects_values_extra_field_rejected() {
3511            let temp_dir = TempDir::new().unwrap();
3512            let (schemas_dir, values_dir) = create_test_schema_with_values(
3513                &temp_dir,
3514                "test",
3515                r#"{
3516                    "version": "1.0",
3517                    "type": "object",
3518                    "properties": {
3519                        "endpoints": {
3520                            "type": "array",
3521                            "items": {
3522                                "type": "object",
3523                                "properties": {
3524                                    "url": {"type": "string"}
3525                                }
3526                            },
3527                            "default": [],
3528                            "description": "Endpoints"
3529                        }
3530                    }
3531                }"#,
3532                r#"{
3533                    "options": {
3534                        "endpoints": [
3535                            {"url": "https://a.example.com", "extra": "field"}
3536                        ]
3537                    }
3538                }"#,
3539            );
3540
3541            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3542            let result = registry.load_values_json(&values_dir);
3543            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3544        }
3545
3546        #[test]
3547        fn test_array_of_objects_values_missing_field_rejected() {
3548            let temp_dir = TempDir::new().unwrap();
3549            let (schemas_dir, values_dir) = create_test_schema_with_values(
3550                &temp_dir,
3551                "test",
3552                r#"{
3553                    "version": "1.0",
3554                    "type": "object",
3555                    "properties": {
3556                        "endpoints": {
3557                            "type": "array",
3558                            "items": {
3559                                "type": "object",
3560                                "properties": {
3561                                    "url": {"type": "string"},
3562                                    "weight": {"type": "integer"}
3563                                }
3564                            },
3565                            "default": [],
3566                            "description": "Endpoints"
3567                        }
3568                    }
3569                }"#,
3570                r#"{
3571                    "options": {
3572                        "endpoints": [
3573                            {"url": "https://a.example.com"}
3574                        ]
3575                    }
3576                }"#,
3577            );
3578
3579            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3580            let result = registry.load_values_json(&values_dir);
3581            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3582        }
3583
3584        // Optional field tests
3585
3586        #[test]
3587        fn test_object_optional_field_can_be_omitted_from_default() {
3588            let temp_dir = TempDir::new().unwrap();
3589            create_test_schema(
3590                &temp_dir,
3591                "test",
3592                r#"{
3593                    "version": "1.0",
3594                    "type": "object",
3595                    "properties": {
3596                        "config": {
3597                            "type": "object",
3598                            "properties": {
3599                                "host": {"type": "string"},
3600                                "debug": {"type": "boolean", "optional": true}
3601                            },
3602                            "default": {"host": "localhost"},
3603                            "description": "Config with optional field"
3604                        }
3605                    }
3606                }"#,
3607            );
3608
3609            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3610            let schema = registry.get("test").unwrap();
3611            assert_eq!(schema.options["config"].option_type, "object");
3612        }
3613
3614        #[test]
3615        fn test_object_optional_field_can_be_included_in_default() {
3616            let temp_dir = TempDir::new().unwrap();
3617            create_test_schema(
3618                &temp_dir,
3619                "test",
3620                r#"{
3621                    "version": "1.0",
3622                    "type": "object",
3623                    "properties": {
3624                        "config": {
3625                            "type": "object",
3626                            "properties": {
3627                                "host": {"type": "string"},
3628                                "debug": {"type": "boolean", "optional": true}
3629                            },
3630                            "default": {"host": "localhost", "debug": true},
3631                            "description": "Config with optional field included"
3632                        }
3633                    }
3634                }"#,
3635            );
3636
3637            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3638            assert!(registry.get("test").is_some());
3639        }
3640
3641        #[test]
3642        fn test_object_optional_field_wrong_type_rejected() {
3643            let temp_dir = TempDir::new().unwrap();
3644            create_test_schema(
3645                &temp_dir,
3646                "test",
3647                r#"{
3648                    "version": "1.0",
3649                    "type": "object",
3650                    "properties": {
3651                        "config": {
3652                            "type": "object",
3653                            "properties": {
3654                                "host": {"type": "string"},
3655                                "debug": {"type": "boolean", "optional": true}
3656                            },
3657                            "default": {"host": "localhost", "debug": "not-a-bool"},
3658                            "description": "Optional field wrong type"
3659                        }
3660                    }
3661                }"#,
3662            );
3663
3664            let result = SchemaRegistry::from_directory(temp_dir.path());
3665            assert!(result.is_err());
3666        }
3667
3668        #[test]
3669        fn test_object_required_field_still_required_with_optional_present() {
3670            let temp_dir = TempDir::new().unwrap();
3671            create_test_schema(
3672                &temp_dir,
3673                "test",
3674                r#"{
3675                    "version": "1.0",
3676                    "type": "object",
3677                    "properties": {
3678                        "config": {
3679                            "type": "object",
3680                            "properties": {
3681                                "host": {"type": "string"},
3682                                "port": {"type": "integer"},
3683                                "debug": {"type": "boolean", "optional": true}
3684                            },
3685                            "default": {"debug": true},
3686                            "description": "Missing required fields"
3687                        }
3688                    }
3689                }"#,
3690            );
3691
3692            let result = SchemaRegistry::from_directory(temp_dir.path());
3693            assert!(result.is_err());
3694        }
3695
3696        #[test]
3697        fn test_object_optional_field_omitted_from_values() {
3698            let temp_dir = TempDir::new().unwrap();
3699            let (schemas_dir, values_dir) = create_test_schema_with_values(
3700                &temp_dir,
3701                "test",
3702                r#"{
3703                    "version": "1.0",
3704                    "type": "object",
3705                    "properties": {
3706                        "config": {
3707                            "type": "object",
3708                            "properties": {
3709                                "host": {"type": "string"},
3710                                "debug": {"type": "boolean", "optional": true}
3711                            },
3712                            "default": {"host": "localhost"},
3713                            "description": "Config"
3714                        }
3715                    }
3716                }"#,
3717                r#"{
3718                    "options": {
3719                        "config": {"host": "example.com"}
3720                    }
3721                }"#,
3722            );
3723
3724            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3725            let result = registry.load_values_json(&values_dir);
3726            assert!(result.is_ok());
3727        }
3728
3729        #[test]
3730        fn test_object_optional_field_included_in_values() {
3731            let temp_dir = TempDir::new().unwrap();
3732            let (schemas_dir, values_dir) = create_test_schema_with_values(
3733                &temp_dir,
3734                "test",
3735                r#"{
3736                    "version": "1.0",
3737                    "type": "object",
3738                    "properties": {
3739                        "config": {
3740                            "type": "object",
3741                            "properties": {
3742                                "host": {"type": "string"},
3743                                "debug": {"type": "boolean", "optional": true}
3744                            },
3745                            "default": {"host": "localhost"},
3746                            "description": "Config"
3747                        }
3748                    }
3749                }"#,
3750                r#"{
3751                    "options": {
3752                        "config": {"host": "example.com", "debug": true}
3753                    }
3754                }"#,
3755            );
3756
3757            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3758            let result = registry.load_values_json(&values_dir);
3759            assert!(result.is_ok());
3760        }
3761
3762        #[test]
3763        fn test_array_of_objects_optional_field_omitted() {
3764            let temp_dir = TempDir::new().unwrap();
3765            let (schemas_dir, values_dir) = create_test_schema_with_values(
3766                &temp_dir,
3767                "test",
3768                r#"{
3769                    "version": "1.0",
3770                    "type": "object",
3771                    "properties": {
3772                        "endpoints": {
3773                            "type": "array",
3774                            "items": {
3775                                "type": "object",
3776                                "properties": {
3777                                    "url": {"type": "string"},
3778                                    "weight": {"type": "integer", "optional": true}
3779                                }
3780                            },
3781                            "default": [],
3782                            "description": "Endpoints"
3783                        }
3784                    }
3785                }"#,
3786                r#"{
3787                    "options": {
3788                        "endpoints": [
3789                            {"url": "https://a.example.com"},
3790                            {"url": "https://b.example.com", "weight": 2}
3791                        ]
3792                    }
3793                }"#,
3794            );
3795
3796            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3797            let result = registry.load_values_json(&values_dir);
3798            assert!(result.is_ok());
3799        }
3800    }
3801
3802    #[test]
3803    fn test_object_valued_map_validates_nested_values() {
3804        // {orgId: {max_candidates: int}} — a map whose values are objects, previously
3805        // inexpressible because map values were scalar-only.
3806        let registry = SchemaRegistry::from_schemas(&[(
3807            "test",
3808            r#"{
3809                "version": "1.0",
3810                "type": "object",
3811                "properties": {
3812                    "org_tweaks": {
3813                        "type": "object",
3814                        "additionalProperties": {
3815                            "type": "object",
3816                            "additionalProperties": {"type": "integer"}
3817                        },
3818                        "default": {},
3819                        "description": ""
3820                    }
3821                }
3822            }"#,
3823        )])
3824        .unwrap();
3825
3826        assert!(
3827            registry
3828                .validate_values(
3829                    "test",
3830                    &json!({"org_tweaks": {"1": {"max_candidates": 20}}})
3831                )
3832                .is_ok()
3833        );
3834        assert!(
3835            registry
3836                .validate_values("test", &json!({"org_tweaks": {}}))
3837                .is_ok()
3838        );
3839        // inner value of the wrong type
3840        assert!(matches!(
3841            registry.validate_values(
3842                "test",
3843                &json!({"org_tweaks": {"1": {"max_candidates": "x"}}})
3844            ),
3845            Err(ValidationError::ValueError { .. })
3846        ));
3847        // map value is not an object
3848        assert!(matches!(
3849            registry.validate_values("test", &json!({"org_tweaks": {"1": 20}})),
3850            Err(ValidationError::ValueError { .. })
3851        ));
3852    }
3853
3854    #[test]
3855    fn test_array_valued_map_validates_nested_values() {
3856        // {key: [string]} — a map whose values are arrays.
3857        let registry = SchemaRegistry::from_schemas(&[(
3858            "test",
3859            r#"{
3860                "version": "1.0",
3861                "type": "object",
3862                "properties": {
3863                    "webhook_logging": {
3864                        "type": "object",
3865                        "additionalProperties": {
3866                            "type": "array",
3867                            "items": {"type": "string"}
3868                        },
3869                        "default": {},
3870                        "description": ""
3871                    }
3872                }
3873            }"#,
3874        )])
3875        .unwrap();
3876
3877        assert!(
3878            registry
3879                .validate_values(
3880                    "test",
3881                    &json!({"webhook_logging": {"a": [], "b": ["x", "y"]}})
3882                )
3883                .is_ok()
3884        );
3885        // array element of the wrong type
3886        assert!(matches!(
3887            registry.validate_values("test", &json!({"webhook_logging": {"a": [1]}})),
3888            Err(ValidationError::ValueError { .. })
3889        ));
3890        // map value is not an array
3891        assert!(matches!(
3892            registry.validate_values("test", &json!({"webhook_logging": {"a": "x"}})),
3893            Err(ValidationError::ValueError { .. })
3894        ));
3895    }
3896
3897    #[test]
3898    fn test_nested_fixed_object_enforces_required_and_rejects_unknown() {
3899        // Map value is a fixed-shape object: `required` and `additionalProperties: false`
3900        // must be injected at depth, not only at the top level.
3901        let registry = SchemaRegistry::from_schemas(&[(
3902            "test",
3903            r#"{
3904                "version": "1.0",
3905                "type": "object",
3906                "properties": {
3907                    "quotas": {
3908                        "type": "object",
3909                        "additionalProperties": {
3910                            "type": "object",
3911                            "properties": {
3912                                "limit": {"type": "integer"},
3913                                "window": {"type": "integer"}
3914                            }
3915                        },
3916                        "default": {},
3917                        "description": ""
3918                    }
3919                }
3920            }"#,
3921        )])
3922        .unwrap();
3923
3924        assert!(
3925            registry
3926                .validate_values("test", &json!({"quotas": {"o": {"limit": 1, "window": 2}}}))
3927                .is_ok()
3928        );
3929        // missing a required nested field
3930        assert!(matches!(
3931            registry.validate_values("test", &json!({"quotas": {"o": {"limit": 1}}})),
3932            Err(ValidationError::ValueError { .. })
3933        ));
3934        // unknown nested field rejected (additionalProperties: false injected at depth)
3935        assert!(matches!(
3936            registry.validate_values(
3937                "test",
3938                &json!({"quotas": {"o": {"limit": 1, "window": 2, "x": 3}}})
3939            ),
3940            Err(ValidationError::ValueError { .. })
3941        ));
3942    }
3943
3944    #[test]
3945    fn test_optional_marker_works_at_depth() {
3946        let registry = SchemaRegistry::from_schemas(&[(
3947            "test",
3948            r#"{
3949                "version": "1.0",
3950                "type": "object",
3951                "properties": {
3952                    "items_map": {
3953                        "type": "object",
3954                        "additionalProperties": {
3955                            "type": "object",
3956                            "properties": {
3957                                "url": {"type": "string"},
3958                                "weight": {"type": "integer", "optional": true}
3959                            }
3960                        },
3961                        "default": {},
3962                        "description": ""
3963                    }
3964                }
3965            }"#,
3966        )])
3967        .unwrap();
3968
3969        // optional field omitted -> ok
3970        assert!(
3971            registry
3972                .validate_values("test", &json!({"items_map": {"a": {"url": "x"}}}))
3973                .is_ok()
3974        );
3975        // required field omitted -> fail
3976        assert!(matches!(
3977            registry.validate_values("test", &json!({"items_map": {"a": {"weight": 1}}})),
3978            Err(ValidationError::ValueError { .. })
3979        ));
3980    }
3981
3982    #[test]
3983    fn test_three_levels_of_nesting() {
3984        // object -> array -> object -> scalar map
3985        let registry = SchemaRegistry::from_schemas(&[(
3986            "test",
3987            r#"{
3988                "version": "1.0",
3989                "type": "object",
3990                "properties": {
3991                    "deep": {
3992                        "type": "object",
3993                        "additionalProperties": {
3994                            "type": "array",
3995                            "items": {
3996                                "type": "object",
3997                                "additionalProperties": {"type": "integer"}
3998                            }
3999                        },
4000                        "default": {},
4001                        "description": ""
4002                    }
4003                }
4004            }"#,
4005        )])
4006        .unwrap();
4007
4008        assert!(
4009            registry
4010                .validate_values("test", &json!({"deep": {"a": [{"x": 1}, {"y": 2}]}}))
4011                .is_ok()
4012        );
4013        assert!(matches!(
4014            registry.validate_values("test", &json!({"deep": {"a": [{"x": "no"}]}})),
4015            Err(ValidationError::ValueError { .. })
4016        ));
4017    }
4018
4019    #[test]
4020    fn test_meta_schema_rejects_malformed_nested_value_types() {
4021        // object value with neither `properties` nor `additionalProperties`
4022        assert!(matches!(
4023            SchemaRegistry::from_schemas(&[(
4024                "test",
4025                r#"{"version":"1.0","type":"object","properties":{
4026                    "m":{"type":"object","additionalProperties":{"type":"object"},"default":{},"description":""}}}"#,
4027            )]),
4028            Err(ValidationError::SchemaError { .. })
4029        ));
4030        // array value without `items`
4031        assert!(matches!(
4032            SchemaRegistry::from_schemas(&[(
4033                "test",
4034                r#"{"version":"1.0","type":"object","properties":{
4035                    "m":{"type":"object","additionalProperties":{"type":"array"},"default":{},"description":""}}}"#,
4036            )]),
4037            Err(ValidationError::SchemaError { .. })
4038        ));
4039        // unknown keyword inside a nested value type
4040        assert!(matches!(
4041            SchemaRegistry::from_schemas(&[(
4042                "test",
4043                r#"{"version":"1.0","type":"object","properties":{
4044                    "m":{"type":"object","additionalProperties":{"type":"string","bogus":1},"default":{},"description":""}}}"#,
4045            )]),
4046            Err(ValidationError::SchemaError { .. })
4047        ));
4048    }
4049
4050    #[test]
4051    fn test_pre_existing_shapes_still_validate() {
4052        // Scalar map and fixed-shape object (the only shapes allowed before) keep working.
4053        let registry = SchemaRegistry::from_schemas(&[(
4054            "test",
4055            r#"{
4056                "version": "1.0",
4057                "type": "object",
4058                "properties": {
4059                    "scopes": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}, "description": ""},
4060                    "config": {"type": "object", "properties": {"host": {"type": "string"}}, "default": {"host": "h"}, "description": ""}
4061                }
4062            }"#,
4063        )])
4064        .unwrap();
4065
4066        assert!(
4067            registry
4068                .validate_values(
4069                    "test",
4070                    &json!({"scopes": {"r": "1"}, "config": {"host": "x"}})
4071                )
4072                .is_ok()
4073        );
4074        assert!(matches!(
4075            registry.validate_values("test", &json!({"config": {"host": "x", "extra": "y"}})),
4076            Err(ValidationError::ValueError { .. })
4077        ));
4078    }
4079}