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 | BlockChainType::Stellar | BlockChainType::Midnight => {}
178			#[allow(unreachable_patterns)]
179			_ => {
180				return Err(ConfigError::validation_error(
181					"Invalid network_type",
182					None,
183					None,
184				));
185			}
186		}
187
188		// Validate slug
189		if !self
190			.slug
191			.chars()
192			.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
193		{
194			return Err(ConfigError::validation_error(
195				"Slug must contain only lowercase letters, numbers, and underscores",
196				None,
197				None,
198			));
199		}
200
201		// Validate RPC URL types and formats based on network
202		let (supported_types, supported_protocols) = match self.network_type {
203			BlockChainType::Midnight => (vec!["ws_rpc"], vec!["wss://", "ws://"]),
204			_ => (vec!["rpc"], vec!["http://", "https://", "wss://", "ws://"]),
205		};
206
207		for rpc_url in &self.rpc_urls {
208			let type_valid = supported_types.contains(&rpc_url.type_.as_str());
209			let protocol_valid = supported_protocols
210				.iter()
211				.any(|protocol| rpc_url.url.starts_with(protocol));
212			let weight_valid = rpc_url.weight <= 100;
213
214			if !type_valid || !protocol_valid || !weight_valid {
215				return Err(ConfigError::validation_error(
216					format!(
217						"Invalid RPC URL configuration for {:?} network:\n\
218						Type: {} (must be one of: {})\n\
219						Protocol: must start with one of: {}\n\
220						Weight: {} (must be <= 100)",
221						self.network_type,
222						rpc_url.type_,
223						supported_types.join(", "),
224						supported_protocols.join(", "),
225						rpc_url.weight
226					),
227					None,
228					None,
229				));
230			}
231		}
232
233		// Validate block time
234		if self.block_time_ms < 100 {
235			return Err(ConfigError::validation_error(
236				"Block time must be at least 100ms",
237				None,
238				None,
239			));
240		}
241
242		// Validate confirmation blocks
243		if self.confirmation_blocks == 0 {
244			return Err(ConfigError::validation_error(
245				"Confirmation blocks must be greater than 0",
246				None,
247				None,
248			));
249		}
250
251		// Validate cron_schedule
252		if self.cron_schedule.is_empty() {
253			return Err(ConfigError::validation_error(
254				"Cron schedule must be provided",
255				None,
256				None,
257			));
258		}
259
260		// Add cron schedule format validation
261		if let Err(e) = cron::Schedule::from_str(&self.cron_schedule) {
262			return Err(ConfigError::validation_error(e.to_string(), None, None));
263		}
264
265		// Validate max_past_blocks
266		if let Some(max_blocks) = self.max_past_blocks {
267			if max_blocks == 0 {
268				return Err(ConfigError::validation_error(
269					"max_past_blocks must be greater than 0",
270					None,
271					None,
272				));
273			}
274
275			let recommended_blocks = self.get_recommended_past_blocks();
276
277			if max_blocks < recommended_blocks {
278				tracing::warn!(
279					"Network '{}' max_past_blocks ({}) below recommended {} \
280					 (cron_interval/block_time + confirmations + 1)",
281					self.slug,
282					max_blocks,
283					recommended_blocks
284				);
285			}
286		}
287
288		// Log a warning if the network uses an insecure protocol
289		self.validate_protocol();
290
291		Ok(())
292	}
293
294	/// Validate the safety of the protocol used in the network
295	///
296	/// Returns if safe, or logs a warning message if unsafe.
297	fn validate_protocol(&self) {
298		for rpc_url in &self.rpc_urls {
299			if rpc_url.url.starts_with("http://") {
300				tracing::warn!(
301					"Network '{}' uses an insecure RPC URL: {}",
302					self.slug,
303					rpc_url.url.as_str()
304				);
305			}
306			// Additional check for websocket connections
307			if rpc_url.url.starts_with("ws://") {
308				tracing::warn!(
309					"Network '{}' uses an insecure WebSocket URL: {}",
310					self.slug,
311					rpc_url.url.as_str()
312				);
313			}
314		}
315	}
316
317	fn validate_uniqueness(
318		instances: &[&Self],
319		current_instance: &Self,
320		file_path: &str,
321	) -> Result<(), ConfigError> {
322		let fields = [
323			("name", &current_instance.name),
324			("slug", &current_instance.slug),
325		];
326
327		for (field_name, field_value) in fields {
328			if instances.iter().any(|existing_network| {
329				let existing_value = match field_name {
330					"name" => &existing_network.name,
331					"slug" => &existing_network.slug,
332					_ => unreachable!(),
333				};
334				normalize_string(existing_value) == normalize_string(field_value)
335			}) {
336				return Err(ConfigError::validation_error(
337					format!("Duplicate network {} found: '{}'", field_name, field_value),
338					None,
339					Some(HashMap::from([
340						(format!("network_{}", field_name), field_value.to_string()),
341						("path".to_string(), file_path.to_string()),
342					])),
343				));
344			}
345		}
346		Ok(())
347	}
348}
349
350#[cfg(test)]
351mod tests {
352	use super::*;
353	use crate::{models::SecretString, utils::tests::builders::network::NetworkBuilder};
354	use std::fs;
355	use tempfile::TempDir;
356	use tracing_test::traced_test;
357
358	// Replace create_valid_network() with NetworkBuilder usage
359	fn create_valid_network() -> Network {
360		NetworkBuilder::new()
361			.name("Test Network")
362			.slug("test_network")
363			.network_type(BlockChainType::EVM)
364			.chain_id(1)
365			.store_blocks(true)
366			.rpc_url("https://test.network")
367			.block_time_ms(1000)
368			.confirmation_blocks(1)
369			.cron_schedule("0 */5 * * * *")
370			.max_past_blocks(10)
371			.build()
372	}
373
374	fn create_valid_midnight_network() -> Network {
375		NetworkBuilder::new()
376			.name("Test Midnight Network")
377			.slug("test_midnight_network")
378			.network_type(BlockChainType::Midnight)
379			.chain_id(0)
380			.store_blocks(false)
381			.add_rpc_url("wss://test.midnight.network", "ws_rpc", 100)
382			.block_time_ms(1000)
383			.confirmation_blocks(1)
384			.cron_schedule("0 */5 * * * *")
385			.max_past_blocks(10)
386			.build()
387	}
388
389	#[test]
390	fn test_get_recommended_past_blocks() {
391		let network = NetworkBuilder::new()
392			.block_time_ms(1000) // 1 second
393			.confirmation_blocks(2)
394			.cron_schedule("0 */5 * * * *") // every 5 minutes
395			.build();
396
397		let cron_interval_ms = get_cron_interval_ms(&network.cron_schedule).unwrap() as u64; // 300.000 (5 minutes in ms)
398		let blocks_per_cron = cron_interval_ms / network.block_time_ms; // 300.000 / 1000 = 300
399		let recommended_past_blocks = blocks_per_cron + network.confirmation_blocks + 1; // 300 + 2 + 1 = 303
400
401		assert_eq!(
402			network.get_recommended_past_blocks(),
403			recommended_past_blocks
404		);
405	}
406
407	#[test]
408	fn test_validate_valid_network() {
409		let network = create_valid_network();
410		assert!(network.validate().is_ok());
411	}
412
413	#[test]
414	fn test_validate_empty_name() {
415		let network = NetworkBuilder::new().name("").build();
416		assert!(matches!(
417			network.validate(),
418			Err(ConfigError::ValidationError(_))
419		));
420	}
421
422	#[test]
423	fn test_validate_invalid_slug() {
424		let network = NetworkBuilder::new().slug("Invalid-Slug").build();
425		assert!(matches!(
426			network.validate(),
427			Err(ConfigError::ValidationError(_))
428		));
429	}
430
431	#[test]
432	fn test_validate_invalid_rpc_url_type() {
433		let mut network = create_valid_network();
434		network.rpc_urls[0].type_ = "invalid".to_string();
435		assert!(matches!(
436			network.validate(),
437			Err(ConfigError::ValidationError(_))
438		));
439	}
440
441	#[test]
442	fn test_validate_invalid_rpc_url_format() {
443		let network = NetworkBuilder::new().rpc_url("invalid-url").build();
444		assert!(matches!(
445			network.validate(),
446			Err(ConfigError::ValidationError(_))
447		));
448	}
449
450	#[test]
451	fn test_validate_invalid_rpc_weight() {
452		let mut network = create_valid_network();
453		network.rpc_urls[0].weight = 101;
454		assert!(matches!(
455			network.validate(),
456			Err(ConfigError::ValidationError(_))
457		));
458	}
459
460	#[test]
461	fn test_validate_invalid_block_time() {
462		let network = NetworkBuilder::new().block_time_ms(50).build();
463		assert!(matches!(
464			network.validate(),
465			Err(ConfigError::ValidationError(_))
466		));
467	}
468
469	#[test]
470	fn test_validate_zero_confirmation_blocks() {
471		let network = NetworkBuilder::new().confirmation_blocks(0).build();
472		assert!(matches!(
473			network.validate(),
474			Err(ConfigError::ValidationError(_))
475		));
476	}
477
478	#[test]
479	fn test_validate_invalid_cron_schedule() {
480		let network = NetworkBuilder::new().cron_schedule("invalid cron").build();
481		assert!(matches!(
482			network.validate(),
483			Err(ConfigError::ValidationError(_))
484		));
485	}
486
487	#[test]
488	fn test_validate_zero_max_past_blocks() {
489		let network = NetworkBuilder::new().max_past_blocks(0).build();
490		assert!(matches!(
491			network.validate(),
492			Err(ConfigError::ValidationError(_))
493		));
494	}
495
496	#[test]
497	fn test_validate_empty_cron_schedule() {
498		let network = NetworkBuilder::new().cron_schedule("").build();
499		assert!(matches!(
500			network.validate(),
501			Err(ConfigError::ValidationError(_))
502		));
503	}
504
505	#[tokio::test]
506	async fn test_invalid_load_from_path() {
507		let path = Path::new("config/networks/invalid.json");
508		assert!(matches!(
509			Network::load_from_path(path).await,
510			Err(ConfigError::FileError(_))
511		));
512	}
513
514	#[tokio::test]
515	async fn test_invalid_config_from_load_from_path() {
516		use std::io::Write;
517		use tempfile::NamedTempFile;
518
519		let mut temp_file = NamedTempFile::new().unwrap();
520		write!(temp_file, "{{\"invalid\": \"json").unwrap();
521
522		let path = temp_file.path();
523
524		assert!(matches!(
525			Network::load_from_path(path).await,
526			Err(ConfigError::ParseError(_))
527		));
528	}
529
530	#[tokio::test]
531	async fn test_load_all_directory_not_found() {
532		let non_existent_path = Path::new("non_existent_directory");
533
534		let result: Result<HashMap<String, Network>, ConfigError> =
535			Network::load_all(Some(non_existent_path)).await;
536		assert!(matches!(result, Err(ConfigError::FileError(_))));
537
538		if let Err(ConfigError::FileError(err)) = result {
539			assert!(err.message.contains("networks directory not found"));
540		}
541	}
542
543	#[test]
544	#[traced_test]
545	fn test_validate_protocol_insecure_rpc() {
546		let network = NetworkBuilder::new()
547			.name("Test Network")
548			.slug("test_network")
549			.network_type(BlockChainType::EVM)
550			.chain_id(1)
551			.store_blocks(true)
552			.add_rpc_url("http://test.network", "rpc", 100)
553			.add_rpc_url("ws://test.network", "rpc", 100)
554			.build();
555
556		network.validate_protocol();
557		assert!(logs_contain(
558			"uses an insecure RPC URL: http://test.network"
559		));
560		assert!(logs_contain(
561			"uses an insecure WebSocket URL: ws://test.network"
562		));
563	}
564
565	#[test]
566	#[traced_test]
567	fn test_validate_protocol_secure_rpc() {
568		let network = NetworkBuilder::new()
569			.name("Test Network")
570			.slug("test_network")
571			.network_type(BlockChainType::EVM)
572			.chain_id(1)
573			.store_blocks(true)
574			.add_rpc_url("https://test.network", "rpc", 100)
575			.add_rpc_url("wss://test.network", "rpc", 100)
576			.build();
577
578		network.validate_protocol();
579		assert!(!logs_contain("uses an insecure RPC URL"));
580		assert!(!logs_contain("uses an insecure WebSocket URL"));
581	}
582
583	#[test]
584	#[traced_test]
585	fn test_validate_protocol_mixed_security() {
586		let network = NetworkBuilder::new()
587			.name("Test Network")
588			.slug("test_network")
589			.network_type(BlockChainType::EVM)
590			.chain_id(1)
591			.store_blocks(true)
592			.add_rpc_url("https://secure.network", "rpc", 100)
593			.add_rpc_url("http://insecure.network", "rpc", 50)
594			.add_rpc_url("wss://secure.ws.network", "rpc", 25)
595			.add_rpc_url("ws://insecure.ws.network", "rpc", 25)
596			.build();
597
598		network.validate_protocol();
599		assert!(logs_contain(
600			"uses an insecure RPC URL: http://insecure.network"
601		));
602		assert!(logs_contain(
603			"uses an insecure WebSocket URL: ws://insecure.ws.network"
604		));
605		assert!(!logs_contain("https://secure.network"));
606		assert!(!logs_contain("wss://secure.ws.network"));
607	}
608
609	#[test]
610	fn test_validate_midnight_network_valid() {
611		let network = create_valid_midnight_network();
612		assert!(network.validate().is_ok());
613	}
614
615	#[test]
616	fn test_validate_midnight_network_invalid_type() {
617		let mut network = create_valid_midnight_network();
618		network.rpc_urls[0].type_ = "rpc".to_string();
619		let result = network.validate();
620		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
621		if let Err(ConfigError::ValidationError(err)) = result {
622			assert!(err.message.contains("Type: rpc (must be one of: ws_rpc)"));
623		}
624	}
625
626	#[test]
627	fn test_validate_midnight_network_invalid_protocol() {
628		let mut network = create_valid_midnight_network();
629		network.rpc_urls[0].url = SecretValue::Plain(SecretString::new(
630			"https://test.midnight.network".to_string(),
631		));
632		let result = network.validate();
633		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
634		if let Err(ConfigError::ValidationError(err)) = result {
635			assert!(err
636				.message
637				.contains("Protocol: must start with one of: wss://, ws://"));
638		}
639	}
640
641	#[test]
642	fn test_validate_evm_network_valid() {
643		let network = create_valid_network();
644		assert!(network.validate().is_ok());
645	}
646
647	#[test]
648	fn test_validate_evm_network_invalid_type() {
649		let mut network = create_valid_network();
650		network.rpc_urls[0].type_ = "ws_rpc".to_string();
651		let result = network.validate();
652		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
653		if let Err(ConfigError::ValidationError(err)) = result {
654			assert!(err.message.contains("Type: ws_rpc (must be one of: rpc)"));
655		}
656	}
657
658	#[test]
659	fn test_validate_evm_network_invalid_protocol() {
660		let mut network = create_valid_network();
661		network.rpc_urls[0].url =
662			SecretValue::Plain(SecretString::new("invalid://test.network".to_string()));
663		let result = network.validate();
664		assert!(matches!(result, Err(ConfigError::ValidationError(_))));
665		if let Err(ConfigError::ValidationError(err)) = result {
666			assert!(err
667				.message
668				.contains("Protocol: must start with one of: http://, https://, wss://, ws://"));
669		}
670	}
671
672	#[tokio::test]
673	async fn test_load_all_duplicate_network_name() {
674		let temp_dir = TempDir::new().unwrap();
675		let file_path_1 = temp_dir.path().join("duplicate_network.json");
676		let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
677
678		let network_config_1 = r#"{
679			"name": " Testnetwork",
680			"slug": "test_network",
681			"network_type": "EVM",
682			"rpc_urls": [
683				{
684					"type_": "rpc",
685					"url": {
686						"type": "plain",
687						"value": "https://eth.drpc.org"
688					},
689					"weight": 100
690				}
691			],
692			"chain_id": 1,
693			"block_time_ms": 1000,
694			"confirmation_blocks": 1,
695			"cron_schedule": "0 */5 * * * *",
696			"max_past_blocks": 10,
697			"store_blocks": true
698		}"#;
699
700		let network_config_2 = r#"{
701			"name": "TestNetwork",
702			"slug": "test_network",
703			"network_type": "EVM",
704			"rpc_urls": [
705				{
706					"type_": "rpc",
707					"url": {
708						"type": "plain",
709						"value": "https://eth.drpc.org"
710					},
711					"weight": 100
712				}
713			],
714			"chain_id": 1,
715			"block_time_ms": 1000,
716			"confirmation_blocks": 1,
717			"cron_schedule": "0 */5 * * * *",
718			"max_past_blocks": 10,
719			"store_blocks": true
720		}"#;
721
722		fs::write(&file_path_1, network_config_1).unwrap();
723		fs::write(&file_path_2, network_config_2).unwrap();
724
725		let result: Result<HashMap<String, Network>, ConfigError> =
726			Network::load_all(Some(temp_dir.path())).await;
727
728		assert!(result.is_err());
729		if let Err(ConfigError::ValidationError(err)) = result {
730			assert!(err.message.contains("Duplicate network name found"));
731		}
732	}
733
734	#[tokio::test]
735	async fn test_load_all_duplicate_network_slug() {
736		let temp_dir = TempDir::new().unwrap();
737		let file_path_1 = temp_dir.path().join("duplicate_network.json");
738		let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
739
740		let network_config_1 = r#"{
741			"name": "Test Network",
742			"slug": "test_network",
743			"network_type": "EVM",
744			"rpc_urls": [
745				{
746					"type_": "rpc",
747					"url": {
748						"type": "plain",
749						"value": "https://eth.drpc.org"
750					},
751					"weight": 100
752				}
753			],
754			"chain_id": 1,
755			"block_time_ms": 1000,
756			"confirmation_blocks": 1,
757			"cron_schedule": "0 */5 * * * *",
758			"max_past_blocks": 10,
759			"store_blocks": true
760		}"#;
761
762		let network_config_2 = r#"{
763			"name": "Test Network 2",
764			"slug": "test_network",
765			"network_type": "EVM",
766			"rpc_urls": [
767				{
768					"type_": "rpc",
769					"url": {
770						"type": "plain",
771						"value": "https://eth.drpc.org"
772					},
773					"weight": 100
774				}
775			],
776			"chain_id": 1,
777			"block_time_ms": 1000,
778			"confirmation_blocks": 1,
779			"cron_schedule": "0 */5 * * * *",
780			"max_past_blocks": 10,
781			"store_blocks": true
782		}"#;
783
784		fs::write(&file_path_1, network_config_1).unwrap();
785		fs::write(&file_path_2, network_config_2).unwrap();
786
787		let result: Result<HashMap<String, Network>, ConfigError> =
788			Network::load_all(Some(temp_dir.path())).await;
789
790		assert!(result.is_err());
791		if let Err(ConfigError::ValidationError(err)) = result {
792			assert!(err.message.contains("Duplicate network slug found"));
793		}
794	}
795}