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