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