Skip to main content

hyperi_rustlib/config/
registry.rs

1// Project:   hyperi-rustlib
2// File:      src/config/registry.rs
3// Purpose:   Auto-registering config registry for reflection and admin endpoints
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Reflectable configuration registry.
10//!
11//! Modules that read config via [`Config::unmarshal_key_registered`] are
12//! automatically recorded in a global registry. This enables:
13//!
14//! - Listing all config sections an application uses
15//! - Dumping the effective config (with redaction) for debugging
16//! - Dumping defaults for documentation
17//! - Future: admin `/config` endpoint, change notifications
18//!
19//! # Auto-registration
20//!
21//! ```rust,no_run
22//! use hyperi_rustlib::config;
23//!
24//! // This automatically registers "expression" in the registry:
25//! let cfg = config::get();
26//! let profile: MyConfig = cfg.unmarshal_key_registered("expression").unwrap_or_default();
27//!
28//! // Later, reflect on all registered sections:
29//! for section in config::registry::sections() {
30//!     println!("{}: {}", section.key, section.type_name);
31//! }
32//! ```
33
34use std::collections::BTreeMap;
35use std::sync::Mutex;
36
37use serde_json::Value as JsonValue;
38
39/// Global config registry singleton.
40static REGISTRY: Mutex<Option<Registry>> = Mutex::new(None);
41
42/// A boxed change listener callback.
43type ChangeCallback = Box<dyn Fn(&JsonValue) + Send>;
44
45/// Change listener storage.
46static LISTENERS: Mutex<Option<BTreeMap<String, Vec<ChangeCallback>>>> = Mutex::new(None);
47
48/// A registered config section.
49#[derive(Debug, Clone)]
50pub struct ConfigSection {
51    /// The config key (e.g., "expression", "memory", "version_check").
52    pub key: String,
53    /// The Rust type name (e.g., "ProfileConfig").
54    pub type_name: String,
55    /// Default values as JSON (from `T::default()`).
56    pub defaults: JsonValue,
57    /// Effective values as JSON (from the cascade).
58    pub effective: JsonValue,
59}
60
61/// The config registry — stores all registered sections.
62#[derive(Debug, Clone, Default)]
63struct Registry {
64    sections: BTreeMap<String, ConfigSection>,
65}
66
67/// Register a config section in the global registry.
68///
69/// Called automatically by [`Config::unmarshal_key_registered`]. Can also
70/// be called manually for sections that don't go through the cascade.
71///
72/// Requires `T: Serialize + Default` so we can capture both the default
73/// and effective values as JSON for reflection.
74pub fn register<T>(key: &str, effective: &T)
75where
76    T: serde::Serialize + Default + 'static,
77{
78    let section = ConfigSection {
79        key: key.to_string(),
80        type_name: std::any::type_name::<T>().to_string(),
81        defaults: serde_json::to_value(T::default()).unwrap_or(JsonValue::Null),
82        effective: serde_json::to_value(effective).unwrap_or(JsonValue::Null),
83    };
84
85    if let Ok(mut guard) = REGISTRY.lock() {
86        let registry = guard.get_or_insert_with(Registry::default);
87        registry.sections.insert(key.to_string(), section);
88    }
89}
90
91/// List all registered config sections, sorted by key.
92#[must_use]
93pub fn sections() -> Vec<ConfigSection> {
94    REGISTRY
95        .lock()
96        .ok()
97        .and_then(|guard| {
98            guard
99                .as_ref()
100                .map(|r| r.sections.values().cloned().collect())
101        })
102        .unwrap_or_default()
103}
104
105/// Dump all effective config values as a JSON object (redacted).
106///
107/// Applies heuristic redaction to fields whose names contain sensitive
108/// patterns (password, secret, token, key, credential, auth, private,
109/// cert, encryption). Fields with `#[serde(skip_serializing)]` are
110/// already excluded at serialisation time — this is the safety net for
111/// fields that weren't annotated.
112#[must_use]
113pub fn dump_effective() -> JsonValue {
114    let mut map: serde_json::Map<String, JsonValue> = sections()
115        .into_iter()
116        .map(|s| (s.key, s.effective))
117        .collect();
118    for value in map.values_mut() {
119        if let JsonValue::Object(obj) = value {
120            redact_sensitive_fields(obj);
121        }
122    }
123    JsonValue::Object(map)
124}
125
126/// Dump effective config WITHOUT redaction (for internal/debug use only).
127///
128/// Do NOT expose this on any endpoint. Use `dump_effective()` for safe output.
129#[must_use]
130pub fn dump_effective_unredacted() -> JsonValue {
131    let map: serde_json::Map<String, JsonValue> = sections()
132        .into_iter()
133        .map(|s| (s.key, s.effective))
134        .collect();
135    JsonValue::Object(map)
136}
137
138/// Dump all default config values as a JSON object (redacted).
139#[must_use]
140pub fn dump_defaults() -> JsonValue {
141    let mut map: serde_json::Map<String, JsonValue> = sections()
142        .into_iter()
143        .map(|s| (s.key, s.defaults))
144        .collect();
145    for value in map.values_mut() {
146        if let JsonValue::Object(obj) = value {
147            redact_sensitive_fields(obj);
148        }
149    }
150    JsonValue::Object(map)
151}
152
153/// Field name patterns that trigger automatic redaction.
154///
155/// Any JSON field whose name (lowercased) contains one of these
156/// substrings will have its value replaced with `"***REDACTED***"`.
157/// Field name patterns that trigger automatic redaction.
158///
159/// Any JSON field whose name (lowercased) contains one of these
160/// substrings will have its value replaced with `"***REDACTED***"`.
161///
162/// This is a safety net — the primary protection is [`SensitiveString`]
163/// on the field type (compile-time safe). This heuristic catches fields
164/// that developers forgot to mark as sensitive.
165const SENSITIVE_PATTERNS: &[&str] = &[
166    "password",
167    "secret",
168    "token",
169    "key",
170    "credential",
171    "auth",
172    "private",
173    "cert",
174    "encryption",
175    "connection_string",
176    "dsn",
177];
178
179const REDACTED: &str = "***REDACTED***";
180
181/// Recursively redact fields with sensitive names.
182fn redact_sensitive_fields(obj: &mut serde_json::Map<String, JsonValue>) {
183    for (key, value) in obj.iter_mut() {
184        let lower = key.to_lowercase();
185        if SENSITIVE_PATTERNS.iter().any(|p| lower.contains(p)) {
186            *value = JsonValue::String(REDACTED.into());
187            continue;
188        }
189        match value {
190            JsonValue::Object(nested) => redact_sensitive_fields(nested),
191            JsonValue::Array(arr) => {
192                for item in arr.iter_mut() {
193                    if let JsonValue::Object(nested) = item {
194                        redact_sensitive_fields(nested);
195                    }
196                }
197            }
198            _ => {}
199        }
200    }
201}
202
203/// Check if a specific key is registered.
204#[must_use]
205pub fn is_registered(key: &str) -> bool {
206    REGISTRY
207        .lock()
208        .ok()
209        .and_then(|guard| guard.as_ref().map(|r| r.sections.contains_key(key)))
210        .unwrap_or(false)
211}
212
213/// Get a single registered section by key.
214#[must_use]
215pub fn get_section(key: &str) -> Option<ConfigSection> {
216    REGISTRY
217        .lock()
218        .ok()
219        .and_then(|guard| guard.as_ref().and_then(|r| r.sections.get(key).cloned()))
220}
221
222/// Subscribe to changes for a specific config key (opt-in).
223///
224/// The callback fires when [`update`] is called for the given key.
225/// Modules that need hot-reload subscribe at init; modules that don't
226/// simply use the `OnceLock` pattern and ignore change events.
227///
228/// The callback receives the new effective value as JSON.
229pub fn on_change(key: &str, callback: impl Fn(&JsonValue) + Send + 'static) {
230    if let Ok(mut guard) = LISTENERS.lock() {
231        let listeners = guard.get_or_insert_with(BTreeMap::new);
232        listeners
233            .entry(key.to_string())
234            .or_default()
235            .push(Box::new(callback));
236    }
237}
238
239/// Re-register a config section and notify listeners.
240///
241/// Call this when config is reloaded (e.g., from `ConfigReloader`).
242/// Listeners registered via [`on_change`] are notified with the new
243/// effective value.
244pub fn update<T>(key: &str, effective: &T)
245where
246    T: serde::Serialize + Default + 'static,
247{
248    let effective_json = serde_json::to_value(effective).unwrap_or(JsonValue::Null);
249
250    // Update the registry entry
251    let section = ConfigSection {
252        key: key.to_string(),
253        type_name: std::any::type_name::<T>().to_string(),
254        defaults: serde_json::to_value(T::default()).unwrap_or(JsonValue::Null),
255        effective: effective_json.clone(),
256    };
257
258    if let Ok(mut guard) = REGISTRY.lock() {
259        let registry = guard.get_or_insert_with(Registry::default);
260        registry.sections.insert(key.to_string(), section);
261    }
262
263    // Notify listeners
264    if let Ok(guard) = LISTENERS.lock()
265        && let Some(listeners) = &*guard
266        && let Some(callbacks) = listeners.get(key)
267    {
268        for cb in callbacks {
269            cb(&effective_json);
270        }
271    }
272}
273
274/// Clear the registry (for testing only).
275#[cfg(test)]
276pub(crate) fn clear() {
277    if let Ok(mut guard) = REGISTRY.lock() {
278        *guard = None;
279    }
280    if let Ok(mut guard) = LISTENERS.lock() {
281        *guard = None;
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use std::sync::Arc;
288    use std::sync::atomic::{AtomicU32, Ordering};
289
290    use super::*;
291
292    /// Tests share global statics — serialise them.
293    static TEST_LOCK: Mutex<()> = Mutex::new(());
294
295    macro_rules! serial_test {
296        () => {
297            let _guard = TEST_LOCK.lock().unwrap();
298            clear();
299        };
300    }
301
302    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
303    struct TestConfig {
304        enabled: bool,
305        threshold: f64,
306        #[serde(skip_serializing)]
307        secret_token: String,
308    }
309
310    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
311    struct SensitiveConfig {
312        host: String,
313        password: String,
314        api_token: String,
315        encryption_key: String,
316        normal_field: u32,
317        nested: NestedSensitive,
318    }
319
320    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
321    struct NestedSensitive {
322        db_password: String,
323        port: u16,
324    }
325
326    #[test]
327    fn register_and_retrieve() {
328        serial_test!();
329
330        let config = TestConfig {
331            enabled: true,
332            threshold: 0.75,
333            secret_token: "hunter2".into(),
334        };
335        register::<TestConfig>("test_module", &config);
336
337        assert!(is_registered("test_module"));
338        assert!(!is_registered("nonexistent"));
339
340        let section = get_section("test_module").unwrap();
341        assert_eq!(section.key, "test_module");
342        assert!(section.type_name.contains("TestConfig"));
343
344        assert_eq!(section.effective["enabled"], true);
345        assert_eq!(section.effective["threshold"], 0.75);
346        // skip_serializing excludes it entirely
347        assert!(section.effective.get("secret_token").is_none());
348
349        assert_eq!(section.defaults["enabled"], false);
350        assert_eq!(section.defaults["threshold"], 0.0);
351    }
352
353    #[test]
354    fn sections_returns_sorted() {
355        serial_test!();
356
357        register::<TestConfig>("zebra", &TestConfig::default());
358        register::<TestConfig>("alpha", &TestConfig::default());
359        register::<TestConfig>("middle", &TestConfig::default());
360
361        let keys: Vec<String> = sections().iter().map(|s| s.key.clone()).collect();
362        assert_eq!(keys, vec!["alpha", "middle", "zebra"]);
363    }
364
365    #[test]
366    fn dump_effective_redacts_sensitive_fields() {
367        serial_test!();
368
369        let config = SensitiveConfig {
370            host: "db.example.com".into(),
371            password: "super_secret".into(),
372            api_token: "tok_abc123".into(),
373            encryption_key: "aes256key".into(),
374            normal_field: 42,
375            nested: NestedSensitive {
376                db_password: "nested_secret".into(),
377                port: 5432,
378            },
379        };
380        register::<SensitiveConfig>("db", &config);
381
382        let dump = dump_effective();
383        // Non-sensitive preserved
384        assert_eq!(dump["db"]["host"], "db.example.com");
385        assert_eq!(dump["db"]["normal_field"], 42);
386        assert_eq!(dump["db"]["nested"]["port"], 5432);
387
388        // Sensitive fields redacted
389        assert_eq!(dump["db"]["password"], REDACTED);
390        assert_eq!(dump["db"]["api_token"], REDACTED);
391        assert_eq!(dump["db"]["encryption_key"], REDACTED);
392        assert_eq!(dump["db"]["nested"]["db_password"], REDACTED);
393    }
394
395    #[test]
396    fn dump_unredacted_preserves_all_fields() {
397        serial_test!();
398
399        let config = SensitiveConfig {
400            password: "visible".into(),
401            ..Default::default()
402        };
403        register::<SensitiveConfig>("db", &config);
404
405        let dump = dump_effective_unredacted();
406        assert_eq!(dump["db"]["password"], "visible");
407    }
408
409    #[test]
410    fn dump_defaults_returns_default_values() {
411        serial_test!();
412
413        register::<TestConfig>(
414            "my_module",
415            &TestConfig {
416                enabled: true,
417                threshold: 0.9,
418                secret_token: String::new(),
419            },
420        );
421
422        let dump = dump_defaults();
423        assert_eq!(dump["my_module"]["enabled"], false);
424        assert_eq!(dump["my_module"]["threshold"], 0.0);
425    }
426
427    #[test]
428    fn re_register_overwrites() {
429        serial_test!();
430
431        let v1 = TestConfig {
432            threshold: 0.5,
433            ..Default::default()
434        };
435        register::<TestConfig>("module", &v1);
436        assert_eq!(get_section("module").unwrap().effective["threshold"], 0.5);
437
438        let v2 = TestConfig {
439            threshold: 0.9,
440            ..Default::default()
441        };
442        register::<TestConfig>("module", &v2);
443        assert_eq!(get_section("module").unwrap().effective["threshold"], 0.9);
444    }
445
446    #[test]
447    fn empty_registry() {
448        serial_test!();
449
450        assert!(sections().is_empty());
451        assert_eq!(dump_effective(), JsonValue::Object(serde_json::Map::new()));
452        assert_eq!(dump_defaults(), JsonValue::Object(serde_json::Map::new()));
453        assert!(!is_registered("anything"));
454        assert!(get_section("anything").is_none());
455    }
456
457    // ── Change notification ─────────────────────────────────────
458
459    #[test]
460    fn on_change_fires_on_update() {
461        serial_test!();
462
463        let counter = Arc::new(AtomicU32::new(0));
464        let counter_clone = counter.clone();
465
466        on_change("my_key", move |_value| {
467            counter_clone.fetch_add(1, Ordering::Relaxed);
468        });
469
470        let config = TestConfig {
471            enabled: true,
472            ..Default::default()
473        };
474        update::<TestConfig>("my_key", &config);
475
476        assert_eq!(counter.load(Ordering::Relaxed), 1);
477
478        // Second update fires again
479        update::<TestConfig>("my_key", &config);
480        assert_eq!(counter.load(Ordering::Relaxed), 2);
481    }
482
483    #[test]
484    fn on_change_receives_new_value() {
485        serial_test!();
486
487        let captured = Arc::new(Mutex::new(JsonValue::Null));
488        let captured_clone = captured.clone();
489
490        on_change("watched", move |value| {
491            if let Ok(mut guard) = captured_clone.lock() {
492                *guard = value.clone();
493            }
494        });
495
496        let config = TestConfig {
497            enabled: true,
498            threshold: 0.99,
499            ..Default::default()
500        };
501        update::<TestConfig>("watched", &config);
502
503        let val = captured.lock().unwrap().clone();
504        assert_eq!(val["enabled"], true);
505        assert_eq!(val["threshold"], 0.99);
506    }
507
508    #[test]
509    fn on_change_only_fires_for_subscribed_key() {
510        serial_test!();
511
512        let counter = Arc::new(AtomicU32::new(0));
513        let counter_clone = counter.clone();
514
515        on_change("key_a", move |_| {
516            counter_clone.fetch_add(1, Ordering::Relaxed);
517        });
518
519        // Update a different key — listener should NOT fire
520        update::<TestConfig>("key_b", &TestConfig::default());
521        assert_eq!(counter.load(Ordering::Relaxed), 0);
522
523        // Update the subscribed key — listener fires
524        update::<TestConfig>("key_a", &TestConfig::default());
525        assert_eq!(counter.load(Ordering::Relaxed), 1);
526    }
527
528    #[test]
529    fn update_also_registers() {
530        serial_test!();
531
532        assert!(!is_registered("fresh"));
533        update::<TestConfig>(
534            "fresh",
535            &TestConfig {
536                enabled: true,
537                ..Default::default()
538            },
539        );
540        assert!(is_registered("fresh"));
541        assert_eq!(get_section("fresh").unwrap().effective["enabled"], true);
542    }
543
544    // ── Redaction test structs (module-level to avoid items_after_statements) ──
545
546    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
547    struct MixedCase {
548        #[serde(rename = "Password")]
549        password_upper: String,
550        #[serde(rename = "API_TOKEN")]
551        token_upper: String,
552        #[serde(rename = "mySecret")]
553        secret_camel: String,
554    }
555
556    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
557    struct DeepNested {
558        level1: Level1,
559    }
560    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
561    struct Level1 {
562        level2: Level2,
563        name: String,
564    }
565    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
566    struct Level2 {
567        api_token: String,
568        db_password: String,
569        port: u16,
570    }
571
572    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
573    struct WithArray {
574        items: Vec<ArrayItem>,
575    }
576    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
577    struct ArrayItem {
578        name: String,
579        secret_key: String,
580    }
581
582    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
583    struct WithDefaultSecret {
584        api_token: String,
585        host: String,
586    }
587    impl Default for WithDefaultSecret {
588        fn default() -> Self {
589            Self {
590                api_token: "default-placeholder-token".into(),
591                host: "localhost".into(),
592            }
593        }
594    }
595
596    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
597    struct DoubleProtected {
598        #[serde(skip_serializing)]
599        #[allow(dead_code)]
600        hidden_secret: String,
601        visible_token: String,
602        normal: String,
603    }
604
605    // ── Redaction guarantee tests ──────────────────────────────
606
607    /// Config struct that exercises ALL sensitive field name patterns.
608    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
609    struct AllSensitivePatterns {
610        // Each SENSITIVE_PATTERNS entry must be covered
611        my_password: String,
612        db_secret: String,
613        api_token: String,
614        encryption_key: String,
615        aws_credential: String,
616        oauth_auth_code: String,
617        private_data: String,
618        tls_cert_path: String,
619        // Non-sensitive controls (must NOT be redacted)
620        hostname: String,
621        port: u16,
622        enabled: bool,
623        timeout_ms: u64,
624    }
625
626    #[test]
627    fn redaction_covers_all_sensitive_patterns() {
628        serial_test!();
629
630        let config = AllSensitivePatterns {
631            my_password: "pass123".into(),
632            db_secret: "sec456".into(),
633            api_token: "tok789".into(),
634            encryption_key: "key012".into(),
635            aws_credential: "cred345".into(),
636            oauth_auth_code: "auth678".into(),
637            private_data: "priv901".into(),
638            tls_cert_path: "/etc/tls/cert.pem".into(),
639            hostname: "db.prod.internal".into(),
640            port: 5432,
641            enabled: true,
642            timeout_ms: 30000,
643        };
644        register::<AllSensitivePatterns>("all_patterns", &config);
645
646        let dump = dump_effective();
647        let section = &dump["all_patterns"];
648
649        // Every sensitive field MUST be redacted
650        assert_eq!(section["my_password"], REDACTED, "password pattern missed");
651        assert_eq!(section["db_secret"], REDACTED, "secret pattern missed");
652        assert_eq!(section["api_token"], REDACTED, "token pattern missed");
653        assert_eq!(section["encryption_key"], REDACTED, "key pattern missed");
654        assert_eq!(
655            section["aws_credential"], REDACTED,
656            "credential pattern missed"
657        );
658        assert_eq!(section["oauth_auth_code"], REDACTED, "auth pattern missed");
659        assert_eq!(section["private_data"], REDACTED, "private pattern missed");
660        assert_eq!(section["tls_cert_path"], REDACTED, "cert pattern missed");
661
662        // Non-sensitive fields MUST be preserved
663        assert_eq!(section["hostname"], "db.prod.internal");
664        assert_eq!(section["port"], 5432);
665        assert_eq!(section["enabled"], true);
666        assert_eq!(section["timeout_ms"], 30000);
667    }
668
669    #[test]
670    fn redaction_is_case_insensitive() {
671        serial_test!();
672
673        let config = MixedCase {
674            password_upper: "visible_if_broken".into(),
675            token_upper: "visible_if_broken".into(),
676            secret_camel: "visible_if_broken".into(),
677        };
678        register::<MixedCase>("case_test", &config);
679
680        let dump = dump_effective();
681        let section = &dump["case_test"];
682
683        assert_eq!(section["Password"], REDACTED);
684        assert_eq!(section["API_TOKEN"], REDACTED);
685        assert_eq!(section["mySecret"], REDACTED);
686    }
687
688    #[test]
689    fn redaction_handles_deeply_nested_secrets() {
690        serial_test!();
691
692        let config = DeepNested {
693            level1: Level1 {
694                level2: Level2 {
695                    api_token: "deep_secret_1".into(),
696                    db_password: "deep_secret_2".into(),
697                    port: 3306,
698                },
699                name: "safe_value".into(),
700            },
701        };
702        register::<DeepNested>("deep", &config);
703
704        let dump = dump_effective();
705        assert_eq!(dump["deep"]["level1"]["level2"]["api_token"], REDACTED);
706        assert_eq!(dump["deep"]["level1"]["level2"]["db_password"], REDACTED);
707        assert_eq!(dump["deep"]["level1"]["level2"]["port"], 3306);
708        assert_eq!(dump["deep"]["level1"]["name"], "safe_value");
709    }
710
711    #[test]
712    fn redaction_handles_arrays_with_sensitive_objects() {
713        serial_test!();
714
715        let config = WithArray {
716            items: vec![
717                ArrayItem {
718                    name: "item1".into(),
719                    secret_key: "sk_1".into(),
720                },
721                ArrayItem {
722                    name: "item2".into(),
723                    secret_key: "sk_2".into(),
724                },
725            ],
726        };
727        register::<WithArray>("array_test", &config);
728
729        let dump = dump_effective();
730        let items = dump["array_test"]["items"].as_array().unwrap();
731        for item in items {
732            assert_eq!(item["secret_key"], REDACTED);
733            assert_ne!(item["name"], REDACTED); // name should be preserved
734        }
735    }
736
737    #[test]
738    fn no_secret_values_in_redacted_dump_string() {
739        serial_test!();
740
741        let secrets = [
742            "hunter2",
743            "sk_live_abc123",
744            "super_s3cret!",
745            "my-private-key-data",
746        ];
747
748        let config = AllSensitivePatterns {
749            my_password: secrets[0].into(),
750            db_secret: secrets[1].into(),
751            api_token: secrets[2].into(),
752            encryption_key: secrets[3].into(),
753            ..Default::default()
754        };
755        register::<AllSensitivePatterns>("leak_check", &config);
756
757        // Serialise the full dump to a string and scan for ANY secret value
758        let dump = dump_effective();
759        let dump_str = serde_json::to_string(&dump).unwrap();
760
761        for secret in &secrets {
762            assert!(
763                !dump_str.contains(secret),
764                "SECRET LEAKED in dump_effective(): '{secret}' found in output"
765            );
766        }
767    }
768
769    #[test]
770    fn defaults_dump_also_redacted() {
771        serial_test!();
772
773        register::<WithDefaultSecret>("default_secrets", &WithDefaultSecret::default());
774
775        let dump = dump_defaults();
776        assert_eq!(dump["default_secrets"]["api_token"], REDACTED);
777        assert_eq!(dump["default_secrets"]["host"], "localhost");
778    }
779
780    #[test]
781    fn skip_serializing_plus_heuristic_double_protection() {
782        serial_test!();
783
784        let config = DoubleProtected {
785            hidden_secret: "should_not_appear".into(),
786            visible_token: "should_be_redacted".into(),
787            normal: "visible".into(),
788        };
789        register::<DoubleProtected>("double", &config);
790
791        let dump = dump_effective();
792        let section = &dump["double"];
793
794        // skip_serializing: field absent entirely
795        assert!(section.get("hidden_secret").is_none());
796        // heuristic: field present but redacted
797        assert_eq!(section["visible_token"], REDACTED);
798        // normal: preserved
799        assert_eq!(section["normal"], "visible");
800
801        // String scan: neither secret should appear
802        let dump_str = serde_json::to_string(&dump).unwrap();
803        assert!(!dump_str.contains("should_not_appear"));
804        assert!(!dump_str.contains("should_be_redacted"));
805    }
806
807    // ── Change notification ─────────────────────────────────────
808
809    #[test]
810    fn multiple_listeners_on_same_key() {
811        serial_test!();
812
813        let c1 = Arc::new(AtomicU32::new(0));
814        let c2 = Arc::new(AtomicU32::new(0));
815        let c1c = c1.clone();
816        let c2c = c2.clone();
817
818        on_change("shared", move |_| {
819            c1c.fetch_add(1, Ordering::Relaxed);
820        });
821        on_change("shared", move |_| {
822            c2c.fetch_add(1, Ordering::Relaxed);
823        });
824
825        update::<TestConfig>("shared", &TestConfig::default());
826
827        assert_eq!(c1.load(Ordering::Relaxed), 1);
828        assert_eq!(c2.load(Ordering::Relaxed), 1);
829    }
830}