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