openzeppelin_monitor/models/config/
network_config.rs

1//! Network configuration loading and validation.
2//!
3//! This module implements the ConfigLoader trait for Network configurations,
4//! allowing network definitions to be loaded from JSON files.
5
6use async_trait::async_trait;
7use std::{collections::HashMap, path::Path, str::FromStr};
8
9use crate::{
10	models::{config::error::ConfigError, BlockChainType, ConfigLoader, Network, SecretValue},
11	utils::{get_cron_interval_ms, normalize_string},
12};
13
14impl Network {
15	/// Calculates the recommended minimum number of past blocks to maintain for this network.
16	///
17	/// This function computes a safe minimum value based on three factors:
18	/// 1. The number of blocks that occur during one cron interval (`blocks_per_cron`)
19	/// 2. The required confirmation blocks for the network
20	/// 3. An additional buffer block (+1)
21	///
22	/// The formula used is: `(cron_interval_ms / block_time_ms) + confirmation_blocks + 1`
23	///
24	/// # Returns
25	/// * `u64` - The recommended minimum number of past blocks to maintain
26	///
27	/// # Note
28	/// If the cron schedule parsing fails, the blocks_per_cron component will be 0,
29	/// resulting in a minimum recommendation of `confirmation_blocks + 1`
30	pub fn get_recommended_past_blocks(&self) -> u64 {
31		let cron_interval_ms = get_cron_interval_ms(&self.cron_schedule).unwrap_or(0) as u64;
32		let blocks_per_cron = cron_interval_ms / self.block_time_ms;
33		blocks_per_cron + self.confirmation_blocks + 1
34	}
35}
36
37#[async_trait]
38impl ConfigLoader for Network {
39	/// Resolve all secrets in the network configuration
40	async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
41		dotenvy::dotenv().ok();
42		let mut network = self.clone();
43
44		for rpc_url in &mut network.rpc_urls {
45			let resolved_url = rpc_url.url.resolve().await.map_err(|e| {
46				ConfigError::parse_error(
47					format!("failed to resolve RPC URL: {}", e),
48					Some(Box::new(e)),
49					None,
50				)
51			})?;
52			rpc_url.url = SecretValue::Plain(resolved_url);
53		}
54		Ok(network)
55	}
56
57	/// Load all network configurations from a directory
58	///
59	/// Reads and parses all JSON files in the specified directory (or default
60	/// config directory) as network configurations.
61	async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
62	where
63		T: FromIterator<(String, Self)>,
64	{
65		let network_dir = path.unwrap_or(Path::new("config/networks"));
66		let mut pairs = Vec::new();
67
68		if !network_dir.exists() {
69			return Err(ConfigError::file_error(
70				"networks directory not found",
71				None,
72				Some(HashMap::from([(
73					"path".to_string(),
74					network_dir.display().to_string(),
75				)])),
76			));
77		}
78
79		for entry in std::fs::read_dir(network_dir).map_err(|e| {
80			ConfigError::file_error(
81				format!("failed to read networks directory: {}", e),
82				Some(Box::new(e)),
83				Some(HashMap::from([(
84					"path".to_string(),
85					network_dir.display().to_string(),
86				)])),
87			)
88		})? {
89			let entry = entry.map_err(|e| {
90				ConfigError::file_error(
91					format!("failed to read directory entry: {}", e),
92					Some(Box::new(e)),
93					Some(HashMap::from([(
94						"path".to_string(),
95						network_dir.display().to_string(),
96					)])),
97				)
98			})?;
99			let path = entry.path();
100
101			if !Self::is_json_file(&path) {
102				continue;
103			}
104
105			let name = path
106				.file_stem()
107				.and_then(|s| s.to_str())
108				.unwrap_or("unknown")
109				.to_string();
110
111			let network = Self::load_from_path(&path).await?;
112
113			let existing_networks: Vec<&Network> =
114				pairs.iter().map(|(_, network)| network).collect();
115			// Check network name uniqueness before pushing
116			Self::validate_uniqueness(&existing_networks, &network, &path.display().to_string())?;
117
118			pairs.push((name, network));
119		}
120
121		Ok(T::from_iter(pairs))
122	}
123
124	/// Load a network configuration from a specific file
125	///
126	/// Reads and parses a single JSON file as a network configuration.
127	async fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigError> {
128		let file = std::fs::File::open(path).map_err(|e| {
129			ConfigError::file_error(
130				format!("failed to open network config file: {}", e),
131				Some(Box::new(e)),
132				Some(HashMap::from([(
133					"path".to_string(),
134					path.display().to_string(),
135				)])),
136			)
137		})?;
138		let mut config: Network = serde_json::from_reader(file).map_err(|e| {
139			ConfigError::parse_error(
140				format!("failed to parse network config: {}", e),
141				Some(Box::new(e)),
142				Some(HashMap::from([(
143					"path".to_string(),
144					path.display().to_string(),
145				)])),
146			)
147		})?;
148
149		// Resolve secrets before validating
150		config = config.resolve_secrets().await?;
151
152		// Validate the config after loading
153		config.validate()?;
154
155		Ok(config)
156	}
157
158	/// Validate the network configuration
159	///
160	/// Ensures that:
161	/// - The network has a valid name and slug
162	/// - At least one RPC URL is specified
163	/// - Required chain-specific parameters are present
164	/// - Block time and confirmation values are reasonable
165	fn validate(&self) -> Result<(), ConfigError> {
166		// Validate network name
167		if self.name.is_empty() {
168			return Err(ConfigError::validation_error(
169				"Network name is required",
170				None,
171				None,
172			));
173		}
174
175		// Validate network_type
176		match self.network_type {
177			BlockChainType::EVM
178			| BlockChainType::Stellar
179			| BlockChainType::Midnight
180			| BlockChainType::Solana => {}
181			#[allow(unreachable_patterns)]
182			_ => {
183				return Err(ConfigError::validation_error(
184					"Invalid network_type",
185					None,
186					None,
187				));
188			}
189		}
190
191		// Validate slug
192		if !self
193			.slug
194			.chars()
195			.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
196		{
197			return Err(ConfigError::validation_error(
198				"Slug must contain only lowercase letters, numbers, and underscores",
199				None,
200				None,
201			));
202		}
203
204		// Validate RPC URL types and formats based on network
205		let (supported_types, supported_protocols) = match self.network_type {
206			BlockChainType::Midnight => (vec!["ws_rpc"], vec!["wss://", "ws://"]),
207			_ => (vec!["rpc"], vec!["http://", "https://", "wss://", "ws://"]),
208		};
209
210		for rpc_url in &self.rpc_urls {
211			let type_valid = supported_types.contains(&rpc_url.type_.as_str());
212			let protocol_valid = supported_protocols
213				.iter()
214				.any(|protocol| rpc_url.url.starts_with(protocol));
215			let weight_valid = rpc_url.weight <= 100;
216
217			if !type_valid || !protocol_valid || !weight_valid {
218				return Err(ConfigError::validation_error(
219					format!(
220						"Invalid RPC URL configuration for {:?} network:\n\
221						Type: {} (must be one of: {})\n\
222						Protocol: must start with one of: {}\n\
223						Weight: {} (must be <= 100)",
224						self.network_type,
225						rpc_url.type_,
226						supported_types.join(", "),
227						supported_protocols.join(", "),
228						rpc_url.weight
229					),
230					None,
231					None,
232				));
233			}
234		}
235
236		// Validate block time
237		if self.block_time_ms < 100 {
238			return Err(ConfigError::validation_error(
239				"Block time must be at least 100ms",
240				None,
241				None,
242			));
243		}
244
245		// Validate confirmation blocks
246		if self.confirmation_blocks == 0 {
247			return Err(ConfigError::validation_error(
248				"Confirmation blocks must be greater than 0",
249				None,
250				None,
251			));
252		}
253
254		// Validate cron_schedule
255		if self.cron_schedule.is_empty() {
256			return Err(ConfigError::validation_error(
257				"Cron schedule must be provided",
258				None,
259				None,
260			));
261		}
262
263		// Add cron schedule format validation
264		if let Err(e) = cron::Schedule::from_str(&self.cron_schedule) {
265			return Err(ConfigError::validation_error(e.to_string(), None, None));
266		}
267
268		// Validate max_past_blocks
269		if let Some(max_blocks) = self.max_past_blocks {
270			if max_blocks == 0 {
271				return Err(ConfigError::validation_error(
272					"max_past_blocks must be greater than 0",
273					None,
274					None,
275				));
276			}
277
278			let recommended_blocks = self.get_recommended_past_blocks();
279
280			if max_blocks < recommended_blocks {
281				tracing::warn!(
282					"Network '{}' max_past_blocks ({}) below recommended {} \
283					 (cron_interval/block_time + confirmations + 1)",
284					self.slug,
285					max_blocks,
286					recommended_blocks
287				);
288			}
289		}
290
291		// Log a warning if the network uses an insecure protocol
292		self.validate_protocol();
293
294		Ok(())
295	}
296
297	/// Validate the safety of the protocol used in the network
298	///
299	/// Returns if safe, or logs a warning message if unsafe.
300	fn validate_protocol(&self) {
301		for rpc_url in &self.rpc_urls {
302			if rpc_url.url.starts_with("http://") {
303				tracing::warn!(
304					"Network '{}' uses an insecure RPC URL: {}",
305					self.slug,
306					rpc_url.url.as_str()
307				);
308			}
309			// Additional check for websocket connections
310			if rpc_url.url.starts_with("ws://") {
311				tracing::warn!(
312					"Network '{}' uses an insecure WebSocket URL: {}",
313					self.slug,
314					rpc_url.url.as_str()
315				);
316			}
317		}
318	}
319
320	fn validate_uniqueness(
321		instances: &[&Self],
322		current_instance: &Self,
323		file_path: &str,
324	) -> Result<(), ConfigError> {
325		let fields = [
326			("name", &current_instance.name),
327			("slug", &current_instance.slug),
328		];
329
330		for (field_name, field_value) in fields {
331			if instances.iter().any(|existing_network| {
332				let existing_value = match field_name {
333					"name" => &existing_network.name,
334					"slug" => &existing_network.slug,
335					_ => unreachable!(),
336				};
337				normalize_string(existing_value) == normalize_string(field_value)
338			}) {
339				return Err(ConfigError::validation_error(
340					format!("Duplicate network {} found: '{}'", field_name, field_value),
341					None,
342					Some(HashMap::from([
343						(format!("network_{}", field_name), field_value.to_string()),
344						("path".to_string(), file_path.to_string()),
345					])),
346				));
347			}
348		}
349		Ok(())
350	}
351}
352
353#[cfg(test)]
354mod tests {
355	use super::*;
356	use crate::{models::SecretString, utils::tests::builders::network::NetworkBuilder};
357	use std::fs;
358	use tempfile::TempDir;
359	use tracing_test::traced_test;
360
361	// Replace create_valid_network() with NetworkBuilder usage
362	fn create_valid_network() -> Network {
363		NetworkBuilder::new()
364			.name("Test Network")
365			.slug("test_network")
366			.network_type(BlockChainType::EVM)
367			.chain_id(1)
368			.store_blocks(true)
369			.rpc_url("https://test.network")
370			.block_time_ms(1000)
371			.confirmation_blocks(1)
372			.cron_schedule("0 */5 * * * *")
373			.max_past_blocks(10)
374			.build()
375	}
376
377	fn create_valid_midnight_network() -> Network {
378		NetworkBuilder::new()
379			.name("Test Midnight Network")
380			.slug("test_midnight_network")
381			.network_type(BlockChainType::Midnight)
382			.chain_id(0)
383			.store_blocks(false)
384			.add_rpc_url("wss://test.midnight.network", "ws_rpc", 100)
385			.block_time_ms(1000)
386			.confirmation_blocks(1)
387			.cron_schedule("0 */5 * * * *")
388			.max_past_blocks(10)
389			.build()
390	}
391
392	#[test]
393	fn test_get_recommended_past_blocks() {
394		let network = NetworkBuilder::new()
395			.block_time_ms(1000) // 1 second
396			.confirmation_blocks(2)
397			.cron_schedule("0 */5 * * * *") // every 5 minutes
398			.build();
399
400		let cron_interval_ms = get_cron_interval_ms(&network.cron_schedule).unwrap() as u64; // 300.000 (5 minutes in ms)
401		let blocks_per_cron = cron_interval_ms / network.block_time_ms; // 300.000 / 1000 = 300
402		let recommended_past_blocks = blocks_per_cron + network.confirmation_blocks + 1; // 300 + 2 + 1 = 303
403
404		assert_eq!(
405			network.get_recommended_past_blocks(),
406			recommended_past_blocks
407		);
408	}
409
410	#[test]
411	fn test_validate_valid_network() {
412		let network = create_valid_network();
413		assert!(network.validate().is_ok());
414	}
415
416	#[test]
417	fn test_validate_empty_name() {
418		let network = NetworkBuilder::new().name("").build();
419		assert!(matches!(
420			network.validate(),
421			Err(ConfigError::ValidationError(_))
422		));
423	}
424
425	#[test]
426	fn test_validate_invalid_slug() {
427		let network = NetworkBuilder::new().slug("Invalid-Slug").build();
428		assert!(matches!(
429			network.validate(),
430			Err(ConfigError::ValidationError(_))
431		));
432	}
433
434	#[test]
435	fn test_validate_invalid_rpc_url_type() {
436		let mut network = create_valid_network();
437		network.rpc_urls[0].type_ = "invalid".to_string();
438		assert!(matches!(
439			network.validate(),
440			Err(ConfigError::ValidationError(_))
441		));
442	}
443
444	#[test]
445	fn test_validate_invalid_rpc_url_format() {
446		let network = NetworkBuilder::new().rpc_url("invalid-url").build();
447		assert!(matches!(
448			network.validate(),
449			Err(ConfigError::ValidationError(_))
450		));
451	}
452
453	#[test]
454	fn test_validate_invalid_rpc_weight() {
455		let mut network = create_valid_network();
456		network.rpc_urls[0].weight = 101;
457		assert!(matches!(
458			network.validate(),
459			Err(ConfigError::ValidationError(_))
460		));
461	}
462
463	#[test]
464	fn test_validate_invalid_block_time() {
465		let network = NetworkBuilder::new().block_time_ms(50).build();
466		assert!(matches!(
467			network.validate(),
468			Err(ConfigError::ValidationError(_))
469		));
470	}
471
472	#[test]
473	fn test_validate_zero_confirmation_blocks() {
474		let network = NetworkBuilder::new().confirmation_blocks(0).build();
475		assert!(matches!(
476			network.validate(),
477			Err(ConfigError::ValidationError(_))
478		));
479	}
480
481	#[test]
482	fn test_validate_invalid_cron_schedule() {
483		let network = NetworkBuilder::new().cron_schedule("invalid cron").build();
484		assert!(matches!(
485			network.validate(),
486			Err(ConfigError::ValidationError(_))
487		));
488	}
489
490	#[test]
491	fn test_validate_zero_max_past_blocks() {
492		let network = NetworkBuilder::new().max_past_blocks(0).build();
493		assert!(matches!(
494			network.validate(),
495			Err(ConfigError::ValidationError(_))
496		));
497	}
498
499	#[test]
500	fn test_validate_empty_cron_schedule() {
501		let network = NetworkBuilder::new().cron_schedule("").build();
502		assert!(matches!(
503			network.validate(),
504			Err(ConfigError::ValidationError(_))
505		));
506	}
507
508	#[tokio::test]
509	async fn test_invalid_load_from_path() {
510		let path = Path::new("config/networks/invalid.json");
511		assert!(matches!(
512			Network::load_from_path(path).await,
513			Err(ConfigError::FileError(_))
514		));
515	}
516
517	#[tokio::test]
518	async fn test_invalid_config_from_load_from_path() {
519		use std::io::Write;
520		use tempfile::NamedTempFile;
521
522		let mut temp_file = NamedTempFile::new().unwrap();
523		write!(temp_file, "{{\"invalid\": \"json").unwrap();
524
525		let path = temp_file.path();
526
527		assert!(matches!(
528			Network::load_from_path(path).await,
529			Err(ConfigError::ParseError(_))
530		));
531	}
532
533	#[tokio::test]
534	async fn test_load_all_directory_not_found() {
535		let non_existent_path = Path::new("non_existent_directory");
536
537		let result: Result<HashMap<String, Network>, ConfigError> =
538			Network::load_all(Some(non_existent_path)).await;
539		assert!(matches!(result, Err(ConfigError::FileError(_))));
540
541		if let Err(ConfigError::FileError(err)) = result {
542			assert!(err.message.contains("networks directory not found"));
543		}
544	}
545
546	#[test]
547	#[traced_test]
548	fn test_validate_protocol_insecure_rpc() {
549		let network = NetworkBuilder::new()
550			.name("Test Network")
551			.slug("test_network")
552			.network_type(BlockChainType::EVM)
553			.chain_id(1)
554			.store_blocks(true)
555			.add_rpc_url("http://test.network", "rpc", 100)
556			.add_rpc_url("ws://test.network", "rpc", 100)
557			.build();
558
559		network.validate_protocol();
560		assert!(logs_contain(
561			"uses an insecure RPC URL: http://test.network"
562		));
563		assert!(logs_contain(
564			"uses an insecure WebSocket URL: ws://test.network"
565		));
566	}
567
568	#[test]
569	#[traced_test]
570	fn test_validate_protocol_secure_rpc() {
571		let network = NetworkBuilder::new()
572			.name("Test Network")
573			.slug("test_network")
574			.network_type(BlockChainType::EVM)
575			.chain_id(1)
576			.store_blocks(true)
577			.add_rpc_url("https://test.network", "rpc", 100)
578			.add_rpc_url("wss://test.network", "rpc", 100)
579			.build();
580
581		network.validate_protocol();
582		assert!(!logs_contain("uses an insecure RPC URL"));
583		assert!(!logs_contain("uses an insecure WebSocket URL"));
584	}
585
586	#[test]
587	#[traced_test]
588	fn test_validate_protocol_mixed_security() {
589		let network = NetworkBuilder::new()
590			.name("Test Network")
591			.slug("test_network")
592			.network_type(BlockChainType::EVM)
593			.chain_id(1)
594			.store_blocks(true)
595			.add_rpc_url("https://secure.network", "rpc", 100)
596			.add_rpc_url("http://insecure.network", "rpc", 50)
597			.add_rpc_url("wss://secure.ws.network", "rpc", 25)
598			.add_rpc_url("ws://insecure.ws.network", "rpc", 25)
599			.build();
600
601		network.validate_protocol();
602		assert!(logs_contain(
603			"uses an insecure RPC URL: http://insecure.network"
604		));
605		assert!(logs_contain(
606			"uses an insecure WebSocket URL: ws://insecure.ws.network"
607		));
608		assert!(!logs_contain("https://secure.network"));
609		assert!(!logs_contain("wss://secure.ws.network"));
610	}
611
612	#[test]
613	fn test_validate_midnight_network_valid() {
614		let network = create_valid_midnight_network();
615		assert!(network.validate().is_ok());
616	}
617
618	#[test]
619	fn test_validate_midnight_network_invalid_type() {
620		let mut network = create_valid_midnight_network();
621		network.rpc_urls[0].type_ = "rpc".to_string();
622		let result = network.validate();
623		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
624		if let Err(ConfigError::ValidationError(err)) = result {
625			assert!(err.message.contains("Type: rpc (must be one of: ws_rpc)"));
626		}
627	}
628
629	#[test]
630	fn test_validate_midnight_network_invalid_protocol() {
631		let mut network = create_valid_midnight_network();
632		network.rpc_urls[0].url = SecretValue::Plain(SecretString::new(
633			"https://test.midnight.network".to_string(),
634		));
635		let result = network.validate();
636		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
637		if let Err(ConfigError::ValidationError(err)) = result {
638			assert!(err
639				.message
640				.contains("Protocol: must start with one of: wss://, ws://"));
641		}
642	}
643
644	#[test]
645	fn test_validate_evm_network_valid() {
646		let network = create_valid_network();
647		assert!(network.validate().is_ok());
648	}
649
650	#[test]
651	fn test_validate_evm_network_invalid_type() {
652		let mut network = create_valid_network();
653		network.rpc_urls[0].type_ = "ws_rpc".to_string();
654		let result = network.validate();
655		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
656		if let Err(ConfigError::ValidationError(err)) = result {
657			assert!(err.message.contains("Type: ws_rpc (must be one of: rpc)"));
658		}
659	}
660
661	#[test]
662	fn test_validate_evm_network_invalid_protocol() {
663		let mut network = create_valid_network();
664		network.rpc_urls[0].url =
665			SecretValue::Plain(SecretString::new("invalid://test.network".to_string()));
666		let result = network.validate();
667		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
668		if let Err(ConfigError::ValidationError(err)) = result {
669			assert!(err
670				.message
671				.contains("Protocol: must start with one of: http://, https://, wss://, ws://"));
672		}
673	}
674
675	#[tokio::test]
676	async fn test_load_all_duplicate_network_name() {
677		let temp_dir = TempDir::new().unwrap();
678		let file_path_1 = temp_dir.path().join("duplicate_network.json");
679		let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
680
681		let network_config_1 = r#"{
682			"name": " Testnetwork",
683			"slug": "test_network",
684			"network_type": "EVM",
685			"rpc_urls": [
686				{
687					"type_": "rpc",
688					"url": {
689						"type": "plain",
690						"value": "https://eth.drpc.org"
691					},
692					"weight": 100
693				}
694			],
695			"chain_id": 1,
696			"block_time_ms": 1000,
697			"confirmation_blocks": 1,
698			"cron_schedule": "0 */5 * * * *",
699			"max_past_blocks": 10,
700			"store_blocks": true
701		}"#;
702
703		let network_config_2 = r#"{
704			"name": "TestNetwork",
705			"slug": "test_network",
706			"network_type": "EVM",
707			"rpc_urls": [
708				{
709					"type_": "rpc",
710					"url": {
711						"type": "plain",
712						"value": "https://eth.drpc.org"
713					},
714					"weight": 100
715				}
716			],
717			"chain_id": 1,
718			"block_time_ms": 1000,
719			"confirmation_blocks": 1,
720			"cron_schedule": "0 */5 * * * *",
721			"max_past_blocks": 10,
722			"store_blocks": true
723		}"#;
724
725		fs::write(&file_path_1, network_config_1).unwrap();
726		fs::write(&file_path_2, network_config_2).unwrap();
727
728		let result: Result<HashMap<String, Network>, ConfigError> =
729			Network::load_all(Some(temp_dir.path())).await;
730
731		assert!(result.is_err());
732		if let Err(ConfigError::ValidationError(err)) = result {
733			assert!(err.message.contains("Duplicate network name found"));
734		}
735	}
736
737	#[tokio::test]
738	async fn test_load_all_duplicate_network_slug() {
739		let temp_dir = TempDir::new().unwrap();
740		let file_path_1 = temp_dir.path().join("duplicate_network.json");
741		let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
742
743		let network_config_1 = r#"{
744			"name": "Test Network",
745			"slug": "test_network",
746			"network_type": "EVM",
747			"rpc_urls": [
748				{
749					"type_": "rpc",
750					"url": {
751						"type": "plain",
752						"value": "https://eth.drpc.org"
753					},
754					"weight": 100
755				}
756			],
757			"chain_id": 1,
758			"block_time_ms": 1000,
759			"confirmation_blocks": 1,
760			"cron_schedule": "0 */5 * * * *",
761			"max_past_blocks": 10,
762			"store_blocks": true
763		}"#;
764
765		let network_config_2 = r#"{
766			"name": "Test Network 2",
767			"slug": "test_network",
768			"network_type": "EVM",
769			"rpc_urls": [
770				{
771					"type_": "rpc",
772					"url": {
773						"type": "plain",
774						"value": "https://eth.drpc.org"
775					},
776					"weight": 100
777				}
778			],
779			"chain_id": 1,
780			"block_time_ms": 1000,
781			"confirmation_blocks": 1,
782			"cron_schedule": "0 */5 * * * *",
783			"max_past_blocks": 10,
784			"store_blocks": true
785		}"#;
786
787		fs::write(&file_path_1, network_config_1).unwrap();
788		fs::write(&file_path_2, network_config_2).unwrap();
789
790		let result: Result<HashMap<String, Network>, ConfigError> =
791			Network::load_all(Some(temp_dir.path())).await;
792
793		assert!(result.is_err());
794		if let Err(ConfigError::ValidationError(err)) = result {
795			assert!(err.message.contains("Duplicate network slug found"));
796		}
797	}
798}