1use std::ffi::{OsStr, OsString};
2use std::net::SocketAddr;
3use std::path::PathBuf;
4
5use crate::ServerError;
6
7use super::types::ServerConfig;
8
9const ENV_PREFIX: &str = "LIMINAL_";
10const LISTEN_ADDRESS: &str = "LIMINAL_LISTEN_ADDRESS";
11const HEALTH_LISTEN_ADDRESS: &str = "LIMINAL_HEALTH_LISTEN_ADDRESS";
12const DRAIN_TIMEOUT_MS: &str = "LIMINAL_DRAIN_TIMEOUT_MS";
13const PERSISTENCE_PATH: &str = "LIMINAL_PERSISTENCE_PATH";
14const CLUSTER_NODE_NAME: &str = "LIMINAL_CLUSTER_NODE_NAME";
15const CLUSTER_SEED_NODES: &str = "LIMINAL_CLUSTER_SEED_NODES";
16const CLUSTER_LISTEN_ADDRESS: &str = "LIMINAL_CLUSTER_LISTEN_ADDRESS";
17const CLUSTER_COOKIE: &str = "LIMINAL_CLUSTER_COOKIE";
18const AUTH_TOKEN: &str = "LIMINAL_AUTH_TOKEN";
19const WEBSOCKET_LISTEN_ADDRESS: &str = "LIMINAL_WEBSOCKET_LISTEN_ADDRESS";
20const WEBSOCKET_PATH: &str = "LIMINAL_WEBSOCKET_PATH";
21const WEBSOCKET_ALLOWED_ORIGINS: &str = "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS";
22const WEBSOCKET_PING_INTERVAL_MS: &str = "LIMINAL_WEBSOCKET_PING_INTERVAL_MS";
23
24pub fn apply_env_overrides(config: ServerConfig) -> Result<ServerConfig, ServerError> {
51 apply_env_overrides_from(config, std::env::vars_os())
52}
53
54pub(crate) fn apply_env_overrides_from<I>(
55 mut config: ServerConfig,
56 variables: I,
57) -> Result<ServerConfig, ServerError>
58where
59 I: IntoIterator<Item = (OsString, OsString)>,
60{
61 for (key, value) in variables {
62 let Some(key) = key.to_str() else {
63 continue;
64 };
65
66 if !key.starts_with(ENV_PREFIX) {
67 continue;
68 }
69
70 match key {
71 LISTEN_ADDRESS => {
72 config.listen_address = parse_socket_addr(LISTEN_ADDRESS, &value)?;
73 }
74 super::extra_listen::ENV => {
75 config.extra_listen_addresses = super::extra_listen::parse(&value)?;
76 }
77 HEALTH_LISTEN_ADDRESS => {
78 config.health_listen_address = parse_socket_addr(HEALTH_LISTEN_ADDRESS, &value)?;
79 }
80 DRAIN_TIMEOUT_MS => {
81 config.drain_timeout_ms = parse_u64(DRAIN_TIMEOUT_MS, &value)?;
82 }
83 PERSISTENCE_PATH => {
84 config.persistence_path = Some(PathBuf::from(value));
85 }
86 CLUSTER_NODE_NAME => {
87 let node_name = env_string(CLUSTER_NODE_NAME, &value)?;
88 cluster_required(&mut config, CLUSTER_NODE_NAME)?.node_name = node_name;
89 }
90 CLUSTER_SEED_NODES => {
91 let seed_nodes = parse_seed_nodes(&value)?;
92 cluster_required(&mut config, CLUSTER_SEED_NODES)?.seed_nodes = seed_nodes;
93 }
94 CLUSTER_LISTEN_ADDRESS => {
95 let listen_address = parse_socket_addr(CLUSTER_LISTEN_ADDRESS, &value)?;
96 cluster_required(&mut config, CLUSTER_LISTEN_ADDRESS)?.listen_address =
97 listen_address;
98 }
99 CLUSTER_COOKIE => {
100 let cookie = env_string(CLUSTER_COOKIE, &value)?;
101 cluster_required(&mut config, CLUSTER_COOKIE)?.cookie = cookie;
102 }
103 WEBSOCKET_LISTEN_ADDRESS => {
104 let listen_address = parse_socket_addr(WEBSOCKET_LISTEN_ADDRESS, &value)?;
105 websocket_required(&mut config, WEBSOCKET_LISTEN_ADDRESS)?.listen_address =
106 listen_address;
107 }
108 WEBSOCKET_PATH => {
109 let path = env_string(WEBSOCKET_PATH, &value)?;
110 websocket_required(&mut config, WEBSOCKET_PATH)?.path = path;
111 }
112 WEBSOCKET_ALLOWED_ORIGINS => {
113 let allowed_origins = parse_allowed_origins(&value)?;
114 websocket_required(&mut config, WEBSOCKET_ALLOWED_ORIGINS)?.allowed_origins =
115 allowed_origins;
116 }
117 WEBSOCKET_PING_INTERVAL_MS => {
118 let interval = parse_u64(WEBSOCKET_PING_INTERVAL_MS, &value)?;
119 websocket_required(&mut config, WEBSOCKET_PING_INTERVAL_MS)?.ping_interval_ms =
120 Some(interval);
121 }
122 AUTH_TOKEN => {
123 let token = env_string(AUTH_TOKEN, &value)?;
128 config.auth = Some(super::types::AuthConfig {
129 token,
130 pass: None,
131 lys: None,
132 require_verified_worker: false,
133 });
134 }
135 unrecognized_key => {
136 tracing::warn!(
140 key = %unrecognized_key,
141 "unrecognized LIMINAL_ environment variable ignored; supported keys are unchanged"
142 );
143 }
144 }
145 }
146
147 Ok(config)
148}
149
150fn parse_socket_addr(name: &str, value: &OsStr) -> Result<SocketAddr, ServerError> {
151 let value = env_string(name, value)?;
152 value.parse::<SocketAddr>().map_err(|error| {
153 config_load(format!(
154 "environment variable {name} must be a socket address: {error}"
155 ))
156 })
157}
158
159fn parse_u64(name: &str, value: &OsStr) -> Result<u64, ServerError> {
160 let value = env_string(name, value)?;
161 value.parse::<u64>().map_err(|error| {
162 config_load(format!(
163 "environment variable {name} must be an unsigned integer: {error}"
164 ))
165 })
166}
167
168fn parse_seed_nodes(value: &OsStr) -> Result<Vec<SocketAddr>, ServerError> {
169 let value = env_string(CLUSTER_SEED_NODES, value)?;
170 if value.trim().is_empty() {
171 return Ok(Vec::new());
172 }
173
174 value
175 .split(',')
176 .enumerate()
177 .map(|(index, candidate)| parse_seed_node(index, candidate))
178 .collect()
179}
180
181fn parse_seed_node(index: usize, candidate: &str) -> Result<SocketAddr, ServerError> {
182 let candidate = candidate.trim();
183 if candidate.is_empty() {
184 return Err(config_load(format!(
185 "environment variable {CLUSTER_SEED_NODES} contains an empty seed node at position {}",
186 index + 1
187 )));
188 }
189
190 candidate.parse::<SocketAddr>().map_err(|error| {
191 config_load(format!(
192 "environment variable {CLUSTER_SEED_NODES} contains invalid seed node '{}' at position {}: {error}",
193 candidate,
194 index + 1
195 ))
196 })
197}
198
199fn env_string(name: &str, value: &OsStr) -> Result<String, ServerError> {
200 value.to_str().map(str::to_owned).ok_or_else(|| {
201 config_load(format!(
202 "environment variable {name} contains non-Unicode data"
203 ))
204 })
205}
206
207fn parse_allowed_origins(value: &OsStr) -> Result<Vec<String>, ServerError> {
213 let value = env_string(WEBSOCKET_ALLOWED_ORIGINS, value)?;
214 if value.trim().is_empty() {
215 return Ok(Vec::new());
216 }
217 value
218 .split(',')
219 .enumerate()
220 .map(|(index, candidate)| {
221 let candidate = candidate.trim();
222 if candidate.is_empty() {
223 Err(config_load(format!(
224 "environment variable {WEBSOCKET_ALLOWED_ORIGINS} contains an empty origin \
225 at position {}",
226 index + 1
227 )))
228 } else {
229 Ok(candidate.to_owned())
230 }
231 })
232 .collect()
233}
234
235fn websocket_required<'a>(
239 config: &'a mut ServerConfig,
240 name: &str,
241) -> Result<&'a mut super::types::WebSocketConfig, ServerError> {
242 config
243 .websocket
244 .as_mut()
245 .ok_or_else(|| ServerError::ConfigValidation {
246 message: format!(
247 "environment variable {name} requires a [websocket] section in the configuration \
248 file"
249 ),
250 })
251}
252
253fn cluster_required<'a>(
254 config: &'a mut ServerConfig,
255 name: &str,
256) -> Result<&'a mut super::types::ClusterConfig, ServerError> {
257 config
258 .cluster
259 .as_mut()
260 .ok_or_else(|| ServerError::ConfigValidation {
261 message: format!(
262 "environment variable {name} requires a [cluster] section in the configuration file"
263 ),
264 })
265}
266
267const fn config_load(message: String) -> ServerError {
268 ServerError::ConfigLoad { message }
269}
270
271#[cfg(test)]
272mod tests {
273 use std::ffi::OsString;
274 use std::net::SocketAddr;
275 use std::path::{Path, PathBuf};
276
277 use crate::ServerError;
278
279 use super::apply_env_overrides_from;
280 use crate::config::types::{ChannelDef, ClusterConfig, RoutingRuleDef, ServerConfig};
281 use crate::config::{load_from_file, validate};
282
283 fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
284 Ok(address.parse()?)
285 }
286
287 fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
288 Ok(ServerConfig {
289 listen_address: socket("127.0.0.1:8080")?,
290 extra_listen_addresses: Vec::new(),
291 health_listen_address: socket("127.0.0.1:8081")?,
292 drain_timeout_ms: 30_000,
293 channels: vec![ChannelDef {
294 name: "orders".to_owned(),
295 schema_ref: None,
296 durable: true,
297 loaded_schema: None,
298 }],
299 routing_rules: vec![RoutingRuleDef {
300 source_channel: "orders".to_owned(),
301 target_channel: "orders".to_owned(),
302 predicate: None,
303 }],
304 persistence_path: Some(PathBuf::from("/tmp")),
305 cluster: Some(ClusterConfig {
306 node_name: "node-a".to_owned(),
307 listen_address: socket("127.0.0.1:9000")?,
308 seed_nodes: vec![socket("127.0.0.1:9001")?],
309 cookie: "test-cookie".to_owned(),
310 }),
311 auth: None,
312 services: crate::config::types::ServicesConfig::default(),
313 limits: crate::config::types::LimitsConfig::default(),
314 participant: None,
315 websocket: None,
316 open: None,
317 templates: crate::services::lifecycle::templates::TemplateSources::default(),
318 agents: None,
319 })
320 }
321
322 fn env_pair(name: &str, value: &str) -> (OsString, OsString) {
323 (OsString::from(name), OsString::from(value))
324 }
325
326 fn write_temp_config(contents: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
327 let path = std::env::temp_dir().join(format!(
328 "liminal-server-env-pipeline-{}.toml",
329 std::process::id()
330 ));
331 std::fs::write(&path, contents)?;
332 Ok(path)
333 }
334
335 fn remove_temp_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
336 if path.exists() {
337 std::fs::remove_file(path)?;
338 }
339 Ok(())
340 }
341
342 #[test]
343 fn listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
344 let config = sample_config()?;
345 let config = apply_env_overrides_from(
346 config,
347 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
348 )?;
349
350 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
351
352 Ok(())
353 }
354
355 #[test]
356 fn health_listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>>
357 {
358 let config = sample_config()?;
359 let config = apply_env_overrides_from(
360 config,
361 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "0.0.0.0:9191")],
362 )?;
363
364 assert_eq!(config.health_listen_address, socket("0.0.0.0:9191")?);
365
366 Ok(())
367 }
368
369 #[test]
370 fn drain_timeout_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
371 let config = sample_config()?;
372 let config =
373 apply_env_overrides_from(config, vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "1250")])?;
374
375 assert_eq!(config.drain_timeout_ms, 1250);
376
377 Ok(())
378 }
379
380 #[test]
381 fn persistence_path_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
382 let config = sample_config()?;
383 let config = apply_env_overrides_from(
384 config,
385 vec![env_pair("LIMINAL_PERSISTENCE_PATH", "/var/lib/liminal")],
386 )?;
387
388 assert_eq!(
389 config.persistence_path.as_deref(),
390 Some(Path::new("/var/lib/liminal"))
391 );
392
393 Ok(())
394 }
395
396 #[test]
397 fn cluster_overrides_replace_existing_cluster_values() -> Result<(), Box<dyn std::error::Error>>
398 {
399 let config = sample_config()?;
400 let config = apply_env_overrides_from(
401 config,
402 vec![
403 env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b"),
404 env_pair(
405 "LIMINAL_CLUSTER_SEED_NODES",
406 "127.0.0.1:9100, 127.0.0.1:9200",
407 ),
408 ],
409 )?;
410
411 let Some(cluster) = config.cluster else {
412 return Err("cluster config should remain present".into());
413 };
414 assert_eq!(cluster.node_name, "node-b");
415 assert_eq!(cluster.seed_nodes.len(), 2);
416 assert_eq!(cluster.seed_nodes[0], socket("127.0.0.1:9100")?);
417 assert_eq!(cluster.seed_nodes[1], socket("127.0.0.1:9200")?);
418
419 Ok(())
420 }
421
422 #[test]
423 fn cluster_listen_address_and_cookie_overrides_replace_values()
424 -> Result<(), Box<dyn std::error::Error>> {
425 let config = sample_config()?;
426 let config = apply_env_overrides_from(
427 config,
428 vec![
429 env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500"),
430 env_pair("LIMINAL_CLUSTER_COOKIE", "override-cookie"),
431 ],
432 )?;
433
434 let Some(cluster) = config.cluster else {
435 return Err("cluster config should remain present".into());
436 };
437 assert_eq!(cluster.listen_address, socket("127.0.0.1:9500")?);
438 assert_eq!(cluster.cookie, "override-cookie");
439
440 Ok(())
441 }
442
443 #[test]
444 fn cluster_listen_address_override_without_cluster_section_returns_validation_error()
445 -> Result<(), Box<dyn std::error::Error>> {
446 let mut config = sample_config()?;
447 config.cluster = None;
448 let result = apply_env_overrides_from(
449 config,
450 vec![env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500")],
451 );
452
453 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
454
455 Ok(())
456 }
457
458 #[test]
459 fn auth_token_override_replaces_existing_token() -> Result<(), Box<dyn std::error::Error>> {
460 let mut config = sample_config()?;
461 config.auth = Some(crate::config::types::AuthConfig {
462 pass: None,
463 token: "file-token".to_owned(),
464 require_verified_worker: false,
465 lys: None,
466 });
467
468 let config =
469 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
470
471 let auth = config.auth.ok_or("auth section should remain present")?;
472 assert_eq!(auth.token, "env-token");
473
474 Ok(())
475 }
476
477 #[test]
478 fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
479 {
480 let mut config = sample_config()?;
481 config.auth = None;
482
483 let config =
484 apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
485
486 let auth = config.auth.ok_or("auth section should have been created")?;
488 assert_eq!(auth.token, "env-token");
489
490 Ok(())
491 }
492
493 #[test]
494 fn absent_environment_variables_leave_config_unchanged()
495 -> Result<(), Box<dyn std::error::Error>> {
496 let config = sample_config()?;
497 let original_address = config.listen_address;
498 let original_health_address = config.health_listen_address;
499 let original_drain_timeout_ms = config.drain_timeout_ms;
500 let original_path = config.persistence_path.clone();
501 let original_cluster_name = config
502 .cluster
503 .as_ref()
504 .map(|cluster| cluster.node_name.clone());
505
506 let config = apply_env_overrides_from(config, Vec::new())?;
507
508 assert_eq!(config.listen_address, original_address);
509 assert_eq!(config.health_listen_address, original_health_address);
510 assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
511 assert_eq!(config.persistence_path, original_path);
512 assert_eq!(
513 config
514 .cluster
515 .as_ref()
516 .map(|cluster| cluster.node_name.clone()),
517 original_cluster_name
518 );
519
520 Ok(())
521 }
522
523 #[test]
524 fn unrecognized_liminal_key_is_ignored_but_warned() -> Result<(), Box<dyn std::error::Error>> {
525 let config = sample_config()?;
526 let original_address = config.listen_address;
527
528 let log = crate::test_log::CapturedLog::default();
529 let config = log.capture(|| {
531 apply_env_overrides_from(config, vec![env_pair("LIMINAL_LISTEN_ADDR", "x")])
532 })?;
533
534 assert_eq!(config.listen_address, original_address);
536
537 let output = log.contents();
538 assert!(
539 output.contains("WARN"),
540 "an unrecognized LIMINAL_ key must be warned about, got: {output}"
541 );
542 assert!(
543 output.contains("LIMINAL_LISTEN_ADDR"),
544 "the warn must name the unrecognized key, got: {output}"
545 );
546
547 Ok(())
548 }
549
550 #[test]
551 fn invalid_listen_address_override_returns_config_load()
552 -> Result<(), Box<dyn std::error::Error>> {
553 let config = sample_config()?;
554 let result = apply_env_overrides_from(
555 config,
556 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
557 );
558
559 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
560
561 Ok(())
562 }
563
564 #[test]
565 fn invalid_health_listen_address_override_returns_config_load()
566 -> Result<(), Box<dyn std::error::Error>> {
567 let config = sample_config()?;
568 let result = apply_env_overrides_from(
569 config,
570 vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
571 );
572
573 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
574
575 Ok(())
576 }
577
578 #[test]
579 fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
580 {
581 let config = sample_config()?;
582 let result = apply_env_overrides_from(
583 config,
584 vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
585 );
586
587 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
588
589 Ok(())
590 }
591
592 #[test]
593 fn cluster_override_without_cluster_section_returns_validation_error()
594 -> Result<(), Box<dyn std::error::Error>> {
595 let mut config = sample_config()?;
596 config.cluster = None;
597 let result = apply_env_overrides_from(
598 config,
599 vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
600 );
601
602 assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
603
604 Ok(())
605 }
606
607 #[test]
608 fn file_then_env_then_validate_pipeline_gives_env_precedence()
609 -> Result<(), Box<dyn std::error::Error>> {
610 let toml = r#"
613listen_address = "127.0.0.1:8080"
614health_listen_address = "127.0.0.1:8081"
615drain_timeout_ms = 30000
616persistence_path = "/tmp"
617
618[auth]
619token = "SYNTHETIC-BEARER-FOR-THE-ENV-PRECEDENCE-PIN"
620
621[[channels]]
622name = "orders"
623durable = true
624
625[[routing_rules]]
626source_channel = "orders"
627target_channel = "orders"
628"#;
629 let path = write_temp_config(toml)?;
630 let config = load_from_file(&path)?;
631 let mut config = apply_env_overrides_from(
632 config,
633 vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
634 )?;
635 validate(&mut config, path.parent())?;
636 remove_temp_file(&path)?;
637
638 assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
639
640 Ok(())
641 }
642
643 fn sample_config_with_websocket() -> Result<ServerConfig, Box<dyn std::error::Error>> {
646 let mut config = sample_config()?;
647 config.websocket = Some(crate::config::types::WebSocketConfig {
648 listen_address: socket("127.0.0.1:8082")?,
649 path: "/liminal".to_owned(),
650 allowed_origins: Vec::new(),
651 allowed_origin_ports: Vec::new(),
652 ping_interval_ms: None,
653 });
654 Ok(config)
655 }
656
657 #[test]
658 fn websocket_overrides_replace_declared_section_values()
659 -> Result<(), Box<dyn std::error::Error>> {
660 let config = sample_config_with_websocket()?;
661 let config = apply_env_overrides_from(
662 config,
663 vec![
664 env_pair("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
665 env_pair("LIMINAL_WEBSOCKET_PATH", "/bridge"),
666 env_pair(
667 "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
668 "https://a.example.com, https://b.example.com",
669 ),
670 env_pair("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
671 ],
672 )?;
673 let websocket = config.websocket.ok_or("websocket section missing")?;
674 assert_eq!(websocket.listen_address, socket("0.0.0.0:9292")?);
675 assert_eq!(websocket.path, "/bridge");
676 assert_eq!(
677 websocket.allowed_origins,
678 vec![
679 "https://a.example.com".to_owned(),
680 "https://b.example.com".to_owned()
681 ]
682 );
683 assert_eq!(websocket.ping_interval_ms, Some(15_000));
684 Ok(())
685 }
686
687 #[test]
688 fn websocket_override_without_declared_section_is_refused()
689 -> Result<(), Box<dyn std::error::Error>> {
690 for (name, value) in [
691 ("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
692 ("LIMINAL_WEBSOCKET_PATH", "/bridge"),
693 ("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "https://a.example.com"),
694 ("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
695 ] {
696 let config = sample_config()?;
697 let result = apply_env_overrides_from(config, vec![env_pair(name, value)]);
698 let Err(ServerError::ConfigValidation { message }) = result else {
699 return Err(format!("{name}: fabricating [websocket] must be refused").into());
700 };
701 assert!(
702 message.contains("[websocket]"),
703 "{name}: expected a section-required error, got: {message}"
704 );
705 }
706 Ok(())
707 }
708
709 #[test]
710 fn websocket_empty_origin_list_override_is_fail_closed()
711 -> Result<(), Box<dyn std::error::Error>> {
712 let config = sample_config_with_websocket()?;
713 let config = apply_env_overrides_from(
714 config,
715 vec![env_pair("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "")],
716 )?;
717 let websocket = config.websocket.ok_or("websocket section missing")?;
718 assert!(websocket.allowed_origins.is_empty());
719 Ok(())
720 }
721
722 #[test]
723 fn websocket_origin_list_with_empty_entry_is_refused() -> Result<(), Box<dyn std::error::Error>>
724 {
725 let config = sample_config_with_websocket()?;
726 let result = apply_env_overrides_from(
727 config,
728 vec![env_pair(
729 "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
730 "https://a.example.com,,https://b.example.com",
731 )],
732 );
733 let Err(ServerError::ConfigLoad { message }) = result else {
734 return Err("an empty origin entry must be a typed load error".into());
735 };
736 assert!(
737 message.contains("empty origin"),
738 "expected an empty-origin error, got: {message}"
739 );
740 Ok(())
741 }
742}