1use std::env;
49use std::fmt;
50use std::path::PathBuf;
51
52use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
53use tracing::warn;
54
55#[derive(Debug, Clone)]
71pub struct ServerConfig {
72 pub database_url: Option<String>,
74 pub jwt_secret: String,
76 pub worker_token: String,
78 pub port: u16,
80 pub allowed_origins: Option<String>,
82 pub dashboard_dir: Option<PathBuf>,
84 pub webhook_url: Option<String>,
86 pub artifacts_dir: Option<PathBuf>,
94 pub artifact_max_bytes: u64,
98 pub purge_max_age_days: u32,
102 pub purge_max_runs_per_workflow: u32,
106 pub purge_dry_run: bool,
110 pub purge_interval_secs: u64,
114 pub provider_account_usage_retention_days: u32,
118 pub is_production: bool,
120 pub rate_limit_auth: Option<u32>,
123 pub rate_limit_general: Option<u32>,
126 pub artifact_backend: String,
128 pub artifact_s3_bucket: Option<String>,
130 pub artifact_s3_region: String,
132 pub artifact_s3_endpoint: Option<String>,
134 pub artifact_s3_prefix: Option<String>,
136 pub artifact_gc_interval_secs: u64,
138 pub artifact_gc_grace_days: u32,
140 pub artifact_gc_dry_run: bool,
142}
143
144#[derive(Debug, Clone)]
158pub struct ConfigError {
159 pub errors: Vec<String>,
161}
162
163impl ConfigError {
164 pub fn new(errors: Vec<String>) -> Self {
166 Self { errors }
167 }
168}
169
170impl fmt::Display for ConfigError {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 writeln!(f, "configuration errors:")?;
173 for error in &self.errors {
174 writeln!(f, " - {error}")?;
175 }
176 Ok(())
177 }
178}
179
180impl std::error::Error for ConfigError {}
181
182const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
183const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
184
185const DEV_SECRET_PREFIX: &str = "ironflow-dev-";
190
191const MIN_PROD_SECRET_BYTES: usize = 32;
194
195fn reject_insecure_secret(name: &str, value: &str, errors: &mut Vec<String>) {
202 if value.starts_with(DEV_SECRET_PREFIX) {
203 errors.push(format!(
204 "{name} must not use a known development default in production"
205 ));
206 } else if value.len() < MIN_PROD_SECRET_BYTES {
207 errors.push(format!(
208 "{name} must be at least {MIN_PROD_SECRET_BYTES} bytes in production, got {}",
209 value.len()
210 ));
211 }
212}
213
214fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
218 match env::var(name).ok() {
219 Some(raw) => match raw.parse::<u32>() {
220 Ok(0) => None,
221 Ok(v) => Some(v),
222 Err(_) => {
223 errors.push(format!(
224 "{name} must be a valid u32 (0 to disable), got: {raw}"
225 ));
226 Some(default)
227 }
228 },
229 None => Some(default),
230 }
231}
232
233impl ServerConfig {
234 pub fn from_env() -> Result<Self, ConfigError> {
259 let is_production = env::var("IRONFLOW_ENV")
260 .map(|v| v.eq_ignore_ascii_case("production"))
261 .unwrap_or(false);
262
263 let mut errors = Vec::new();
264
265 let database_url = env::var("DATABASE_URL").ok();
266 if is_production && database_url.is_none() {
267 errors.push("DATABASE_URL is required in production".to_string());
268 }
269
270 let jwt_secret_env = env::var("JWT_SECRET").ok();
271 let jwt_secret = match jwt_secret_env {
272 Some(val) => {
273 if is_production {
274 reject_insecure_secret("JWT_SECRET", &val, &mut errors);
275 }
276 val
277 }
278 None if is_production => {
279 errors.push("JWT_SECRET is required in production".to_string());
280 String::new()
281 }
282 None => {
283 warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
284 DEV_JWT_SECRET.to_string()
285 }
286 };
287
288 let worker_token_env = env::var("WORKER_TOKEN").ok();
289 let worker_token = match worker_token_env {
290 Some(val) => {
291 if is_production {
292 reject_insecure_secret("WORKER_TOKEN", &val, &mut errors);
293 }
294 val
295 }
296 None if is_production => {
297 errors.push("WORKER_TOKEN is required in production".to_string());
298 String::new()
299 }
300 None => {
301 warn!(
302 "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
303 );
304 DEV_WORKER_TOKEN.to_string()
305 }
306 };
307
308 let port = match env::var("PORT").ok() {
309 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
310 errors.push(format!("PORT must be a valid u16, got: {raw}"));
311 0
312 }),
313 None => 3000,
314 };
315
316 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
317 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
318 let webhook_url = env::var("WEBHOOK_URL").ok();
319
320 let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
321 let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
322
323 let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
324 let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
325 Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
326 errors.push(format!(
327 "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
328 ));
329 DEFAULT_MAX_ARTIFACT_BYTES
330 }),
331 None => DEFAULT_MAX_ARTIFACT_BYTES,
332 };
333
334 let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
335 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
336 errors.push(format!(
337 "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
338 ));
339 90
340 }),
341 None => 90,
342 };
343 let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
344 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
345 errors.push(format!(
346 "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
347 ));
348 1000
349 }),
350 None => 1000,
351 };
352 let purge_dry_run = env::var("PURGE_DRY_RUN")
353 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
354 .unwrap_or(false);
355 let provider_account_usage_retention_days =
356 match env::var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS").ok() {
357 Some(raw) => match raw.parse::<u32>() {
358 Ok(days) if days >= 1 => days,
359 _ => {
360 errors.push(format!(
361 "PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS must be an integer >= 1, got: {raw}"
362 ));
363 30
364 }
365 },
366 None => 30,
367 };
368 let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
369 Some(raw) => {
370 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
371 errors.push(format!(
372 "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
373 ));
374 86400
375 });
376 if parsed < 60 {
377 errors.push(format!(
378 "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
379 ));
380 }
381 parsed
382 }
383 None => 86400,
384 };
385
386 let artifact_backend = env::var("ARTIFACT_BACKEND")
387 .unwrap_or_else(|_| "local".to_string())
388 .to_lowercase();
389 let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
390 let artifact_s3_region =
391 env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
392 let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
393 let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
394
395 if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
396 errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
397 }
398 if artifact_backend != "local" && artifact_backend != "s3" {
399 errors.push(format!(
400 "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
401 ));
402 }
403
404 let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
405 Some(raw) => {
406 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
407 errors.push(format!(
408 "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
409 ));
410 86400
411 });
412 if parsed < 60 {
413 errors.push(format!(
414 "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
415 ));
416 }
417 parsed
418 }
419 None => 86400,
420 };
421 let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
422 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
423 errors.push(format!(
424 "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
425 ));
426 7
427 }),
428 None => 7,
429 };
430 let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
431 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
432 .unwrap_or(false);
433
434 if !errors.is_empty() {
435 return Err(ConfigError::new(errors));
436 }
437
438 Ok(Self {
439 database_url,
440 jwt_secret,
441 worker_token,
442 port,
443 allowed_origins,
444 dashboard_dir,
445 webhook_url,
446 is_production,
447 rate_limit_auth,
448 rate_limit_general,
449 artifacts_dir,
450 artifact_max_bytes,
451 purge_max_age_days,
452 purge_max_runs_per_workflow,
453 purge_dry_run,
454 purge_interval_secs,
455 provider_account_usage_retention_days,
456 artifact_backend,
457 artifact_s3_bucket,
458 artifact_s3_region,
459 artifact_s3_endpoint,
460 artifact_s3_prefix,
461 artifact_gc_interval_secs,
462 artifact_gc_grace_days,
463 artifact_gc_dry_run,
464 })
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use std::sync::Mutex;
471
472 use super::*;
473
474 static ENV_LOCK: Mutex<()> = Mutex::new(());
476
477 unsafe fn clear_env() {
481 unsafe {
482 env::remove_var("IRONFLOW_ENV");
483 env::remove_var("DATABASE_URL");
484 env::remove_var("JWT_SECRET");
485 env::remove_var("WORKER_TOKEN");
486 env::remove_var("PORT");
487 env::remove_var("ALLOWED_ORIGINS");
488 env::remove_var("DASHBOARD_DIR");
489 env::remove_var("WEBHOOK_URL");
490 env::remove_var("RATE_LIMIT_AUTH");
491 env::remove_var("RATE_LIMIT_GENERAL");
492 env::remove_var("PURGE_MAX_AGE_DAYS");
493 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
494 env::remove_var("PURGE_DRY_RUN");
495 env::remove_var("PURGE_INTERVAL_SECS");
496 env::remove_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS");
497 env::remove_var("ARTIFACT_BACKEND");
498 env::remove_var("ARTIFACT_S3_BUCKET");
499 env::remove_var("ARTIFACT_S3_REGION");
500 env::remove_var("ARTIFACT_S3_ENDPOINT");
501 env::remove_var("ARTIFACT_S3_PREFIX");
502 env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
503 env::remove_var("ARTIFACT_GC_GRACE_DAYS");
504 env::remove_var("ARTIFACT_GC_DRY_RUN");
505 }
506 }
507
508 #[test]
509 fn config_error_display_lists_all_errors() {
510 let err = ConfigError::new(vec![
511 "JWT_SECRET is required".to_string(),
512 "DATABASE_URL is required".to_string(),
513 ]);
514 let msg = err.to_string();
515 assert!(msg.contains("JWT_SECRET"));
516 assert!(msg.contains("DATABASE_URL"));
517 assert!(msg.contains("configuration errors:"));
518 }
519
520 #[test]
521 fn config_error_is_std_error() {
522 let err = ConfigError::new(vec!["test".to_string()]);
523 let _: &dyn std::error::Error = &err;
524 }
525
526 #[test]
527 fn default_dev_config_succeeds() {
528 let _guard = ENV_LOCK.lock().unwrap();
529 unsafe { clear_env() };
530
531 let config = ServerConfig::from_env().expect("dev config should succeed");
532 assert!(!config.is_production);
533 assert_eq!(config.port, 3000);
534 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
535 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
536 }
537
538 #[test]
539 fn production_without_secrets_fails() {
540 let _guard = ENV_LOCK.lock().unwrap();
541 unsafe {
542 clear_env();
543 env::set_var("IRONFLOW_ENV", "production");
544 }
545
546 let result = ServerConfig::from_env();
547 assert!(result.is_err());
548 let err = result.unwrap_err();
549 assert!(err.errors.len() >= 3);
550 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
551 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
552 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
553
554 unsafe { env::remove_var("IRONFLOW_ENV") };
555 }
556
557 #[test]
558 fn invalid_port_returns_error() {
559 let _guard = ENV_LOCK.lock().unwrap();
560 unsafe {
561 clear_env();
562 env::set_var("PORT", "not-a-number");
563 }
564
565 let result = ServerConfig::from_env();
566 assert!(result.is_err());
567 let err = result.unwrap_err();
568 assert!(err.errors.iter().any(|e| e.contains("PORT")));
569
570 unsafe { env::remove_var("PORT") };
571 }
572
573 #[test]
574 fn default_rate_limits() {
575 let _guard = ENV_LOCK.lock().unwrap();
576 unsafe { clear_env() };
577
578 let config = ServerConfig::from_env().unwrap();
579 assert_eq!(config.rate_limit_auth, Some(10));
580 assert_eq!(config.rate_limit_general, Some(60));
581 }
582
583 #[test]
584 fn custom_rate_limits() {
585 let _guard = ENV_LOCK.lock().unwrap();
586 unsafe {
587 clear_env();
588 env::set_var("RATE_LIMIT_AUTH", "20");
589 env::set_var("RATE_LIMIT_GENERAL", "120");
590 }
591
592 let config = ServerConfig::from_env().unwrap();
593 assert_eq!(config.rate_limit_auth, Some(20));
594 assert_eq!(config.rate_limit_general, Some(120));
595
596 unsafe {
597 env::remove_var("RATE_LIMIT_AUTH");
598 env::remove_var("RATE_LIMIT_GENERAL");
599 }
600 }
601
602 #[test]
603 fn zero_rate_limit_disables() {
604 let _guard = ENV_LOCK.lock().unwrap();
605 unsafe {
606 clear_env();
607 env::set_var("RATE_LIMIT_AUTH", "0");
608 env::set_var("RATE_LIMIT_GENERAL", "0");
609 }
610
611 let config = ServerConfig::from_env().unwrap();
612 assert!(config.rate_limit_auth.is_none());
613 assert!(config.rate_limit_general.is_none());
614
615 unsafe {
616 env::remove_var("RATE_LIMIT_AUTH");
617 env::remove_var("RATE_LIMIT_GENERAL");
618 }
619 }
620
621 #[test]
622 fn invalid_rate_limit_returns_error() {
623 let _guard = ENV_LOCK.lock().unwrap();
624 unsafe {
625 clear_env();
626 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
627 }
628
629 let result = ServerConfig::from_env();
630 assert!(result.is_err());
631 let err = result.unwrap_err();
632 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
633
634 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
635 }
636
637 #[test]
638 fn default_purge_config() {
639 let _guard = ENV_LOCK.lock().unwrap();
640 unsafe { clear_env() };
641
642 let config = ServerConfig::from_env().unwrap();
643 assert_eq!(config.purge_max_age_days, 90);
644 assert_eq!(config.purge_max_runs_per_workflow, 1000);
645 assert!(!config.purge_dry_run);
646 assert_eq!(config.purge_interval_secs, 86400);
647 assert_eq!(config.provider_account_usage_retention_days, 30);
648 }
649
650 #[test]
651 fn provider_account_usage_retention_days_from_env() {
652 let _guard = ENV_LOCK.lock().unwrap();
653 unsafe { clear_env() };
655 unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "7") };
656 let config = ServerConfig::from_env().unwrap();
657 assert_eq!(config.provider_account_usage_retention_days, 7);
658
659 unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "0") };
660 assert!(ServerConfig::from_env().is_err());
661 unsafe { clear_env() };
662 }
663
664 #[test]
665 fn custom_purge_config() {
666 let _guard = ENV_LOCK.lock().unwrap();
667 unsafe {
668 clear_env();
669 env::set_var("PURGE_MAX_AGE_DAYS", "30");
670 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
671 env::set_var("PURGE_DRY_RUN", "true");
672 env::set_var("PURGE_INTERVAL_SECS", "3600");
673 }
674
675 let config = ServerConfig::from_env().unwrap();
676 assert_eq!(config.purge_max_age_days, 30);
677 assert_eq!(config.purge_max_runs_per_workflow, 500);
678 assert!(config.purge_dry_run);
679 assert_eq!(config.purge_interval_secs, 3600);
680
681 unsafe {
682 env::remove_var("PURGE_MAX_AGE_DAYS");
683 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
684 env::remove_var("PURGE_DRY_RUN");
685 env::remove_var("PURGE_INTERVAL_SECS");
686 }
687 }
688
689 #[test]
690 fn invalid_purge_max_age_days_returns_error() {
691 let _guard = ENV_LOCK.lock().unwrap();
692 unsafe {
693 clear_env();
694 env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
695 }
696
697 let result = ServerConfig::from_env();
698 assert!(result.is_err());
699 let err = result.unwrap_err();
700 assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
701
702 unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
703 }
704
705 const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
707 const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
708
709 unsafe fn setup_prod(jwt: &str, worker: &str) {
716 unsafe {
717 clear_env();
718 env::set_var("IRONFLOW_ENV", "production");
719 env::set_var("DATABASE_URL", "postgres://x");
720 env::set_var("JWT_SECRET", jwt);
721 env::set_var("WORKER_TOKEN", worker);
722 }
723 }
724
725 #[test]
726 fn from_env_production_rejects_known_dev_jwt_secret() {
727 let _guard = ENV_LOCK.lock().unwrap();
728 unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
731
732 let err = ServerConfig::from_env().unwrap_err();
733 assert!(
734 err.errors
735 .iter()
736 .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
737 "expected a known-default rejection for JWT_SECRET, got {:?}",
738 err.errors
739 );
740 assert!(
741 !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
742 "a strong WORKER_TOKEN must not be flagged, got {:?}",
743 err.errors
744 );
745
746 unsafe { clear_env() };
747 }
748
749 #[test]
750 fn from_env_production_rejects_known_dev_worker_token() {
751 let _guard = ENV_LOCK.lock().unwrap();
752 unsafe { setup_prod(STRONG_JWT, DEV_WORKER_TOKEN) };
754
755 let err = ServerConfig::from_env().unwrap_err();
756 assert!(
757 err.errors
758 .iter()
759 .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
760 "expected a known-default rejection for WORKER_TOKEN, got {:?}",
761 err.errors
762 );
763 assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
764
765 unsafe { clear_env() };
766 }
767
768 #[test]
769 fn from_env_production_rejects_short_jwt_secret() {
770 let _guard = ENV_LOCK.lock().unwrap();
771 unsafe { setup_prod("shortsecret", STRONG_WORKER) };
774
775 let err = ServerConfig::from_env().unwrap_err();
776 assert!(
777 err.errors
778 .iter()
779 .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
780 "expected a length rejection for JWT_SECRET, got {:?}",
781 err.errors
782 );
783
784 unsafe { clear_env() };
785 }
786
787 #[test]
788 fn from_env_production_rejects_short_worker_token() {
789 let _guard = ENV_LOCK.lock().unwrap();
790 unsafe { setup_prod(STRONG_JWT, "tinytoken") };
792
793 let err = ServerConfig::from_env().unwrap_err();
794 assert!(
795 err.errors
796 .iter()
797 .any(|e| e.contains("WORKER_TOKEN") && e.contains("32 bytes")),
798 "expected a length rejection for WORKER_TOKEN, got {:?}",
799 err.errors
800 );
801
802 unsafe { clear_env() };
803 }
804
805 #[test]
806 fn from_env_production_accepts_strong_secrets() {
807 let _guard = ENV_LOCK.lock().unwrap();
808 unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
810
811 let config = ServerConfig::from_env().expect("strong secrets should be accepted");
812 assert!(config.is_production);
813 assert_eq!(config.jwt_secret, STRONG_JWT);
814 assert_eq!(config.worker_token, STRONG_WORKER);
815
816 unsafe { clear_env() };
817 }
818
819 #[test]
820 fn from_env_production_secret_length_boundary() {
821 let exactly_32: &str = "0123456789abcdef0123456789abcdef";
824 let just_under: &str = "0123456789abcdef0123456789abcde";
825 assert_eq!(exactly_32.len(), 32);
826 assert_eq!(just_under.len(), 31);
827
828 let _guard = ENV_LOCK.lock().unwrap();
829
830 unsafe { setup_prod(exactly_32, STRONG_WORKER) };
832 let config = ServerConfig::from_env().expect("a 32-byte secret is accepted");
833 assert_eq!(config.jwt_secret, exactly_32);
834
835 unsafe { setup_prod(just_under, STRONG_WORKER) };
837 let err = ServerConfig::from_env().unwrap_err();
838 assert!(
839 err.errors
840 .iter()
841 .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
842 "31 bytes must be rejected, got {:?}",
843 err.errors
844 );
845
846 unsafe { clear_env() };
847 }
848
849 #[test]
850 fn from_env_development_accepts_weak_secret() {
851 let _guard = ENV_LOCK.lock().unwrap();
854 unsafe {
856 clear_env();
857 env::set_var("IRONFLOW_ENV", "development");
858 env::set_var("JWT_SECRET", "ironflow-dev-jwt-secret");
859 env::set_var("WORKER_TOKEN", "tinytoken");
860 }
861
862 let config = ServerConfig::from_env().expect("weak secrets are allowed outside production");
863 assert!(!config.is_production);
864 assert_eq!(config.jwt_secret, "ironflow-dev-jwt-secret");
865 assert_eq!(config.worker_token, "tinytoken");
866
867 unsafe { clear_env() };
868 }
869
870 #[test]
871 fn from_env_production_lists_all_insecure_secrets() {
872 let _guard = ENV_LOCK.lock().unwrap();
877 unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
879
880 let err = ServerConfig::from_env().unwrap_err();
881 assert!(
882 err.errors.iter().any(|e| e.contains("JWT_SECRET")),
883 "JWT_SECRET must be listed, got {:?}",
884 err.errors
885 );
886 assert!(
887 err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
888 "WORKER_TOKEN must be listed, got {:?}",
889 err.errors
890 );
891
892 unsafe { clear_env() };
893 }
894}