Skip to main content

liminal_server/config/
validation.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::ServerError;
5
6use super::types::{LoadedSchema, ServerConfig, ServiceProfile};
7
8/// Validates a fully loaded server configuration before startup.
9///
10/// Validation is intentionally limited to deterministic semantic checks and
11/// filesystem inspection. It does not bind sockets, connect to peers, or perform
12/// any other network I/O. Beyond the semantic checks it also resolves and loads
13/// each channel's `schema_ref` from disk (relative to `base_dir`, or verbatim for
14/// absolute paths), parses the JSON Schema document, and stores it on the channel
15/// so the channel can later be built with a real schema. A missing, unreadable, or
16/// non-JSON schema file is an accumulated validation error like any other.
17///
18/// `base_dir` is the directory the config file was loaded from; relative
19/// `schema_ref` paths resolve against it. When it is `None` (e.g. a config
20/// assembled in memory), relative paths resolve against the process working
21/// directory, so callers that construct a config directly should use absolute
22/// `schema_ref` paths.
23///
24/// # Errors
25///
26/// Returns [`ServerError::ConfigValidation`] containing all discovered validation
27/// errors when the configuration is not safe to use for startup.
28pub 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
52/// Resolves, reads, and parses each channel's `schema_ref`, storing the loaded
53/// document on the channel. Follows the same deterministic-local-FS discipline as
54/// [`validate_persistence_path`]: every failure is accumulated rather than
55/// short-circuiting, so an operator sees all schema problems at once.
56fn 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
76/// Resolves a `schema_ref` to a concrete path: absolute refs are used verbatim,
77/// relative refs are joined onto `base_dir` (or the working directory when there
78/// is no base directory).
79fn resolve_schema_path(schema_ref: &Path, base_dir: Option<&Path>) -> PathBuf {
80    // `Path::join` returns `schema_ref` unchanged when it is absolute, so the
81    // base-dir arm covers both the absolute and relative cases.
82    base_dir.map_or_else(|| schema_ref.to_path_buf(), |dir| dir.join(schema_ref))
83}
84
85/// Reads, JSON-parses, and schema-compiles a schema file, returning the loaded
86/// document or a human-readable reason on failure (missing/unreadable file,
87/// invalid JSON, or valid JSON that is not a compilable JSON Schema). The
88/// compile check runs here so every schema problem surfaces in the accumulated
89/// validation pass instead of deferring to a different error class at channel
90/// construction.
91fn 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
270/// Validates the optional `[auth]` section. When present its token must be
271/// non-empty: an empty token would gate nothing (every client's empty `auth_token`
272/// would match), so it is rejected rather than silently leaving the server open.
273/// The token is not trimmed — a shared secret may legitimately contain leading or
274/// trailing whitespace.
275fn 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
285/// Validates the `[services]` profile selection.
286///
287/// An unrecognised `profile` value is a typed config validation error. When the
288/// profile is `worker-front-door`, config that asks for machinery the profile does
289/// not build is rejected via [`worker_front_door_field_errors`].
290fn validate_services(config: &ServerConfig, errors: &mut Vec<String>) {
291    let profile = match config.services.profile() {
292        Ok(profile) => profile,
293        Err(error) => {
294            // `profile()` only ever yields a `ConfigValidation` carrying the bare
295            // field message; surface it directly so it reads like every other
296            // accumulated error rather than the wrapped `Display` prefix.
297            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
310/// Cross-field checks for the worker-front-door profile: config that asks for
311/// machinery the profile does not build — channels, routing rules, a persistence
312/// path, or a cluster — is rejected rather than silently ignored. The front door
313/// constructs no channel, conversation, haematite, or distribution services, so
314/// honouring any of those keys is impossible and accepting them quietly would be a
315/// silent tradeoff.
316///
317/// Called from BOTH the file-loading validation pass ([`validate_services`]) and
318/// the runtime construction path
319/// ([`build_connection_services`](crate::server::connection::build_connection_services)),
320/// so a directly-constructed `ServerConfig` that skips file validation still cannot
321/// combine the worker profile with full-only machinery.
322pub(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    /// A worker-front-door config: no channels, routing, persistence, or cluster —
406    /// the shape the front-door profile requires.
407    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    /// Writes `contents` to a fresh uniquely-named temp file and returns its path.
597    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        // Valid JSON that is not a compilable JSON Schema: a schema document
685        // must be an object, so a bare array parses but fails compilation.
686        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        // The default services config resolves to the full profile.
741        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        // Start from the full sample (channels + cluster present) but flip only the
784        // profile: every full-mode-only knob must be rejected, not silently ignored.
785        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        // The signed §5 defaults resolve from an absent `[limits]` section.
805        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    /// §5 cap-refusal (config half): every zero cap is a typed config validation
823    /// error — the unlimited-by-silence state §5 outlaws — reported by field name.
824    #[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}