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