openzeppelin_relayer/config/config_file/
mod.rs

1//! This module provides functionality for loading and validating configuration files
2//! for a blockchain relayer application. It includes definitions for configuration
3//! structures, error handling, and validation logic to ensure that the configuration
4//! is correct and complete before use.
5//!
6//! The module supports configuration for different network types, including EVM, Solana,
7//! and Stellar, and ensures that test signers are only used with test networks.
8//!
9//! # Modules
10//! - `relayer`: Handles relayer-specific configuration.
11//! - `signer`: Manages signer-specific configuration.
12//! - `notification`: Deals with notification-specific configuration.
13//! - `network`: Handles network configuration, including network overrides and custom networks.
14//!
15//! # Errors
16//! The module defines a comprehensive set of errors to handle various issues that might
17//! arise during configuration loading and validation, such as missing fields, invalid
18//! formats, and invalid references.
19//!
20//! # Usage
21//! To use this module, load a configuration file using `load_config`, which will parse
22//! the file and validate its contents. If the configuration is valid, it can be used
23//! to initialize the application components.
24use crate::{
25    config::ConfigFileError,
26    models::{
27        relayer::{RelayerFileConfig, RelayersFileConfig},
28        signer::{SignerFileConfig, SignersFileConfig},
29        NotificationConfig, NotificationConfigs,
30    },
31};
32use serde::{Deserialize, Serialize};
33use std::{
34    collections::HashSet,
35    fs::{self},
36};
37
38mod plugin;
39pub use plugin::*;
40
41pub mod network;
42pub use network::{
43    EvmNetworkConfig, NetworkConfigCommon, NetworkFileConfig, NetworksFileConfig,
44    SolanaNetworkConfig, StellarNetworkConfig,
45};
46
47#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
48#[serde(rename_all = "lowercase")]
49pub enum ConfigFileNetworkType {
50    Evm,
51    Stellar,
52    Solana,
53}
54
55#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct Config {
57    pub relayers: Vec<RelayerFileConfig>,
58    pub signers: Vec<SignerFileConfig>,
59    pub notifications: Vec<NotificationConfig>,
60    pub networks: NetworksFileConfig,
61    pub plugins: Option<Vec<PluginFileConfig>>,
62}
63
64impl Config {
65    /// Validates the configuration by checking the validity of relayers, signers, and
66    /// notifications.
67    ///
68    /// This method ensures that all references between relayers, signers, and notifications are
69    /// valid. It also checks that test signers are only used with test networks.
70    ///
71    /// # Errors
72    /// Returns a `ConfigFileError` if any validation checks fail.
73    pub fn validate(&self) -> Result<(), ConfigFileError> {
74        self.validate_networks()?;
75        self.validate_relayers(&self.networks)?;
76        self.validate_signers()?;
77        self.validate_notifications()?;
78        self.validate_plugins()?;
79
80        self.validate_relayer_signer_refs()?;
81        self.validate_relayer_notification_refs()?;
82
83        Ok(())
84    }
85
86    /// Validates that all relayer references to signers are valid.
87    ///
88    /// This method checks that each relayer references an existing signer and that test signers
89    /// are only used with test networks.
90    ///
91    /// # Errors
92    /// Returns a `ConfigFileError::InvalidReference` if a relayer references a non-existent signer.
93    /// Returns a `ConfigFileError::TestSigner` if a test signer is used on a production network.
94    fn validate_relayer_signer_refs(&self) -> Result<(), ConfigFileError> {
95        let signer_ids: HashSet<_> = self.signers.iter().map(|s| &s.id).collect();
96
97        for relayer in &self.relayers {
98            if !signer_ids.contains(&relayer.signer_id) {
99                return Err(ConfigFileError::InvalidReference(format!(
100                    "Relayer '{}' references non-existent signer '{}'",
101                    relayer.id, relayer.signer_id
102                )));
103            }
104        }
105
106        Ok(())
107    }
108
109    /// Validates that all relayer references to notifications are valid.
110    ///
111    /// This method checks that each relayer references an existing notification, if specified.
112    ///
113    /// # Errors
114    /// Returns a `ConfigFileError::InvalidReference` if a relayer references a non-existent
115    /// notification.
116    fn validate_relayer_notification_refs(&self) -> Result<(), ConfigFileError> {
117        let notification_ids: HashSet<_> = self.notifications.iter().map(|s| &s.id).collect();
118
119        for relayer in &self.relayers {
120            if let Some(notification_id) = &relayer.notification_id {
121                if !notification_ids.contains(notification_id) {
122                    return Err(ConfigFileError::InvalidReference(format!(
123                        "Relayer '{}' references non-existent notification '{}'",
124                        relayer.id, notification_id
125                    )));
126                }
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Validates that all relayers are valid and have unique IDs.
134    fn validate_relayers(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> {
135        RelayersFileConfig::new(self.relayers.clone()).validate(networks)
136    }
137
138    /// Validates that all signers are valid and have unique IDs.
139    fn validate_signers(&self) -> Result<(), ConfigFileError> {
140        SignersFileConfig::new(self.signers.clone()).validate()
141    }
142
143    /// Validates that all notifications are valid and have unique IDs.
144    fn validate_notifications(&self) -> Result<(), ConfigFileError> {
145        NotificationConfigs::new(self.notifications.clone()).validate()
146    }
147
148    /// Validates that all networks are valid and have unique IDs.
149    fn validate_networks(&self) -> Result<(), ConfigFileError> {
150        if self.networks.is_empty() {
151            return Ok(()); // No networks to validate
152        }
153
154        self.networks.validate()
155    }
156
157    /// Validates that all plugins are valid and have unique IDs.
158    fn validate_plugins(&self) -> Result<(), ConfigFileError> {
159        if let Some(plugins) = &self.plugins {
160            PluginsFileConfig::new(plugins.clone()).validate()
161        } else {
162            Ok(())
163        }
164    }
165}
166
167/// Loads and validates a configuration file from the specified path.
168///
169/// This function reads the configuration file, parses it as JSON, and validates its contents.
170/// If the configuration is valid, it returns a `Config` object.
171///
172/// # Arguments
173/// * `config_file_path` - A string slice that holds the path to the configuration file.
174///
175/// # Errors
176/// Returns a `ConfigFileError` if the file cannot be read, parsed, or if the configuration is
177/// invalid.
178pub fn load_config(config_file_path: &str) -> Result<Config, ConfigFileError> {
179    let config_str = fs::read_to_string(config_file_path)?;
180    let config: Config = serde_json::from_str(&config_str)?;
181    config.validate()?;
182    Ok(config)
183}
184
185#[cfg(test)]
186mod tests {
187    use crate::models::{
188        signer::{LocalSignerFileConfig, SignerFileConfig, SignerFileConfigEnum},
189        NotificationType, PlainOrEnvValue, SecretString,
190    };
191    use std::path::Path;
192
193    use super::*;
194
195    fn create_valid_config() -> Config {
196        Config {
197            relayers: vec![RelayerFileConfig {
198                id: "test-1".to_string(),
199                name: "Test Relayer".to_string(),
200                network: "test-network".to_string(),
201                paused: false,
202                network_type: ConfigFileNetworkType::Evm,
203                policies: None,
204                signer_id: "test-1".to_string(),
205                notification_id: Some("test-1".to_string()),
206                custom_rpc_urls: None,
207            }],
208            signers: vec![SignerFileConfig {
209                id: "test-1".to_string(),
210                config: SignerFileConfigEnum::Local(LocalSignerFileConfig {
211                    path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(),
212                    passphrase: PlainOrEnvValue::Plain {
213                        value: SecretString::new("test"),
214                    },
215                }),
216            }],
217            notifications: vec![NotificationConfig {
218                id: "test-1".to_string(),
219                r#type: NotificationType::Webhook,
220                url: "https://api.example.com/notifications".to_string(),
221                signing_key: None,
222            }],
223            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
224                common: NetworkConfigCommon {
225                    network: "test-network".to_string(),
226                    from: None,
227                    rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
228                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
229                    average_blocktime_ms: Some(12000),
230                    is_testnet: Some(true),
231                    tags: Some(vec!["test".to_string()]),
232                },
233                chain_id: Some(31337),
234                required_confirmations: Some(1),
235                features: None,
236                symbol: Some("ETH".to_string()),
237            })])
238            .expect("Failed to create NetworksFileConfig for test"),
239            plugins: Some(vec![PluginFileConfig {
240                id: "test-1".to_string(),
241                path: "/app/plugins/test-plugin.ts".to_string(),
242                timeout: None,
243            }]),
244        }
245    }
246
247    #[test]
248    fn test_valid_config_validation() {
249        let config = create_valid_config();
250        assert!(config.validate().is_ok());
251    }
252
253    #[test]
254    fn test_empty_relayers() {
255        let config = Config {
256            relayers: Vec::new(),
257            signers: Vec::new(),
258            notifications: Vec::new(),
259            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
260                common: NetworkConfigCommon {
261                    network: "test-network".to_string(),
262                    from: None,
263                    rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
264                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
265                    average_blocktime_ms: Some(12000),
266                    is_testnet: Some(true),
267                    tags: Some(vec!["test".to_string()]),
268                },
269                chain_id: Some(31337),
270                required_confirmations: Some(1),
271                features: None,
272                symbol: Some("ETH".to_string()),
273            })])
274            .unwrap(),
275            plugins: Some(vec![]),
276        };
277        assert!(config.validate().is_ok());
278    }
279
280    #[test]
281    fn test_empty_signers() {
282        let config = Config {
283            relayers: Vec::new(),
284            signers: Vec::new(),
285            notifications: Vec::new(),
286            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
287                common: NetworkConfigCommon {
288                    network: "test-network".to_string(),
289                    from: None,
290                    rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
291                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
292                    average_blocktime_ms: Some(12000),
293                    is_testnet: Some(true),
294                    tags: Some(vec!["test".to_string()]),
295                },
296                chain_id: Some(31337),
297                required_confirmations: Some(1),
298                features: None,
299                symbol: Some("ETH".to_string()),
300            })])
301            .unwrap(),
302            plugins: Some(vec![]),
303        };
304        assert!(config.validate().is_ok());
305    }
306
307    #[test]
308    fn test_invalid_id_format() {
309        let mut config = create_valid_config();
310        config.relayers[0].id = "invalid@id".to_string();
311        assert!(matches!(
312            config.validate(),
313            Err(ConfigFileError::InvalidIdFormat(_))
314        ));
315    }
316
317    #[test]
318    fn test_id_too_long() {
319        let mut config = create_valid_config();
320        config.relayers[0].id = "a".repeat(37);
321        assert!(matches!(
322            config.validate(),
323            Err(ConfigFileError::InvalidIdLength(_))
324        ));
325    }
326
327    #[test]
328    fn test_relayers_duplicate_ids() {
329        let mut config = create_valid_config();
330        config.relayers.push(config.relayers[0].clone());
331        assert!(matches!(
332            config.validate(),
333            Err(ConfigFileError::DuplicateId(_))
334        ));
335    }
336
337    #[test]
338    fn test_signers_duplicate_ids() {
339        let mut config = create_valid_config();
340        config.signers.push(config.signers[0].clone());
341
342        assert!(matches!(
343            config.validate(),
344            Err(ConfigFileError::DuplicateId(_))
345        ));
346    }
347
348    #[test]
349    fn test_missing_name() {
350        let mut config = create_valid_config();
351        config.relayers[0].name = "".to_string();
352        assert!(matches!(
353            config.validate(),
354            Err(ConfigFileError::MissingField(_))
355        ));
356    }
357
358    #[test]
359    fn test_missing_network() {
360        let mut config = create_valid_config();
361        config.relayers[0].network = "".to_string();
362        assert!(matches!(
363            config.validate(),
364            Err(ConfigFileError::InvalidFormat(_))
365        ));
366    }
367
368    #[test]
369    fn test_invalid_signer_id_reference() {
370        let mut config = create_valid_config();
371        config.relayers[0].signer_id = "invalid@id".to_string();
372        assert!(matches!(
373            config.validate(),
374            Err(ConfigFileError::InvalidReference(_))
375        ));
376    }
377
378    #[test]
379    fn test_invalid_notification_id_reference() {
380        let mut config = create_valid_config();
381        config.relayers[0].notification_id = Some("invalid@id".to_string());
382        assert!(matches!(
383            config.validate(),
384            Err(ConfigFileError::InvalidReference(_))
385        ));
386    }
387
388    #[test]
389    fn test_config_with_networks() {
390        let mut config = create_valid_config();
391        config.relayers[0].network = "custom-evm".to_string();
392
393        let network_items = vec![serde_json::from_value(serde_json::json!({
394            "type": "evm",
395            "network": "custom-evm",
396            "required_confirmations": 1,
397            "chain_id": 1234,
398            "rpc_urls": ["https://rpc.example.com"],
399            "symbol": "ETH"
400        }))
401        .unwrap()];
402        config.networks = NetworksFileConfig::new(network_items).unwrap();
403
404        assert!(
405            config.validate().is_ok(),
406            "Error validating config: {:?}",
407            config.validate().err()
408        );
409    }
410
411    #[test]
412    fn test_config_with_invalid_networks() {
413        let mut config = create_valid_config();
414        let network_items = vec![serde_json::from_value(serde_json::json!({
415            "type": "evm",
416            "network": "invalid-network",
417            "rpc_urls": ["https://rpc.example.com"]
418        }))
419        .unwrap()];
420        config.networks = NetworksFileConfig::new(network_items.clone())
421            .expect("Should allow creation, validation happens later or should fail here");
422
423        let result = config.validate();
424        assert!(result.is_err());
425        assert!(matches!(
426            result,
427            Err(ConfigFileError::MissingField(_)) | Err(ConfigFileError::InvalidFormat(_))
428        ));
429    }
430
431    #[test]
432    fn test_config_with_duplicate_network_names() {
433        let mut config = create_valid_config();
434        let network_items = vec![
435            serde_json::from_value(serde_json::json!({
436                "type": "evm",
437                "network": "custom-evm",
438                "chain_id": 1234,
439                "rpc_urls": ["https://rpc1.example.com"]
440            }))
441            .unwrap(),
442            serde_json::from_value(serde_json::json!({
443                "type": "evm",
444                "network": "custom-evm",
445                "chain_id": 5678,
446                "rpc_urls": ["https://rpc2.example.com"]
447            }))
448            .unwrap(),
449        ];
450        let networks_config_result = NetworksFileConfig::new(network_items);
451        assert!(
452            networks_config_result.is_err(),
453            "NetworksFileConfig::new should detect duplicate IDs"
454        );
455
456        if let Ok(parsed_networks) = networks_config_result {
457            config.networks = parsed_networks;
458            let result = config.validate();
459            assert!(result.is_err());
460            assert!(matches!(result, Err(ConfigFileError::DuplicateId(_))));
461        } else if let Err(e) = networks_config_result {
462            assert!(matches!(e, ConfigFileError::DuplicateId(_)));
463        }
464    }
465
466    #[test]
467    fn test_config_with_invalid_network_inheritance() {
468        let mut config = create_valid_config();
469        let network_items = vec![serde_json::from_value(serde_json::json!({
470            "type": "evm",
471            "network": "custom-evm",
472            "from": "non-existent-network",
473            "rpc_urls": ["https://rpc.example.com"]
474        }))
475        .unwrap()];
476        let networks_config_result = NetworksFileConfig::new(network_items);
477
478        match networks_config_result {
479            Ok(parsed_networks) => {
480                config.networks = parsed_networks;
481                let validation_result = config.validate();
482                assert!(
483                    validation_result.is_err(),
484                    "Validation should fail due to invalid inheritance reference"
485                );
486                assert!(matches!(
487                    validation_result,
488                    Err(ConfigFileError::InvalidReference(_))
489                ));
490            }
491            Err(e) => {
492                assert!(
493                    matches!(e, ConfigFileError::InvalidReference(_)),
494                    "Expected InvalidReference from new or flatten"
495                );
496            }
497        }
498    }
499
500    #[test]
501    fn test_deserialize_config_with_evm_network() {
502        let config_str = r#"
503        {
504            "relayers": [],
505            "signers": [],
506            "notifications": [],
507            "plugins": [],
508            "networks": [
509                {
510                    "type": "evm",
511                    "network": "custom-evm",
512                    "chain_id": 1234,
513                    "required_confirmations": 1,
514                    "symbol": "ETH",
515                    "rpc_urls": ["https://rpc.example.com"]
516                }
517            ]
518        }
519        "#;
520        let result: Result<Config, _> = serde_json::from_str(config_str);
521        assert!(result.is_ok());
522        let config = result.unwrap();
523        assert_eq!(config.networks.len(), 1);
524
525        let network_config = config.networks.first().expect("Should have one network");
526        assert!(matches!(network_config, NetworkFileConfig::Evm(_)));
527        if let NetworkFileConfig::Evm(evm_config) = network_config {
528            assert_eq!(evm_config.common.network, "custom-evm");
529            assert_eq!(evm_config.chain_id, Some(1234));
530        }
531    }
532
533    #[test]
534    fn test_deserialize_config_with_solana_network() {
535        let config_str = r#"
536        {
537            "relayers": [],
538            "signers": [],
539            "notifications": [],
540            "plugins": [],
541            "networks": [
542                {
543                    "type": "solana",
544                    "network": "custom-solana",
545                    "rpc_urls": ["https://rpc.solana.example.com"]
546                }
547            ]
548        }
549        "#;
550        let result: Result<Config, _> = serde_json::from_str(config_str);
551        assert!(result.is_ok());
552        let config = result.unwrap();
553        assert_eq!(config.networks.len(), 1);
554
555        let network_config = config.networks.first().expect("Should have one network");
556        assert!(matches!(network_config, NetworkFileConfig::Solana(_)));
557        if let NetworkFileConfig::Solana(sol_config) = network_config {
558            assert_eq!(sol_config.common.network, "custom-solana");
559        }
560    }
561
562    #[test]
563    fn test_deserialize_config_with_stellar_network() {
564        let config_str = r#"
565        {
566            "relayers": [],
567            "signers": [],
568            "notifications": [],
569            "plugins": [],
570            "networks": [
571                {
572                    "type": "stellar",
573                    "network": "custom-stellar",
574                    "rpc_urls": ["https://rpc.stellar.example.com"]
575                }
576            ]
577        }
578        "#;
579        let result: Result<Config, _> = serde_json::from_str(config_str);
580        assert!(result.is_ok());
581        let config = result.unwrap();
582        assert_eq!(config.networks.len(), 1);
583
584        let network_config = config.networks.first().expect("Should have one network");
585        assert!(matches!(network_config, NetworkFileConfig::Stellar(_)));
586        if let NetworkFileConfig::Stellar(stl_config) = network_config {
587            assert_eq!(stl_config.common.network, "custom-stellar");
588        }
589    }
590
591    #[test]
592    fn test_deserialize_config_with_mixed_networks() {
593        let config_str = r#"
594        {
595            "relayers": [],
596            "signers": [],
597            "notifications": [],
598            "plugins": [],
599            "networks": [
600                {
601                    "type": "evm",
602                    "network": "custom-evm",
603                    "chain_id": 1234,
604                    "required_confirmations": 1,
605                    "symbol": "ETH",
606                    "rpc_urls": ["https://rpc.example.com"]
607                },
608                {
609                    "type": "solana",
610                    "network": "custom-solana",
611                    "rpc_urls": ["https://rpc.solana.example.com"]
612                }
613            ]
614        }
615        "#;
616        let result: Result<Config, _> = serde_json::from_str(config_str);
617        assert!(result.is_ok());
618        let config = result.unwrap();
619        assert_eq!(config.networks.len(), 2);
620    }
621
622    #[test]
623    #[should_panic(
624        expected = "NetworksFileConfig cannot be empty - networks must contain at least one network configuration"
625    )]
626    fn test_deserialize_config_with_empty_networks_array() {
627        let config_str = r#"
628        {
629            "relayers": [],
630            "signers": [],
631            "notifications": [],
632            "networks": []
633        }
634        "#;
635        let _result: Config = serde_json::from_str(config_str).unwrap();
636    }
637
638    #[test]
639    fn test_deserialize_config_without_networks_field() {
640        let config_str = r#"
641        {
642            "relayers": [],
643            "signers": [],
644            "notifications": []
645        }
646        "#;
647        let result: Result<Config, _> = serde_json::from_str(config_str);
648        assert!(result.is_ok());
649    }
650
651    use std::fs::File;
652    use std::io::Write;
653    use tempfile::tempdir;
654
655    fn setup_network_file(dir_path: &Path, file_name: &str, content: &str) {
656        let file_path = dir_path.join(file_name);
657        let mut file = File::create(&file_path).expect("Failed to create temp network file");
658        writeln!(file, "{}", content).expect("Failed to write to temp network file");
659    }
660
661    #[test]
662    fn test_deserialize_config_with_networks_from_directory() {
663        let dir = tempdir().expect("Failed to create temp dir");
664        let network_dir_path = dir.path();
665
666        setup_network_file(
667            network_dir_path,
668            "evm_net.json",
669            r#"{"networks": [{"type": "evm", "network": "custom-evm-file", "required_confirmations": 1, "symbol": "ETH", "chain_id": 5678, "rpc_urls": ["https://rpc.file-evm.com"]}]}"#,
670        );
671        setup_network_file(
672            network_dir_path,
673            "sol_net.json",
674            r#"{"networks": [{"type": "solana", "network": "custom-solana-file", "rpc_urls": ["https://rpc.file-solana.com"]}]}"#,
675        );
676
677        let config_json = serde_json::json!({
678            "relayers": [],
679            "signers": [],
680            "notifications": [],
681            "plugins": [],
682            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
683        });
684        let config_str =
685            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
686
687        let result: Result<Config, _> = serde_json::from_str(&config_str);
688        assert!(result.is_ok(), "Deserialization failed: {:?}", result.err());
689
690        if let Ok(config) = result {
691            assert_eq!(
692                config.networks.len(),
693                2,
694                "Incorrect number of networks loaded"
695            );
696            let has_evm = config.networks.iter().any(|n| matches!(n, NetworkFileConfig::Evm(evm) if evm.common.network == "custom-evm-file"));
697            let has_solana = config.networks.iter().any(|n| matches!(n, NetworkFileConfig::Solana(sol) if sol.common.network == "custom-solana-file"));
698            assert!(has_evm, "EVM network from file not found or incorrect");
699            assert!(
700                has_solana,
701                "Solana network from file not found or incorrect"
702            );
703        }
704    }
705
706    #[test]
707    fn test_deserialize_config_with_empty_networks_directory() {
708        let dir = tempdir().expect("Failed to create temp dir");
709        let network_dir_path = dir.path();
710
711        let config_json = serde_json::json!({
712            "relayers": [],
713            "signers": [],
714            "notifications": [],
715            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
716        });
717        let config_str =
718            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
719
720        let result: Result<Config, _> = serde_json::from_str(&config_str);
721        assert!(
722            result.is_err(),
723            "Deserialization should fail for empty directory"
724        );
725    }
726
727    #[test]
728    fn test_deserialize_config_with_non_existent_networks_directory() {
729        let dir = tempdir().expect("Failed to create temp dir");
730        let non_existent_path = dir.path().join("non_existent_sub_dir");
731
732        let config_json = serde_json::json!({
733            "relayers": [],
734            "signers": [],
735            "notifications": [],
736            "networks": non_existent_path.to_str().expect("Path should be valid UTF-8")
737        });
738        let config_str =
739            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
740
741        let result: Result<Config, _> = serde_json::from_str(&config_str);
742        assert!(
743            result.is_err(),
744            "Deserialization should fail for non-existent directory"
745        );
746    }
747
748    #[test]
749    fn test_deserialize_config_with_networks_path_as_file() {
750        let dir = tempdir().expect("Failed to create temp dir");
751        let network_file_path = dir.path().join("im_a_file.json");
752        File::create(&network_file_path).expect("Failed to create temp file");
753
754        let config_json = serde_json::json!({
755            "relayers": [],
756            "signers": [],
757            "notifications": [],
758            "networks": network_file_path.to_str().expect("Path should be valid UTF-8")
759        });
760        let config_str =
761            serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
762
763        let result: Result<Config, _> = serde_json::from_str(&config_str);
764        assert!(
765            result.is_err(),
766            "Deserialization should fail if path is a file, not a directory"
767        );
768    }
769
770    #[test]
771    fn test_deserialize_config_network_dir_with_invalid_json_file() {
772        let dir = tempdir().expect("Failed to create temp dir");
773        let network_dir_path = dir.path();
774        setup_network_file(
775            network_dir_path,
776            "invalid.json",
777            r#"{"networks": [{"type": "evm", "network": "broken""#,
778        ); // Malformed JSON
779
780        let config_json = serde_json::json!({
781            "relayers": [], "signers": [], "notifications": [],
782            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
783        });
784        let config_str = serde_json::to_string(&config_json).expect("Failed to serialize");
785
786        let result: Result<Config, _> = serde_json::from_str(&config_str);
787        assert!(
788            result.is_err(),
789            "Deserialization should fail with invalid JSON in network file"
790        );
791    }
792
793    #[test]
794    fn test_deserialize_config_network_dir_with_non_network_config_json_file() {
795        let dir = tempdir().expect("Failed to create temp dir");
796        let network_dir_path = dir.path();
797        setup_network_file(network_dir_path, "not_a_network.json", r#"{"foo": "bar"}"#); // Valid JSON, but not NetworkFileConfig
798
799        let config_json = serde_json::json!({
800            "relayers": [], "signers": [], "notifications": [],
801            "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
802        });
803        let config_str = serde_json::to_string(&config_json).expect("Failed to serialize");
804
805        let result: Result<Config, _> = serde_json::from_str(&config_str);
806        assert!(
807            result.is_err(),
808            "Deserialization should fail if file is not a valid NetworkFileConfig"
809        );
810    }
811
812    #[test]
813    fn test_deserialize_config_still_works_with_array_of_networks() {
814        let config_str = r#"
815        {
816            "relayers": [],
817            "signers": [],
818            "notifications": [],
819            "plugins": [],
820            "networks": [
821                {
822                    "type": "evm",
823                    "network": "custom-evm-array",
824                    "chain_id": 1234,
825                    "required_confirmations": 1,
826                    "symbol": "ETH",
827                    "rpc_urls": ["https://rpc.example.com"]
828                }
829            ]
830        }
831        "#;
832        let result: Result<Config, _> = serde_json::from_str(config_str);
833        assert!(
834            result.is_ok(),
835            "Deserialization with array failed: {:?}",
836            result.err()
837        );
838        if let Ok(config) = result {
839            assert_eq!(config.networks.len(), 1);
840
841            let network_config = config.networks.first().expect("Should have one network");
842            assert!(matches!(network_config, NetworkFileConfig::Evm(_)));
843            if let NetworkFileConfig::Evm(evm_config) = network_config {
844                assert_eq!(evm_config.common.network, "custom-evm-array");
845            }
846        }
847    }
848
849    #[test]
850    fn test_create_valid_networks_file_config_works() {
851        let networks = vec![NetworkFileConfig::Evm(EvmNetworkConfig {
852            common: NetworkConfigCommon {
853                network: "test-network".to_string(),
854                from: None,
855                rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
856                explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
857                average_blocktime_ms: Some(12000),
858                is_testnet: Some(true),
859                tags: Some(vec!["test".to_string()]),
860            },
861            chain_id: Some(31337),
862            required_confirmations: Some(1),
863            features: None,
864            symbol: Some("ETH".to_string()),
865        })];
866
867        let config = NetworksFileConfig::new(networks).unwrap();
868        assert_eq!(config.len(), 1);
869        assert_eq!(config.first().unwrap().network_name(), "test-network");
870    }
871
872    fn setup_config_file(dir_path: &Path, file_name: &str, content: &str) {
873        let file_path = dir_path.join(file_name);
874        let mut file = File::create(&file_path).expect("Failed to create temp config file");
875        write!(file, "{}", content).expect("Failed to write to temp config file");
876    }
877
878    #[test]
879    fn test_load_config_success() {
880        let dir = tempdir().expect("Failed to create temp dir");
881        let config_path = dir.path().join("valid_config.json");
882
883        let config_content = serde_json::json!({
884            "relayers": [{
885                "id": "test-relayer",
886                "name": "Test Relayer",
887                "network": "test-network",
888                "paused": false,
889                "network_type": "evm",
890                "signer_id": "test-signer"
891            }],
892            "signers": [{
893                "id": "test-signer",
894                "type": "local",
895                "config": {
896                    "path": "tests/utils/test_keys/unit-test-local-signer.json",
897                    "passphrase": {
898                        "value": "test",
899                        "type": "plain"
900                    }
901                }
902            }],
903            "notifications": [{
904                "id": "test-notification",
905                "type": "webhook",
906                "url": "https://api.example.com/notifications"
907            }],
908            "networks": [{
909                "type": "evm",
910                "network": "test-network",
911                "chain_id": 31337,
912                "required_confirmations": 1,
913                "symbol": "ETH",
914                "rpc_urls": ["https://rpc.test.example.com"],
915                "is_testnet": true
916            }],
917            "plugins": [{
918                "id": "plugin-id",
919                "path": "/app/plugins/plugin.ts",
920                "timeout": 12
921            }],
922        });
923
924        setup_config_file(dir.path(), "valid_config.json", &config_content.to_string());
925
926        let result = load_config(config_path.to_str().unwrap());
927        assert!(result.is_ok());
928
929        let config = result.unwrap();
930        assert_eq!(config.relayers.len(), 1);
931        assert_eq!(config.signers.len(), 1);
932        assert_eq!(config.networks.len(), 1);
933        assert_eq!(config.plugins.unwrap().len(), 1);
934    }
935
936    #[test]
937    fn test_load_config_file_not_found() {
938        let result = load_config("non_existent_file.json");
939        assert!(result.is_err());
940        assert!(matches!(result.unwrap_err(), ConfigFileError::IoError(_)));
941    }
942
943    #[test]
944    fn test_load_config_invalid_json() {
945        let dir = tempdir().expect("Failed to create temp dir");
946        let config_path = dir.path().join("invalid.json");
947
948        setup_config_file(dir.path(), "invalid.json", "{ invalid json }");
949
950        let result = load_config(config_path.to_str().unwrap());
951        assert!(result.is_err());
952        assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
953    }
954
955    #[test]
956    fn test_load_config_invalid_config_structure() {
957        let dir = tempdir().expect("Failed to create temp dir");
958        let config_path = dir.path().join("invalid_structure.json");
959
960        let invalid_config = serde_json::json!({
961            "relayers": "not_an_array",
962            "signers": [],
963            "notifications": [],
964            "networks": [{
965                "type": "evm",
966                "network": "test-network",
967                "chain_id": 31337,
968                "required_confirmations": 1,
969                "symbol": "ETH",
970                "rpc_urls": ["https://rpc.test.example.com"]
971            }]
972        });
973
974        setup_config_file(
975            dir.path(),
976            "invalid_structure.json",
977            &invalid_config.to_string(),
978        );
979
980        let result = load_config(config_path.to_str().unwrap());
981        assert!(result.is_err());
982        assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
983    }
984
985    #[test]
986    fn test_load_config_with_unicode_content() {
987        let dir = tempdir().expect("Failed to create temp dir");
988        let config_path = dir.path().join("unicode_config.json");
989
990        // Use ASCII-compatible IDs since the validation might reject Unicode in IDs
991        let config_content = serde_json::json!({
992            "relayers": [{
993                "id": "test-relayer-unicode",
994                "name": "Test Relayer 测试",
995                "network": "test-network-unicode",
996                "paused": false,
997                "network_type": "evm",
998                "signer_id": "test-signer-unicode"
999            }],
1000            "signers": [{
1001                "id": "test-signer-unicode",
1002                "type": "local",
1003                "config": {
1004                    "path": "tests/utils/test_keys/unit-test-local-signer.json",
1005                    "passphrase": {
1006                        "value": "test",
1007                        "type": "plain"
1008                    }
1009                }
1010            }],
1011            "notifications": [{
1012                "id": "test-notification-unicode",
1013                "type": "webhook",
1014                "url": "https://api.example.com/notifications"
1015            }],
1016            "networks": [{
1017                "type": "evm",
1018                "network": "test-network-unicode",
1019                "chain_id": 31337,
1020                "required_confirmations": 1,
1021                "symbol": "ETH",
1022                "rpc_urls": ["https://rpc.test.example.com"],
1023                "is_testnet": true
1024            }],
1025            "plugins": []
1026        });
1027
1028        setup_config_file(
1029            dir.path(),
1030            "unicode_config.json",
1031            &config_content.to_string(),
1032        );
1033
1034        let result = load_config(config_path.to_str().unwrap());
1035        assert!(result.is_ok());
1036
1037        let config = result.unwrap();
1038        assert_eq!(config.relayers[0].id, "test-relayer-unicode");
1039        assert_eq!(config.signers[0].id, "test-signer-unicode");
1040    }
1041
1042    #[test]
1043    fn test_load_config_with_empty_file() {
1044        let dir = tempdir().expect("Failed to create temp dir");
1045        let config_path = dir.path().join("empty.json");
1046
1047        setup_config_file(dir.path(), "empty.json", "");
1048
1049        let result = load_config(config_path.to_str().unwrap());
1050        assert!(result.is_err());
1051        assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
1052    }
1053
1054    #[test]
1055    fn test_config_serialization_works() {
1056        let config = create_valid_config();
1057
1058        let serialized = serde_json::to_string(&config);
1059        assert!(serialized.is_ok());
1060
1061        // Just test that serialization works, not round-trip due to complex serde structure
1062        let serialized_str = serialized.unwrap();
1063        assert!(!serialized_str.is_empty());
1064        assert!(serialized_str.contains("relayers"));
1065        assert!(serialized_str.contains("signers"));
1066        assert!(serialized_str.contains("networks"));
1067    }
1068
1069    #[test]
1070    fn test_config_serialization_contains_expected_fields() {
1071        let config = create_valid_config();
1072
1073        let serialized = serde_json::to_string(&config);
1074        assert!(serialized.is_ok());
1075
1076        let serialized_str = serialized.unwrap();
1077
1078        // Check that important fields are present in serialized JSON
1079        assert!(serialized_str.contains("\"id\":\"test-1\""));
1080        assert!(serialized_str.contains("\"name\":\"Test Relayer\""));
1081        assert!(serialized_str.contains("\"network\":\"test-network\""));
1082        assert!(serialized_str.contains("\"type\":\"evm\""));
1083    }
1084
1085    #[test]
1086    fn test_validate_relayers_method() {
1087        let config = create_valid_config();
1088        let result = config.validate_relayers(&config.networks);
1089        assert!(result.is_ok());
1090    }
1091
1092    #[test]
1093    fn test_validate_signers_method() {
1094        let config = create_valid_config();
1095        let result = config.validate_signers();
1096        assert!(result.is_ok());
1097    }
1098
1099    #[test]
1100    fn test_validate_notifications_method() {
1101        let config = create_valid_config();
1102        let result = config.validate_notifications();
1103        assert!(result.is_ok());
1104    }
1105
1106    #[test]
1107    fn test_validate_networks_method() {
1108        let config = create_valid_config();
1109        let result = config.validate_networks();
1110        assert!(result.is_ok());
1111    }
1112
1113    #[test]
1114    fn test_validate_plugins_method() {
1115        let config = create_valid_config();
1116        let result = config.validate_plugins();
1117        assert!(result.is_ok());
1118    }
1119
1120    #[test]
1121    fn test_validate_plugins_method_with_empty_plugins() {
1122        let config = Config {
1123            relayers: vec![],
1124            signers: vec![],
1125            notifications: vec![],
1126            networks: NetworksFileConfig::new(vec![]).unwrap(),
1127            plugins: Some(vec![]),
1128        };
1129        let result = config.validate_plugins();
1130        assert!(result.is_ok());
1131    }
1132
1133    #[test]
1134    fn test_validate_plugins_method_with_invalid_plugin_extension() {
1135        let config = Config {
1136            relayers: vec![],
1137            signers: vec![],
1138            notifications: vec![],
1139            networks: NetworksFileConfig::new(vec![]).unwrap(),
1140            plugins: Some(vec![PluginFileConfig {
1141                id: "id".to_string(),
1142                path: "/app/plugins/test-plugin.js".to_string(),
1143                timeout: None,
1144            }]),
1145        };
1146        let result = config.validate_plugins();
1147        assert!(result.is_err());
1148    }
1149
1150    #[test]
1151    fn test_config_with_maximum_length_ids() {
1152        let mut config = create_valid_config();
1153        let max_length_id = "a".repeat(36); // Maximum allowed length
1154        config.relayers[0].id = max_length_id.clone();
1155        config.relayers[0].signer_id = config.signers[0].id.clone();
1156
1157        let result = config.validate();
1158        assert!(result.is_ok());
1159    }
1160
1161    #[test]
1162    fn test_config_with_special_characters_in_names() {
1163        let mut config = create_valid_config();
1164        config.relayers[0].name = "Test-Relayer_123!@#$%^&*()".to_string();
1165
1166        let result = config.validate();
1167        assert!(result.is_ok());
1168    }
1169
1170    #[test]
1171    fn test_config_with_very_long_urls() {
1172        let mut config = create_valid_config();
1173        let long_url = format!(
1174            "https://very-long-domain-name-{}.example.com/api/v1/endpoint",
1175            "x".repeat(100)
1176        );
1177        config.notifications[0].url = long_url;
1178
1179        let result = config.validate();
1180        assert!(result.is_ok());
1181    }
1182
1183    #[test]
1184    fn test_config_with_only_signers_validation() {
1185        let config = Config {
1186            relayers: vec![],
1187            signers: vec![SignerFileConfig {
1188                id: "test-signer".to_string(),
1189                config: SignerFileConfigEnum::Local(LocalSignerFileConfig {
1190                    path: "test-path".to_string(),
1191                    passphrase: PlainOrEnvValue::Plain {
1192                        value: SecretString::new("test-passphrase"),
1193                    },
1194                }),
1195            }],
1196            notifications: vec![],
1197            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1198                common: NetworkConfigCommon {
1199                    network: "test-network".to_string(),
1200                    from: None,
1201                    rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
1202                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1203                    average_blocktime_ms: Some(12000),
1204                    is_testnet: Some(true),
1205                    tags: Some(vec!["test".to_string()]),
1206                },
1207                chain_id: Some(31337),
1208                required_confirmations: Some(1),
1209                features: None,
1210                symbol: Some("ETH".to_string()),
1211            })])
1212            .unwrap(),
1213            plugins: Some(vec![]),
1214        };
1215
1216        let result = config.validate();
1217        assert!(result.is_ok());
1218    }
1219
1220    #[test]
1221    fn test_config_with_only_notifications() {
1222        let config = Config {
1223            relayers: vec![],
1224            signers: vec![],
1225            notifications: vec![NotificationConfig {
1226                id: "test-notification".to_string(),
1227                r#type: NotificationType::Webhook,
1228                url: "https://api.example.com/notifications".to_string(),
1229                signing_key: None,
1230            }],
1231            networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1232                common: NetworkConfigCommon {
1233                    network: "test-network".to_string(),
1234                    from: None,
1235                    rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
1236                    explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1237                    average_blocktime_ms: Some(12000),
1238                    is_testnet: Some(true),
1239                    tags: Some(vec!["test".to_string()]),
1240                },
1241                chain_id: Some(31337),
1242                required_confirmations: Some(1),
1243                features: None,
1244                symbol: Some("ETH".to_string()),
1245            })])
1246            .unwrap(),
1247            plugins: Some(vec![]),
1248        };
1249
1250        let result = config.validate();
1251        assert!(result.is_ok());
1252    }
1253
1254    #[test]
1255    fn test_config_with_mixed_network_types_in_relayers() {
1256        let mut config = create_valid_config();
1257
1258        // Add Solana relayer
1259        config.relayers.push(RelayerFileConfig {
1260            id: "solana-relayer".to_string(),
1261            name: "Solana Test Relayer".to_string(),
1262            network: "devnet".to_string(),
1263            paused: false,
1264            network_type: ConfigFileNetworkType::Solana,
1265            policies: None,
1266            signer_id: "test-1".to_string(),
1267            notification_id: None,
1268            custom_rpc_urls: None,
1269        });
1270
1271        // Add Stellar relayer
1272        config.relayers.push(RelayerFileConfig {
1273            id: "stellar-relayer".to_string(),
1274            name: "Stellar Test Relayer".to_string(),
1275            network: "testnet".to_string(),
1276            paused: true,
1277            network_type: ConfigFileNetworkType::Stellar,
1278            policies: None,
1279            signer_id: "test-1".to_string(),
1280            notification_id: Some("test-1".to_string()),
1281            custom_rpc_urls: None,
1282        });
1283
1284        let devnet_network = NetworkFileConfig::Solana(SolanaNetworkConfig {
1285            common: NetworkConfigCommon {
1286                network: "devnet".to_string(),
1287                from: None,
1288                rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]),
1289                explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]),
1290                average_blocktime_ms: Some(400),
1291                is_testnet: Some(true),
1292                tags: Some(vec!["test".to_string()]),
1293            },
1294        });
1295
1296        let testnet_network = NetworkFileConfig::Stellar(StellarNetworkConfig {
1297            common: NetworkConfigCommon {
1298                network: "testnet".to_string(),
1299                from: None,
1300                rpc_urls: Some(vec!["https://soroban-testnet.stellar.org".to_string()]),
1301                explorer_urls: Some(vec!["https://stellar.expert/explorer/testnet".to_string()]),
1302                average_blocktime_ms: Some(5000),
1303                is_testnet: Some(true),
1304                tags: Some(vec!["test".to_string()]),
1305            },
1306            passphrase: Some("Test SDF Network ; September 2015".to_string()),
1307        });
1308
1309        let mut networks = config.networks.networks;
1310        networks.push(devnet_network);
1311        networks.push(testnet_network);
1312        config.networks =
1313            NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig");
1314
1315        let result = config.validate();
1316        assert!(result.is_ok());
1317    }
1318
1319    #[test]
1320    fn test_config_with_all_network_types() {
1321        let mut config = create_valid_config();
1322
1323        // Add Solana network
1324        let solana_network = NetworkFileConfig::Solana(SolanaNetworkConfig {
1325            common: NetworkConfigCommon {
1326                network: "solana-test".to_string(),
1327                from: None,
1328                rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]),
1329                explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1330                average_blocktime_ms: Some(400),
1331                is_testnet: Some(true),
1332                tags: Some(vec!["solana".to_string()]),
1333            },
1334        });
1335
1336        // Add Stellar network
1337        let stellar_network = NetworkFileConfig::Stellar(StellarNetworkConfig {
1338            common: NetworkConfigCommon {
1339                network: "stellar-test".to_string(),
1340                from: None,
1341                rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]),
1342                explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1343                average_blocktime_ms: Some(5000),
1344                is_testnet: Some(true),
1345                tags: Some(vec!["stellar".to_string()]),
1346            },
1347            passphrase: Some("Test Network ; September 2015".to_string()),
1348        });
1349
1350        // Get the existing networks and add new ones
1351        let mut existing_networks = Vec::new();
1352        for network in config.networks.iter() {
1353            existing_networks.push(network.clone());
1354        }
1355        existing_networks.push(solana_network);
1356        existing_networks.push(stellar_network);
1357
1358        config.networks = NetworksFileConfig::new(existing_networks).unwrap();
1359
1360        let result = config.validate();
1361        assert!(result.is_ok());
1362    }
1363
1364    #[test]
1365    fn test_config_error_propagation_from_relayers() {
1366        let mut config = create_valid_config();
1367        config.relayers[0].id = "".to_string(); // Invalid empty ID
1368
1369        let result = config.validate();
1370        assert!(result.is_err());
1371        assert!(matches!(
1372            result.unwrap_err(),
1373            ConfigFileError::MissingField(_)
1374        ));
1375    }
1376
1377    #[test]
1378    fn test_config_error_propagation_from_signers() {
1379        let mut config = create_valid_config();
1380        config.signers[0].id = "".to_string(); // Invalid empty ID
1381
1382        let result = config.validate();
1383        assert!(result.is_err());
1384        // The error should be InvalidIdLength since empty ID is caught by signer validation
1385        assert!(matches!(
1386            result.unwrap_err(),
1387            ConfigFileError::InvalidIdLength(_)
1388        ));
1389    }
1390
1391    #[test]
1392    fn test_config_error_propagation_from_notifications() {
1393        let mut config = create_valid_config();
1394        config.notifications[0].id = "".to_string(); // Invalid empty ID
1395
1396        let result = config.validate();
1397        assert!(result.is_err());
1398
1399        let error = result.unwrap_err();
1400        assert!(matches!(error, ConfigFileError::InvalidFormat(_)));
1401    }
1402
1403    #[test]
1404    fn test_config_with_paused_relayers() {
1405        let mut config = create_valid_config();
1406        config.relayers[0].paused = true;
1407
1408        let result = config.validate();
1409        assert!(result.is_ok()); // Paused relayers should still be valid
1410    }
1411
1412    #[test]
1413    fn test_config_with_none_notification_id() {
1414        let mut config = create_valid_config();
1415        config.relayers[0].notification_id = None;
1416
1417        let result = config.validate();
1418        assert!(result.is_ok()); // None notification_id should be valid
1419    }
1420
1421    #[test]
1422    fn test_config_file_network_type_display() {
1423        let evm = ConfigFileNetworkType::Evm;
1424        let solana = ConfigFileNetworkType::Solana;
1425        let stellar = ConfigFileNetworkType::Stellar;
1426
1427        // Test that Debug formatting works (which is what we have)
1428        let evm_str = format!("{:?}", evm);
1429        let solana_str = format!("{:?}", solana);
1430        let stellar_str = format!("{:?}", stellar);
1431
1432        assert!(evm_str.contains("Evm"));
1433        assert!(solana_str.contains("Solana"));
1434        assert!(stellar_str.contains("Stellar"));
1435    }
1436
1437    #[test]
1438    fn test_config_file_plugins_validation_with_empty_plugins() {
1439        let config = Config {
1440            relayers: vec![],
1441            signers: vec![],
1442            notifications: vec![],
1443            networks: NetworksFileConfig::new(vec![]).unwrap(),
1444            plugins: None,
1445        };
1446        let result = config.validate_plugins();
1447        assert!(result.is_ok());
1448    }
1449
1450    #[test]
1451    fn test_config_file_without_plugins() {
1452        let dir = tempdir().expect("Failed to create temp dir");
1453        let config_path = dir.path().join("valid_config.json");
1454
1455        let config_content = serde_json::json!({
1456            "relayers": [{
1457                "id": "test-relayer",
1458                "name": "Test Relayer",
1459                "network": "test-network",
1460                "paused": false,
1461                "network_type": "evm",
1462                "signer_id": "test-signer"
1463            }],
1464            "signers": [{
1465                "id": "test-signer",
1466                "type": "local",
1467                "config": {
1468                    "path": "tests/utils/test_keys/unit-test-local-signer.json",
1469                    "passphrase": {
1470                        "value": "test",
1471                        "type": "plain"
1472                    }
1473                }
1474            }],
1475            "notifications": [{
1476                "id": "test-notification",
1477                "type": "webhook",
1478                "url": "https://api.example.com/notifications"
1479            }],
1480            "networks": [{
1481                "type": "evm",
1482                "network": "test-network",
1483                "chain_id": 31337,
1484                "required_confirmations": 1,
1485                "symbol": "ETH",
1486                "rpc_urls": ["https://rpc.test.example.com"],
1487                "is_testnet": true
1488            }]
1489        });
1490
1491        setup_config_file(dir.path(), "valid_config.json", &config_content.to_string());
1492
1493        let result = load_config(config_path.to_str().unwrap());
1494        assert!(result.is_ok());
1495
1496        let config = result.unwrap();
1497        assert_eq!(config.relayers.len(), 1);
1498        assert_eq!(config.signers.len(), 1);
1499        assert_eq!(config.networks.len(), 1);
1500        assert!(config.plugins.is_none());
1501    }
1502}