Skip to main content

ironflow_api/
config.rs

1//! Server configuration with startup validation.
2//!
3//! Loads configuration from environment variables and validates that all
4//! required values are present **at startup**, not at first use.
5//!
6//! # Environment Variables
7//!
8//! | Variable | Required | Default | Description |
9//! |----------|----------|---------|-------------|
10//! | `DATABASE_URL` | **prod** | - | PostgreSQL connection string |
11//! | `JWT_SECRET` | **prod** | dev default | JWT signing secret |
12//! | `WORKER_TOKEN` | **prod** | dev default | Worker-to-API auth token |
13//! | `PORT` | no | `3000` | HTTP listen port |
14//! | `ALLOWED_ORIGINS` | no | same-origin | Comma-separated CORS origins |
15//! | `DASHBOARD_DIR` | no | embedded | Filesystem path to dashboard assets |
16//! | `WEBHOOK_URL` | no | - | Outbound webhook URL for notifications |
17//! | `IRONFLOW_ENV` | no | `development` | `production` or `development` |
18//! | `RATE_LIMIT_AUTH` | no | `10` | Auth rate limit (req/min/IP). `0` = disabled |
19//! | `RATE_LIMIT_GENERAL` | no | `60` | General rate limit (req/min/IP). `0` = disabled |
20//! | `ARTIFACTS_DIR` | no | - | Filesystem root for artifact blobs. Unset disables artifacts |
21//! | `ARTIFACT_MAX_BYTES` | no | `104857600` | Maximum size of a single artifact |
22//! | `PURGE_MAX_AGE_DAYS` | no | `90` | Runs older than this are purged |
23//! | `PURGE_MAX_RUNS_PER_WORKFLOW` | no | `1000` | Max terminal runs kept per workflow |
24//! | `PURGE_DRY_RUN` | no | `false` | Log what would be purged without deleting |
25//! | `PURGE_INTERVAL_SECS` | no | `86400` | Seconds between purge ticks (min 60) |
26//! | `ARTIFACT_BACKEND` | no | `local` | Blob storage backend: `local` or `s3` |
27//! | `ARTIFACT_S3_BUCKET` | **if s3** | - | S3 bucket name |
28//! | `ARTIFACT_S3_REGION` | no | `eu-west-1` | S3 region |
29//! | `ARTIFACT_S3_ENDPOINT` | no | - | Custom S3 endpoint (MinIO, R2) |
30//! | `ARTIFACT_S3_PREFIX` | no | - | Key prefix within the bucket |
31//! | `ARTIFACT_GC_INTERVAL_SECS` | no | `86400` | Seconds between GC ticks (min 60) |
32//! | `ARTIFACT_GC_GRACE_DAYS` | no | `7` | Days before an orphan blob is deleted |
33//! | `ARTIFACT_GC_DRY_RUN` | no | `false` | Log what would be GC'd without deleting |
34//!
35//! # Examples
36//!
37//! ```no_run
38//! use ironflow_api::config::ServerConfig;
39//!
40//! # fn example() -> Result<(), ironflow_api::config::ConfigError> {
41//! let config = ServerConfig::from_env()?;
42//! println!("Listening on port {}", config.port);
43//! # Ok(())
44//! # }
45//! ```
46
47use std::env;
48use std::fmt;
49use std::path::PathBuf;
50
51use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
52use tracing::warn;
53
54/// Server configuration loaded from environment variables.
55///
56/// Use [`ServerConfig::from_env`] to load and validate at startup.
57///
58/// # Examples
59///
60/// ```no_run
61/// use ironflow_api::config::ServerConfig;
62///
63/// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
64/// let config = ServerConfig::from_env()?;
65/// assert!(config.port > 0);
66/// # Ok(())
67/// # }
68/// ```
69#[derive(Debug, Clone)]
70pub struct ServerConfig {
71    /// PostgreSQL connection string. Required in production.
72    pub database_url: Option<String>,
73    /// JWT signing secret.
74    pub jwt_secret: String,
75    /// Worker-to-API authentication token.
76    pub worker_token: String,
77    /// HTTP listen port.
78    pub port: u16,
79    /// Comma-separated list of allowed CORS origins.
80    pub allowed_origins: Option<String>,
81    /// Filesystem path to dashboard assets (overrides embedded).
82    pub dashboard_dir: Option<PathBuf>,
83    /// Outbound webhook URL for event notifications.
84    pub webhook_url: Option<String>,
85    /// Filesystem root for artifact blobs.
86    ///
87    /// `None` leaves artifacts disabled: the artifact routes answer `501` and
88    /// a step that declares one fails explicitly. Every other endpoint is
89    /// unaffected, so an existing deployment upgrades without changes.
90    ///
91    /// Read from `ARTIFACTS_DIR`.
92    pub artifacts_dir: Option<PathBuf>,
93    /// Maximum size of a single artifact, in bytes.
94    ///
95    /// Read from `ARTIFACT_MAX_BYTES`, defaulting to 100 MiB.
96    pub artifact_max_bytes: u64,
97    /// Maximum age of a run before it becomes eligible for purging, in days.
98    ///
99    /// Read from `PURGE_MAX_AGE_DAYS`, defaulting to 90.
100    pub purge_max_age_days: u32,
101    /// Maximum number of terminal runs to keep per workflow.
102    ///
103    /// Read from `PURGE_MAX_RUNS_PER_WORKFLOW`, defaulting to 1000.
104    pub purge_max_runs_per_workflow: u32,
105    /// When `true`, the purger logs what would be deleted but does not delete.
106    ///
107    /// Read from `PURGE_DRY_RUN`, defaulting to `false`.
108    pub purge_dry_run: bool,
109    /// Interval between purge ticks, in seconds.
110    ///
111    /// Read from `PURGE_INTERVAL_SECS`, defaulting to 86400 (once per day).
112    pub purge_interval_secs: u64,
113    /// Whether the server is running in production mode.
114    pub is_production: bool,
115    /// Rate limit for auth credential routes (sign-in, sign-up) in requests
116    /// per minute per IP. `None` disables rate limiting on these routes.
117    pub rate_limit_auth: Option<u32>,
118    /// Rate limit for general public API routes in requests per minute per IP.
119    /// `None` disables rate limiting on these routes.
120    pub rate_limit_general: Option<u32>,
121    /// Blob storage backend: `local` or `s3`.
122    pub artifact_backend: String,
123    /// S3 bucket name (required when `artifact_backend` is `s3`).
124    pub artifact_s3_bucket: Option<String>,
125    /// S3 region, defaults to `eu-west-1`.
126    pub artifact_s3_region: String,
127    /// Custom S3 endpoint for MinIO, R2, or GCS S3-compat.
128    pub artifact_s3_endpoint: Option<String>,
129    /// Key prefix within the S3 bucket.
130    pub artifact_s3_prefix: Option<String>,
131    /// Seconds between GC ticks, defaults to 86400 (once per day).
132    pub artifact_gc_interval_secs: u64,
133    /// Days before an orphan blob is eligible for deletion, defaults to 7.
134    pub artifact_gc_grace_days: u32,
135    /// When `true`, the GC logs what would be deleted without deleting.
136    pub artifact_gc_dry_run: bool,
137}
138
139/// Configuration validation error.
140///
141/// Collects all missing/invalid values so the operator sees every problem
142/// in a single error message, not one at a time.
143///
144/// # Examples
145///
146/// ```
147/// use ironflow_api::config::ConfigError;
148///
149/// let err = ConfigError::new(vec!["JWT_SECRET is required in production".to_string()]);
150/// assert!(err.to_string().contains("JWT_SECRET"));
151/// ```
152#[derive(Debug, Clone)]
153pub struct ConfigError {
154    /// Individual validation failure messages.
155    pub errors: Vec<String>,
156}
157
158impl ConfigError {
159    /// Create a new `ConfigError` from a list of validation messages.
160    pub fn new(errors: Vec<String>) -> Self {
161        Self { errors }
162    }
163}
164
165impl fmt::Display for ConfigError {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        writeln!(f, "configuration errors:")?;
168        for error in &self.errors {
169            writeln!(f, "  - {error}")?;
170        }
171        Ok(())
172    }
173}
174
175impl std::error::Error for ConfigError {}
176
177const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
178const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
179
180/// Prefix shared by every known development secret default. In production a
181/// secret carrying this prefix is refused outright, so a fresh deploy that
182/// copied the setup template verbatim cannot boot with a value published in
183/// the repository.
184const DEV_SECRET_PREFIX: &str = "ironflow-dev-";
185
186/// Minimum accepted length, in bytes, for a production secret. A value shorter
187/// than this is trivially brute-forced against an HS256 signature.
188const MIN_PROD_SECRET_BYTES: usize = 32;
189
190/// Reject a production secret that is a known development default or too short.
191///
192/// Pushes at most one message to `errors` (a known default is reported as such,
193/// not also as "too short"), so the collect-all pattern lists each faulty
194/// secret exactly once. Called only in production and only for a secret that
195/// was explicitly set; an unset secret is handled by the "required" check.
196fn reject_insecure_secret(name: &str, value: &str, errors: &mut Vec<String>) {
197    if value.starts_with(DEV_SECRET_PREFIX) {
198        errors.push(format!(
199            "{name} must not use a known development default in production"
200        ));
201    } else if value.len() < MIN_PROD_SECRET_BYTES {
202        errors.push(format!(
203            "{name} must be at least {MIN_PROD_SECRET_BYTES} bytes in production, got {}",
204            value.len()
205        ));
206    }
207}
208
209/// Parse an optional u32 env var. Returns `Some(default)` if unset,
210/// `Some(value)` if set to a positive number, `None` if set to `0`
211/// (meaning disabled). Pushes to `errors` if the value is not a valid u32.
212fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
213    match env::var(name).ok() {
214        Some(raw) => match raw.parse::<u32>() {
215            Ok(0) => None,
216            Ok(v) => Some(v),
217            Err(_) => {
218                errors.push(format!(
219                    "{name} must be a valid u32 (0 to disable), got: {raw}"
220                ));
221                Some(default)
222            }
223        },
224        None => Some(default),
225    }
226}
227
228impl ServerConfig {
229    /// Load configuration from environment variables and validate.
230    ///
231    /// In production mode (`IRONFLOW_ENV=production`), `JWT_SECRET` and
232    /// `WORKER_TOKEN` must be explicitly set, must not carry the known
233    /// development prefix `ironflow-dev-`, and must be at least 32 bytes long.
234    /// `DATABASE_URL` is required in production.
235    ///
236    /// In development mode, insecure defaults are used with a warning.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`ConfigError`] with all validation failures collected,
241    /// so the operator can fix everything in one pass.
242    ///
243    /// # Examples
244    ///
245    /// ```no_run
246    /// use ironflow_api::config::ServerConfig;
247    ///
248    /// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
249    /// let config = ServerConfig::from_env()?;
250    /// # Ok(())
251    /// # }
252    /// ```
253    pub fn from_env() -> Result<Self, ConfigError> {
254        let is_production = env::var("IRONFLOW_ENV")
255            .map(|v| v.eq_ignore_ascii_case("production"))
256            .unwrap_or(false);
257
258        let mut errors = Vec::new();
259
260        let database_url = env::var("DATABASE_URL").ok();
261        if is_production && database_url.is_none() {
262            errors.push("DATABASE_URL is required in production".to_string());
263        }
264
265        let jwt_secret_env = env::var("JWT_SECRET").ok();
266        let jwt_secret = match jwt_secret_env {
267            Some(val) => {
268                if is_production {
269                    reject_insecure_secret("JWT_SECRET", &val, &mut errors);
270                }
271                val
272            }
273            None if is_production => {
274                errors.push("JWT_SECRET is required in production".to_string());
275                String::new()
276            }
277            None => {
278                warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
279                DEV_JWT_SECRET.to_string()
280            }
281        };
282
283        let worker_token_env = env::var("WORKER_TOKEN").ok();
284        let worker_token = match worker_token_env {
285            Some(val) => {
286                if is_production {
287                    reject_insecure_secret("WORKER_TOKEN", &val, &mut errors);
288                }
289                val
290            }
291            None if is_production => {
292                errors.push("WORKER_TOKEN is required in production".to_string());
293                String::new()
294            }
295            None => {
296                warn!(
297                    "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
298                );
299                DEV_WORKER_TOKEN.to_string()
300            }
301        };
302
303        let port = match env::var("PORT").ok() {
304            Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
305                errors.push(format!("PORT must be a valid u16, got: {raw}"));
306                0
307            }),
308            None => 3000,
309        };
310
311        let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
312        let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
313        let webhook_url = env::var("WEBHOOK_URL").ok();
314
315        let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
316        let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
317
318        let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
319        let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
320            Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
321                errors.push(format!(
322                    "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
323                ));
324                DEFAULT_MAX_ARTIFACT_BYTES
325            }),
326            None => DEFAULT_MAX_ARTIFACT_BYTES,
327        };
328
329        let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
330            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
331                errors.push(format!(
332                    "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
333                ));
334                90
335            }),
336            None => 90,
337        };
338        let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
339            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
340                errors.push(format!(
341                    "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
342                ));
343                1000
344            }),
345            None => 1000,
346        };
347        let purge_dry_run = env::var("PURGE_DRY_RUN")
348            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
349            .unwrap_or(false);
350        let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
351            Some(raw) => {
352                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
353                    errors.push(format!(
354                        "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
355                    ));
356                    86400
357                });
358                if parsed < 60 {
359                    errors.push(format!(
360                        "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
361                    ));
362                }
363                parsed
364            }
365            None => 86400,
366        };
367
368        let artifact_backend = env::var("ARTIFACT_BACKEND")
369            .unwrap_or_else(|_| "local".to_string())
370            .to_lowercase();
371        let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
372        let artifact_s3_region =
373            env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
374        let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
375        let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
376
377        if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
378            errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
379        }
380        if artifact_backend != "local" && artifact_backend != "s3" {
381            errors.push(format!(
382                "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
383            ));
384        }
385
386        let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
387            Some(raw) => {
388                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
389                    errors.push(format!(
390                        "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
391                    ));
392                    86400
393                });
394                if parsed < 60 {
395                    errors.push(format!(
396                        "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
397                    ));
398                }
399                parsed
400            }
401            None => 86400,
402        };
403        let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
404            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
405                errors.push(format!(
406                    "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
407                ));
408                7
409            }),
410            None => 7,
411        };
412        let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
413            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
414            .unwrap_or(false);
415
416        if !errors.is_empty() {
417            return Err(ConfigError::new(errors));
418        }
419
420        Ok(Self {
421            database_url,
422            jwt_secret,
423            worker_token,
424            port,
425            allowed_origins,
426            dashboard_dir,
427            webhook_url,
428            is_production,
429            rate_limit_auth,
430            rate_limit_general,
431            artifacts_dir,
432            artifact_max_bytes,
433            purge_max_age_days,
434            purge_max_runs_per_workflow,
435            purge_dry_run,
436            purge_interval_secs,
437            artifact_backend,
438            artifact_s3_bucket,
439            artifact_s3_region,
440            artifact_s3_endpoint,
441            artifact_s3_prefix,
442            artifact_gc_interval_secs,
443            artifact_gc_grace_days,
444            artifact_gc_dry_run,
445        })
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use std::sync::Mutex;
452
453    use super::*;
454
455    // Env var mutations are not thread-safe -- serialize all tests that touch them.
456    static ENV_LOCK: Mutex<()> = Mutex::new(());
457
458    /// # Safety
459    ///
460    /// Must be called while holding `ENV_LOCK`.
461    unsafe fn clear_env() {
462        unsafe {
463            env::remove_var("IRONFLOW_ENV");
464            env::remove_var("DATABASE_URL");
465            env::remove_var("JWT_SECRET");
466            env::remove_var("WORKER_TOKEN");
467            env::remove_var("PORT");
468            env::remove_var("ALLOWED_ORIGINS");
469            env::remove_var("DASHBOARD_DIR");
470            env::remove_var("WEBHOOK_URL");
471            env::remove_var("RATE_LIMIT_AUTH");
472            env::remove_var("RATE_LIMIT_GENERAL");
473            env::remove_var("PURGE_MAX_AGE_DAYS");
474            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
475            env::remove_var("PURGE_DRY_RUN");
476            env::remove_var("PURGE_INTERVAL_SECS");
477            env::remove_var("ARTIFACT_BACKEND");
478            env::remove_var("ARTIFACT_S3_BUCKET");
479            env::remove_var("ARTIFACT_S3_REGION");
480            env::remove_var("ARTIFACT_S3_ENDPOINT");
481            env::remove_var("ARTIFACT_S3_PREFIX");
482            env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
483            env::remove_var("ARTIFACT_GC_GRACE_DAYS");
484            env::remove_var("ARTIFACT_GC_DRY_RUN");
485        }
486    }
487
488    #[test]
489    fn config_error_display_lists_all_errors() {
490        let err = ConfigError::new(vec![
491            "JWT_SECRET is required".to_string(),
492            "DATABASE_URL is required".to_string(),
493        ]);
494        let msg = err.to_string();
495        assert!(msg.contains("JWT_SECRET"));
496        assert!(msg.contains("DATABASE_URL"));
497        assert!(msg.contains("configuration errors:"));
498    }
499
500    #[test]
501    fn config_error_is_std_error() {
502        let err = ConfigError::new(vec!["test".to_string()]);
503        let _: &dyn std::error::Error = &err;
504    }
505
506    #[test]
507    fn default_dev_config_succeeds() {
508        let _guard = ENV_LOCK.lock().unwrap();
509        unsafe { clear_env() };
510
511        let config = ServerConfig::from_env().expect("dev config should succeed");
512        assert!(!config.is_production);
513        assert_eq!(config.port, 3000);
514        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
515        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
516    }
517
518    #[test]
519    fn production_without_secrets_fails() {
520        let _guard = ENV_LOCK.lock().unwrap();
521        unsafe {
522            clear_env();
523            env::set_var("IRONFLOW_ENV", "production");
524        }
525
526        let result = ServerConfig::from_env();
527        assert!(result.is_err());
528        let err = result.unwrap_err();
529        assert!(err.errors.len() >= 3);
530        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
531        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
532        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
533
534        unsafe { env::remove_var("IRONFLOW_ENV") };
535    }
536
537    #[test]
538    fn invalid_port_returns_error() {
539        let _guard = ENV_LOCK.lock().unwrap();
540        unsafe {
541            clear_env();
542            env::set_var("PORT", "not-a-number");
543        }
544
545        let result = ServerConfig::from_env();
546        assert!(result.is_err());
547        let err = result.unwrap_err();
548        assert!(err.errors.iter().any(|e| e.contains("PORT")));
549
550        unsafe { env::remove_var("PORT") };
551    }
552
553    #[test]
554    fn default_rate_limits() {
555        let _guard = ENV_LOCK.lock().unwrap();
556        unsafe { clear_env() };
557
558        let config = ServerConfig::from_env().unwrap();
559        assert_eq!(config.rate_limit_auth, Some(10));
560        assert_eq!(config.rate_limit_general, Some(60));
561    }
562
563    #[test]
564    fn custom_rate_limits() {
565        let _guard = ENV_LOCK.lock().unwrap();
566        unsafe {
567            clear_env();
568            env::set_var("RATE_LIMIT_AUTH", "20");
569            env::set_var("RATE_LIMIT_GENERAL", "120");
570        }
571
572        let config = ServerConfig::from_env().unwrap();
573        assert_eq!(config.rate_limit_auth, Some(20));
574        assert_eq!(config.rate_limit_general, Some(120));
575
576        unsafe {
577            env::remove_var("RATE_LIMIT_AUTH");
578            env::remove_var("RATE_LIMIT_GENERAL");
579        }
580    }
581
582    #[test]
583    fn zero_rate_limit_disables() {
584        let _guard = ENV_LOCK.lock().unwrap();
585        unsafe {
586            clear_env();
587            env::set_var("RATE_LIMIT_AUTH", "0");
588            env::set_var("RATE_LIMIT_GENERAL", "0");
589        }
590
591        let config = ServerConfig::from_env().unwrap();
592        assert!(config.rate_limit_auth.is_none());
593        assert!(config.rate_limit_general.is_none());
594
595        unsafe {
596            env::remove_var("RATE_LIMIT_AUTH");
597            env::remove_var("RATE_LIMIT_GENERAL");
598        }
599    }
600
601    #[test]
602    fn invalid_rate_limit_returns_error() {
603        let _guard = ENV_LOCK.lock().unwrap();
604        unsafe {
605            clear_env();
606            env::set_var("RATE_LIMIT_AUTH", "not-a-number");
607        }
608
609        let result = ServerConfig::from_env();
610        assert!(result.is_err());
611        let err = result.unwrap_err();
612        assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
613
614        unsafe { env::remove_var("RATE_LIMIT_AUTH") };
615    }
616
617    #[test]
618    fn default_purge_config() {
619        let _guard = ENV_LOCK.lock().unwrap();
620        unsafe { clear_env() };
621
622        let config = ServerConfig::from_env().unwrap();
623        assert_eq!(config.purge_max_age_days, 90);
624        assert_eq!(config.purge_max_runs_per_workflow, 1000);
625        assert!(!config.purge_dry_run);
626        assert_eq!(config.purge_interval_secs, 86400);
627    }
628
629    #[test]
630    fn custom_purge_config() {
631        let _guard = ENV_LOCK.lock().unwrap();
632        unsafe {
633            clear_env();
634            env::set_var("PURGE_MAX_AGE_DAYS", "30");
635            env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
636            env::set_var("PURGE_DRY_RUN", "true");
637            env::set_var("PURGE_INTERVAL_SECS", "3600");
638        }
639
640        let config = ServerConfig::from_env().unwrap();
641        assert_eq!(config.purge_max_age_days, 30);
642        assert_eq!(config.purge_max_runs_per_workflow, 500);
643        assert!(config.purge_dry_run);
644        assert_eq!(config.purge_interval_secs, 3600);
645
646        unsafe {
647            env::remove_var("PURGE_MAX_AGE_DAYS");
648            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
649            env::remove_var("PURGE_DRY_RUN");
650            env::remove_var("PURGE_INTERVAL_SECS");
651        }
652    }
653
654    #[test]
655    fn invalid_purge_max_age_days_returns_error() {
656        let _guard = ENV_LOCK.lock().unwrap();
657        unsafe {
658            clear_env();
659            env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
660        }
661
662        let result = ServerConfig::from_env();
663        assert!(result.is_err());
664        let err = result.unwrap_err();
665        assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
666
667        unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
668    }
669
670    // Strong secrets: >= 32 bytes and not carrying the dev prefix.
671    const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
672    const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
673
674    /// Wipe the environment, then set production mode with the given secrets.
675    ///
676    /// # Safety
677    ///
678    /// Mutates process-global environment variables; must be called while
679    /// holding `ENV_LOCK` so no other test observes a torn environment.
680    unsafe fn setup_prod(jwt: &str, worker: &str) {
681        unsafe {
682            clear_env();
683            env::set_var("IRONFLOW_ENV", "production");
684            env::set_var("DATABASE_URL", "postgres://x");
685            env::set_var("JWT_SECRET", jwt);
686            env::set_var("WORKER_TOKEN", worker);
687        }
688    }
689
690    #[test]
691    fn from_env_production_rejects_known_dev_jwt_secret() {
692        let _guard = ENV_LOCK.lock().unwrap();
693        // "ironflow-dev-jwt-secret" is the exact value the setup template shipped.
694        // SAFETY: env writes serialized by ENV_LOCK, held above.
695        unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
696
697        let err = ServerConfig::from_env().unwrap_err();
698        assert!(
699            err.errors
700                .iter()
701                .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
702            "expected a known-default rejection for JWT_SECRET, got {:?}",
703            err.errors
704        );
705        assert!(
706            !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
707            "a strong WORKER_TOKEN must not be flagged, got {:?}",
708            err.errors
709        );
710
711        unsafe { clear_env() };
712    }
713
714    #[test]
715    fn from_env_production_rejects_known_dev_worker_token() {
716        let _guard = ENV_LOCK.lock().unwrap();
717        // SAFETY: env writes serialized by ENV_LOCK, held above.
718        unsafe { setup_prod(STRONG_JWT, DEV_WORKER_TOKEN) };
719
720        let err = ServerConfig::from_env().unwrap_err();
721        assert!(
722            err.errors
723                .iter()
724                .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
725            "expected a known-default rejection for WORKER_TOKEN, got {:?}",
726            err.errors
727        );
728        assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
729
730        unsafe { clear_env() };
731    }
732
733    #[test]
734    fn from_env_production_rejects_short_jwt_secret() {
735        let _guard = ENV_LOCK.lock().unwrap();
736        // "shortsecret" is not a dev default, but is under 32 bytes.
737        // SAFETY: env writes serialized by ENV_LOCK, held above.
738        unsafe { setup_prod("shortsecret", STRONG_WORKER) };
739
740        let err = ServerConfig::from_env().unwrap_err();
741        assert!(
742            err.errors
743                .iter()
744                .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
745            "expected a length rejection for JWT_SECRET, got {:?}",
746            err.errors
747        );
748
749        unsafe { clear_env() };
750    }
751
752    #[test]
753    fn from_env_production_rejects_short_worker_token() {
754        let _guard = ENV_LOCK.lock().unwrap();
755        // SAFETY: env writes serialized by ENV_LOCK, held above.
756        unsafe { setup_prod(STRONG_JWT, "tinytoken") };
757
758        let err = ServerConfig::from_env().unwrap_err();
759        assert!(
760            err.errors
761                .iter()
762                .any(|e| e.contains("WORKER_TOKEN") && e.contains("32 bytes")),
763            "expected a length rejection for WORKER_TOKEN, got {:?}",
764            err.errors
765        );
766
767        unsafe { clear_env() };
768    }
769
770    #[test]
771    fn from_env_production_accepts_strong_secrets() {
772        let _guard = ENV_LOCK.lock().unwrap();
773        // SAFETY: env writes serialized by ENV_LOCK, held above.
774        unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
775
776        let config = ServerConfig::from_env().expect("strong secrets should be accepted");
777        assert!(config.is_production);
778        assert_eq!(config.jwt_secret, STRONG_JWT);
779        assert_eq!(config.worker_token, STRONG_WORKER);
780
781        unsafe { clear_env() };
782    }
783
784    #[test]
785    fn from_env_production_secret_length_boundary() {
786        // Exactly 32 bytes is accepted; 31 is rejected. Guards the `<` in the
787        // length check against an off-by-one drift to `<=`.
788        let exactly_32: &str = "0123456789abcdef0123456789abcdef";
789        let just_under: &str = "0123456789abcdef0123456789abcde";
790        assert_eq!(exactly_32.len(), 32);
791        assert_eq!(just_under.len(), 31);
792
793        let _guard = ENV_LOCK.lock().unwrap();
794
795        // SAFETY: env writes serialized by ENV_LOCK, held above.
796        unsafe { setup_prod(exactly_32, STRONG_WORKER) };
797        let config = ServerConfig::from_env().expect("a 32-byte secret is accepted");
798        assert_eq!(config.jwt_secret, exactly_32);
799
800        // SAFETY: ENV_LOCK still held.
801        unsafe { setup_prod(just_under, STRONG_WORKER) };
802        let err = ServerConfig::from_env().unwrap_err();
803        assert!(
804            err.errors
805                .iter()
806                .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
807            "31 bytes must be rejected, got {:?}",
808            err.errors
809        );
810
811        unsafe { clear_env() };
812    }
813
814    #[test]
815    fn from_env_development_accepts_weak_secret() {
816        // The value/length checks run in production only: a short, dev-prefixed
817        // secret set outside production is accepted and used unchanged.
818        let _guard = ENV_LOCK.lock().unwrap();
819        // SAFETY: env writes serialized by ENV_LOCK, held above.
820        unsafe {
821            clear_env();
822            env::set_var("IRONFLOW_ENV", "development");
823            env::set_var("JWT_SECRET", "ironflow-dev-jwt-secret");
824            env::set_var("WORKER_TOKEN", "tinytoken");
825        }
826
827        let config = ServerConfig::from_env().expect("weak secrets are allowed outside production");
828        assert!(!config.is_production);
829        assert_eq!(config.jwt_secret, "ironflow-dev-jwt-secret");
830        assert_eq!(config.worker_token, "tinytoken");
831
832        unsafe { clear_env() };
833    }
834
835    #[test]
836    fn from_env_production_lists_all_insecure_secrets() {
837        // Non-regression: the exact state a fresh deploy reached by copying the
838        // setup template `.env.example` verbatim and flipping IRONFLOW_ENV to
839        // production. It used to boot silently; it must now be refused, with
840        // both secrets named.
841        let _guard = ENV_LOCK.lock().unwrap();
842        // SAFETY: env writes serialized by ENV_LOCK, held above.
843        unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
844
845        let err = ServerConfig::from_env().unwrap_err();
846        assert!(
847            err.errors.iter().any(|e| e.contains("JWT_SECRET")),
848            "JWT_SECRET must be listed, got {:?}",
849            err.errors
850        );
851        assert!(
852            err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
853            "WORKER_TOKEN must be listed, got {:?}",
854            err.errors
855        );
856
857        unsafe { clear_env() };
858    }
859}