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::{Arc, Mutex};
489 use std::time::Duration;
490
491 use ironflow_store::memory::InMemoryStore;
492
493 use crate::purger::{DEFAULT_SIGNAL_RETENTION_DAYS, RunPurger};
494
495 use super::*;
496
497 static ENV_LOCK: Mutex<()> = Mutex::new(());
499
500 unsafe fn clear_env() {
504 unsafe {
505 env::remove_var("IRONFLOW_ENV");
506 env::remove_var("DATABASE_URL");
507 env::remove_var("JWT_SECRET");
508 env::remove_var("WORKER_TOKEN");
509 env::remove_var("PORT");
510 env::remove_var("ALLOWED_ORIGINS");
511 env::remove_var("DASHBOARD_DIR");
512 env::remove_var("WEBHOOK_URL");
513 env::remove_var("RATE_LIMIT_AUTH");
514 env::remove_var("RATE_LIMIT_GENERAL");
515 env::remove_var("PURGE_MAX_AGE_DAYS");
516 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
517 env::remove_var("PURGE_DRY_RUN");
518 env::remove_var("PURGE_INTERVAL_SECS");
519 env::remove_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS");
520 env::remove_var("SIGNAL_RETENTION_DAYS");
521 env::remove_var("ARTIFACT_BACKEND");
522 env::remove_var("ARTIFACT_S3_BUCKET");
523 env::remove_var("ARTIFACT_S3_REGION");
524 env::remove_var("ARTIFACT_S3_ENDPOINT");
525 env::remove_var("ARTIFACT_S3_PREFIX");
526 env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
527 env::remove_var("ARTIFACT_GC_GRACE_DAYS");
528 env::remove_var("ARTIFACT_GC_DRY_RUN");
529 }
530 }
531
532 #[test]
533 fn config_error_display_lists_all_errors() {
534 let err = ConfigError::new(vec![
535 "JWT_SECRET is required".to_string(),
536 "DATABASE_URL is required".to_string(),
537 ]);
538 let msg = err.to_string();
539 assert!(msg.contains("JWT_SECRET"));
540 assert!(msg.contains("DATABASE_URL"));
541 assert!(msg.contains("configuration errors:"));
542 }
543
544 #[test]
545 fn config_error_is_std_error() {
546 let err = ConfigError::new(vec!["test".to_string()]);
547 let _: &dyn std::error::Error = &err;
548 }
549
550 #[test]
551 fn default_dev_config_succeeds() {
552 let _guard = ENV_LOCK.lock().unwrap();
553 unsafe { clear_env() };
554
555 let config = ServerConfig::from_env().expect("dev config should succeed");
556 assert!(!config.is_production);
557 assert_eq!(config.port, 3000);
558 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
559 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
560 }
561
562 #[test]
563 fn production_without_secrets_fails() {
564 let _guard = ENV_LOCK.lock().unwrap();
565 unsafe {
566 clear_env();
567 env::set_var("IRONFLOW_ENV", "production");
568 }
569
570 let result = ServerConfig::from_env();
571 assert!(result.is_err());
572 let err = result.unwrap_err();
573 assert!(err.errors.len() >= 3);
574 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
575 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
576 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
577
578 unsafe { env::remove_var("IRONFLOW_ENV") };
579 }
580
581 #[test]
582 fn invalid_port_returns_error() {
583 let _guard = ENV_LOCK.lock().unwrap();
584 unsafe {
585 clear_env();
586 env::set_var("PORT", "not-a-number");
587 }
588
589 let result = ServerConfig::from_env();
590 assert!(result.is_err());
591 let err = result.unwrap_err();
592 assert!(err.errors.iter().any(|e| e.contains("PORT")));
593
594 unsafe { env::remove_var("PORT") };
595 }
596
597 #[test]
598 fn default_rate_limits() {
599 let _guard = ENV_LOCK.lock().unwrap();
600 unsafe { clear_env() };
601
602 let config = ServerConfig::from_env().unwrap();
603 assert_eq!(config.rate_limit_auth, Some(10));
604 assert_eq!(config.rate_limit_general, Some(60));
605 }
606
607 #[test]
608 fn custom_rate_limits() {
609 let _guard = ENV_LOCK.lock().unwrap();
610 unsafe {
611 clear_env();
612 env::set_var("RATE_LIMIT_AUTH", "20");
613 env::set_var("RATE_LIMIT_GENERAL", "120");
614 }
615
616 let config = ServerConfig::from_env().unwrap();
617 assert_eq!(config.rate_limit_auth, Some(20));
618 assert_eq!(config.rate_limit_general, Some(120));
619
620 unsafe {
621 env::remove_var("RATE_LIMIT_AUTH");
622 env::remove_var("RATE_LIMIT_GENERAL");
623 }
624 }
625
626 #[test]
627 fn zero_rate_limit_disables() {
628 let _guard = ENV_LOCK.lock().unwrap();
629 unsafe {
630 clear_env();
631 env::set_var("RATE_LIMIT_AUTH", "0");
632 env::set_var("RATE_LIMIT_GENERAL", "0");
633 }
634
635 let config = ServerConfig::from_env().unwrap();
636 assert!(config.rate_limit_auth.is_none());
637 assert!(config.rate_limit_general.is_none());
638
639 unsafe {
640 env::remove_var("RATE_LIMIT_AUTH");
641 env::remove_var("RATE_LIMIT_GENERAL");
642 }
643 }
644
645 #[test]
646 fn invalid_rate_limit_returns_error() {
647 let _guard = ENV_LOCK.lock().unwrap();
648 unsafe {
649 clear_env();
650 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
651 }
652
653 let result = ServerConfig::from_env();
654 assert!(result.is_err());
655 let err = result.unwrap_err();
656 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
657
658 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
659 }
660
661 #[test]
662 fn default_purge_config() {
663 let _guard = ENV_LOCK.lock().unwrap();
664 unsafe { clear_env() };
665
666 let config = ServerConfig::from_env().unwrap();
667 assert_eq!(config.purge_max_age_days, 90);
668 assert_eq!(config.purge_max_runs_per_workflow, 1000);
669 assert!(!config.purge_dry_run);
670 assert_eq!(config.purge_interval_secs, 86400);
671 assert_eq!(config.provider_account_usage_retention_days, 30);
672 assert_eq!(config.signal_retention_days, 7);
673 }
674
675 #[test]
676 fn provider_account_usage_retention_days_from_env() {
677 let _guard = ENV_LOCK.lock().unwrap();
678 unsafe { clear_env() };
680 unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "7") };
681 let config = ServerConfig::from_env().unwrap();
682 assert_eq!(config.provider_account_usage_retention_days, 7);
683
684 unsafe { env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "0") };
685 assert!(ServerConfig::from_env().is_err());
686 unsafe { clear_env() };
687 }
688
689 #[test]
690 fn signal_retention_days_from_env() {
691 let _guard = ENV_LOCK.lock().unwrap();
692 unsafe { clear_env() };
694 unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "3") };
695 let config = ServerConfig::from_env().unwrap();
696 assert_eq!(config.signal_retention_days, 3);
697
698 unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "0") };
699 assert!(ServerConfig::from_env().is_err());
700 unsafe { env::set_var("SIGNAL_RETENTION_DAYS", "soon") };
701 assert!(ServerConfig::from_env().is_err());
702 unsafe { clear_env() };
703 }
704
705 #[test]
706 fn purger_from_config_uses_env_values() {
707 let _guard = ENV_LOCK.lock().unwrap();
708 unsafe {
709 clear_env();
710 env::set_var("SIGNAL_RETENTION_DAYS", "1");
711 env::set_var("PURGE_MAX_AGE_DAYS", "30");
712 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "50");
713 env::set_var("PURGE_DRY_RUN", "true");
714 env::set_var("PURGE_INTERVAL_SECS", "3600");
715 env::set_var("PROVIDER_ACCOUNT_USAGE_RETENTION_DAYS", "5");
716 }
717 let config = ServerConfig::from_env().unwrap();
718 unsafe { clear_env() };
719
720 let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
721 assert_eq!(purger.signal_retention_days, 1);
722 assert_eq!(purger.usage_retention_days, 5);
723 assert_eq!(purger.policy.max_age_days, 30);
724 assert_eq!(purger.policy.max_runs_per_workflow, 50);
725 assert!(purger.policy.dry_run);
726 assert_eq!(purger.interval, Duration::from_secs(3600));
727 }
728
729 #[test]
730 fn purger_from_config_defaults() {
731 let _guard = ENV_LOCK.lock().unwrap();
732 unsafe { clear_env() };
733 let config = ServerConfig::from_env().unwrap();
734
735 let purger = RunPurger::from_config(Arc::new(InMemoryStore::new()), &config);
736 assert_eq!(purger.signal_retention_days, DEFAULT_SIGNAL_RETENTION_DAYS);
737 assert_eq!(purger.signal_retention_days, 7);
738 }
739
740 #[test]
741 fn custom_purge_config() {
742 let _guard = ENV_LOCK.lock().unwrap();
743 unsafe {
744 clear_env();
745 env::set_var("PURGE_MAX_AGE_DAYS", "30");
746 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
747 env::set_var("PURGE_DRY_RUN", "true");
748 env::set_var("PURGE_INTERVAL_SECS", "3600");
749 }
750
751 let config = ServerConfig::from_env().unwrap();
752 assert_eq!(config.purge_max_age_days, 30);
753 assert_eq!(config.purge_max_runs_per_workflow, 500);
754 assert!(config.purge_dry_run);
755 assert_eq!(config.purge_interval_secs, 3600);
756
757 unsafe {
758 env::remove_var("PURGE_MAX_AGE_DAYS");
759 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
760 env::remove_var("PURGE_DRY_RUN");
761 env::remove_var("PURGE_INTERVAL_SECS");
762 }
763 }
764
765 #[test]
766 fn invalid_purge_max_age_days_returns_error() {
767 let _guard = ENV_LOCK.lock().unwrap();
768 unsafe {
769 clear_env();
770 env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
771 }
772
773 let result = ServerConfig::from_env();
774 assert!(result.is_err());
775 let err = result.unwrap_err();
776 assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
777
778 unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
779 }
780
781 const STRONG_JWT: &str = "prod-jwt-secret-0123456789abcdef0123456789";
783 const STRONG_WORKER: &str = "prod-worker-token-0123456789abcdef01234567";
784
785 unsafe fn setup_prod(jwt: &str, worker: &str) {
792 unsafe {
793 clear_env();
794 env::set_var("IRONFLOW_ENV", "production");
795 env::set_var("DATABASE_URL", "postgres://x");
796 env::set_var("JWT_SECRET", jwt);
797 env::set_var("WORKER_TOKEN", worker);
798 }
799 }
800
801 #[test]
802 fn from_env_production_rejects_known_dev_jwt_secret() {
803 let _guard = ENV_LOCK.lock().unwrap();
804 unsafe { setup_prod("ironflow-dev-jwt-secret", STRONG_WORKER) };
807
808 let err = ServerConfig::from_env().unwrap_err();
809 assert!(
810 err.errors
811 .iter()
812 .any(|e| e.contains("JWT_SECRET") && e.contains("known development default")),
813 "expected a known-default rejection for JWT_SECRET, got {:?}",
814 err.errors
815 );
816 assert!(
817 !err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
818 "a strong WORKER_TOKEN must not be flagged, got {:?}",
819 err.errors
820 );
821
822 unsafe { clear_env() };
823 }
824
825 #[test]
826 fn from_env_production_rejects_known_dev_worker_token() {
827 let _guard = ENV_LOCK.lock().unwrap();
828 unsafe { setup_prod(STRONG_JWT, DEV_WORKER_TOKEN) };
830
831 let err = ServerConfig::from_env().unwrap_err();
832 assert!(
833 err.errors
834 .iter()
835 .any(|e| e.contains("WORKER_TOKEN") && e.contains("known development default")),
836 "expected a known-default rejection for WORKER_TOKEN, got {:?}",
837 err.errors
838 );
839 assert!(!err.errors.iter().any(|e| e.contains("JWT_SECRET")));
840
841 unsafe { clear_env() };
842 }
843
844 #[test]
845 fn from_env_production_rejects_short_jwt_secret() {
846 let _guard = ENV_LOCK.lock().unwrap();
847 unsafe { setup_prod("shortsecret", STRONG_WORKER) };
850
851 let err = ServerConfig::from_env().unwrap_err();
852 assert!(
853 err.errors
854 .iter()
855 .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
856 "expected a length rejection for JWT_SECRET, got {:?}",
857 err.errors
858 );
859
860 unsafe { clear_env() };
861 }
862
863 #[test]
864 fn from_env_production_rejects_short_worker_token() {
865 let _guard = ENV_LOCK.lock().unwrap();
866 unsafe { setup_prod(STRONG_JWT, "tinytoken") };
868
869 let err = ServerConfig::from_env().unwrap_err();
870 assert!(
871 err.errors
872 .iter()
873 .any(|e| e.contains("WORKER_TOKEN") && e.contains("32 bytes")),
874 "expected a length rejection for WORKER_TOKEN, got {:?}",
875 err.errors
876 );
877
878 unsafe { clear_env() };
879 }
880
881 #[test]
882 fn from_env_production_accepts_strong_secrets() {
883 let _guard = ENV_LOCK.lock().unwrap();
884 unsafe { setup_prod(STRONG_JWT, STRONG_WORKER) };
886
887 let config = ServerConfig::from_env().expect("strong secrets should be accepted");
888 assert!(config.is_production);
889 assert_eq!(config.jwt_secret, STRONG_JWT);
890 assert_eq!(config.worker_token, STRONG_WORKER);
891
892 unsafe { clear_env() };
893 }
894
895 #[test]
896 fn from_env_production_secret_length_boundary() {
897 let exactly_32: &str = "0123456789abcdef0123456789abcdef";
900 let just_under: &str = "0123456789abcdef0123456789abcde";
901 assert_eq!(exactly_32.len(), 32);
902 assert_eq!(just_under.len(), 31);
903
904 let _guard = ENV_LOCK.lock().unwrap();
905
906 unsafe { setup_prod(exactly_32, STRONG_WORKER) };
908 let config = ServerConfig::from_env().expect("a 32-byte secret is accepted");
909 assert_eq!(config.jwt_secret, exactly_32);
910
911 unsafe { setup_prod(just_under, STRONG_WORKER) };
913 let err = ServerConfig::from_env().unwrap_err();
914 assert!(
915 err.errors
916 .iter()
917 .any(|e| e.contains("JWT_SECRET") && e.contains("32 bytes")),
918 "31 bytes must be rejected, got {:?}",
919 err.errors
920 );
921
922 unsafe { clear_env() };
923 }
924
925 #[test]
926 fn from_env_development_accepts_weak_secret() {
927 let _guard = ENV_LOCK.lock().unwrap();
930 unsafe {
932 clear_env();
933 env::set_var("IRONFLOW_ENV", "development");
934 env::set_var("JWT_SECRET", "ironflow-dev-jwt-secret");
935 env::set_var("WORKER_TOKEN", "tinytoken");
936 }
937
938 let config = ServerConfig::from_env().expect("weak secrets are allowed outside production");
939 assert!(!config.is_production);
940 assert_eq!(config.jwt_secret, "ironflow-dev-jwt-secret");
941 assert_eq!(config.worker_token, "tinytoken");
942
943 unsafe { clear_env() };
944 }
945
946 #[test]
947 fn from_env_production_lists_all_insecure_secrets() {
948 let _guard = ENV_LOCK.lock().unwrap();
953 unsafe { setup_prod("ironflow-dev-jwt-secret", "ironflow-dev-worker-token") };
955
956 let err = ServerConfig::from_env().unwrap_err();
957 assert!(
958 err.errors.iter().any(|e| e.contains("JWT_SECRET")),
959 "JWT_SECRET must be listed, got {:?}",
960 err.errors
961 );
962 assert!(
963 err.errors.iter().any(|e| e.contains("WORKER_TOKEN")),
964 "WORKER_TOKEN must be listed, got {:?}",
965 err.errors
966 );
967
968 unsafe { clear_env() };
969 }
970}