Skip to main content

ch_config/config/
model.rs

1use super::Result;
2use super::{CaptchaConfig, EmailConfig, MariadbConfig, MediaLibraryConfig, SpacetimeDbConfig};
3
4/// Typed application configuration assembled from independent sections.
5#[derive(Clone, Debug, Default)]
6pub struct AppConfig {
7    /// Optional MariaDB connection configuration.
8    pub mariadb: MariadbConfig,
9    /// Optional SpacetimeDB endpoint configuration.
10    pub spacetimedb: SpacetimeDbConfig,
11    /// Optional durable media storage configuration.
12    pub media_library: MediaLibraryConfig,
13    /// Optional captcha policy.
14    pub captcha: CaptchaConfig,
15    /// Optional email delivery configuration.
16    pub email: EmailConfig,
17}
18
19impl AppConfig {
20    /// Resolves deployment-specific environment values on top of application
21    /// defaults and validates the resulting configuration.
22    pub fn from_env(mut defaults: Self) -> Result<Self> {
23        defaults.apply_environment(&mut crate::environment::value)?;
24        defaults.validate()?;
25        Ok(defaults)
26    }
27
28    fn apply_environment(
29        &mut self,
30        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
31    ) -> Result<()> {
32        self.mariadb.apply_environment(value)?;
33        self.spacetimedb.apply_environment(value)?;
34        self.media_library.apply_environment(value)?;
35        self.captcha.apply_environment(value)?;
36        self.email.apply_environment(value)?;
37        Ok(())
38    }
39
40    /// Validates all configured sections without performing I/O.
41    pub fn validate(&self) -> Result<()> {
42        self.mariadb.validate()?;
43        self.spacetimedb.validate()?;
44        self.media_library.validate()?;
45        self.captcha.validate()?;
46        self.email.validate()?;
47        Ok(())
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use std::collections::HashMap;
54
55    use super::*;
56
57    #[test]
58    fn empty_defaults_are_valid() {
59        AppConfig::default()
60            .validate()
61            .expect("an application may opt into only the sections it uses");
62    }
63
64    #[test]
65    fn environment_values_override_only_supplied_defaults() {
66        let mut values = HashMap::from([
67            ("DATABASE_URL", "mysql://database.invalid/app".to_string()),
68            ("CAPTCHA_BYPASS", "true".to_string()),
69        ]);
70        let mut config = AppConfig {
71            captcha: CaptchaConfig::standard(),
72            ..AppConfig::default()
73        };
74
75        config
76            .apply_environment(&mut |name| Ok(values.remove(name)))
77            .expect("environment values should resolve");
78        config
79            .validate()
80            .expect("resolved defaults should validate");
81
82        assert_eq!(
83            config.mariadb.connection_url().expect("database URL"),
84            "mysql://database.invalid/app"
85        );
86        assert!(config.captcha.bypass);
87    }
88
89    #[test]
90    fn public_parsers_return_sanitized_concrete_errors() {
91        use super::super::{EmailSmtpSecurity, EmailTransport, MediaLibraryBackend};
92
93        fn concrete_error<T: std::error::Error + Send + Sync + 'static>(_: &T) {}
94        for error in [
95            "private-rejected-value"
96                .parse::<EmailTransport>()
97                .unwrap_err(),
98            "private-rejected-value"
99                .parse::<EmailSmtpSecurity>()
100                .unwrap_err(),
101            "private-rejected-value"
102                .parse::<MediaLibraryBackend>()
103                .unwrap_err(),
104        ] {
105            concrete_error(&error);
106            assert!(!error.to_string().contains("private-rejected-value"));
107            assert!(!format!("{error:?}").contains("private-rejected-value"));
108        }
109    }
110
111    #[test]
112    fn debug_output_redacts_credentials() {
113        let config = AppConfig {
114            mariadb: MariadbConfig::new("mysql://user:password@database.invalid/schema"),
115            email: EmailConfig::smtp(
116                "sender@example.invalid",
117                "Example",
118                "mail.example.invalid",
119                2525,
120                super::super::EmailSmtpSecurity::StartTls,
121            ),
122            ..AppConfig::default()
123        };
124        let mut config = config;
125        config.email.smtp.username = Some("account".to_string());
126        config.email.smtp.password = Some("secret".to_string());
127
128        config.spacetimedb =
129            SpacetimeDbConfig::new("https://database.invalid?token=spacetime-secret");
130        config.media_library.object_root = "objects".into();
131        config.media_library.s3.bucket = "media".into();
132        config.media_library.s3.endpoint =
133            Some("https://storage.invalid?token=storage-secret".into());
134        config.email.mailersend.api_url = "https://mail.invalid?token=mail-secret".into();
135        config
136            .validate()
137            .expect("query parameters are valid configuration");
138
139        let debug = format!("{config:?}");
140        assert!(!debug.contains("user:password"));
141        assert!(!debug.contains("secret"));
142        assert!(!debug.contains("account"));
143        assert!(!debug.contains("spacetime-secret"));
144        assert!(!debug.contains("storage-secret"));
145        assert!(!debug.contains("mail-secret"));
146        assert!(debug.contains("[REDACTED]"));
147    }
148}