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