1use std::env;
51use std::fmt;
52use std::path::PathBuf;
53
54use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
55use tracing::warn;
56
57use crate::rate_limit::TrustedProxies;
58
59use self::proxies::parse_trusted_proxies;
60use self::secrets::{ResolvedSecret, SecretMode, resolve_secret};
61
62mod proxies;
63mod secrets;
64
65#[derive(Debug, Clone)]
81pub struct ServerConfig {
82 pub database_url: Option<String>,
84 pub jwt_secret: String,
86 pub worker_token: String,
88 pub port: u16,
90 pub allowed_origins: Option<String>,
92 pub dashboard_dir: Option<PathBuf>,
94 pub webhook_url: Option<String>,
96 pub artifacts_dir: Option<PathBuf>,
104 pub artifact_max_bytes: u64,
108 pub purge_max_age_days: u32,
112 pub purge_max_runs_per_workflow: u32,
116 pub purge_dry_run: bool,
120 pub purge_interval_secs: u64,
124 pub provider_account_usage_retention_days: u32,
128 pub signal_retention_days: u32,
132 pub is_production: bool,
134 pub cookie_secure: bool,
140 pub rate_limit_auth: Option<u32>,
143 pub rate_limit_general: Option<u32>,
146 pub trusted_proxies: TrustedProxies,
150 pub artifact_backend: String,
152 pub artifact_s3_bucket: Option<String>,
154 pub artifact_s3_region: String,
156 pub artifact_s3_endpoint: Option<String>,
158 pub artifact_s3_prefix: Option<String>,
160 pub artifact_gc_interval_secs: u64,
162 pub artifact_gc_grace_days: u32,
164 pub artifact_gc_dry_run: bool,
166}
167
168#[derive(Debug, Clone)]
182pub struct ConfigError {
183 pub errors: Vec<String>,
185}
186
187impl ConfigError {
188 pub fn new(errors: Vec<String>) -> Self {
190 Self { errors }
191 }
192}
193
194impl fmt::Display for ConfigError {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 writeln!(f, "configuration errors:")?;
197 for error in &self.errors {
198 writeln!(f, " - {error}")?;
199 }
200 Ok(())
201 }
202}
203
204impl std::error::Error for ConfigError {}
205
206fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
210 match env::var(name).ok() {
211 Some(raw) => match raw.parse::<u32>() {
212 Ok(0) => None,
213 Ok(v) => Some(v),
214 Err(_) => {
215 errors.push(format!(
216 "{name} must be a valid u32 (0 to disable), got: {raw}"
217 ));
218 Some(default)
219 }
220 },
221 None => Some(default),
222 }
223}
224
225impl ServerConfig {
226 pub fn from_env() -> Result<Self, ConfigError> {
259 let secret_mode = SecretMode::from_ironflow_env(env::var("IRONFLOW_ENV").ok().as_deref());
260 let is_production = secret_mode == SecretMode::Production;
261
262 let mut errors = Vec::new();
263
264 let insecure_cookies = env::var("IRONFLOW_INSECURE_COOKIES")
265 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
266 .unwrap_or(false);
267 if is_production && insecure_cookies {
268 warn!("IRONFLOW_INSECURE_COOKIES is ignored in production, cookies stay Secure");
269 }
270 let cookie_secure = is_production || !insecure_cookies;
271
272 let database_url = env::var("DATABASE_URL").ok();
273 if is_production && database_url.is_none() {
274 errors.push("DATABASE_URL is required in production".to_string());
275 }
276
277 let jwt_secret = match resolve_secret(
278 "JWT_SECRET",
279 env::var("JWT_SECRET").ok(),
280 secret_mode,
281 ) {
282 Ok(ResolvedSecret::Provided(secret)) => secret,
283 Ok(ResolvedSecret::Generated(secret)) => {
284 warn!(
285 "JWT_SECRET not set, generated an ephemeral secret: dashboard sessions end with this process"
286 );
287 secret
288 }
289 Err(e) => {
290 errors.push(e);
291 String::new()
292 }
293 };
294
295 let worker_token = match resolve_secret(
296 "WORKER_TOKEN",
297 env::var("WORKER_TOKEN").ok(),
298 secret_mode,
299 ) {
300 Ok(ResolvedSecret::Provided(token)) => token,
301 Ok(ResolvedSecret::Generated(token)) => {
304 warn!(
305 "WORKER_TOKEN not set, generated an ephemeral token for this process: start workers with WORKER_TOKEN={token}"
306 );
307 token
308 }
309 Err(e) => {
310 errors.push(e);
311 String::new()
312 }
313 };
314
315 let port = match env::var("PORT").ok() {
316 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
317 errors.push(format!("PORT must be a valid u16, got: {raw}"));
318 0
319 }),
320 None => 3000,
321 };
322
323 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
324 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
325 let webhook_url = env::var("WEBHOOK_URL").ok();
326
327 let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
328 let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
329 let trusted_proxies =
330 parse_trusted_proxies(env::var("TRUSTED_PROXIES").ok().as_deref(), &mut errors);
331
332 let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
333 let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
334 Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
335 errors.push(format!(
336 "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
337 ));
338 DEFAULT_MAX_ARTIFACT_BYTES
339 }),
340 None => DEFAULT_MAX_ARTIFACT_BYTES,
341 };
342
343 let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
344 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
345 errors.push(format!(
346 "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
347 ));
348 90
349 }),
350 None => 90,
351 };
352 let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
353 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
354 errors.push(format!(
355 "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
356 ));
357 1000
358 }),
359 None => 1000,
360 };
361 let purge_dry_run = env::var("PURGE_DRY_RUN")
362 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
363 .unwrap_or(false);
364 let provider_account_usage_retention_days =
365 match env::var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS").ok() {
366 Some(raw) => match raw.parse::<u32>() {
367 Ok(days) if days >= 1 => days,
368 _ => {
369 errors.push(format!(
370 "PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS must be an integer >= 1, got: {raw}"
371 ));
372 30
373 }
374 },
375 None => 30,
376 };
377 let signal_retention_days = match env::var("SIGNAL_RETENTION_DAYS").ok() {
378 Some(raw) => match raw.parse::<u32>() {
379 Ok(days) if days >= 1 => days,
380 _ => {
381 errors.push(format!(
382 "SIGNAL_RETENTION_DAYS must be an integer >= 1, got: {raw}"
383 ));
384 7
385 }
386 },
387 None => 7,
388 };
389 let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
390 Some(raw) => {
391 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
392 errors.push(format!(
393 "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
394 ));
395 86400
396 });
397 if parsed < 60 {
398 errors.push(format!(
399 "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
400 ));
401 }
402 parsed
403 }
404 None => 86400,
405 };
406
407 let artifact_backend = env::var("ARTIFACT_BACKEND")
408 .unwrap_or_else(|_| "local".to_string())
409 .to_lowercase();
410 let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
411 let artifact_s3_region =
412 env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
413 let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
414 let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
415
416 if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
417 errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
418 }
419 if artifact_backend != "local" && artifact_backend != "s3" {
420 errors.push(format!(
421 "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
422 ));
423 }
424
425 let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
426 Some(raw) => {
427 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
428 errors.push(format!(
429 "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
430 ));
431 86400
432 });
433 if parsed < 60 {
434 errors.push(format!(
435 "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
436 ));
437 }
438 parsed
439 }
440 None => 86400,
441 };
442 let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
443 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
444 errors.push(format!(
445 "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
446 ));
447 7
448 }),
449 None => 7,
450 };
451 let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
452 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
453 .unwrap_or(false);
454
455 if !errors.is_empty() {
456 return Err(ConfigError::new(errors));
457 }
458
459 Ok(Self {
460 database_url,
461 jwt_secret,
462 worker_token,
463 port,
464 allowed_origins,
465 dashboard_dir,
466 webhook_url,
467 is_production,
468 cookie_secure,
469 rate_limit_auth,
470 rate_limit_general,
471 trusted_proxies,
472 artifacts_dir,
473 artifact_max_bytes,
474 purge_max_age_days,
475 purge_max_runs_per_workflow,
476 purge_dry_run,
477 purge_interval_secs,
478 provider_account_usage_retention_days,
479 signal_retention_days,
480 artifact_backend,
481 artifact_s3_bucket,
482 artifact_s3_region,
483 artifact_s3_endpoint,
484 artifact_s3_prefix,
485 artifact_gc_interval_secs,
486 artifact_gc_grace_days,
487 artifact_gc_dry_run,
488 })
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use std::sync::{Arc, Mutex};
495 use std::time::Duration;
496
497 use ironflow_store::memory::InMemoryStore;
498
499 use crate::purger::{DEFAULT_SIGNAL_RETENTION_DAYS, RunPurger};
500
501 use super::*;
502
503 static ENV_LOCK: Mutex<()> = Mutex::new(());
505
506 unsafe fn clear_env() {
510 unsafe {
511 env::remove_var("IRONFLOW_ENV");
512 env::remove_var("IRONFLOW_INSECURE_COOKIES");
513 env::remove_var("DATABASE_URL");
514 env::remove_var("JWT_SECRET");
515 env::remove_var("WORKER_TOKEN");
516 env::remove_var("PORT");
517 env::remove_var("ALLOWED_ORIGINS");
518 env::remove_var("DASHBOARD_DIR");
519 env::remove_var("WEBHOOK_URL");
520 env::remove_var("RATE_LIMIT_AUTH");
521 env::remove_var("RATE_LIMIT_GENERAL");
522 env::remove_var("TRUSTED_PROXIES");
523 env::remove_var("PURGE_MAX_AGE_DAYS");
524 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
525 env::remove_var("PURGE_DRY_RUN");
526 env::remove_var("PURGE_INTERVAL_SECS");
527 env::remove_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS");
528 env::remove_var("SIGNAL_RETENTION_DAYS");
529 env::remove_var("ARTIFACT_BACKEND");
530 env::remove_var("ARTIFACT_S3_BUCKET");
531 env::remove_var("ARTIFACT_S3_REGION");
532 env::remove_var("ARTIFACT_S3_ENDPOINT");
533 env::remove_var("ARTIFACT_S3_PREFIX");
534 env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
535 env::remove_var("ARTIFACT_GC_GRACE_DAYS");
536 env::remove_var("ARTIFACT_GC_DRY_RUN");
537 }
538 }
539
540 unsafe fn setup_dev() {
547 unsafe {
548 clear_env();
549 env::set_var("IRONFLOW_ENV", "development");
550 }
551 }
552
553 #[test]
554 fn config_error_display_lists_all_errors() {
555 let err = ConfigError::new(vec![
556 "JWT_SECRET is required".to_string(),
557 "DATABASE_URL is required".to_string(),
558 ]);
559 let msg = err.to_string();
560 assert!(msg.contains("JWT_SECRET"));
561 assert!(msg.contains("DATABASE_URL"));
562 assert!(msg.contains("configuration errors:"));
563 }
564
565 #[test]
566 fn config_error_is_std_error() {
567 let err = ConfigError::new(vec!["test".to_string()]);
568 let _: &dyn std::error::Error = &err;
569 }
570
571 #[test]
572 fn unset_env_without_secrets_fails() {
573 let _guard = ENV_LOCK.lock().unwrap();
576 unsafe { clear_env() };
578
579 let err = ServerConfig::from_env().unwrap_err();
580 for name in ["JWT_SECRET", "WORKER_TOKEN"] {
581 assert!(
582 err.errors
583 .iter()
584 .any(|e| e.contains(name) && e.contains("required")),
585 "{name} must be required, got {:?}",
586 err.errors
587 );
588 }
589 assert!(!err.errors.iter().any(|e| e.contains("DATABASE_URL")));
591 }
592
593 #[test]
594 fn unset_env_rejects_known_default_and_short_secret() {
595 let _guard = ENV_LOCK.lock().unwrap();
597 unsafe {
599 clear_env();
600 env::set_var("JWT_SECRET", "ironflow-dev-secret");
601 env::set_var("WORKER_TOKEN", "x");
602 }
603
604 let err = ServerConfig::from_env().unwrap_err();
605 let has = |name: &str, needle: &str| {
606 err.errors
607 .iter()
608 .any(|e| e.contains(name) && e.contains(needle))
609 };
610 assert!(has("JWT_SECRET", "known development default"), "{err}");
611 assert!(has("WORKER_TOKEN", "32 bytes"), "{err}");
612
613 unsafe { clear_env() };
614 }
615
616 #[test]
617 fn explicit_dev_without_secrets_generates_ephemeral_ones() {
618 let _guard = ENV_LOCK.lock().unwrap();
619 unsafe { setup_dev() };
621
622 let first = ServerConfig::from_env().expect("explicit dev boots without secrets");
623 let second = ServerConfig::from_env().expect("explicit dev boots without secrets");
624 assert!(!first.is_production);
625 assert!(first.jwt_secret.len() >= 32 && first.worker_token.len() >= 32);
626 assert!(!first.worker_token.starts_with("ironflow-dev-"));
627 assert_ne!(first.jwt_secret, second.jwt_secret);
629 assert_ne!(first.worker_token, second.worker_token);
630 assert_ne!(first.jwt_secret, first.worker_token);
631
632 unsafe { clear_env() };
633 }
634
635 #[test]
636 fn production_without_secrets_fails() {
637 let _guard = ENV_LOCK.lock().unwrap();
638 unsafe {
639 clear_env();
640 env::set_var("IRONFLOW_ENV", "production");
641 }
642
643 let result = ServerConfig::from_env();
644 assert!(result.is_err());
645 let err = result.unwrap_err();
646 assert!(err.errors.len() >= 3);
647 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
648 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
649 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
650
651 unsafe { env::remove_var("IRONFLOW_ENV") };
652 }
653
654 #[test]
655 fn invalid_port_returns_error() {
656 let _guard = ENV_LOCK.lock().unwrap();
657 unsafe {
658 setup_dev();
659 env::set_var("PORT", "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("PORT")));
666
667 unsafe { env::remove_var("PORT") };
668 }
669
670 #[test]
671 fn default_rate_limits() {
672 let _guard = ENV_LOCK.lock().unwrap();
673 unsafe { setup_dev() };
674
675 let config = ServerConfig::from_env().unwrap();
676 assert_eq!(config.rate_limit_auth, Some(10));
677 assert_eq!(config.rate_limit_general, Some(60));
678 }
679
680 #[test]
681 fn custom_rate_limits() {
682 let _guard = ENV_LOCK.lock().unwrap();
683 unsafe {
684 setup_dev();
685 env::set_var("RATE_LIMIT_AUTH", "20");
686 env::set_var("RATE_LIMIT_GENERAL", "120");
687 }
688
689 let config = ServerConfig::from_env().unwrap();
690 assert_eq!(config.rate_limit_auth, Some(20));
691 assert_eq!(config.rate_limit_general, Some(120));
692
693 unsafe {
694 env::remove_var("RATE_LIMIT_AUTH");
695 env::remove_var("RATE_LIMIT_GENERAL");
696 }
697 }
698
699 #[test]
700 fn zero_rate_limit_disables() {
701 let _guard = ENV_LOCK.lock().unwrap();
702 unsafe {
703 setup_dev();
704 env::set_var("RATE_LIMIT_AUTH", "0");
705 env::set_var("RATE_LIMIT_GENERAL", "0");
706 }
707
708 let config = ServerConfig::from_env().unwrap();
709 assert!(config.rate_limit_auth.is_none());
710 assert!(config.rate_limit_general.is_none());
711
712 unsafe {
713 env::remove_var("RATE_LIMIT_AUTH");
714 env::remove_var("RATE_LIMIT_GENERAL");
715 }
716 }
717
718 #[test]
719 fn invalid_rate_limit_returns_error() {
720 let _guard = ENV_LOCK.lock().unwrap();
721 unsafe {
722 setup_dev();
723 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
724 }
725
726 let result = ServerConfig::from_env();
727 assert!(result.is_err());
728 let err = result.unwrap_err();
729 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
730
731 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
732 }
733
734 #[test]
735 fn trusted_proxies_are_read_from_the_environment() {
736 let _guard = ENV_LOCK.lock().unwrap();
737 unsafe { setup_dev() };
738 assert!(ServerConfig::from_env().unwrap().trusted_proxies.is_empty());
739
740 unsafe { env::set_var("TRUSTED_PROXIES", "10.0.0.0/8") };
741 let config = ServerConfig::from_env().unwrap();
742 assert!(config.trusted_proxies.contains("10.1.2.3".parse().unwrap()));
743
744 unsafe { env::set_var("TRUSTED_PROXIES", "not-an-ip") };
745 let err = ServerConfig::from_env().unwrap_err();
746 assert!(err.errors.iter().any(|e| e.contains("TRUSTED_PROXIES")));
747
748 unsafe { env::remove_var("TRUSTED_PROXIES") };
749 }
750
751 #[test]
752 fn default_purge_config() {
753 let _guard = ENV_LOCK.lock().unwrap();
754 unsafe { setup_dev() };
755
756 let config = ServerConfig::from_env().unwrap();
757 assert_eq!(config.purge_max_age_days, 90);
758 assert_eq!(config.purge_max_runs_per_workflow, 1000);
759 assert!(!config.purge_dry_run);
760 assert_eq!(config.purge_interval_secs, 86400);
761 assert_eq!(config.provider_account_usage_retention_days, 30);
762 assert_eq!(config.signal_retention_days, 7);
763 }
764
765 #[test]
766 fn provider_account_usage_retention_days_from_env() {
767 let _guard = ENV_LOCK.lock().unwrap();
768 unsafe { setup_dev() };
770 unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "7") };
771 let config = ServerConfig::from_env().unwrap();
772 assert_eq!(config.provider_account_usage_retention_days, 7);
773
774 unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "0") };
775 assert!(ServerConfig::from_env().is_err());
776 unsafe { clear_env() };
777 }
778
779 #[test]
780 fn signal_retention_days_from_env() {
781 let _guard = ENV_LOCK.lock().unwrap();
782 unsafe { setup_dev() };
784 unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "3") };
785 let config = ServerConfig::from_env().unwrap();
786 assert_eq!(config.signal_retention_days, 3);
787
788 unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "0") };
789 assert!(ServerConfig::from_env().is_err());
790 unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "soon") };
791 assert!(ServerConfig::from_env().is_err());
792 unsafe { clear_env() };
793 }
794
795 #[test]
796 fn purger_from_config_uses_env_values() {
797 let _guard = ENV_LOCK.lock().unwrap();
798 unsafe {
799 setup_dev();
800 env::set_var("SIGNAL_RETENTION_DAYS", "1");
801 env::set_var("PURGE_MAX_AGE_DAYS", "30");
802 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "50");
803 env::set_var("PURGE_DRY_RUN", "true");
804 env::set_var("PURGE_INTERVAL_SECS", "3600");
805 env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "5");
806 }
807 let config = ServerConfig::from_env().unwrap();
808 unsafe { clear_env() };
809
810 let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
811 assert_eq!(purger.signal_retention_days, 1);
812 assert_eq!(purger.usage_retention_days, 5);
813 assert_eq!(purger.policy.max_age_days, 30);
814 assert_eq!(purger.policy.max_runs_per_workflow, 50);
815 assert!(purger.policy.dry_run);
816 assert_eq!(purger.interval, Duration::from_secs(3600));
817 }
818
819 #[test]
820 fn purger_from_config_defaults() {
821 let _guard = ENV_LOCK.lock().unwrap();
822 unsafe { setup_dev() };
823 let config = ServerConfig::from_env().unwrap();
824
825 let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
826 assert_eq!(purger.signal_retention_days, DEFAULT_SIGNAL_RETENTION_DAYS);
827 assert_eq!(purger.signal_retention_days, 7);
828 }
829
830 #[test]
831 fn custom_purge_config() {
832 let _guard = ENV_LOCK.lock().unwrap();
833 unsafe {
834 setup_dev();
835 env::set_var("PURGE_MAX_AGE_DAYS", "30");
836 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
837 env::set_var("PURGE_DRY_RUN", "true");
838 env::set_var("PURGE_INTERVAL_SECS", "3600");
839 }
840
841 let config = ServerConfig::from_env().unwrap();
842 assert_eq!(config.purge_max_age_days, 30);
843 assert_eq!(config.purge_max_runs_per_workflow, 500);
844 assert!(config.purge_dry_run);
845 assert_eq!(config.purge_interval_secs, 3600);
846
847 unsafe {
848 env::remove_var("PURGE_MAX_AGE_DAYS");
849 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
850 env::remove_var("PURGE_DRY_RUN");
851 env::remove_var("PURGE_INTERVAL_SECS");
852 }
853 }
854
855 #[test]
856 fn invalid_purge_max_age_days_returns_error() {
857 let _guard = ENV_LOCK.lock().unwrap();
858 unsafe {
859 setup_dev();
860 env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
861 }
862
863 let result = ServerConfig::from_env();
864 assert!(result.is_err());
865 let err = result.unwrap_err();
866 assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
867
868 unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
869 }
870
871 const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
873 const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
874
875 unsafe fn setup_prod(jwt: &str, worker: &str) {
882 unsafe {
883 clear_env();
884 env::set_var("IRONFLOW_ENV", "production");
885 env::set_var("DATABASE_URL", "postgres://x");
886 env::set_var("JWT_SECRET", jwt);
887 env::set_var("WORKER_TOKEN", worker);
888 }
889 }
890
891 #[test]
892 fn from_env_production_rejects_known_dev_jwt_secret() {
893 let _guard = ENV_LOCK.lock().unwrap();
894 unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
897
898 let err = ServerConfig::from_env().unwrap_err();
899 assert!(
900 err.errors
901 .iter()
902 .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
903 "expected a known-default rejection for JWT_SECRET, got {:?}",
904 err.errors
905 );
906 assert!(
907 !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
908 "a strong WORKER_TOKEN must not be flagged, got {:?}",
909 err.errors
910 );
911
912 unsafe { clear_env() };
913 }
914
915 #[test]
916 fn from_env_production_rejects_known_dev_worker_token() {
917 let _guard = ENV_LOCK.lock().unwrap();
918 unsafe { setup_prod(STRONG_JWT, "ironflow-dev-worker-token") };
920
921 let err = ServerConfig::from_env().unwrap_err();
922 assert!(
923 err.errors
924 .iter()
925 .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
926 "expected a known-default rejection for WORKER_TOKEN, got {:?}",
927 err.errors
928 );
929 assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
930
931 unsafe { clear_env() };
932 }
933
934 #[test]
935 fn from_env_production_accepts_strong_secrets() {
936 let _guard = ENV_LOCK.lock().unwrap();
937 unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
939
940 let config = ServerConfig::from_env().expect("strong secrets should be accepted");
941 assert!(config.is_production);
942 assert_eq!(config.jwt_secret, STRONG_JWT);
943 assert_eq!(config.worker_token, STRONG_WORKER);
944
945 unsafe { clear_env() };
946 }
947
948 #[test]
949 fn from_env_production_cookie_secure_even_if_insecure_requested() {
950 let _guard = ENV_LOCK.lock().unwrap();
951 unsafe {
953 setup_prod(STRONG_JWT, STRONG_WORKER);
954 env::set_var("IRONFLOW_INSECURE_COOKIES", "true");
955 }
956
957 let config = ServerConfig::from_env().expect("production config should load");
958 assert!(config.cookie_secure);
959
960 unsafe { clear_env() };
961 }
962
963 #[test]
964 fn from_env_development_cookie_secure_by_default() {
965 let _guard = ENV_LOCK.lock().unwrap();
966 unsafe { setup_dev() };
968
969 let config = ServerConfig::from_env().expect("dev config should load");
970 assert!(!config.is_production);
971 assert!(config.cookie_secure);
972
973 unsafe { clear_env() };
974 }
975
976 #[test]
977 fn from_env_development_insecure_cookies_opt_out() {
978 let _guard = ENV_LOCK.lock().unwrap();
979 unsafe {
981 setup_dev();
982 env::set_var("IRONFLOW_INSECURE_COOKIES", "TRUE");
983 }
984
985 let config = ServerConfig::from_env().expect("dev config should load");
986 assert!(!config.cookie_secure);
987
988 unsafe { clear_env() };
989 }
990
991 #[test]
992 fn from_env_explicit_dev_accepts_short_secret_but_not_dev_default() {
993 let _guard = ENV_LOCK.lock().unwrap();
996 unsafe {
998 setup_dev();
999 env::set_var("JWT_SECRET", "local-jwt");
1000 env::set_var("WORKER_TOKEN", "tinytoken");
1001 }
1002 let config = ServerConfig::from_env().expect("short secrets are allowed in explicit dev");
1003 assert_eq!(config.jwt_secret, "local-jwt");
1004 assert_eq!(config.worker_token, "tinytoken");
1005
1006 unsafe { env::set_var("WORKER_TOKEN", "ironflow-dev-worker-token") };
1008 let err = ServerConfig::from_env().unwrap_err();
1009 assert!(
1010 err.to_string().contains("known development default"),
1011 "{err}"
1012 );
1013
1014 unsafe { clear_env() };
1015 }
1016
1017 #[test]
1018 fn from_env_production_lists_all_insecure_secrets() {
1019 let _guard = ENV_LOCK.lock().unwrap();
1024 unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
1026
1027 let err = ServerConfig::from_env().unwrap_err();
1028 assert!(
1029 err.errors.iter().any(|e| e.contains("JWT_SECRET")),
1030 "JWT_SECRET must be listed, got {:?}",
1031 err.errors
1032 );
1033 assert!(
1034 err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
1035 "WORKER_TOKEN must be listed, got {:?}",
1036 err.errors
1037 );
1038
1039 unsafe { clear_env() };
1040 }
1041}