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