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