mod auth;
mod database;
mod jwt;
mod loader;
mod network;
pub mod privacy;
mod server;
mod services;
mod webauthn;
pub use auth::{default_challenge_expiry, AppleConfig, EmailConfig, GoogleConfig, SolanaConfig};
pub use database::{
default_connect_timeout, default_idle_timeout, default_max_connections,
default_min_connections, DatabaseConfig,
};
pub use jwt::{
default_access_expiry, default_audience, default_issuer, default_refresh_expiry, JwtConfig,
};
pub use network::{
default_access_cookie_name, default_path_prefix, default_refresh_cookie_name,
default_same_site, CookieConfig, CorsConfig,
};
pub use privacy::{
default_min_deposit_lamports, default_session_ttl_secs, default_sidecar_timeout_ms,
default_sidecar_url, PrivacyConfig,
};
pub use server::{default_auth_base_path, default_host, default_port, ServerConfig};
pub use services::{
default_auth_limit, default_credit_limit, default_environment, default_general_limit,
default_rate_limit_store, default_wallet_unlock_ttl, default_webhook_retries,
default_webhook_timeout, default_window_secs, NotificationConfig, RateLimitConfig, SsoConfig,
WalletConfig, WalletRecoveryMode, WebhookConfig,
};
pub use webauthn::{default_challenge_ttl, WebAuthnConfig};
use crate::errors::AppError;
use crate::middleware::rate_limit::RateLimitStore;
use loader::*;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
pub jwt: JwtConfig,
#[serde(default)]
pub email: EmailConfig,
#[serde(default)]
pub google: GoogleConfig,
#[serde(default)]
pub apple: AppleConfig,
#[serde(default)]
pub solana: SolanaConfig,
#[serde(default)]
pub webauthn: WebAuthnConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub cookie: CookieConfig,
#[serde(default)]
pub webhook: WebhookConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub notification: NotificationConfig,
#[serde(default)]
pub sso: SsoConfig,
#[serde(default)]
pub wallet: WalletConfig,
#[serde(default)]
pub privacy: PrivacyConfig,
}
const MIN_JWT_SECRET_LENGTH: usize = 32;
fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
ip.octets()[0] == 10
|| (ip.octets()[0] == 172 && (ip.octets()[1] >= 16 && ip.octets()[1] <= 31))
|| (ip.octets()[0] == 192 && ip.octets()[1] == 168)
|| ip.octets()[0] == 127
|| (ip.octets()[0] == 169 && ip.octets()[1] == 254)
|| ip.octets()[0] == 0
|| (ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000) == 64)
|| (ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0)
|| (ip.octets()[0] == 198 && (ip.octets()[1] == 18 || ip.octets()[1] == 19))
|| ip.octets()[0] >= 224
}
fn is_unique_local_v6(v6: std::net::Ipv6Addr) -> bool {
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00
}
fn is_link_local_v6(v6: std::net::Ipv6Addr) -> bool {
let segments = v6.segments();
(segments[0] & 0xffc0) == 0xfe80
}
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => is_private_ipv4(v4),
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| is_unique_local_v6(v6)
|| is_link_local_v6(v6)
}
}
}
impl Config {
pub fn validate(&self) -> Result<(), AppError> {
if self.jwt.secret.len() < MIN_JWT_SECRET_LENGTH {
return Err(AppError::Config(format!(
"JWT_SECRET must be at least {} characters for security (got {})",
MIN_JWT_SECRET_LENGTH,
self.jwt.secret.len()
)));
}
if self.jwt.access_token_expiry == 0 {
return Err(AppError::Config(
"JWT_ACCESS_EXPIRY must be greater than 0".into(),
));
}
if self.jwt.refresh_token_expiry == 0 {
return Err(AppError::Config(
"JWT_REFRESH_EXPIRY must be greater than 0".into(),
));
}
if self.jwt.refresh_token_expiry <= self.jwt.access_token_expiry {
tracing::warn!(
"JWT_REFRESH_EXPIRY ({}) should be longer than JWT_ACCESS_EXPIRY ({})",
self.jwt.refresh_token_expiry,
self.jwt.access_token_expiry
);
}
let env_lc = self.notification.environment.trim().to_ascii_lowercase();
let is_production_strict = matches!(env_lc.as_str(), "production" | "prod");
let is_production_like =
!matches!(env_lc.as_str(), "dev" | "development" | "local" | "test");
if self.rate_limit.enabled {
if self.rate_limit.auth_limit == 0 {
return Err(AppError::Config(
"RATE_LIMIT_AUTH must be greater than 0 when rate limiting is enabled".into(),
));
}
if self.rate_limit.window_secs == 0 {
return Err(AppError::Config(
"RATE_LIMIT_WINDOW must be greater than 0 when rate limiting is enabled".into(),
));
}
match self.rate_limit.store.as_str() {
"memory" => {
if is_production_like && RateLimitStore::is_multi_instance_environment() {
return Err(AppError::Config(
"RATE_LIMIT_STORE=memory is not allowed in production-like multi-instance deployments. Use RATE_LIMIT_STORE=redis."
.into(),
));
}
}
"redis" => {
#[cfg(not(feature = "redis-rate-limit"))]
return Err(AppError::Config(
"RATE_LIMIT_STORE=redis requires the 'redis-rate-limit' feature. \
Compile with: cargo build --features redis-rate-limit"
.into(),
));
#[cfg(feature = "redis-rate-limit")]
if self.rate_limit.redis_url.is_none() {
return Err(AppError::Config(
"REDIS_URL is required when RATE_LIMIT_STORE=redis".into(),
));
}
}
_ => {
return Err(AppError::Config(
"RATE_LIMIT_STORE must be 'memory' or 'redis'".into(),
));
}
}
}
if self.google.enabled && self.google.client_id.is_none() {
return Err(AppError::Config(
"GOOGLE_CLIENT_ID is required when Google auth is enabled".into(),
));
}
if self.apple.enabled {
if self.apple.client_id.is_none() && self.apple.allowed_client_ids.is_empty() {
return Err(AppError::Config(
"APPLE_CLIENT_ID or APPLE_ALLOWED_CLIENT_IDS is required when Apple auth is enabled".into(),
));
}
if self.apple.team_id.is_none() {
return Err(AppError::Config(
"APPLE_TEAM_ID is required when Apple auth is enabled".into(),
));
}
}
if self.webhook.enabled {
let url_str = self.webhook.url.as_ref().ok_or_else(|| {
AppError::Config("WEBHOOK_URL is required when webhooks are enabled".into())
})?;
if self.webhook.secret.is_none() {
return Err(AppError::Config(
"WEBHOOK_SECRET is required when webhooks are enabled".into(),
));
}
self.validate_webhook_url(url_str)?;
}
if self.cookie.enabled && !self.cookie.secure {
if is_production_like {
return Err(AppError::Config(
"COOKIE_SECURE must be true in production-like environments".into(),
));
}
let is_localhost = self.server.host == "127.0.0.1"
|| self.server.host == "localhost"
|| self.server.host == "::1"
|| self.server.host == "0.0.0.0";
if is_localhost {
tracing::info!(
"COOKIE_SECURE is false (localhost development mode). \
Set COOKIE_SECURE=true for non-localhost deployments."
);
} else {
tracing::warn!(
"COOKIE_SECURE is false but HOST is {} (not localhost). \
Cookies will be transmitted insecurely over HTTP! \
Set COOKIE_SECURE=true and use HTTPS for any non-localhost deployment.",
self.server.host
);
}
}
if self.cookie.same_site.to_lowercase() == "none" && !self.cookie.secure {
return Err(AppError::Config(
"COOKIE_SAME_SITE=none requires COOKIE_SECURE=true".into(),
));
}
if let Some(ref webhook_url) = self.notification.discord_webhook_url {
if webhook_url.trim().is_empty() {
return Err(AppError::Config(
"DISCORD_WEBHOOK_URL cannot be empty when set".into(),
));
}
}
if let Some(ref token) = self.notification.telegram_bot_token {
if token.trim().is_empty() {
return Err(AppError::Config(
"TELEGRAM_BOT_TOKEN cannot be empty when set".into(),
));
}
}
if let Some(ref chat_id) = self.notification.telegram_chat_id {
if chat_id.trim().is_empty() {
return Err(AppError::Config(
"TELEGRAM_CHAT_ID cannot be empty when set".into(),
));
}
}
let is_production = is_production_strict;
if is_production_like {
let totp_secret = std::env::var("TOTP_ENCRYPTION_SECRET").unwrap_or_default();
if totp_secret.is_empty() {
return Err(AppError::Config(
"TOTP_ENCRYPTION_SECRET is required in production-like environments".into(),
));
}
}
if is_production_like && self.jwt.rsa_private_key_pem.is_none() {
return Err(AppError::Config(
"JWT_RSA_PRIVATE_KEY is required in production-like environments".into(),
));
}
if let Some(ref pem) = self.jwt.rsa_private_key_pem {
use rsa::pkcs1::DecodeRsaPrivateKey;
use rsa::RsaPrivateKey;
RsaPrivateKey::from_pkcs1_pem(pem).map_err(|e| {
AppError::Config(format!(
"Invalid JWT_RSA_PRIVATE_KEY (expected PKCS#1 PEM): {}",
e
))
})?;
}
if self.server.frontend_url.is_none() {
tracing::warn!(
"FRONTEND_URL not set - email verification and password reset links \
will use http://localhost:3000. Set FRONTEND_URL in production."
);
}
if is_production && !self.email.require_verification {
tracing::warn!(
"EMAIL_REQUIRE_VERIFICATION is false in production. \
Users can register without verifying their email address."
);
}
if let Some(callback_url) = &self.server.sso_callback_url {
let url = url::Url::parse(callback_url)
.map_err(|e| AppError::Config(format!("Invalid SSO_CALLBACK_URL: {}", e)))?;
if url.scheme() != "http" && url.scheme() != "https" {
return Err(AppError::Config(
"SSO_CALLBACK_URL must use http or https scheme".into(),
));
}
if is_production && url.scheme() != "https" {
return Err(AppError::Config(
"SSO_CALLBACK_URL must use HTTPS in production".into(),
));
}
} else if is_production && self.sso.enabled {
let frontend_https = self
.server
.frontend_url
.as_ref()
.map(|url| url.starts_with("https://"))
.unwrap_or(false);
if !frontend_https {
return Err(AppError::Config(
"SSO_CALLBACK_URL must be set to an HTTPS URL in production when FRONTEND_URL is not https".into(),
));
}
}
for origin in &self.cors.allowed_origins {
let url = url::Url::parse(origin)
.map_err(|_| AppError::Config(format!("Invalid CORS origin: {}", origin)))?;
if url.scheme() != "http" && url.scheme() != "https" {
return Err(AppError::Config(format!(
"Invalid CORS origin scheme: {}",
origin
)));
}
if url.host_str().is_none() {
return Err(AppError::Config(format!(
"Invalid CORS origin host: {}",
origin
)));
}
}
if is_production_like && self.cors.allowed_origins.is_empty() {
return Err(AppError::Config(
"CORS_ORIGINS must be configured in production-like environments. \
Set CORS_ORIGINS to a comma-separated list of allowed origins."
.into(),
));
}
if self.privacy.enabled {
if self.privacy.sidecar_api_key.is_none() {
return Err(AppError::Config(
"SIDECAR_API_KEY is required when Privacy Cash is enabled".into(),
));
}
if let Some(ref key) = self.privacy.note_encryption_key {
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key) {
Ok(bytes) if bytes.len() == 32 => {}
Ok(bytes) => {
return Err(AppError::Config(format!(
"NOTE_ENCRYPTION_KEY must decode to 32 bytes (got {} bytes)",
bytes.len()
)));
}
Err(e) => {
return Err(AppError::Config(format!(
"NOTE_ENCRYPTION_KEY must be valid base64: {}",
e
)));
}
}
} else {
return Err(AppError::Config(
"NOTE_ENCRYPTION_KEY is required when Privacy Cash is enabled".into(),
));
}
let url = url::Url::parse(&self.privacy.sidecar_url).map_err(|e| {
AppError::Config(format!("Invalid PRIVACY_CASH_SIDECAR_URL: {}", e))
})?;
if url.scheme() != "http" && url.scheme() != "https" {
return Err(AppError::Config(
"PRIVACY_CASH_SIDECAR_URL must use http or https scheme".into(),
));
}
if is_production && url.scheme() != "https" {
return Err(AppError::Config(
"PRIVACY_CASH_SIDECAR_URL must use HTTPS in production".into(),
));
}
}
Ok(())
}
fn validate_webhook_url(&self, url_str: &str) -> Result<(), AppError> {
use std::net::IpAddr;
use url::Url;
let url = Url::parse(url_str)
.map_err(|e| AppError::Config(format!("Invalid WEBHOOK_URL: {}", e)))?;
let is_production = self
.notification
.environment
.eq_ignore_ascii_case("production")
|| self.notification.environment.eq_ignore_ascii_case("prod");
if is_production && url.scheme() != "https" {
return Err(AppError::Config(
"WEBHOOK_URL must use HTTPS in production".into(),
));
}
if url.scheme() != "http" && url.scheme() != "https" {
return Err(AppError::Config(
"WEBHOOK_URL must use http or https scheme".into(),
));
}
let host = url
.host()
.ok_or_else(|| AppError::Config("WEBHOOK_URL must have a host".into()))?;
match host {
url::Host::Ipv4(ipv4) => {
if is_private_ip(IpAddr::V4(ipv4)) {
return Err(AppError::Config(
"WEBHOOK_URL cannot point to private IP addresses".into(),
));
}
return Ok(());
}
url::Host::Ipv6(ipv6) => {
if is_private_ip(IpAddr::V6(ipv6)) {
return Err(AppError::Config(
"WEBHOOK_URL cannot point to private IP addresses".into(),
));
}
return Ok(());
}
url::Host::Domain(domain) => {
if domain == "localhost" {
return Err(AppError::Config(
"WEBHOOK_URL cannot point to localhost".into(),
));
}
if domain.ends_with(".internal") || domain.ends_with(".local") {
return Err(AppError::Config(
"WEBHOOK_URL cannot point to internal endpoints".into(),
));
}
}
}
Ok(())
}
pub fn from_env() -> Result<Self, AppError> {
let jwt_secret = std::env::var("JWT_SECRET")
.map_err(|_| AppError::Config("JWT_SECRET environment variable is required".into()))?;
let config = Config {
server: load_server_config(),
jwt: load_jwt_config(jwt_secret),
email: load_email_config(),
google: load_google_config(),
apple: load_apple_config(),
solana: load_solana_config(),
webauthn: load_webauthn_config(),
cors: load_cors_config(),
cookie: load_cookie_config(),
webhook: load_webhook_config(),
rate_limit: load_rate_limit_config(),
database: load_database_config(),
notification: load_notification_config(),
sso: load_sso_config(),
wallet: load_wallet_config(),
privacy: load_privacy_config(),
};
config.validate()?;
Ok(config)
}
#[allow(dead_code)]
pub async fn apply_db_settings(
&mut self,
settings: &crate::services::SettingsService,
) -> Result<(), AppError> {
if let Some(enabled) = settings.get_bool("auth_google_enabled").await? {
self.google.enabled = enabled;
tracing::debug!(enabled, "DB override: auth_google_enabled");
}
if let Some(client_id) = settings.get("auth_google_client_id").await? {
if !client_id.is_empty() {
self.google.client_id = Some(client_id);
tracing::debug!("DB override: auth_google_client_id");
}
}
if let Some(enabled) = settings.get_bool("auth_apple_enabled").await? {
self.apple.enabled = enabled;
tracing::debug!(enabled, "DB override: auth_apple_enabled");
}
if let Some(client_id) = settings.get("auth_apple_client_id").await? {
if !client_id.is_empty() {
self.apple.client_id = Some(client_id);
tracing::debug!("DB override: auth_apple_client_id");
}
}
if let Some(client_ids) = settings.get("auth_apple_allowed_client_ids").await? {
let parsed: Vec<String> = client_ids
.split(',')
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect();
if !parsed.is_empty() {
self.apple.allowed_client_ids = parsed;
tracing::debug!("DB override: auth_apple_allowed_client_ids");
}
}
if let Some(team_id) = settings.get("auth_apple_team_id").await? {
if !team_id.is_empty() {
self.apple.team_id = Some(team_id);
tracing::debug!("DB override: auth_apple_team_id");
}
}
if let Some(key_id) = settings.get("auth_apple_key_id").await? {
if !key_id.is_empty() {
self.apple.key_id = Some(key_id);
tracing::debug!("DB override: auth_apple_key_id");
}
}
if let Some(private_key_pem) = settings.get("auth_apple_private_key_pem").await? {
if !private_key_pem.is_empty() {
self.apple.private_key_pem = Some(private_key_pem);
tracing::debug!("DB override: auth_apple_private_key_pem");
}
}
if let Some(enabled) = settings.get_bool("auth_solana_enabled").await? {
self.solana.enabled = enabled;
tracing::debug!(enabled, "DB override: auth_solana_enabled");
}
if let Some(expiry) = settings.get_u64("auth_solana_challenge_expiry").await? {
self.solana.challenge_expiry_seconds = expiry;
tracing::debug!(expiry, "DB override: auth_solana_challenge_expiry");
}
if let Some(enabled) = settings.get_bool("auth_webauthn_enabled").await? {
self.webauthn.enabled = enabled;
tracing::debug!(enabled, "DB override: auth_webauthn_enabled");
}
if let Some(rp_id) = settings.get("auth_webauthn_rp_id").await? {
if !rp_id.is_empty() {
self.webauthn.rp_id = Some(rp_id);
tracing::debug!("DB override: auth_webauthn_rp_id");
}
}
if let Some(rp_name) = settings.get("auth_webauthn_rp_name").await? {
if !rp_name.is_empty() {
self.webauthn.rp_name = Some(rp_name);
tracing::debug!("DB override: auth_webauthn_rp_name");
}
}
if let Some(rp_origin) = settings.get("auth_webauthn_rp_origin").await? {
if !rp_origin.is_empty() {
self.webauthn.rp_origin = Some(rp_origin);
tracing::debug!("DB override: auth_webauthn_rp_origin");
}
}
if let Some(enabled) = settings.get_bool("auth_email_enabled").await? {
self.email.enabled = enabled;
tracing::debug!(enabled, "DB override: auth_email_enabled");
}
if let Some(require_verification) =
settings.get_bool("auth_email_require_verification").await?
{
self.email.require_verification = require_verification;
tracing::debug!(
require_verification,
"DB override: auth_email_require_verification"
);
}
if let Some(block_disposable) = settings.get_bool("auth_email_block_disposable").await? {
self.email.block_disposable_emails = block_disposable;
tracing::debug!(block_disposable, "DB override: auth_email_block_disposable");
}
if let Some(enabled) = settings.get_bool("feature_privacy_cash").await? {
self.privacy.enabled = enabled;
tracing::debug!(enabled, "DB override: feature_privacy_cash");
}
if let Some(enabled) = settings.get_bool("feature_wallet_signing").await? {
self.wallet.enabled = enabled;
tracing::debug!(enabled, "DB override: feature_wallet_signing");
}
if let Some(enabled) = settings.get_bool("feature_sso").await? {
self.sso.enabled = enabled;
tracing::debug!(enabled, "DB override: feature_sso");
}
if let Some(cors_origins) = settings.get("security_cors_origins").await? {
if !cors_origins.is_empty() {
self.cors.allowed_origins = cors_origins
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
tracing::debug!("DB override: security_cors_origins");
}
}
if let Some(cookie_domain) = settings.get("security_cookie_domain").await? {
if !cookie_domain.is_empty() {
self.cookie.domain = Some(cookie_domain);
tracing::debug!("DB override: security_cookie_domain");
}
}
if let Some(cookie_secure) = settings.get_bool("security_cookie_secure").await? {
self.cookie.secure = cookie_secure;
tracing::debug!(cookie_secure, "DB override: security_cookie_secure");
}
if let Some(same_site) = settings.get("security_cookie_same_site").await? {
if !same_site.is_empty() {
self.cookie.same_site = same_site;
tracing::debug!("DB override: security_cookie_same_site");
}
}
if let Some(session_timeout) = settings.get_u64("security_session_timeout").await? {
self.jwt.refresh_token_expiry = session_timeout;
tracing::debug!(session_timeout, "DB override: security_session_timeout");
}
if let Some(frontend_url) = settings.get("server_frontend_url").await? {
if !frontend_url.is_empty() {
self.server.frontend_url = Some(frontend_url);
tracing::debug!("DB override: server_frontend_url");
}
}
if let Some(base_path) = settings.get("server_base_path").await? {
if !base_path.is_empty() {
self.server.auth_base_path = base_path;
tracing::debug!("DB override: server_base_path");
}
}
if let Some(trust_proxy) = settings.get_bool("server_trust_proxy").await? {
self.server.trust_proxy = trust_proxy;
tracing::debug!(trust_proxy, "DB override: server_trust_proxy");
}
if let Some(enabled) = settings.get_bool("webhook_enabled").await? {
self.webhook.enabled = enabled;
tracing::debug!(enabled, "DB override: webhook_enabled");
}
if let Some(url) = settings.get("webhook_url").await? {
if !url.is_empty() {
self.webhook.url = Some(url);
tracing::debug!("DB override: webhook_url");
}
}
if let Some(timeout) = settings.get_u64("webhook_timeout").await? {
self.webhook.timeout_secs = timeout;
tracing::debug!(timeout, "DB override: webhook_timeout");
}
if let Some(retries) = settings.get_u32("webhook_retries").await? {
self.webhook.retry_attempts = retries;
tracing::debug!(retries, "DB override: webhook_retries");
}
if let Some(auth_limit) = settings.get_u32("rate_limit_auth").await? {
self.rate_limit.auth_limit = auth_limit;
tracing::debug!(auth_limit, "DB override: rate_limit_auth");
}
if let Some(general_limit) = settings.get_u32("rate_limit_general").await? {
self.rate_limit.general_limit = general_limit;
tracing::debug!(general_limit, "DB override: rate_limit_general");
}
if let Some(credit_limit) = settings.get_u32("rate_limit_credit").await? {
self.rate_limit.credit_limit = credit_limit;
tracing::debug!(credit_limit, "DB override: rate_limit_credit");
}
if let Some(window_secs) = settings.get_u64("rate_limit_window").await? {
self.rate_limit.window_secs = window_secs;
tracing::debug!(window_secs, "DB override: rate_limit_window");
}
tracing::info!("Applied database settings (DB values override environment)");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use crate::test_env::{lock_env, set_env};
fn base_config() -> Config {
Config {
server: ServerConfig::default(),
jwt: JwtConfig {
secret: "s".repeat(MIN_JWT_SECRET_LENGTH),
rsa_private_key_pem: None,
issuer: default_issuer(),
audience: default_audience(),
access_token_expiry: default_access_expiry(),
refresh_token_expiry: default_refresh_expiry(),
},
email: EmailConfig::default(),
google: GoogleConfig {
enabled: false,
client_id: None,
},
apple: AppleConfig {
enabled: false,
client_id: None,
team_id: None,
..AppleConfig::default()
},
solana: SolanaConfig::default(),
webauthn: WebAuthnConfig::default(),
cors: CorsConfig::default(),
cookie: CookieConfig::default(),
webhook: WebhookConfig::default(),
rate_limit: RateLimitConfig::default(),
database: DatabaseConfig::default(),
notification: NotificationConfig::default(),
sso: SsoConfig::default(),
wallet: WalletConfig::default(),
privacy: PrivacyConfig::default(),
}
}
fn valid_note_key() -> String {
base64::engine::general_purpose::STANDARD.encode([0u8; 32])
}
fn test_rsa_private_key_pem() -> String {
use rand::rngs::OsRng;
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::RsaPrivateKey;
let key = RsaPrivateKey::new(&mut OsRng, 1024).unwrap();
key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
.unwrap()
.to_string()
}
fn enable_privacy(config: &mut Config) {
config.privacy.enabled = true;
config.privacy.sidecar_api_key = Some("sidecar-key".to_string());
config.privacy.note_encryption_key = Some(valid_note_key());
}
#[test]
fn test_cookie_secure_required_in_production() {
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = false;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("COOKIE_SECURE must be true in production-like environments"));
}
#[test]
fn test_cookie_secure_required_in_staging() {
let mut config = base_config();
config.notification.environment = "staging".to_string();
config.cookie.secure = false;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("COOKIE_SECURE must be true in production-like environments"));
}
#[test]
fn test_cookie_secure_allowed_in_non_production() {
let mut config = base_config();
config.notification.environment = "development".to_string();
config.cookie.secure = false;
assert!(config.validate().is_ok());
}
#[test]
fn test_cookie_secure_passes_in_production() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true;
config.cors.allowed_origins = vec!["https://example.com".to_string()];
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
assert!(config.validate().is_ok());
}
#[test]
fn test_jwt_rsa_private_key_required_in_production() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true;
config.cors.allowed_origins = vec!["https://example.com".to_string()];
config.jwt.rsa_private_key_pem = None;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("JWT_RSA_PRIVATE_KEY is required in production-like environments"));
}
#[test]
fn test_jwt_rsa_private_key_required_in_staging() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "staging".to_string();
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = None;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("JWT_RSA_PRIVATE_KEY is required in production-like environments"));
}
#[test]
fn test_jwt_rsa_private_key_rejects_invalid_pem() {
let mut config = base_config();
config.jwt.rsa_private_key_pem = Some("not-a-pem".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("Invalid JWT_RSA_PRIVATE_KEY"));
}
#[test]
fn test_rate_limit_store_rejects_invalid() {
let mut config = base_config();
config.rate_limit.store = "invalid".to_string();
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("RATE_LIMIT_STORE must be 'memory' or 'redis'"));
}
#[test]
#[cfg(feature = "redis-rate-limit")]
fn test_rate_limit_redis_requires_url() {
let mut config = base_config();
config.rate_limit.store = "redis".to_string();
config.rate_limit.redis_url = None;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("REDIS_URL is required"));
}
#[test]
#[cfg(feature = "redis-rate-limit")]
fn test_rate_limit_redis_accepts_valid_url() {
let mut config = base_config();
config.rate_limit.store = "redis".to_string();
config.rate_limit.redis_url = Some("redis://localhost:6379".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_rate_limit_memory_rejected_in_production_multi_instance() {
let _lock = lock_env();
let _replicas = set_env("REPLICAS", Some("2"));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec!["https://app.example.com".to_string()];
config.rate_limit.store = "memory".to_string();
config.rate_limit.redis_url = None;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("RATE_LIMIT_STORE=memory is not allowed"));
}
#[test]
fn test_from_env_runs_validation() {
let _lock = lock_env();
let _jwt = set_env("JWT_SECRET", Some("short"));
let _google = set_env("GOOGLE_ENABLED", Some("false"));
let err = Config::from_env().unwrap_err().to_string();
assert!(err.contains("JWT_SECRET must be at least"));
}
#[test]
fn test_cors_required_in_production() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true; config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec![];
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("CORS_ORIGINS must be configured in production-like environments"));
}
#[test]
fn test_cors_required_in_staging() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "staging".to_string();
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec![];
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("CORS_ORIGINS must be configured in production-like environments"));
}
#[test]
fn test_cors_allowed_empty_in_development() {
let mut config = base_config();
config.notification.environment = "development".to_string();
config.cors.allowed_origins = vec![];
assert!(config.validate().is_ok());
}
#[test]
fn test_cors_passes_in_production_with_origins() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec!["https://example.com".to_string()];
assert!(config.validate().is_ok());
}
#[test]
fn test_privacy_sidecar_requires_https_in_production() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec!["https://example.com".to_string()];
enable_privacy(&mut config);
config.privacy.sidecar_url = "http://sidecar.example.com".to_string();
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("PRIVACY_CASH_SIDECAR_URL must use HTTPS in production"));
}
#[test]
fn test_privacy_sidecar_allows_http_in_development() {
let mut config = base_config();
config.notification.environment = "development".to_string();
enable_privacy(&mut config);
config.privacy.sidecar_url = "http://sidecar.example.com".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_cors_rejects_invalid_origin() {
let mut config = base_config();
config.cors.allowed_origins = vec!["not-a-url".to_string()];
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("Invalid CORS origin"));
}
#[test]
fn test_sso_callback_url_requires_https_in_production() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.sso.enabled = true;
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec!["https://example.com".to_string()];
config.server.sso_callback_url = Some("http://auth.example.com/auth/sso/callback".into());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("SSO_CALLBACK_URL must use HTTPS in production"));
}
#[test]
fn test_sso_callback_url_required_when_frontend_not_https_in_production() {
let _lock = lock_env();
let totp_secret = "s".repeat(MIN_JWT_SECRET_LENGTH);
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(&totp_secret));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.sso.enabled = true;
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
config.cors.allowed_origins = vec!["https://example.com".to_string()];
config.server.frontend_url = Some("http://example.com".to_string());
config.server.sso_callback_url = None;
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains(
"SSO_CALLBACK_URL must be set to an HTTPS URL in production when FRONTEND_URL is not https"
)
);
}
#[test]
fn test_totp_encryption_secret_required_in_production() {
let _lock = lock_env();
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(""));
let mut config = base_config();
config.notification.environment = "production".to_string();
config.cookie.secure = true;
config.cors.allowed_origins = vec!["https://example.com".to_string()];
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("TOTP_ENCRYPTION_SECRET is required in production-like environments"));
}
#[test]
fn test_totp_encryption_secret_required_in_staging() {
let _lock = lock_env();
let _totp = set_env("TOTP_ENCRYPTION_SECRET", Some(""));
let mut config = base_config();
config.notification.environment = "staging".to_string();
config.cookie.secure = true;
config.jwt.rsa_private_key_pem = Some(test_rsa_private_key_pem());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("TOTP_ENCRYPTION_SECRET is required in production-like environments"));
}
#[test]
fn test_webhook_url_rejects_private_ipv6() {
let mut config = base_config();
config.webhook.enabled = true;
config.webhook.url = Some("http://[fd00::1]/webhook".to_string());
config.webhook.secret = Some("secret".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("private IP addresses"), "{}", err);
}
#[test]
fn test_webhook_url_accepts_public_ipv6() {
let mut config = base_config();
config.webhook.enabled = true;
config.webhook.url = Some("http://[2001:db8::1]/webhook".to_string());
config.webhook.secret = Some("secret".to_string());
if let Err(e) = config.validate() {
panic!("{}", e);
}
}
#[test]
fn test_webhook_url_rejects_reserved_ipv4() {
let cases = [
"http://0.0.0.0/webhook",
"http://100.64.0.1/webhook",
"http://192.0.0.1/webhook",
"http://198.18.0.1/webhook",
"http://224.0.0.1/webhook",
];
for url in cases {
let mut config = base_config();
config.webhook.enabled = true;
config.webhook.url = Some(url.to_string());
config.webhook.secret = Some("secret".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("private IP addresses"), "{}", err);
}
}
#[test]
fn test_webhook_url_rejects_local_hostname() {
let mut config = base_config();
config.webhook.enabled = true;
config.webhook.url = Some("http://webhook.local/path".to_string());
config.webhook.secret = Some("secret".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("internal endpoints"), "{}", err);
}
#[test]
fn test_notification_config_rejects_empty_discord_webhook() {
let mut config = base_config();
config.notification.discord_webhook_url = Some(" ".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("DISCORD_WEBHOOK_URL cannot be empty"),
"{}",
err
);
}
#[test]
fn test_notification_config_rejects_empty_telegram_fields() {
let mut config = base_config();
config.notification.telegram_bot_token = Some("".to_string());
config.notification.telegram_chat_id = Some(" ".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("TELEGRAM_BOT_TOKEN cannot be empty"),
"{}",
err
);
}
#[test]
fn test_notification_config_rejects_empty_telegram_chat_id() {
let mut config = base_config();
config.notification.telegram_bot_token = Some("token".to_string());
config.notification.telegram_chat_id = Some(" ".to_string());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("TELEGRAM_CHAT_ID cannot be empty"), "{}", err);
}
}