1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::ServerError;
5
6use super::types::{LoadedSchema, ServerConfig, ServiceProfile};
7
8pub fn validate(config: &mut ServerConfig, base_dir: Option<&Path>) -> Result<(), ServerError> {
29 let mut errors = Vec::new();
30
31 validate_listen_address(config, &mut errors);
32 validate_health_listen_address(config, &mut errors);
33 validate_drain_timeout(config, &mut errors);
34 validate_channels(config, &mut errors);
35 validate_routing_rules(config, &mut errors);
36 validate_persistence_path(config, &mut errors);
37 validate_cluster(config, &mut errors);
38 validate_auth(config, &mut errors);
39 validate_services(config, &mut errors);
40 config.limits.collect_errors(&mut errors);
41 load_channel_schemas(config, base_dir, &mut errors);
42
43 if errors.is_empty() {
44 Ok(())
45 } else {
46 Err(ServerError::ConfigValidation {
47 message: errors.join("; "),
48 })
49 }
50}
51
52fn load_channel_schemas(
57 config: &mut ServerConfig,
58 base_dir: Option<&Path>,
59 errors: &mut Vec<String>,
60) {
61 for channel in &mut config.channels {
62 let Some(schema_ref) = channel.schema_ref.as_ref() else {
63 continue;
64 };
65 let path = resolve_schema_path(schema_ref, base_dir);
66 match load_schema_document(&path) {
67 Ok(loaded) => channel.loaded_schema = Some(loaded),
68 Err(reason) => errors.push(format!(
69 "channels.schema_ref '{}': {reason}",
70 schema_ref.display()
71 )),
72 }
73 }
74}
75
76fn resolve_schema_path(schema_ref: &Path, base_dir: Option<&Path>) -> PathBuf {
80 base_dir.map_or_else(|| schema_ref.to_path_buf(), |dir| dir.join(schema_ref))
83}
84
85fn load_schema_document(path: &Path) -> Result<LoadedSchema, String> {
92 let bytes = std::fs::read(path)
93 .map_err(|error| format!("schema file '{}' is unreadable: {error}", path.display()))?;
94 let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
95 format!(
96 "schema file '{}' is not valid JSON: {error}",
97 path.display()
98 )
99 })?;
100 liminal::channel::Schema::new(document.clone()).map_err(|error| {
101 format!(
102 "schema file '{}' is not a valid JSON Schema: {error}",
103 path.display()
104 )
105 })?;
106 Ok(LoadedSchema { bytes, document })
107}
108
109fn validate_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
110 if config.listen_address.port() == 0 {
111 errors.push("listen_address: port must be non-zero".to_owned());
112 }
113}
114
115fn validate_health_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
116 if config.health_listen_address.port() == 0 {
117 errors.push("health_listen_address: port must be non-zero".to_owned());
118 }
119
120 if config.health_listen_address == config.listen_address {
121 errors.push(
122 "health_listen_address: must differ from listen_address for probe isolation".to_owned(),
123 );
124 } else if config.health_listen_address.port() == config.listen_address.port() {
125 errors.push(
126 "health_listen_address: port must differ from listen_address port for probe isolation"
127 .to_owned(),
128 );
129 }
130}
131
132fn validate_drain_timeout(config: &ServerConfig, errors: &mut Vec<String>) {
133 if config.drain_timeout_ms == 0 {
134 errors.push("drain_timeout_ms: must be greater than zero".to_owned());
135 }
136}
137
138fn validate_channels(config: &ServerConfig, errors: &mut Vec<String>) {
139 let mut seen = BTreeSet::new();
140 let mut duplicates = BTreeSet::new();
141
142 for channel in &config.channels {
143 let name = channel.name.trim();
144 if name.is_empty() {
145 errors.push("channels.name: channel name must not be empty".to_owned());
146 continue;
147 }
148
149 if !seen.insert(name.to_owned()) {
150 duplicates.insert(name.to_owned());
151 }
152 }
153
154 if !duplicates.is_empty() {
155 let names = duplicates.into_iter().collect::<Vec<_>>().join(", ");
156 errors.push(format!("channels.name: duplicate channel names: {names}"));
157 }
158}
159
160fn validate_routing_rules(config: &ServerConfig, errors: &mut Vec<String>) {
161 let channel_names = config
162 .channels
163 .iter()
164 .map(|channel| channel.name.as_str())
165 .collect::<BTreeSet<_>>();
166
167 for (index, rule) in config.routing_rules.iter().enumerate() {
168 let source = rule.source_channel.trim();
169 if source.is_empty() {
170 errors.push(format!(
171 "routing_rules[{index}].source_channel: source channel must not be empty"
172 ));
173 } else if !channel_names.contains(source) {
174 errors.push(format!(
175 "routing_rules[{index}].source_channel: unknown channel '{source}'"
176 ));
177 }
178
179 let target = rule.target_channel.trim();
180 if target.is_empty() {
181 errors.push(format!(
182 "routing_rules[{index}].target_channel: target channel must not be empty"
183 ));
184 } else if !channel_names.contains(target) {
185 errors.push(format!(
186 "routing_rules[{index}].target_channel: unknown channel '{target}'"
187 ));
188 }
189 }
190}
191
192fn validate_persistence_path(config: &ServerConfig, errors: &mut Vec<String>) {
193 let Some(path) = config.persistence_path.as_deref() else {
194 return;
195 };
196
197 match std::fs::metadata(path) {
198 Ok(metadata) => {
199 if !metadata.is_dir() {
200 errors.push(format!(
201 "persistence_path '{}': path must be an existing directory",
202 path.display()
203 ));
204 } else if metadata.permissions().readonly() {
205 errors.push(format!(
206 "persistence_path '{}': path is not writable",
207 path.display()
208 ));
209 }
210 }
211 Err(error) => {
212 errors.push(format!(
213 "persistence_path '{}': path is unreachable: {error}",
214 path.display()
215 ));
216 }
217 }
218}
219
220fn validate_cluster(config: &ServerConfig, errors: &mut Vec<String>) {
221 let Some(cluster) = config.cluster.as_ref() else {
222 return;
223 };
224
225 if cluster.node_name.trim().is_empty() {
226 errors.push("cluster.node_name: node name must not be empty".to_owned());
227 }
228
229 if cluster.cookie.is_empty() {
230 errors.push("cluster.cookie: distribution cookie must not be empty".to_owned());
231 }
232
233 if cluster.listen_address.port() == 0 {
234 errors.push("cluster.listen_address: distribution port must be non-zero".to_owned());
235 }
236
237 if cluster.listen_address == config.listen_address {
238 errors.push(
239 "cluster.listen_address: distribution port must differ from the client listen_address"
240 .to_owned(),
241 );
242 }
243
244 let mut seed_node_counts = BTreeMap::new();
245 for (index, seed_node) in cluster.seed_nodes.iter().enumerate() {
246 if seed_node.port() == 0 {
247 errors.push(format!(
248 "cluster.seed_nodes[{index}]: seed node port must be non-zero"
249 ));
250 }
251 seed_node_counts
252 .entry(seed_node.to_string())
253 .and_modify(|count| *count += 1)
254 .or_insert(1_usize);
255 }
256
257 let duplicates = seed_node_counts
258 .into_iter()
259 .filter_map(|(seed_node, count)| (count > 1).then_some(seed_node))
260 .collect::<Vec<_>>();
261
262 if !duplicates.is_empty() {
263 errors.push(format!(
264 "cluster.seed_nodes: duplicate seed nodes: {}",
265 duplicates.join(", ")
266 ));
267 }
268}
269
270fn validate_auth(config: &ServerConfig, errors: &mut Vec<String>) {
276 let Some(auth) = config.auth.as_ref() else {
277 return;
278 };
279
280 if auth.token.is_empty() {
281 errors.push("auth.token: authentication token must not be empty".to_owned());
282 }
283}
284
285fn validate_services(config: &ServerConfig, errors: &mut Vec<String>) {
291 let profile = match config.services.profile() {
292 Ok(profile) => profile,
293 Err(error) => {
294 match error {
298 crate::ServerError::ConfigValidation { message } => errors.push(message),
299 other => errors.push(other.to_string()),
300 }
301 return;
302 }
303 };
304
305 if profile == ServiceProfile::WorkerFrontDoor {
306 errors.extend(worker_front_door_field_errors(config));
307 }
308}
309
310pub(crate) fn worker_front_door_field_errors(config: &ServerConfig) -> Vec<String> {
323 let mut errors = Vec::new();
324 if !config.channels.is_empty() {
325 errors.push(
326 "services.profile: \"worker-front-door\" builds no channels; remove the \
327 [[channels]] entries or use profile = \"full\""
328 .to_owned(),
329 );
330 }
331 if !config.routing_rules.is_empty() {
332 errors.push(
333 "services.profile: \"worker-front-door\" builds no channels to route between; \
334 remove the [[routing_rules]] entries or use profile = \"full\""
335 .to_owned(),
336 );
337 }
338 if config.persistence_path.is_some() {
339 errors.push(
340 "services.profile: \"worker-front-door\" builds no durable store; remove \
341 persistence_path or use profile = \"full\""
342 .to_owned(),
343 );
344 }
345 if config.cluster.is_some() {
346 errors.push(
347 "services.profile: \"worker-front-door\" builds no channel cluster; remove the \
348 [cluster] section or use profile = \"full\""
349 .to_owned(),
350 );
351 }
352 errors
353}
354
355#[cfg(test)]
356mod tests {
357 use std::fs;
358 use std::net::SocketAddr;
359 use std::path::PathBuf;
360 use std::sync::atomic::{AtomicU64, Ordering};
361
362 use crate::ServerError;
363
364 use super::validate;
365 use crate::config::types::{
366 AuthConfig, ChannelDef, ClusterConfig, LimitsConfig, RoutingRuleDef, ServerConfig,
367 ServicesConfig,
368 };
369
370 static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
371
372 fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
373 Ok(address.parse()?)
374 }
375
376 fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
377 Ok(ServerConfig {
378 listen_address: socket("127.0.0.1:8080")?,
379 health_listen_address: socket("127.0.0.1:8081")?,
380 drain_timeout_ms: 30_000,
381 channels: vec![ChannelDef {
382 name: "orders".to_owned(),
383 schema_ref: None,
384 durable: true,
385 loaded_schema: None,
386 }],
387 routing_rules: vec![RoutingRuleDef {
388 source_channel: "orders".to_owned(),
389 target_channel: "orders".to_owned(),
390 predicate: None,
391 }],
392 persistence_path: None,
393 cluster: Some(ClusterConfig {
394 node_name: "node-a".to_owned(),
395 listen_address: socket("127.0.0.1:9000")?,
396 seed_nodes: vec![socket("127.0.0.1:9001")?],
397 cookie: "test-cookie".to_owned(),
398 }),
399 auth: None,
400 services: ServicesConfig::default(),
401 limits: LimitsConfig::default(),
402 })
403 }
404
405 fn worker_front_door_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
408 Ok(ServerConfig {
409 channels: Vec::new(),
410 routing_rules: Vec::new(),
411 persistence_path: None,
412 cluster: None,
413 services: ServicesConfig {
414 profile: "worker-front-door".to_owned(),
415 },
416 ..sample_config()?
417 })
418 }
419
420 fn unique_temp_dir(label: &str) -> PathBuf {
421 let id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
422 std::env::temp_dir().join(format!(
423 "liminal-server-validation-{label}-{}-{id}",
424 std::process::id()
425 ))
426 }
427
428 fn config_validation_message(result: Result<(), ServerError>) -> String {
429 let Err(ServerError::ConfigValidation { message }) = result else {
430 return String::new();
431 };
432 message
433 }
434
435 #[test]
436 fn valid_config_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
437 let mut config = sample_config()?;
438
439 validate(&mut config, None)?;
440
441 Ok(())
442 }
443
444 #[test]
445 fn invalid_listen_address_reports_field_name() -> Result<(), Box<dyn std::error::Error>> {
446 let mut config = sample_config()?;
447 config.listen_address = socket("127.0.0.1:0")?;
448
449 let message = config_validation_message(validate(&mut config, None));
450
451 assert!(message.contains("listen_address"));
452 assert!(message.contains("port"));
453
454 Ok(())
455 }
456
457 #[test]
458 fn invalid_health_listen_address_reports_field_name() -> Result<(), Box<dyn std::error::Error>>
459 {
460 let mut config = sample_config()?;
461 config.health_listen_address = socket("127.0.0.1:0")?;
462
463 let message = config_validation_message(validate(&mut config, None));
464
465 assert!(message.contains("health_listen_address"));
466 assert!(message.contains("port"));
467
468 Ok(())
469 }
470
471 #[test]
472 fn matching_health_and_main_listen_addresses_are_rejected()
473 -> Result<(), Box<dyn std::error::Error>> {
474 let mut config = sample_config()?;
475 config.health_listen_address = config.listen_address;
476
477 let message = config_validation_message(validate(&mut config, None));
478
479 assert!(message.contains("health_listen_address"));
480 assert!(message.contains("listen_address"));
481
482 Ok(())
483 }
484
485 #[test]
486 fn matching_health_and_main_listen_ports_are_rejected() -> Result<(), Box<dyn std::error::Error>>
487 {
488 let mut config = sample_config()?;
489 config.health_listen_address = socket("0.0.0.0:8080")?;
490
491 let message = config_validation_message(validate(&mut config, None));
492
493 assert!(message.contains("health_listen_address"));
494 assert!(message.contains("port"));
495
496 Ok(())
497 }
498
499 #[test]
500 fn zero_drain_timeout_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
501 let mut config = sample_config()?;
502 config.drain_timeout_ms = 0;
503
504 let message = config_validation_message(validate(&mut config, None));
505
506 assert!(message.contains("drain_timeout_ms"));
507 assert!(message.contains("greater than zero"));
508
509 Ok(())
510 }
511
512 #[test]
513 fn duplicate_channel_names_are_listed() -> Result<(), Box<dyn std::error::Error>> {
514 let mut config = sample_config()?;
515 config.channels.push(ChannelDef {
516 name: "orders".to_owned(),
517 schema_ref: None,
518 durable: false,
519 loaded_schema: None,
520 });
521
522 let message = config_validation_message(validate(&mut config, None));
523
524 assert!(message.contains("duplicate"));
525 assert!(message.contains("orders"));
526
527 Ok(())
528 }
529
530 #[test]
531 fn unreachable_persistence_path_reports_path() -> Result<(), Box<dyn std::error::Error>> {
532 let mut config = sample_config()?;
533 let path = unique_temp_dir("missing");
534 config.persistence_path = Some(path.clone());
535
536 let message = config_validation_message(validate(&mut config, None));
537
538 assert!(message.contains("persistence_path"));
539 assert!(message.contains(&path.display().to_string()));
540
541 Ok(())
542 }
543
544 #[test]
545 fn file_persistence_path_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
546 let mut config = sample_config()?;
547 let path = unique_temp_dir("file");
548 fs::write(&path, "not a directory")?;
549 config.persistence_path = Some(path.clone());
550
551 let message = config_validation_message(validate(&mut config, None));
552 fs::remove_file(&path)?;
553
554 assert!(message.contains("persistence_path"));
555 assert!(message.contains("directory"));
556
557 Ok(())
558 }
559
560 #[test]
561 fn multiple_validation_errors_are_reported_together() -> Result<(), Box<dyn std::error::Error>>
562 {
563 let mut config = sample_config()?;
564 let missing_path = unique_temp_dir("multi-missing");
565 config.listen_address = socket("127.0.0.1:0")?;
566 config.channels.push(ChannelDef {
567 name: "orders".to_owned(),
568 schema_ref: None,
569 durable: false,
570 loaded_schema: None,
571 });
572 config.persistence_path = Some(missing_path.clone());
573
574 let message = config_validation_message(validate(&mut config, None));
575
576 assert!(message.contains("listen_address"));
577 assert!(message.contains("duplicate channel names: orders"));
578 assert!(message.contains(&missing_path.display().to_string()));
579
580 Ok(())
581 }
582
583 #[test]
584 fn routing_rules_reference_configured_channels() -> Result<(), Box<dyn std::error::Error>> {
585 let mut config = sample_config()?;
586 config.routing_rules[0].target_channel = "unknown".to_owned();
587
588 let message = config_validation_message(validate(&mut config, None));
589
590 assert!(message.contains("routing_rules[0].target_channel"));
591 assert!(message.contains("unknown"));
592
593 Ok(())
594 }
595
596 fn write_temp_schema(
598 label: &str,
599 contents: &str,
600 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
601 let path = unique_temp_dir(label).with_extension("json");
602 fs::write(&path, contents)?;
603 Ok(path)
604 }
605
606 #[test]
607 fn absolute_schema_ref_is_loaded_and_parsed() -> Result<(), Box<dyn std::error::Error>> {
608 let schema = r#"{"type":"object","properties":{"id":{"type":"integer"}}}"#;
609 let schema_path = write_temp_schema("load-ok", schema)?;
610 let mut config = sample_config()?;
611 config.channels[0].schema_ref = Some(schema_path.clone());
612
613 let result = validate(&mut config, None);
614 fs::remove_file(&schema_path)?;
615 result?;
616
617 let loaded = config.channels[0]
618 .loaded_schema
619 .as_ref()
620 .ok_or("schema should have been loaded onto the channel")?;
621 assert_eq!(loaded.bytes, schema.as_bytes());
622 assert_eq!(
623 loaded.document.get("type").and_then(|t| t.as_str()),
624 Some("object")
625 );
626
627 Ok(())
628 }
629
630 #[test]
631 fn relative_schema_ref_resolves_against_base_dir() -> Result<(), Box<dyn std::error::Error>> {
632 let dir = unique_temp_dir("relative-base");
633 fs::create_dir_all(&dir)?;
634 let schema = r#"{"type":"object"}"#;
635 fs::write(dir.join("orders.json"), schema)?;
636
637 let mut config = sample_config()?;
638 config.channels[0].schema_ref = Some(PathBuf::from("orders.json"));
639
640 let result = validate(&mut config, Some(&dir));
641 fs::remove_dir_all(&dir)?;
642 result?;
643
644 assert!(config.channels[0].loaded_schema.is_some());
645
646 Ok(())
647 }
648
649 #[test]
650 fn missing_schema_ref_file_reports_validation_error() -> Result<(), Box<dyn std::error::Error>>
651 {
652 let missing = unique_temp_dir("missing-schema").with_extension("json");
653 let mut config = sample_config()?;
654 config.channels[0].schema_ref = Some(missing.clone());
655
656 let message = config_validation_message(validate(&mut config, None));
657
658 assert!(message.contains("schema_ref"));
659 assert!(message.contains(&missing.display().to_string()));
660 assert!(message.contains("unreadable"));
661
662 Ok(())
663 }
664
665 #[test]
666 fn invalid_json_schema_ref_reports_validation_error() -> Result<(), Box<dyn std::error::Error>>
667 {
668 let schema_path = write_temp_schema("bad-json", "{ this is not json")?;
669 let mut config = sample_config()?;
670 config.channels[0].schema_ref = Some(schema_path.clone());
671
672 let message = config_validation_message(validate(&mut config, None));
673 fs::remove_file(&schema_path)?;
674
675 assert!(message.contains("schema_ref"));
676 assert!(message.contains("not valid JSON"));
677
678 Ok(())
679 }
680
681 #[test]
682 fn valid_json_invalid_schema_ref_reports_validation_error()
683 -> Result<(), Box<dyn std::error::Error>> {
684 let schema_path = write_temp_schema("bad-schema", "[]")?;
687 let mut config = sample_config()?;
688 config.channels[0].schema_ref = Some(schema_path.clone());
689
690 let message = config_validation_message(validate(&mut config, None));
691 fs::remove_file(&schema_path)?;
692
693 assert!(message.contains("schema_ref"));
694 assert!(message.contains("not a valid JSON Schema"));
695
696 Ok(())
697 }
698
699 #[test]
700 fn present_non_empty_auth_token_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
701 let mut config = sample_config()?;
702 config.auth = Some(AuthConfig {
703 token: "s3cr3t".to_owned(),
704 });
705
706 validate(&mut config, None)?;
707
708 Ok(())
709 }
710
711 #[test]
712 fn empty_auth_token_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
713 let mut config = sample_config()?;
714 config.auth = Some(AuthConfig {
715 token: String::new(),
716 });
717
718 let message = config_validation_message(validate(&mut config, None));
719
720 assert!(message.contains("auth.token"));
721 assert!(message.contains("must not be empty"));
722
723 Ok(())
724 }
725
726 #[test]
727 fn absent_auth_section_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
728 let mut config = sample_config()?;
729 config.auth = None;
730
731 validate(&mut config, None)?;
732
733 Ok(())
734 }
735
736 #[test]
737 fn default_profile_is_full_and_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
738 let mut config = sample_config()?;
739
740 assert_eq!(
742 config.services.profile()?,
743 crate::config::types::ServiceProfile::Full
744 );
745 validate(&mut config, None)?;
746
747 Ok(())
748 }
749
750 #[test]
751 fn unknown_profile_is_a_validation_error() -> Result<(), Box<dyn std::error::Error>> {
752 let mut config = sample_config()?;
753 config.services = ServicesConfig {
754 profile: "banana".to_owned(),
755 };
756
757 let message = config_validation_message(validate(&mut config, None));
758
759 assert!(message.contains("services.profile"));
760 assert!(message.contains("banana"));
761 assert!(message.contains("worker-front-door"));
762
763 Ok(())
764 }
765
766 #[test]
767 fn worker_front_door_profile_with_empty_topology_passes()
768 -> Result<(), Box<dyn std::error::Error>> {
769 let mut config = worker_front_door_config()?;
770
771 assert_eq!(
772 config.services.profile()?,
773 crate::config::types::ServiceProfile::WorkerFrontDoor
774 );
775 validate(&mut config, None)?;
776
777 Ok(())
778 }
779
780 #[test]
781 fn worker_front_door_profile_rejects_channels_persistence_and_cluster()
782 -> Result<(), Box<dyn std::error::Error>> {
783 let mut config = sample_config()?;
786 config.services = ServicesConfig {
787 profile: "worker-front-door".to_owned(),
788 };
789 config.persistence_path = Some(PathBuf::from("/tmp"));
790
791 let message = config_validation_message(validate(&mut config, None));
792
793 assert!(message.contains("builds no channels"));
794 assert!(message.contains("builds no durable store"));
795 assert!(message.contains("builds no channel cluster"));
796
797 Ok(())
798 }
799
800 #[test]
801 fn default_limits_pass_validation_and_carry_signed_numbers()
802 -> Result<(), Box<dyn std::error::Error>> {
803 let mut config = sample_config()?;
804 assert_eq!(config.limits.max_connections, 256);
806 assert_eq!(config.limits.max_subscriptions_per_connection, 32);
807 assert_eq!(config.limits.max_conversations_per_connection, 32);
808 assert_eq!(config.limits.max_pending_pushes_per_connection, 32);
809 assert_eq!(
810 config
811 .limits
812 .max_pending_conversation_replies_per_connection,
813 32
814 );
815 assert_eq!(config.limits.max_pending_replies_per_conversation, 8);
816 assert_eq!(config.limits.max_connection_inbox_bytes, 4 * 1024 * 1024);
817 assert_eq!(config.limits.max_subscription_inbox_depth, 256);
818 validate(&mut config, None)?;
819 Ok(())
820 }
821
822 #[test]
825 fn zero_limits_are_typed_config_errors() -> Result<(), Box<dyn std::error::Error>> {
826 type LimitMutator = (&'static str, fn(&mut ServerConfig));
827 let mutators: [LimitMutator; 8] = [
828 ("max_connections", |c| c.limits.max_connections = 0),
829 ("max_subscriptions_per_connection", |c| {
830 c.limits.max_subscriptions_per_connection = 0;
831 }),
832 ("max_conversations_per_connection", |c| {
833 c.limits.max_conversations_per_connection = 0;
834 }),
835 ("max_pending_pushes_per_connection", |c| {
836 c.limits.max_pending_pushes_per_connection = 0;
837 }),
838 ("max_pending_conversation_replies_per_connection", |c| {
839 c.limits.max_pending_conversation_replies_per_connection = 0;
840 }),
841 ("max_pending_replies_per_conversation", |c| {
842 c.limits.max_pending_replies_per_conversation = 0;
843 }),
844 ("max_connection_inbox_bytes", |c| {
845 c.limits.max_connection_inbox_bytes = 0;
846 }),
847 ("max_subscription_inbox_depth", |c| {
848 c.limits.max_subscription_inbox_depth = 0;
849 }),
850 ];
851 for (field, mutate) in mutators {
852 let mut config = sample_config()?;
853 mutate(&mut config);
854 let message = config_validation_message(validate(&mut config, None));
855 assert!(
856 message.contains(&format!("limits.{field}")),
857 "zero {field} must report a typed limits.{field} error, got: {message}"
858 );
859 assert!(
860 message.contains("greater than zero"),
861 "the {field} refusal must say why: {message}"
862 );
863 }
864 Ok(())
865 }
866}