use std::env;
use std::fmt;
use std::path::PathBuf;
use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
use tracing::warn;
use crate::rate_limit::TrustedProxies;
use self::proxies::parse_trusted_proxies;
use self::secrets::{ResolvedSecret, SecretMode, resolve_secret};
mod proxies;
mod secrets;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub database_url: Option<String>,
pub jwt_secret: String,
pub worker_token: String,
pub port: u16,
pub allowed_origins: Option<String>,
pub dashboard_dir: Option<PathBuf>,
pub webhook_url: Option<String>,
pub artifacts_dir: Option<PathBuf>,
pub artifact_max_bytes: u64,
pub purge_max_age_days: u32,
pub purge_max_runs_per_workflow: u32,
pub purge_dry_run: bool,
pub purge_interval_secs: u64,
pub provider_account_usage_retention_days: u32,
pub signal_retention_days: u32,
pub is_production: bool,
pub cookie_secure: bool,
pub rate_limit_auth: Option<u32>,
pub rate_limit_general: Option<u32>,
pub sse_max_connections_per_principal: Option<u32>,
pub sse_max_connections: Option<u32>,
pub trusted_proxies: TrustedProxies,
pub artifact_backend: String,
pub artifact_s3_bucket: Option<String>,
pub artifact_s3_region: String,
pub artifact_s3_endpoint: Option<String>,
pub artifact_s3_prefix: Option<String>,
pub artifact_gc_interval_secs: u64,
pub artifact_gc_grace_days: u32,
pub artifact_gc_dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct ConfigError {
pub errors: Vec<String>,
}
impl ConfigError {
pub fn new(errors: Vec<String>) -> Self {
Self { errors }
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "configuration errors:")?;
for error in &self.errors {
writeln!(f, " - {error}")?;
}
Ok(())
}
}
impl std::error::Error for ConfigError {}
fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
match env::var(name).ok() {
Some(raw) => match raw.parse::<u32>() {
Ok(0) => None,
Ok(v) => Some(v),
Err(_) => {
errors.push(format!(
"{name} must be a valid u32 (0 to disable), got: {raw}"
));
Some(default)
}
},
None => Some(default),
}
}
impl ServerConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let secret_mode = SecretMode::from_ironflow_env(env::var("IRONFLOW_ENV").ok().as_deref());
let is_production = secret_mode == SecretMode::Production;
let mut errors = Vec::new();
let insecure_cookies = env::var("IRONFLOW_INSECURE_COOKIES")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if is_production && insecure_cookies {
warn!("IRONFLOW_INSECURE_COOKIES is ignored in production, cookies stay Secure");
}
let cookie_secure = is_production || !insecure_cookies;
let database_url = env::var("DATABASE_URL").ok();
if is_production && database_url.is_none() {
errors.push("DATABASE_URL is required in production".to_string());
}
let jwt_secret = match resolve_secret(
"JWT_SECRET",
env::var("JWT_SECRET").ok(),
secret_mode,
) {
Ok(ResolvedSecret::Provided(secret)) => secret,
Ok(ResolvedSecret::Generated(secret)) => {
warn!(
"JWT_SECRET not set, generated an ephemeral secret: dashboard sessions end with this process"
);
secret
}
Err(e) => {
errors.push(e);
String::new()
}
};
let worker_token = match resolve_secret(
"WORKER_TOKEN",
env::var("WORKER_TOKEN").ok(),
secret_mode,
) {
Ok(ResolvedSecret::Provided(token)) => token,
Ok(ResolvedSecret::Generated(token)) => {
warn!(
"WORKER_TOKEN not set, generated an ephemeral token for this process: start workers with WORKER_TOKEN={token}"
);
token
}
Err(e) => {
errors.push(e);
String::new()
}
};
let port = match env::var("PORT").ok() {
Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
errors.push(format!("PORT must be a valid u16, got: {raw}"));
0
}),
None => 3000,
};
let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
let webhook_url = env::var("WEBHOOK_URL").ok();
let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
let sse_max_connections_per_principal =
parse_optional_u32("SSE_MAX_CONNECTIONS_PER_PRINCIPAL", 5, &mut errors);
let sse_max_connections = parse_optional_u32("SSE_MAX_CONNECTIONS", 1000, &mut errors);
let trusted_proxies = parse_trusted_proxies(
env::var("TRUSTED_PROXIES").ok().as_deref(),
env::var("TRUSTED_PROXY_HEADER").ok().as_deref(),
&mut errors,
);
let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
errors.push(format!(
"ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
));
DEFAULT_MAX_ARTIFACT_BYTES
}),
None => DEFAULT_MAX_ARTIFACT_BYTES,
};
let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
errors.push(format!(
"PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
));
90
}),
None => 90,
};
let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
errors.push(format!(
"PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
));
1000
}),
None => 1000,
};
let purge_dry_run = env::var("PURGE_DRY_RUN")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
let provider_account_usage_retention_days =
match env::var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS").ok() {
Some(raw) => match raw.parse::<u32>() {
Ok(days) if days >= 1 => days,
_ => {
errors.push(format!(
"PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS must be an integer >= 1, got: {raw}"
));
30
}
},
None => 30,
};
let signal_retention_days = match env::var("SIGNAL_RETENTION_DAYS").ok() {
Some(raw) => match raw.parse::<u32>() {
Ok(days) if days >= 1 => days,
_ => {
errors.push(format!(
"SIGNAL_RETENTION_DAYS must be an integer >= 1, got: {raw}"
));
7
}
},
None => 7,
};
let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
Some(raw) => {
let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
errors.push(format!(
"PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
));
86400
});
if parsed < 60 {
errors.push(format!(
"PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
));
}
parsed
}
None => 86400,
};
let artifact_backend = env::var("ARTIFACT_BACKEND")
.unwrap_or_else(|_| "local".to_string())
.to_lowercase();
let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
let artifact_s3_region =
env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
}
if artifact_backend != "local" && artifact_backend != "s3" {
errors.push(format!(
"ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
));
}
let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
Some(raw) => {
let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
errors.push(format!(
"ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
));
86400
});
if parsed < 60 {
errors.push(format!(
"ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
));
}
parsed
}
None => 86400,
};
let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
errors.push(format!(
"ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
));
7
}),
None => 7,
};
let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if !errors.is_empty() {
return Err(ConfigError::new(errors));
}
Ok(Self {
database_url,
jwt_secret,
worker_token,
port,
allowed_origins,
dashboard_dir,
webhook_url,
is_production,
cookie_secure,
rate_limit_auth,
rate_limit_general,
sse_max_connections_per_principal,
sse_max_connections,
trusted_proxies,
artifacts_dir,
artifact_max_bytes,
purge_max_age_days,
purge_max_runs_per_workflow,
purge_dry_run,
purge_interval_secs,
provider_account_usage_retention_days,
signal_retention_days,
artifact_backend,
artifact_s3_bucket,
artifact_s3_region,
artifact_s3_endpoint,
artifact_s3_prefix,
artifact_gc_interval_secs,
artifact_gc_grace_days,
artifact_gc_dry_run,
})
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ironflow_store::memory::InMemoryStore;
use crate::purger::{DEFAULT_SIGNAL_RETENTION_DAYS, RunPurger};
use crate::rate_limit::ClientIpHeader;
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
unsafe fn clear_env() {
unsafe {
env::remove_var("IRONFLOW_ENV");
env::remove_var("IRONFLOW_INSECURE_COOKIES");
env::remove_var("DATABASE_URL");
env::remove_var("JWT_SECRET");
env::remove_var("WORKER_TOKEN");
env::remove_var("PORT");
env::remove_var("ALLOWED_ORIGINS");
env::remove_var("DASHBOARD_DIR");
env::remove_var("WEBHOOK_URL");
env::remove_var("RATE_LIMIT_AUTH");
env::remove_var("RATE_LIMIT_GENERAL");
env::remove_var("SSE_MAX_CONNECTIONS_PER_PRINCIPAL");
env::remove_var("SSE_MAX_CONNECTIONS");
env::remove_var("TRUSTED_PROXIES");
env::remove_var("TRUSTED_PROXY_HEADER");
env::remove_var("PURGE_MAX_AGE_DAYS");
env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
env::remove_var("PURGE_DRY_RUN");
env::remove_var("PURGE_INTERVAL_SECS");
env::remove_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS");
env::remove_var("SIGNAL_RETENTION_DAYS");
env::remove_var("ARTIFACT_BACKEND");
env::remove_var("ARTIFACT_S3_BUCKET");
env::remove_var("ARTIFACT_S3_REGION");
env::remove_var("ARTIFACT_S3_ENDPOINT");
env::remove_var("ARTIFACT_S3_PREFIX");
env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
env::remove_var("ARTIFACT_GC_GRACE_DAYS");
env::remove_var("ARTIFACT_GC_DRY_RUN");
}
}
unsafe fn setup_dev() {
unsafe {
clear_env();
env::set_var("IRONFLOW_ENV", "development");
}
}
#[test]
fn config_error_display_lists_all_errors() {
let err = ConfigError::new(vec![
"JWT_SECRET is required".to_string(),
"DATABASE_URL is required".to_string(),
]);
let msg = err.to_string();
assert!(msg.contains("JWT_SECRET"));
assert!(msg.contains("DATABASE_URL"));
assert!(msg.contains("configuration errors:"));
}
#[test]
fn config_error_is_std_error() {
let err = ConfigError::new(vec!["test".to_string()]);
let _: &dyn std::error::Error = &err;
}
#[test]
fn unset_env_without_secrets_fails() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { clear_env() };
let err = ServerConfig::from_env().unwrap_err();
for name in ["JWT_SECRET", "WORKER_TOKEN"] {
assert!(
err.errors
.iter()
.any(|e| e.contains(name) && e.contains("required")),
"{name} must be required, got {:?}",
err.errors
);
}
assert!(!err.errors.iter().any(|e| e.contains("DATABASE_URL")));
}
#[test]
fn unset_env_rejects_known_default_and_short_secret() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("JWT_SECRET", "ironflow-dev-secret");
env::set_var("WORKER_TOKEN", "x");
}
let err = ServerConfig::from_env().unwrap_err();
let has = |name: &str, needle: &str| {
err.errors
.iter()
.any(|e| e.contains(name) && e.contains(needle))
};
assert!(has("JWT_SECRET", "known development default"), "{err}");
assert!(has("WORKER_TOKEN", "32 bytes"), "{err}");
unsafe { clear_env() };
}
#[test]
fn explicit_dev_without_secrets_generates_ephemeral_ones() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
let first = ServerConfig::from_env().expect("explicit dev boots without secrets");
let second = ServerConfig::from_env().expect("explicit dev boots without secrets");
assert!(!first.is_production);
assert!(first.jwt_secret.len() >= 32 && first.worker_token.len() >= 32);
assert!(!first.worker_token.starts_with("ironflow-dev-"));
assert_ne!(first.jwt_secret, second.jwt_secret);
assert_ne!(first.worker_token, second.worker_token);
assert_ne!(first.jwt_secret, first.worker_token);
unsafe { clear_env() };
}
#[test]
fn production_without_secrets_fails() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("IRONFLOW_ENV", "production");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.len() >= 3);
assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
unsafe { env::remove_var("IRONFLOW_ENV") };
}
#[test]
fn invalid_port_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("PORT", "not-a-number");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.iter().any(|e| e.contains("PORT")));
unsafe { env::remove_var("PORT") };
}
#[test]
fn default_rate_limits() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.rate_limit_auth, Some(10));
assert_eq!(config.rate_limit_general, Some(60));
}
#[test]
fn custom_rate_limits() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("RATE_LIMIT_AUTH", "20");
env::set_var("RATE_LIMIT_GENERAL", "120");
}
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.rate_limit_auth, Some(20));
assert_eq!(config.rate_limit_general, Some(120));
unsafe {
env::remove_var("RATE_LIMIT_AUTH");
env::remove_var("RATE_LIMIT_GENERAL");
}
}
#[test]
fn zero_rate_limit_disables() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("RATE_LIMIT_AUTH", "0");
env::set_var("RATE_LIMIT_GENERAL", "0");
}
let config = ServerConfig::from_env().unwrap();
assert!(config.rate_limit_auth.is_none());
assert!(config.rate_limit_general.is_none());
unsafe {
env::remove_var("RATE_LIMIT_AUTH");
env::remove_var("RATE_LIMIT_GENERAL");
}
}
#[test]
fn invalid_rate_limit_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("RATE_LIMIT_AUTH", "not-a-number");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
unsafe { env::remove_var("RATE_LIMIT_AUTH") };
}
#[test]
fn default_sse_limits() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.sse_max_connections_per_principal, Some(5));
assert_eq!(config.sse_max_connections, Some(1000));
}
#[test]
fn custom_sse_limits() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("SSE_MAX_CONNECTIONS_PER_PRINCIPAL", "12");
env::set_var("SSE_MAX_CONNECTIONS", "300");
}
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.sse_max_connections_per_principal, Some(12));
assert_eq!(config.sse_max_connections, Some(300));
unsafe { clear_env() };
}
#[test]
fn zero_sse_limit_disables() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("SSE_MAX_CONNECTIONS_PER_PRINCIPAL", "0");
env::set_var("SSE_MAX_CONNECTIONS", "0");
}
let config = ServerConfig::from_env().unwrap();
assert!(config.sse_max_connections_per_principal.is_none());
assert!(config.sse_max_connections.is_none());
unsafe { clear_env() };
}
#[test]
fn invalid_sse_limit_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("SSE_MAX_CONNECTIONS_PER_PRINCIPAL", "-1");
env::set_var("SSE_MAX_CONNECTIONS", "lots");
}
let err = ServerConfig::from_env().unwrap_err();
let reports = |name: &str| err.errors.iter().any(|e| e.contains(name));
assert!(reports("SSE_MAX_CONNECTIONS_PER_PRINCIPAL"));
assert!(reports("SSE_MAX_CONNECTIONS must"));
unsafe { clear_env() };
}
#[test]
fn trusted_proxies_are_read_from_the_environment() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
assert!(ServerConfig::from_env().unwrap().trusted_proxies.is_empty());
unsafe { env::set_var("TRUSTED_PROXIES", "10.0.0.0/8") };
let config = ServerConfig::from_env().unwrap();
assert!(config.trusted_proxies.contains("10.1.2.3".parse().unwrap()));
assert_eq!(
config.trusted_proxies.header(),
ClientIpHeader::XForwardedFor
);
unsafe { env::set_var("TRUSTED_PROXY_HEADER", "x-real-ip") };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.trusted_proxies.header(), ClientIpHeader::XRealIp);
unsafe { env::set_var("TRUSTED_PROXY_HEADER", "forwarded") };
let err = ServerConfig::from_env().unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("TRUSTED_PROXY_HEADER"))
);
unsafe { env::remove_var("TRUSTED_PROXY_HEADER") };
unsafe { env::set_var("TRUSTED_PROXIES", "not-an-ip") };
let err = ServerConfig::from_env().unwrap_err();
assert!(err.errors.iter().any(|e| e.contains("TRUSTED_PROXIES")));
unsafe { env::remove_var("TRUSTED_PROXIES") };
}
#[test]
fn default_purge_config() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.purge_max_age_days, 90);
assert_eq!(config.purge_max_runs_per_workflow, 1000);
assert!(!config.purge_dry_run);
assert_eq!(config.purge_interval_secs, 86400);
assert_eq!(config.provider_account_usage_retention_days, 30);
assert_eq!(config.signal_retention_days, 7);
}
#[test]
fn provider_account_usage_retention_days_from_env() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "7") };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.provider_account_usage_retention_days, 7);
unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "0") };
assert!(ServerConfig::from_env().is_err());
unsafe { clear_env() };
}
#[test]
fn signal_retention_days_from_env() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "3") };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.signal_retention_days, 3);
unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "0") };
assert!(ServerConfig::from_env().is_err());
unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "soon") };
assert!(ServerConfig::from_env().is_err());
unsafe { clear_env() };
}
#[test]
fn purger_from_config_uses_env_values() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("SIGNAL_RETENTION_DAYS", "1");
env::set_var("PURGE_MAX_AGE_DAYS", "30");
env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "50");
env::set_var("PURGE_DRY_RUN", "true");
env::set_var("PURGE_INTERVAL_SECS", "3600");
env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "5");
}
let config = ServerConfig::from_env().unwrap();
unsafe { clear_env() };
let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
assert_eq!(purger.signal_retention_days, 1);
assert_eq!(purger.usage_retention_days, 5);
assert_eq!(purger.policy.max_age_days, 30);
assert_eq!(purger.policy.max_runs_per_workflow, 50);
assert!(purger.policy.dry_run);
assert_eq!(purger.interval, Duration::from_secs(3600));
}
#[test]
fn purger_from_config_defaults() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
let config = ServerConfig::from_env().unwrap();
let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
assert_eq!(purger.signal_retention_days, DEFAULT_SIGNAL_RETENTION_DAYS);
assert_eq!(purger.signal_retention_days, 7);
}
#[test]
fn custom_purge_config() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("PURGE_MAX_AGE_DAYS", "30");
env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
env::set_var("PURGE_DRY_RUN", "true");
env::set_var("PURGE_INTERVAL_SECS", "3600");
}
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.purge_max_age_days, 30);
assert_eq!(config.purge_max_runs_per_workflow, 500);
assert!(config.purge_dry_run);
assert_eq!(config.purge_interval_secs, 3600);
unsafe {
env::remove_var("PURGE_MAX_AGE_DAYS");
env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
env::remove_var("PURGE_DRY_RUN");
env::remove_var("PURGE_INTERVAL_SECS");
}
}
#[test]
fn invalid_purge_max_age_days_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
}
const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
unsafe fn setup_prod(jwt: &str, worker: &str) {
unsafe {
clear_env();
env::set_var("IRONFLOW_ENV", "production");
env::set_var("DATABASE_URL", "postgres://x");
env::set_var("JWT_SECRET", jwt);
env::set_var("WORKER_TOKEN", worker);
}
}
#[test]
fn from_env_production_rejects_known_dev_jwt_secret() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
let err = ServerConfig::from_env().unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
"expected a known-default rejection for JWT_SECRET, got {:?}",
err.errors
);
assert!(
!err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
"a strong WORKER_TOKEN must not be flagged, got {:?}",
err.errors
);
unsafe { clear_env() };
}
#[test]
fn from_env_production_rejects_known_dev_worker_token() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_prod(STRONG_JWT, "ironflow-dev-worker-token") };
let err = ServerConfig::from_env().unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
"expected a known-default rejection for WORKER_TOKEN, got {:?}",
err.errors
);
assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
unsafe { clear_env() };
}
#[test]
fn from_env_production_accepts_strong_secrets() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
let config = ServerConfig::from_env().expect("strong secrets should be accepted");
assert!(config.is_production);
assert_eq!(config.jwt_secret, STRONG_JWT);
assert_eq!(config.worker_token, STRONG_WORKER);
unsafe { clear_env() };
}
#[test]
fn from_env_production_cookie_secure_even_if_insecure_requested() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_prod(STRONG_JWT, STRONG_WORKER);
env::set_var("IRONFLOW_INSECURE_COOKIES", "true");
}
let config = ServerConfig::from_env().expect("production config should load");
assert!(config.cookie_secure);
unsafe { clear_env() };
}
#[test]
fn from_env_development_cookie_secure_by_default() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_dev() };
let config = ServerConfig::from_env().expect("dev config should load");
assert!(!config.is_production);
assert!(config.cookie_secure);
unsafe { clear_env() };
}
#[test]
fn from_env_development_insecure_cookies_opt_out() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("IRONFLOW_INSECURE_COOKIES", "TRUE");
}
let config = ServerConfig::from_env().expect("dev config should load");
assert!(!config.cookie_secure);
unsafe { clear_env() };
}
#[test]
fn from_env_explicit_dev_accepts_short_secret_but_not_dev_default() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
setup_dev();
env::set_var("JWT_SECRET", "local-jwt");
env::set_var("WORKER_TOKEN", "tinytoken");
}
let config = ServerConfig::from_env().expect("short secrets are allowed in explicit dev");
assert_eq!(config.jwt_secret, "local-jwt");
assert_eq!(config.worker_token, "tinytoken");
unsafe { env::set_var("WORKER_TOKEN", "ironflow-dev-worker-token") };
let err = ServerConfig::from_env().unwrap_err();
assert!(
err.to_string().contains("known development default"),
"{err}"
);
unsafe { clear_env() };
}
#[test]
fn from_env_production_lists_all_insecure_secrets() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
let err = ServerConfig::from_env().unwrap_err();
assert!(
err.errors.iter().any(|e| e.contains("JWT_SECRET")),
"JWT_SECRET must be listed, got {:?}",
err.errors
);
assert!(
err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
"WORKER_TOKEN must be listed, got {:?}",
err.errors
);
unsafe { clear_env() };
}
}