1use std::env;
2use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3use std::path::PathBuf;
4use std::time::Duration;
5
6use anyhow::{Context, bail};
7
8use crate::mail::MailConfig;
9use crate::storage::StorageConfig;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum Environment {
15 Local,
17 Testing,
19 Production,
21}
22
23impl Environment {
24 fn parse(value: &str) -> anyhow::Result<Self> {
25 match value.to_ascii_lowercase().as_str() {
26 "local" | "dev" | "development" => Ok(Self::Local),
27 "testing" | "test" => Ok(Self::Testing),
28 "production" | "prod" => Ok(Self::Production),
29 other => bail!("APP_ENV must be local, testing or production, got `{other}`"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[non_exhaustive]
37pub enum CspMode {
38 #[default]
42 Relaxed,
43 Strict,
48 Off,
50}
51
52impl CspMode {
53 fn parse(value: &str) -> anyhow::Result<Self> {
54 match value.to_ascii_lowercase().as_str() {
55 "relaxed" | "" => Ok(Self::Relaxed),
56 "strict" => Ok(Self::Strict),
57 "off" | "false" | "none" => Ok(Self::Off),
58 other => bail!("CSP must be relaxed, strict or off, got `{other}`"),
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
65#[non_exhaustive]
66pub enum PermissionsMode {
67 #[default]
71 Default,
72 Off,
74 Custom(String),
76}
77
78impl PermissionsMode {
79 fn parse(value: &str) -> anyhow::Result<Self> {
80 let value = value.trim();
81 match value.to_ascii_lowercase().as_str() {
82 "default" | "" => Ok(Self::Default),
83 "off" | "false" | "none" => Ok(Self::Off),
84 _ => {
85 crate::security::parse_permissions_policy(value)
86 .with_context(|| format!("PERMISSIONS_POLICY is not usable: `{value}`"))?;
87 Ok(Self::Custom(value.to_owned()))
88 }
89 }
90 }
91}
92
93setting_enum! {
94 pub enum SessionDriver ("SESSION_DRIVER") {
96 Cookie = "cookie",
98 Database = "database",
100 }
101}
102
103setting_enum! {
104 pub enum LogFormat ("LOG_FORMAT") {
106 Text = "text",
108 Json = "json",
110 }
111}
112
113setting_enum! {
114 pub enum CacheStore ("CACHE_STORE") {
116 Memory = "memory",
118 Database = "database",
120 }
121}
122
123#[derive(Clone)]
125#[non_exhaustive]
126pub struct Config {
127 pub name: String,
129 pub env: Environment,
131 pub debug: bool,
133 pub url: String,
135 pub key: Option<String>,
137 pub host: IpAddr,
139 pub port: u16,
141 pub views_path: PathBuf,
143 pub public_path: PathBuf,
145 pub session_lifetime: Duration,
148 pub session_cookie: String,
150 pub session_driver: SessionDriver,
154 pub remember_lifetime: Duration,
157 pub database_url: String,
159 pub database_pool_size: u32,
161 pub database_acquire_timeout: Duration,
164 pub database_statement_timeout: Option<Duration>,
167 pub request_timeout: Option<Duration>,
170 pub locale: String,
173 pub fallback_locale: String,
175 pub currency: String,
179 pub lang_path: PathBuf,
181 pub mail: MailConfig,
183 pub queue_workers: usize,
185 pub scheduler: bool,
187 pub log_format: LogFormat,
189 pub log_file: Option<PathBuf>,
191 pub timezone: crate::timezone::Zone,
195 pub cache_store: CacheStore,
197 pub storage_path: PathBuf,
199 pub storage: StorageConfig,
201 pub upload_max_size: usize,
203 pub live_snapshot_max: usize,
205 pub csp: CspMode,
207 pub permissions_policy: PermissionsMode,
210 pub vars: std::collections::HashMap<String, String>,
213 pub analytics: AnalyticsConfig,
215 pub trusted_proxies: crate::TrustedProxies,
217 pub trusted_hosts: Vec<String>,
221}
222
223impl std::fmt::Debug for Config {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 f.debug_struct("Config")
227 .field("name", &self.name)
228 .field("env", &self.env)
229 .field("debug", &self.debug)
230 .field("url", &self.url)
231 .field("key", &self.key.as_ref().map(|_| "[hidden]"))
232 .field("host", &self.host)
233 .field("port", &self.port)
234 .field("views_path", &self.views_path)
235 .field("public_path", &self.public_path)
236 .field("permissions_policy", &self.permissions_policy)
237 .field("session_lifetime", &self.session_lifetime)
238 .field("session_cookie", &self.session_cookie)
239 .field("session_driver", &self.session_driver)
240 .field("remember_lifetime", &self.remember_lifetime)
241 .field("database_url", &self.database_url)
242 .field("database_pool_size", &self.database_pool_size)
243 .field("database_acquire_timeout", &self.database_acquire_timeout)
244 .field(
245 "database_statement_timeout",
246 &self.database_statement_timeout,
247 )
248 .field("request_timeout", &self.request_timeout)
249 .field("locale", &self.locale)
250 .field("fallback_locale", &self.fallback_locale)
251 .field("currency", &self.currency)
252 .field("lang_path", &self.lang_path)
253 .field("mail", &self.mail)
254 .field("queue_workers", &self.queue_workers)
255 .field("scheduler", &self.scheduler)
256 .field("log_format", &self.log_format)
257 .field("log_file", &self.log_file)
258 .field("timezone", &self.timezone)
259 .field("cache_store", &self.cache_store)
260 .field("storage_path", &self.storage_path)
261 .field("storage", &self.storage)
262 .field("upload_max_size", &self.upload_max_size)
263 .field("live_snapshot_max", &self.live_snapshot_max)
264 .field("csp", &self.csp)
265 .field(
266 "vars",
267 &self.vars.keys().collect::<std::collections::BTreeSet<_>>(),
268 )
269 .field("analytics", &self.analytics)
270 .field("trusted_proxies", &self.trusted_proxies)
271 .field("trusted_hosts", &self.trusted_hosts)
272 .finish()
273 }
274}
275
276#[derive(Clone, Default)]
280#[non_exhaustive]
281pub struct AnalyticsConfig {
282 pub google_site_verification: Option<String>,
284 pub ga4_measurement_id: Option<String>,
286 pub ga4_api_secret: Option<String>,
288 pub gtm_container_id: Option<String>,
290}
291
292impl std::fmt::Debug for AnalyticsConfig {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 f.debug_struct("AnalyticsConfig")
296 .field("google_site_verification", &self.google_site_verification)
297 .field("ga4_measurement_id", &self.ga4_measurement_id)
298 .field(
299 "ga4_api_secret",
300 &self.ga4_api_secret.as_ref().map(|_| "[hidden]"),
301 )
302 .field("gtm_container_id", &self.gtm_container_id)
303 .finish()
304 }
305}
306
307impl Config {
308 pub fn load() -> crate::Result<Self> {
310 let _ = dotenvy::dotenv();
311 Self::from_env()
312 }
313
314 pub fn from_env() -> crate::Result<Self> {
316 Self::from_vars(|name| env::var(name).ok())
317 }
318
319 pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> crate::Result<Self> {
323 Ok(Self::read(get)?)
324 }
325
326 fn read(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
327 let v = Vars(get);
328 let env = Environment::parse(&v.or("APP_ENV", "local"))?;
329 let debug = v.bool("APP_DEBUG", env == Environment::Local)?;
330 let host = v
331 .or("APP_HOST", "127.0.0.1")
332 .parse()
333 .context("APP_HOST must be an IP address")?;
334 let port = v
335 .or("APP_PORT", "3000")
336 .parse()
337 .context("APP_PORT must be a port number")?;
338 let key = v.get("APP_KEY").filter(|k| !k.is_empty());
339
340 if env == Environment::Production && key.is_none() {
341 bail!("APP_KEY must be set in production");
342 }
343
344 Ok(Self {
345 name: v.or("APP_NAME", "Renox"),
346 env,
347 debug,
348 url: v.or("APP_URL", &format!("http://{host}:{port}")),
349 key,
350 host,
351 port,
352 views_path: v.or("VIEWS_PATH", "resources/views").into(),
353 public_path: v.or("PUBLIC_PATH", "public").into(),
354 session_lifetime: Duration::from_secs(
355 v.or("SESSION_LIFETIME", "120")
356 .parse::<u64>()
357 .context("SESSION_LIFETIME must be a number of minutes")?
358 .saturating_mul(60),
359 ),
360 session_cookie: v.or("SESSION_COOKIE", "renox_session"),
361 session_driver: SessionDriver::parse(&v.or("SESSION_DRIVER", "cookie"))?,
362 remember_lifetime: Duration::from_secs(
363 v.or("REMEMBER_LIFETIME", "43200")
364 .parse::<u64>()
365 .context("REMEMBER_LIFETIME must be a number of minutes")?
366 .saturating_mul(60),
367 ),
368 database_url: v.or("DATABASE_URL", "sqlite://storage/app.db"),
369 database_pool_size: v
370 .or("DATABASE_POOL_SIZE", "8")
371 .parse()
372 .ok()
373 .filter(|n| *n > 0)
374 .context("DATABASE_POOL_SIZE must be a number above 0")?,
375 database_acquire_timeout: v
376 .seconds("DATABASE_ACQUIRE_TIMEOUT", 5)?
377 .unwrap_or(Duration::from_secs(5)),
378 database_statement_timeout: v.seconds("DATABASE_STATEMENT_TIMEOUT", 30)?,
379 request_timeout: v.seconds("REQUEST_TIMEOUT", 60)?,
380 locale: v.or("APP_LOCALE", "en"),
381 fallback_locale: v.or("APP_FALLBACK_LOCALE", "en"),
382 currency: match v.or("APP_CURRENCY", "USD").trim().to_ascii_uppercase() {
383 code if code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase()) => code,
384 other => {
385 bail!("APP_CURRENCY must be an ISO 4217 code like USD or EUR, got `{other}`")
386 }
387 },
388 lang_path: v.or("LANG_PATH", "resources/lang").into(),
389 mail: MailConfig {
390 mailer: crate::mail::MailDriver::parse(&v.or("MAIL_MAILER", "log"))?,
391 host: v.or("MAIL_HOST", "localhost"),
392 port: v
393 .get("MAIL_PORT")
394 .filter(|p| !p.is_empty())
395 .map(|p| p.parse())
396 .transpose()
397 .context("MAIL_PORT must be a port number")?,
398 username: v.get("MAIL_USERNAME").filter(|v| !v.is_empty()),
399 password: v.get("MAIL_PASSWORD").filter(|v| !v.is_empty()),
400 encryption: crate::mail::MailEncryption::parse(
401 &v.or("MAIL_ENCRYPTION", "starttls"),
402 )?,
403 from_address: v.or("MAIL_FROM_ADDRESS", "hello@example.com"),
404 from_name: v.get("MAIL_FROM_NAME").filter(|v| !v.is_empty()),
405 timeout: v
406 .seconds("MAIL_TIMEOUT", 10)?
407 .unwrap_or(Duration::from_secs(10)),
408 failover: v
409 .or("MAIL_FAILOVER", "")
410 .split(',')
411 .map(|name| name.trim().to_owned())
412 .filter(|name| !name.is_empty())
413 .collect(),
414 },
415 queue_workers: v
416 .or("QUEUE_WORKERS", "2")
417 .parse()
418 .context("QUEUE_WORKERS must be a number")?,
419 scheduler: v.bool("SCHEDULER", true)?,
420 log_format: LogFormat::parse(&v.or("LOG_FORMAT", "text"))?,
421 log_file: v
422 .get("LOG_FILE")
423 .filter(|p| !p.is_empty())
424 .map(PathBuf::from),
425 timezone: v
426 .or("APP_TIMEZONE", "UTC")
427 .parse()
428 .map_err(|err| anyhow::anyhow!("APP_TIMEZONE: {err}"))?,
429 cache_store: CacheStore::parse(&v.or("CACHE_STORE", "memory"))?,
430 storage_path: v.or("STORAGE_PATH", "storage").into(),
431 storage: StorageConfig {
432 disk: crate::storage::DiskDriver::parse(&v.or("STORAGE_DISK", "local"))?,
433 bucket: v.optional("S3_BUCKET"),
434 region: v.optional("S3_REGION"),
435 endpoint: v.optional("S3_ENDPOINT"),
436 access_key_id: v.optional("S3_ACCESS_KEY_ID"),
437 secret_access_key: v.optional("S3_SECRET_ACCESS_KEY"),
438 url: v.optional("STORAGE_URL"),
439 root: None,
440 },
441 upload_max_size: v
442 .or("UPLOAD_MAX_SIZE", "10")
443 .parse::<usize>()
444 .ok()
445 .and_then(|mb| mb.checked_mul(1024 * 1024))
446 .context("UPLOAD_MAX_SIZE must be a number of megabytes")?,
447 live_snapshot_max: v
448 .or("LIVE_SNAPSHOT_MAX_SIZE", "64")
449 .parse::<usize>()
450 .ok()
451 .and_then(|kb| kb.checked_mul(1024))
452 .context("LIVE_SNAPSHOT_MAX_SIZE must be a number of kilobytes")?,
453 csp: CspMode::parse(&v.or("CSP", "relaxed"))?,
454 permissions_policy: PermissionsMode::parse(&v.or("PERMISSIONS_POLICY", "default"))?,
455 trusted_proxies: crate::TrustedProxies::read(&v.or("TRUSTED_PROXIES", ""))?,
456 trusted_hosts: v
457 .or("TRUSTED_HOSTS", "")
458 .split(',')
459 .map(|host| host.trim().to_ascii_lowercase())
460 .filter(|host| !host.is_empty())
461 .collect(),
462 vars: Default::default(),
463 analytics: AnalyticsConfig {
464 google_site_verification: v.optional("GOOGLE_SITE_VERIFICATION"),
465 ga4_measurement_id: v.optional("GA4_MEASUREMENT_ID"),
466 ga4_api_secret: v.optional("GA4_API_SECRET"),
467 gtm_container_id: v.optional("GTM_CONTAINER_ID"),
468 },
469 })
470 }
471
472 pub fn var(&self, name: &str) -> Option<String> {
475 self.vars
476 .get(name)
477 .cloned()
478 .or_else(|| env::var(name).ok())
479 .filter(|v| !v.is_empty())
480 }
481
482 pub fn addr(&self) -> SocketAddr {
484 SocketAddr::new(self.host, self.port)
485 }
486}
487
488impl Default for Config {
489 fn default() -> Self {
490 Self {
491 name: "Renox".into(),
492 env: Environment::Local,
493 debug: true,
494 url: "http://127.0.0.1:3000".into(),
495 key: None,
496 host: IpAddr::V4(Ipv4Addr::LOCALHOST),
497 port: 3000,
498 views_path: "resources/views".into(),
499 public_path: "public".into(),
500 session_lifetime: Duration::from_secs(120 * 60),
501 session_cookie: "renox_session".into(),
502 session_driver: SessionDriver::Cookie,
503 remember_lifetime: Duration::from_secs(43_200 * 60),
504 database_url: "sqlite::memory:".into(),
505 database_pool_size: 8,
506 database_acquire_timeout: Duration::from_secs(30),
509 database_statement_timeout: Some(Duration::from_secs(30)),
510 request_timeout: Some(Duration::from_secs(60)),
511 locale: "en".into(),
512 fallback_locale: "en".into(),
513 currency: "USD".into(),
514 lang_path: "resources/lang".into(),
515 mail: MailConfig {
516 mailer: crate::mail::MailDriver::Memory,
517 ..MailConfig::default()
518 },
519 queue_workers: 0,
520 scheduler: false,
521 log_format: LogFormat::Text,
522 log_file: None,
523 timezone: crate::timezone::Zone::default(),
524 cache_store: CacheStore::Memory,
525 storage_path: "storage".into(),
526 storage: StorageConfig::default(),
527 upload_max_size: 10 * 1024 * 1024,
528 live_snapshot_max: 64 * 1024,
529 csp: CspMode::Relaxed,
530 permissions_policy: PermissionsMode::Default,
531 vars: Default::default(),
532 analytics: AnalyticsConfig::default(),
533 trusted_proxies: Default::default(),
534 trusted_hosts: Vec::new(),
535 }
536 }
537}
538
539struct Vars<F>(F);
541
542impl<F: Fn(&str) -> Option<String>> Vars<F> {
543 fn get(&self, name: &str) -> Option<String> {
544 (self.0)(name)
545 }
546
547 fn optional(&self, name: &str) -> Option<String> {
548 self.get(name).filter(|v| !v.is_empty())
549 }
550
551 fn or(&self, name: &str, default: &str) -> String {
552 self.get(name).unwrap_or_else(|| default.to_owned())
553 }
554
555 fn seconds(&self, name: &str, default: u64) -> anyhow::Result<Option<Duration>> {
557 let value: u64 = self
558 .or(name, &default.to_string())
559 .parse()
560 .with_context(|| format!("{name} must be a number of seconds"))?;
561 Ok((value > 0).then(|| Duration::from_secs(value)))
562 }
563
564 fn bool(&self, name: &str, default: bool) -> anyhow::Result<bool> {
565 match self.get(name) {
566 None => Ok(default),
567 Some(v) => match v.to_ascii_lowercase().as_str() {
568 "1" | "true" | "yes" | "on" => Ok(true),
569 "0" | "false" | "no" | "off" | "" => Ok(false),
570 other => bail!("{name} must be true or false, got `{other}`"),
571 },
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn parses_environment_aliases() {
582 assert_eq!(Environment::parse("local").unwrap(), Environment::Local);
583 assert_eq!(Environment::parse("DEV").unwrap(), Environment::Local);
584 assert_eq!(Environment::parse("test").unwrap(), Environment::Testing);
585 assert_eq!(Environment::parse("prod").unwrap(), Environment::Production);
586 assert!(Environment::parse("staging").is_err());
587 }
588
589 fn load(pairs: &[(&str, &str)]) -> anyhow::Result<Config> {
590 let vars: std::collections::HashMap<String, String> = pairs
591 .iter()
592 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
593 .collect();
594 Config::read(|name| vars.get(name).cloned())
595 }
596
597 #[test]
598 fn defaults_without_any_variable() {
599 let c = load(&[]).unwrap();
600 assert_eq!(c.env, Environment::Local);
601 assert!(c.debug, "local defaults to debug");
602 assert_eq!(
603 (c.port, c.database_pool_size, c.upload_max_size),
604 (3000, 8, 10 * 1024 * 1024)
605 );
606 assert_eq!(c.database_acquire_timeout, Duration::from_secs(5));
607 assert_eq!(c.request_timeout, Some(Duration::from_secs(60)));
608 assert_eq!(c.cache_store, CacheStore::Memory);
609 assert_eq!(c.csp, CspMode::Relaxed);
610 assert_eq!(c.permissions_policy, PermissionsMode::Default);
611 assert!(c.key.is_none() && c.mail.port.is_none());
612 }
613
614 #[test]
615 fn debug_hides_secrets() {
616 let mut c = load(&[("APP_KEY", "base64:c2VjcmV0"), ("MAIL_PASSWORD", "hunter2")]).unwrap();
617 c.vars
618 .insert("XENDIT_SECRET_KEY".into(), "xnd_live_123".into());
619 let shown = format!("{c:?}");
620 for secret in ["c2VjcmV0", "hunter2", "xnd_live_123"] {
621 assert!(!shown.contains(secret), "{secret} in {shown}");
622 }
623 assert!(
624 shown.contains("XENDIT_SECRET_KEY"),
625 "variable names stay: {shown}"
626 );
627 assert!(shown.contains("[hidden]"));
628 }
629
630 #[test]
631 fn session_driver() {
632 assert_eq!(load(&[]).unwrap().session_driver, SessionDriver::Cookie);
633 assert_eq!(
634 load(&[("SESSION_DRIVER", "database")])
635 .unwrap()
636 .session_driver,
637 SessionDriver::Database
638 );
639 assert!(load(&[("SESSION_DRIVER", "redis")]).is_err());
640 }
641
642 #[test]
643 fn log_format_and_file() {
644 let c = load(&[]).unwrap();
645 assert_eq!((c.log_format.as_str(), c.log_file), ("text", None));
646 let c = load(&[("LOG_FORMAT", "json"), ("LOG_FILE", "storage/logs/app.log")]).unwrap();
647 assert_eq!(c.log_format, LogFormat::Json);
648 assert_eq!(c.log_file, Some(PathBuf::from("storage/logs/app.log")));
649 let err = load(&[("LOG_FORMAT", "xml")]).unwrap_err();
650 assert!(err.to_string().contains("LOG_FORMAT"), "{err}");
651 }
652
653 #[test]
654 fn currency() {
655 assert_eq!(load(&[]).unwrap().currency, "USD");
656 assert_eq!(load(&[("APP_CURRENCY", " idr ")]).unwrap().currency, "IDR");
657 for bad in ["US", "rupiah", "U$D"] {
658 let err = load(&[("APP_CURRENCY", bad)]).unwrap_err();
659 assert!(err.to_string().contains("APP_CURRENCY"), "{err}");
660 }
661 }
662
663 #[test]
664 fn reads_every_kind_of_value() {
665 let c = load(&[
666 ("APP_ENV", "production"),
667 ("APP_KEY", "base64:abc"),
668 ("APP_DEBUG", "off"),
669 ("APP_PORT", "8080"),
670 ("DATABASE_POOL_SIZE", "3"),
671 ("REQUEST_TIMEOUT", "0"),
672 ("DATABASE_STATEMENT_TIMEOUT", "5"),
673 ("UPLOAD_MAX_SIZE", "2"),
674 ("LIVE_SNAPSHOT_MAX_SIZE", "2"),
675 ("MAIL_PORT", "2525"),
676 ("MAIL_USERNAME", ""),
677 ("MAIL_FAILOVER", "backup, log"),
678 ("CSP", "strict"),
679 ("PERMISSIONS_POLICY", "geolocation=(self), camera=()"),
680 ("TRUSTED_PROXIES", "10.0.0.0/8"),
681 ("TRUSTED_HOSTS", "shop.example.com, *.Example.org"),
682 ("S3_BUCKET", "files"),
683 ])
684 .unwrap();
685 assert_eq!(c.env, Environment::Production);
686 assert!(!c.debug);
687 assert_eq!((c.port, c.database_pool_size), (8080, 3));
688 assert_eq!(c.request_timeout, None, "0 means no limit");
689 assert_eq!(c.database_statement_timeout, Some(Duration::from_secs(5)));
690 assert_eq!(c.upload_max_size, 2 * 1024 * 1024);
691 assert_eq!(c.live_snapshot_max, 2048);
692 assert_eq!(c.mail.port, Some(2525));
693 assert_eq!(c.mail.username, None, "empty means unset");
694 assert_eq!(c.mail.failover, ["backup", "log"]);
695 assert_eq!(c.csp, CspMode::Strict);
696 assert_eq!(
697 c.permissions_policy,
698 PermissionsMode::Custom("geolocation=(self), camera=()".into())
699 );
700 assert!(c.trusted_proxies.contains("10.1.2.3".parse().unwrap()));
701 assert_eq!(c.trusted_hosts, ["shop.example.com", "*.example.org"]);
702 assert_eq!(c.storage.bucket.as_deref(), Some("files"));
703 }
704
705 #[test]
706 fn permissions_policy_takes_default_off_or_a_header_value() {
707 for (value, want) in [
708 ("", PermissionsMode::Default),
709 ("DEFAULT", PermissionsMode::Default),
710 ("off", PermissionsMode::Off),
711 ("false", PermissionsMode::Off),
712 (
713 "camera=(self \"https://a.example\"), payment=*",
714 PermissionsMode::Custom("camera=(self \"https://a.example\"), payment=*".into()),
715 ),
716 ] {
717 let c = load(&[("PERMISSIONS_POLICY", value)]).unwrap();
718 assert_eq!(c.permissions_policy, want, "{value}");
719 }
720 let c = load(&[("CSP", "off")]).unwrap();
722 assert_eq!(c.permissions_policy, PermissionsMode::Default);
723 }
724
725 #[test]
726 fn refuses_bad_values_with_the_variable_name() {
727 for (name, value) in [
728 ("APP_ENV", "staging"),
729 ("APP_DEBUG", "maybe"),
730 ("APP_PORT", "http"),
731 ("APP_HOST", "localhost"),
732 ("DATABASE_POOL_SIZE", "0"),
733 ("REQUEST_TIMEOUT", "-1"),
734 ("UPLOAD_MAX_SIZE", "99999999999999999"),
735 ("MAIL_PORT", "99999"),
736 ("CSP", "loose"),
737 ("PERMISSIONS_POLICY", "camera"),
738 ("PERMISSIONS_POLICY", "Camera=(self)"),
739 ("PERMISSIONS_POLICY", "camera=(http://x.example)"),
740 ("TRUSTED_PROXIES", "proxy.local"),
741 ("SESSION_DRIVER", "redis"),
742 ("CACHE_STORE", "redis"),
743 ("LOG_FORMAT", "xml"),
744 ("MAIL_MAILER", "sendgrid"),
745 ("MAIL_ENCRYPTION", "ssl3"),
746 ("STORAGE_DISK", "ftp"),
747 ("APP_TIMEZONE", "Mars/Olympus"),
748 ] {
749 let err = load(&[(name, value)])
750 .err()
751 .map(|e| format!("{e:#}"))
752 .unwrap_or_default();
753 assert!(
754 err.contains(name) || err.contains(value),
755 "{name}={value}: {err}"
756 );
757 }
758 let err = load(&[("APP_ENV", "production")]).unwrap_err();
759 assert!(
760 format!("{err}").contains("APP_KEY"),
761 "production needs a key"
762 );
763 }
764
765 #[test]
767 fn settings_print_and_serialize_as_their_word() {
768 assert_eq!(SessionDriver::Database.to_string(), "database");
769 assert_eq!(
770 serde_json::to_string(&CacheStore::Database).unwrap(),
771 r#""database""#
772 );
773 assert_eq!(format!("{}", LogFormat::Json), "json");
774 }
775}