//! Tests for [`super`]. use super::*; use std::sync::Mutex; /// Mutex to serialize tests that call Config::from_env(), since env vars are /// process-global and concurrent mutation causes flaky failures. static ENV_LOCK: Mutex<()> = Mutex::new(()); /// All env var keys that Config::from_env() reads. Used by the guard to /// snapshot and restore state so tests don't leak into each other. const CONFIG_ENV_VARS: &[&str] = &[ "HOST", "PORT", "DATABASE_URL", "HOST_URL", "SIGNING_SECRET", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION", "S3_PUBLIC_BUCKET", "S3_ARTIFACT_BUCKET", "ARTIFACT_S3_ENDPOINT", "ARTIFACT_S3_BUCKET", "ARTIFACT_S3_ACCESS_KEY", "ARTIFACT_S3_SECRET_KEY", "ARTIFACT_S3_REGION", "ARTIFACT_BASE_URL", "SYNCKIT_S3_ENDPOINT", "SYNCKIT_S3_BUCKET", "SYNCKIT_S3_ACCESS_KEY", "SYNCKIT_S3_SECRET_KEY", "SYNCKIT_S3_REGION", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET_V2", "ADMIN_USER_ID", "SYNCKIT_JWT_SECRET", "SCAN_ENABLED", "CLAMAV_SOCKET", "YARA_RULES_DIR", "MALWAREBAZAAR_ENABLED", "URLHAUS_ENABLED", "ABUSE_CH_AUTH_KEY", "METADEFENDER_API_KEY", "GIT_REPOS_PATH", "POSTMARK_WEBHOOK_TOKEN", "POSTMARK_BROADCAST_WEBHOOK_TOKEN", "GIT_SSH_HOST", "MT_BASE_URL", "FAN_PLUS_STRIPE_PRICE_ID", "CREATOR_TIER_BASIC_PRICE_ID", "CREATOR_TIER_SMALL_FILES_PRICE_ID", "CREATOR_TIER_BIG_FILES_PRICE_ID", "CREATOR_TIER_EVERYTHING_PRICE_ID", "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID", "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID", "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID", "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID", "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_FOUNDER_WINDOW_OPEN", "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN", "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN", "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", "WAM_URL", "WAM_TOKEN", "ACCESS_GATE", "SSO_PROVIDER_URL", "SSO_CLIENT_ID", "SSO_KEY", ]; /// RAII guard that snapshots config-related env vars on creation and restores /// them when dropped. Also holds the ENV_LOCK so tests run serially. struct EnvGuard { _lock: std::sync::MutexGuard<'static, ()>, snapshot: Vec<(&'static str, Option)>, } impl EnvGuard { fn new() -> Self { let lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let snapshot = CONFIG_ENV_VARS .iter() .map(|&key| (key, std::env::var(key).ok())) .collect(); Self { _lock: lock, snapshot, } } /// Remove all config env vars so from_env() sees a clean slate. fn clear_all() { for &key in CONFIG_ENV_VARS { // SAFETY: test-only, serialized by mutex unsafe { std::env::remove_var(key); } } } } impl Drop for EnvGuard { fn drop(&mut self) { for (key, val) in &self.snapshot { match val { // SAFETY: test-only, serialized by mutex Some(v) => unsafe { std::env::set_var(key, v) }, None => unsafe { std::env::remove_var(key) }, } } } } // ---- tests ---- #[test] fn socket_addr_combines_host_and_port() { let config = Config { host: "127.0.0.1".parse().unwrap(), port: 8080, database_url: "postgres://test".to_string(), host_url: Arc::from("http://localhost:8080"), signing_secret: "secret".to_string(), storage: None, synckit_storage: None, public_storage: None, artifact_storage: None, artifact_base_url: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, cdn_base_url: "https://cdn.localhost".to_string(), user_pages_host: Arc::from("u.localhost"), access_gate: AccessGate::Open, sso: None, rate_limits: crate::constants::RateLimits::production(), build: BuildConfig { trigger_token: None, host_linux: None, host_darwin: None, git_repos_path: None, git_ssh_host: None, }, email_webhooks: EmailWebhookConfig { webhook_token: None, broadcast_webhook_token: None, inbound_webhook_token: None, enforce_sender_auth: true, }, creator_pricing: CreatorTierPricing { fan_plus_price_id: None, tier_prices: HashMap::new(), tier_annual_prices: HashMap::new(), tier_founder_prices: HashMap::new(), tier_founder_annual_prices: HashMap::new(), founder_window_open: false, }, integrations: IntegrationsConfig { mt_base_url: None, wam_url: None, internal_shared_secret: None, cli_service_token: None, alerts_ingest_token: None, }, }; let addr = config.socket_addr(); assert_eq!(addr.port(), 8080); assert_eq!(addr.ip().to_string(), "127.0.0.1"); } #[test] fn config_error_display() { assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address"); assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number"); assert!( ConfigError::MissingDatabaseUrl .to_string() .contains("DATABASE_URL") ); } // ---- from_env validation tests ---- #[test] fn from_env_succeeds_with_required_vars() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); } let config = Config::from_env().expect("should succeed with DATABASE_URL set"); assert_eq!(config.database_url, "postgres://localhost/test_db"); // Defaults: host=127.0.0.1, port=3000 assert_eq!(config.host.to_string(), "127.0.0.1"); assert_eq!(config.port, 3000); // Signing secret should be a random 64-char hex string in dev mode assert!(!config.signing_secret.is_empty()); drop(guard); } #[test] fn from_env_fails_without_database_url() { let guard = EnvGuard::new(); EnvGuard::clear_all(); let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingDatabaseUrl), "expected MissingDatabaseUrl, got: {err}" ); drop(guard); } #[test] fn from_env_fails_in_production_without_signing_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("HOST", "0.0.0.0"); // production indicator } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingSigningSecret), "expected MissingSigningSecret, got: {err}" ); drop(guard); } #[test] fn from_env_fails_without_cdn_base_url_even_outside_production() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate // No production indicator: HOST stays unset, so this is a dev config. // It must STILL fail. The requirement is unconditional precisely so // no environment can reach the old presigned fallback, which minted // a 24-hour URL into the durable `projects.cover_image_url` column. // CDN_BASE_URL deliberately unset. } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingCdnBaseUrl), "expected MissingCdnBaseUrl, got: {err}" ); drop(guard); } #[test] fn from_env_accepts_production_with_cdn_base_url() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); std::env::set_var("HOST", "0.0.0.0"); std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work"); } let config = Config::from_env().expect("production config with CDN should succeed"); assert_eq!(config.cdn_base_url, "https://cdn.makenot.work"); drop(guard); } #[test] fn from_env_fails_with_https_host_url_without_signing_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingSigningSecret), "expected MissingSigningSecret, got: {err}" ); drop(guard); } #[test] fn from_env_fails_with_short_synckit_jwt_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // 31 chars, one under the floor. std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31)); } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::WeakSynckitJwtSecret), "expected WeakSynckitJwtSecret, got: {err}" ); drop(guard); } #[test] fn from_env_accepts_strong_synckit_jwt_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); std::env::set_var("SYNCKIT_JWT_SECRET", "y".repeat(32)); } let config = Config::from_env().expect("32-char JWT secret should be accepted"); assert_eq!( config.synckit_jwt_secret.as_deref(), Some("y".repeat(32).as_str()) ); drop(guard); } #[test] fn from_env_uses_random_dev_secret_when_not_production() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET } let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET"); // Should be a 64-char hex string (256-bit random) assert_eq!( config.signing_secret.len(), 64, "expected 64-char hex signing secret, got length {}", config.signing_secret.len() ); assert!( config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()), "expected hex signing secret, got: {}", config.signing_secret ); drop(guard); } #[test] fn from_env_storage_none_when_partially_set() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // Set only some S3 vars, missing S3_SECRET_KEY and S3_ACCESS_KEY std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); std::env::set_var("S3_BUCKET", "test-bucket"); } let config = Config::from_env().expect("should succeed"); assert!( config.storage.is_none(), "storage should be None when S3 vars are only partially set" ); drop(guard); } #[test] fn from_env_storage_some_when_fully_set() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); std::env::set_var("S3_BUCKET", "test-bucket"); std::env::set_var("S3_ACCESS_KEY", "ak"); std::env::set_var("S3_SECRET_KEY", "sk"); } let config = Config::from_env().expect("should succeed"); let storage = config .storage .expect("storage should be Some when all S3 vars set"); assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com"); assert_eq!(storage.bucket, "test-bucket"); assert_eq!(storage.region, "us-east-1"); // default region drop(guard); } #[test] fn from_env_stripe_none_when_secret_key_missing() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // Set webhook secret but not secret key std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test"); } let config = Config::from_env().expect("should succeed"); assert!( config.stripe.is_none(), "stripe should be None when STRIPE_SECRET_KEY is missing" ); drop(guard); } #[test] fn from_env_stripe_none_when_webhook_secret_missing() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // Set secret key but not webhook secret std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc"); } let config = Config::from_env().expect("should succeed"); assert!( config.stripe.is_none(), "stripe should be None when STRIPE_WEBHOOK_SECRET is missing" ); drop(guard); } #[test] fn from_env_stripe_some_when_fully_set() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc"); std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test"); } let config = Config::from_env().expect("should succeed"); let stripe = config .stripe .expect("stripe should be Some when fully configured"); assert_eq!(stripe.secret_key, "sk_test_abc"); assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]); assert!(stripe.webhook_secret_v2.is_none()); drop(guard); } #[test] fn from_env_invalid_host_rejected() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("HOST", "not-an-ip"); } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::InvalidHost), "expected InvalidHost, got: {err}" ); drop(guard); } #[test] fn from_env_invalid_port_rejected() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("PORT", "not-a-number"); } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::InvalidPort), "expected InvalidPort, got: {err}" ); drop(guard); } #[test] fn from_env_scan_disabled_when_explicitly_off() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SCAN_ENABLED", "false"); } let config = Config::from_env().expect("should succeed"); assert!( config.scan.is_none(), "scan should be None when SCAN_ENABLED=false" ); drop(guard); } #[test] fn from_env_scan_enabled_by_default() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); } let config = Config::from_env().expect("should succeed"); assert!( config.scan.is_some(), "scan should be Some by default (enabled unless explicitly disabled)" ); drop(guard); }