use super::Result;
use super::{CaptchaConfig, EmailConfig, MariadbConfig, MediaLibraryConfig, SpacetimeDbConfig};
#[derive(Clone, Debug, Default)]
pub struct AppConfig {
pub mariadb: MariadbConfig,
pub spacetimedb: SpacetimeDbConfig,
pub media_library: MediaLibraryConfig,
pub captcha: CaptchaConfig,
pub email: EmailConfig,
}
impl AppConfig {
pub fn from_env(mut defaults: Self) -> Result<Self> {
defaults.apply_environment(&mut crate::environment::value)?;
defaults.validate()?;
Ok(defaults)
}
fn apply_environment(
&mut self,
value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
) -> Result<()> {
self.mariadb.apply_environment(value)?;
self.spacetimedb.apply_environment(value)?;
self.media_library.apply_environment(value)?;
self.captcha.apply_environment(value)?;
self.email.apply_environment(value)?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
self.mariadb.validate()?;
self.spacetimedb.validate()?;
self.media_library.validate()?;
self.captcha.validate()?;
self.email.validate()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn empty_defaults_are_valid() {
AppConfig::default()
.validate()
.expect("an application may opt into only the sections it uses");
}
#[test]
fn environment_values_override_only_supplied_defaults() {
let mut values = HashMap::from([
("DATABASE_URL", "mysql://database.invalid/app".to_string()),
("CAPTCHA_BYPASS", "true".to_string()),
]);
let mut config = AppConfig {
captcha: CaptchaConfig::standard(),
..AppConfig::default()
};
config
.apply_environment(&mut |name| Ok(values.remove(name)))
.expect("environment values should resolve");
config
.validate()
.expect("resolved defaults should validate");
assert_eq!(
config.mariadb.connection_url().expect("database URL"),
"mysql://database.invalid/app"
);
assert!(config.captcha.bypass);
}
#[test]
fn public_parsers_return_sanitized_concrete_errors() {
use super::super::{EmailSmtpSecurity, EmailTransport, MediaLibraryBackend};
fn concrete_error<T: std::error::Error + Send + Sync + 'static>(_: &T) {}
for error in [
"private-rejected-value"
.parse::<EmailTransport>()
.unwrap_err(),
"private-rejected-value"
.parse::<EmailSmtpSecurity>()
.unwrap_err(),
"private-rejected-value"
.parse::<MediaLibraryBackend>()
.unwrap_err(),
] {
concrete_error(&error);
assert!(!error.to_string().contains("private-rejected-value"));
assert!(!format!("{error:?}").contains("private-rejected-value"));
}
}
#[test]
fn debug_output_redacts_credentials() {
let config = AppConfig {
mariadb: MariadbConfig::new("mysql://user:password@database.invalid/schema"),
email: EmailConfig::smtp(
"sender@example.invalid",
"Example",
"mail.example.invalid",
2525,
super::super::EmailSmtpSecurity::StartTls,
),
..AppConfig::default()
};
let mut config = config;
config.email.smtp.username = Some("account".to_string());
config.email.smtp.password = Some("secret".to_string());
config.spacetimedb =
SpacetimeDbConfig::new("https://database.invalid?token=spacetime-secret");
config.media_library.object_root = "objects".into();
config.media_library.s3.bucket = "media".into();
config.media_library.s3.endpoint =
Some("https://storage.invalid?token=storage-secret".into());
config.email.mailersend.api_url = "https://mail.invalid?token=mail-secret".into();
config
.validate()
.expect("query parameters are valid configuration");
let debug = format!("{config:?}");
assert!(!debug.contains("user:password"));
assert!(!debug.contains("secret"));
assert!(!debug.contains("account"));
assert!(!debug.contains("spacetime-secret"));
assert!(!debug.contains("storage-secret"));
assert!(!debug.contains("mail-secret"));
assert!(debug.contains("[REDACTED]"));
}
}