openzeppelin_monitor/models/config/
trigger_config.rs

1//! Trigger configuration loading and validation.
2//!
3//! This module implements the ConfigLoader trait for Trigger configurations,
4//! allowing triggers to be loaded from JSON files.
5
6use async_trait::async_trait;
7use email_address::EmailAddress;
8use serde::Deserialize;
9use std::{collections::HashMap, fs, path::Path};
10
11use crate::{
12	models::{
13		config::error::ConfigError, ConfigLoader, SecretValue, Trigger, TriggerType,
14		TriggerTypeConfig,
15	},
16	services::trigger::validate_script_config,
17	utils::normalize_string,
18};
19
20const TELEGRAM_MAX_BODY_LENGTH: usize = 4096;
21const DISCORD_MAX_BODY_LENGTH: usize = 2000;
22
23/// File structure for trigger configuration files
24#[derive(Debug, Deserialize)]
25pub struct TriggerConfigFile {
26	/// Map of trigger names to their configurations
27	#[serde(flatten)]
28	pub triggers: HashMap<String, Trigger>,
29}
30
31#[async_trait]
32impl ConfigLoader for Trigger {
33	async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
34		dotenvy::dotenv().ok();
35
36		let mut trigger = self.clone();
37
38		match &mut trigger.config {
39			TriggerTypeConfig::Slack { slack_url, .. } => {
40				let resolved_url = slack_url.resolve().await.map_err(|e| {
41					ConfigError::parse_error(
42						format!("failed to resolve Slack URL: {}", e),
43						Some(Box::new(e)),
44						None,
45					)
46				})?;
47				*slack_url = SecretValue::Plain(resolved_url);
48			}
49			TriggerTypeConfig::Email {
50				username, password, ..
51			} => {
52				let resolved_username = username.resolve().await.map_err(|e| {
53					ConfigError::parse_error(
54						format!("failed to resolve SMTP username: {}", e),
55						Some(Box::new(e)),
56						None,
57					)
58				})?;
59				*username = SecretValue::Plain(resolved_username);
60
61				let resolved_password = password.resolve().await.map_err(|e| {
62					ConfigError::parse_error(
63						format!("failed to resolve SMTP password: {}", e),
64						Some(Box::new(e)),
65						None,
66					)
67				})?;
68				*password = SecretValue::Plain(resolved_password);
69			}
70			TriggerTypeConfig::Webhook { url, secret, .. } => {
71				let resolved_url = url.resolve().await.map_err(|e| {
72					ConfigError::parse_error(
73						format!("failed to resolve webhook URL: {}", e),
74						Some(Box::new(e)),
75						None,
76					)
77				})?;
78				*url = SecretValue::Plain(resolved_url);
79
80				if let Some(secret) = secret {
81					let resolved_secret = secret.resolve().await.map_err(|e| {
82						ConfigError::parse_error(
83							format!("failed to resolve webhook secret: {}", e),
84							Some(Box::new(e)),
85							None,
86						)
87					})?;
88					*secret = SecretValue::Plain(resolved_secret);
89				}
90			}
91			TriggerTypeConfig::Telegram { token, .. } => {
92				let resolved_token = token.resolve().await.map_err(|e| {
93					ConfigError::parse_error(
94						format!("failed to resolve Telegram token: {}", e),
95						Some(Box::new(e)),
96						None,
97					)
98				})?;
99				*token = SecretValue::Plain(resolved_token);
100			}
101			TriggerTypeConfig::Discord { discord_url, .. } => {
102				let resolved_url = discord_url.resolve().await.map_err(|e| {
103					ConfigError::parse_error(
104						format!("failed to resolve Discord URL: {}", e),
105						Some(Box::new(e)),
106						None,
107					)
108				})?;
109				*discord_url = SecretValue::Plain(resolved_url);
110			}
111			_ => {}
112		}
113
114		Ok(trigger)
115	}
116
117	/// Load all trigger configurations from a directory
118	///
119	/// Reads and parses all JSON files in the specified directory (or default
120	/// config directory) as trigger configurations.
121	async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
122	where
123		T: FromIterator<(String, Self)>,
124	{
125		let config_dir = path.unwrap_or(Path::new("config/triggers"));
126
127		if !config_dir.exists() {
128			return Err(ConfigError::file_error(
129				"triggers directory not found",
130				None,
131				Some(HashMap::from([(
132					"path".to_string(),
133					config_dir.display().to_string(),
134				)])),
135			));
136		}
137
138		let entries = fs::read_dir(config_dir).map_err(|e| {
139			ConfigError::file_error(
140				format!("failed to read triggers directory: {}", e),
141				Some(Box::new(e)),
142				Some(HashMap::from([(
143					"path".to_string(),
144					config_dir.display().to_string(),
145				)])),
146			)
147		})?;
148
149		let mut trigger_pairs = Vec::new();
150		for entry in entries {
151			let entry = entry.map_err(|e| {
152				ConfigError::file_error(
153					format!("failed to read directory entry: {}", e),
154					Some(Box::new(e)),
155					Some(HashMap::from([(
156						"path".to_string(),
157						config_dir.display().to_string(),
158					)])),
159				)
160			})?;
161			if Self::is_json_file(&entry.path()) {
162				let file_path = entry.path();
163				let content = fs::read_to_string(&file_path).map_err(|e| {
164					ConfigError::file_error(
165						format!("failed to read trigger config file: {}", e),
166						Some(Box::new(e)),
167						Some(HashMap::from([(
168							"path".to_string(),
169							file_path.display().to_string(),
170						)])),
171					)
172				})?;
173				let file_triggers: TriggerConfigFile =
174					serde_json::from_str(&content).map_err(|e| {
175						ConfigError::parse_error(
176							format!("failed to parse trigger config: {}", e),
177							Some(Box::new(e)),
178							Some(HashMap::from([(
179								"path".to_string(),
180								file_path.display().to_string(),
181							)])),
182						)
183					})?;
184
185				// Validate each trigger before adding it
186				for (name, mut trigger) in file_triggers.triggers {
187					// Resolve secrets before validating
188					trigger = trigger.resolve_secrets().await?;
189					if let Err(validation_error) = trigger.validate() {
190						return Err(ConfigError::validation_error(
191							format!(
192								"Validation failed for trigger '{}': {}",
193								name, validation_error
194							),
195							Some(Box::new(validation_error)),
196							Some(HashMap::from([
197								("path".to_string(), file_path.display().to_string()),
198								("trigger_name".to_string(), name.clone()),
199							])),
200						));
201					}
202
203					let existing_triggers: Vec<&Trigger> =
204						trigger_pairs.iter().map(|(_, trigger)| trigger).collect();
205					// Check trigger name uniqueness before pushing
206					Self::validate_uniqueness(
207						&existing_triggers,
208						&trigger,
209						&file_path.display().to_string(),
210					)?;
211
212					trigger_pairs.push((name, trigger));
213				}
214			}
215		}
216		Ok(T::from_iter(trigger_pairs))
217	}
218
219	/// Load a trigger configuration from a specific file
220	///
221	/// Reads and parses a single JSON file as a trigger configuration.
222	async fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
223		let file = std::fs::File::open(path)
224			.map_err(|e| ConfigError::file_error(e.to_string(), None, None))?;
225		let mut config: Trigger = serde_json::from_reader(file)
226			.map_err(|e| ConfigError::parse_error(e.to_string(), None, None))?;
227
228		// Resolve secrets before validating
229		config = config.resolve_secrets().await?;
230
231		// Validate the config after loading
232		config.validate()?;
233
234		Ok(config)
235	}
236
237	/// Validate the trigger configuration
238	///
239	/// Ensures that:
240	/// - The trigger has a valid name
241	/// - The trigger type is supported
242	/// - Required configuration fields for the trigger type are present
243	/// - URLs are valid for webhook and Slack triggers
244	/// - Script paths exist for script triggers
245	fn validate(&self) -> Result<(), ConfigError> {
246		// Validate trigger name
247		if self.name.is_empty() {
248			return Err(ConfigError::validation_error(
249				"Trigger cannot be empty",
250				None,
251				None,
252			));
253		}
254
255		match &self.trigger_type {
256			TriggerType::Slack => {
257				if let TriggerTypeConfig::Slack {
258					slack_url,
259					message,
260					retry_policy: _,
261				} = &self.config
262				{
263					// Validate webhook URL
264					if !slack_url.starts_with("https://hooks.slack.com/") {
265						return Err(ConfigError::validation_error(
266							"Invalid Slack webhook URL format",
267							None,
268							None,
269						));
270					}
271					// Validate message
272					if message.title.trim().is_empty() {
273						return Err(ConfigError::validation_error(
274							"Title cannot be empty",
275							None,
276							None,
277						));
278					}
279					// Validate template is not empty
280					if message.body.trim().is_empty() {
281						return Err(ConfigError::validation_error(
282							"Body cannot be empty",
283							None,
284							None,
285						));
286					}
287				}
288			}
289			TriggerType::Email => {
290				if let TriggerTypeConfig::Email {
291					host,
292					port: _,
293					username,
294					password,
295					message,
296					sender,
297					recipients,
298					retry_policy: _,
299				} = &self.config
300				{
301					// Validate host
302					if host.trim().is_empty() {
303						return Err(ConfigError::validation_error(
304							"Host cannot be empty",
305							None,
306							None,
307						));
308					}
309					// Validate host format
310					if !host.contains('.')
311						|| !host
312							.chars()
313							.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
314					{
315						return Err(ConfigError::validation_error(
316							"Invalid SMTP host format",
317							None,
318							None,
319						));
320					}
321
322					// Basic username validation
323					if username.is_empty() {
324						return Err(ConfigError::validation_error(
325							"SMTP username cannot be empty",
326							None,
327							None,
328						));
329					}
330					if username.as_str().chars().any(|c| c.is_control()) {
331						return Err(ConfigError::validation_error(
332							"SMTP username contains invalid control characters",
333							None,
334							None,
335						));
336					}
337					// Validate password
338					if password.trim().is_empty() {
339						return Err(ConfigError::validation_error(
340							"Password cannot be empty",
341							None,
342							None,
343						));
344					}
345					// Validate message
346					if message.title.trim().is_empty() {
347						return Err(ConfigError::validation_error(
348							"Title cannot be empty",
349							None,
350							None,
351						));
352					}
353					if message.body.trim().is_empty() {
354						return Err(ConfigError::validation_error(
355							"Body cannot be empty",
356							None,
357							None,
358						));
359					}
360					// Validate subject according to RFC 5322
361					// Max length of 998 characters, no control chars except whitespace
362					if message.title.len() > 998 {
363						return Err(ConfigError::validation_error(
364							"Subject exceeds maximum length of 998 characters",
365							None,
366							None,
367						));
368					}
369					if message
370						.title
371						.chars()
372						.any(|c| c.is_control() && !c.is_whitespace())
373					{
374						return Err(ConfigError::validation_error(
375							"Subject contains invalid control characters",
376							None,
377							None,
378						));
379					}
380					// Add minimum length check after trim
381					if message.title.trim().is_empty() {
382						return Err(ConfigError::validation_error(
383							"Subject must contain at least 1 character",
384							None,
385							None,
386						));
387					}
388
389					// Validate email body according to RFC 5322
390					// Check for control characters (except CR, LF, and whitespace)
391					if message
392						.body
393						.chars()
394						.any(|c| c.is_control() && !matches!(c, '\r' | '\n' | '\t' | ' '))
395					{
396						return Err(ConfigError::validation_error(
397							"Body contains invalid control characters",
398							None,
399							None,
400						));
401					}
402
403					// Validate sender
404					if !EmailAddress::is_valid(sender.as_str()) {
405						return Err(ConfigError::validation_error(
406							format!("Invalid sender email address: {}", sender),
407							None,
408							None,
409						));
410					}
411
412					// Validate recipients
413					if recipients.is_empty() {
414						return Err(ConfigError::validation_error(
415							"Recipients cannot be empty",
416							None,
417							None,
418						));
419					}
420					for recipient in recipients {
421						if !EmailAddress::is_valid(recipient.as_str()) {
422							return Err(ConfigError::validation_error(
423								format!("Invalid recipient email address: {}", recipient),
424								None,
425								None,
426							));
427						}
428					}
429				}
430			}
431			TriggerType::Webhook => {
432				if let TriggerTypeConfig::Webhook {
433					url,
434					method,
435					message,
436					..
437				} = &self.config
438				{
439					// Validate URL format
440					if !url.starts_with("http://") && !url.starts_with("https://") {
441						return Err(ConfigError::validation_error(
442							"Invalid webhook URL format",
443							None,
444							None,
445						));
446					}
447					// Validate HTTP method
448					if let Some(method) = method {
449						match method.to_uppercase().as_str() {
450							"GET" | "POST" | "PUT" | "DELETE" => {}
451							_ => {
452								return Err(ConfigError::validation_error(
453									"Invalid HTTP method",
454									None,
455									None,
456								));
457							}
458						}
459					}
460					// Validate message
461					if message.title.trim().is_empty() {
462						return Err(ConfigError::validation_error(
463							"Title cannot be empty",
464							None,
465							None,
466						));
467					}
468					if message.body.trim().is_empty() {
469						return Err(ConfigError::validation_error(
470							"Body cannot be empty",
471							None,
472							None,
473						));
474					}
475				}
476			}
477			TriggerType::Telegram => {
478				if let TriggerTypeConfig::Telegram {
479					token,
480					chat_id,
481					message,
482					..
483				} = &self.config
484				{
485					// Validate token
486					// /^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$/ regex
487					if token.trim().is_empty() {
488						return Err(ConfigError::validation_error(
489							"Token cannot be empty",
490							None,
491							None,
492						));
493					}
494
495					// Safely compile and use the regex
496					match regex::Regex::new(r"^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$") {
497						Ok(re) => {
498							if !re.is_match(token.as_str()) {
499								return Err(ConfigError::validation_error(
500									"Invalid token format",
501									None,
502									None,
503								));
504							}
505						}
506						Err(e) => {
507							return Err(ConfigError::validation_error(
508								format!("Failed to validate token format: {}", e),
509								None,
510								None,
511							));
512						}
513					}
514
515					// Validate chat ID
516					if chat_id.trim().is_empty() {
517						return Err(ConfigError::validation_error(
518							"Chat ID cannot be empty",
519							None,
520							None,
521						));
522					}
523					// Validate message
524					if message.title.trim().is_empty() {
525						return Err(ConfigError::validation_error(
526							"Title cannot be empty",
527							None,
528							None,
529						));
530					}
531					if message.body.trim().is_empty() {
532						return Err(ConfigError::validation_error(
533							"Body cannot be empty",
534							None,
535							None,
536						));
537					}
538					// Validate template max length
539					if message.body.len() > TELEGRAM_MAX_BODY_LENGTH {
540						return Err(ConfigError::validation_error(
541							format!(
542								"Message body should not exceed {} characters",
543								TELEGRAM_MAX_BODY_LENGTH
544							),
545							None,
546							None,
547						));
548					}
549				}
550			}
551			TriggerType::Discord => {
552				if let TriggerTypeConfig::Discord {
553					discord_url,
554					message,
555					..
556				} = &self.config
557				{
558					// Validate webhook URL
559					if !discord_url.starts_with("https://discord.com/api/webhooks/") {
560						return Err(ConfigError::validation_error(
561							"Invalid Discord webhook URL format",
562							None,
563							None,
564						));
565					}
566					// Validate message
567					if message.title.trim().is_empty() {
568						return Err(ConfigError::validation_error(
569							"Title cannot be empty",
570							None,
571							None,
572						));
573					}
574					if message.body.trim().is_empty() {
575						return Err(ConfigError::validation_error(
576							"Body cannot be empty",
577							None,
578							None,
579						));
580					}
581					// Validate template max length
582					if message.body.len() > DISCORD_MAX_BODY_LENGTH {
583						return Err(ConfigError::validation_error(
584							format!(
585								"Message body should not exceed {} characters",
586								DISCORD_MAX_BODY_LENGTH
587							),
588							None,
589							None,
590						));
591					}
592				}
593			}
594			TriggerType::Script => {
595				if let TriggerTypeConfig::Script {
596					script_path,
597					language,
598					timeout_ms,
599					..
600				} = &self.config
601				{
602					validate_script_config(script_path, language, timeout_ms)?;
603				}
604			}
605		}
606
607		// Log a warning if the trigger uses an insecure protocol
608		self.validate_protocol();
609
610		Ok(())
611	}
612
613	/// Validate the safety of the protocols used in the trigger
614	///
615	/// Returns if safe, or logs a warning message if unsafe.
616	fn validate_protocol(&self) {
617		match &self.config {
618			TriggerTypeConfig::Slack { slack_url, .. } => {
619				if !slack_url.starts_with("https://") {
620					tracing::warn!("Slack URL uses an insecure protocol: {}", slack_url);
621				}
622			}
623			TriggerTypeConfig::Discord { discord_url, .. } => {
624				if !discord_url.starts_with("https://") {
625					tracing::warn!("Discord URL uses an insecure protocol: {}", discord_url);
626				}
627			}
628			TriggerTypeConfig::Telegram { .. } => {}
629			TriggerTypeConfig::Script { script_path, .. } => {
630				// Check script file permissions on Unix systems
631				#[cfg(unix)]
632				{
633					use std::os::unix::fs::PermissionsExt;
634					if let Ok(metadata) = std::fs::metadata(script_path) {
635						let permissions = metadata.permissions();
636						let mode = permissions.mode();
637						if mode & 0o022 != 0 {
638							tracing::warn!(
639								"Script file has overly permissive write permissions: {}.The recommended permissions are `644` (`rw-r--r--`)",
640								script_path
641							);
642						}
643					}
644				}
645			}
646			TriggerTypeConfig::Email { port, .. } => {
647				let secure_ports = [993, 587, 465];
648				if let Some(port) = port {
649					if !secure_ports.contains(port) {
650						tracing::warn!("Email port is not using a secure protocol: {}", port);
651					}
652				}
653			}
654			TriggerTypeConfig::Webhook { url, headers, .. } => {
655				if !url.starts_with("https://") {
656					tracing::warn!("Webhook URL uses an insecure protocol: {}", url);
657				}
658				// Check for security headers
659				match headers {
660					Some(headers) => {
661						if !headers.contains_key("X-API-Key")
662							&& !headers.contains_key("Authorization")
663						{
664							tracing::warn!("Webhook lacks authentication headers");
665						}
666					}
667					None => {
668						tracing::warn!("Webhook lacks authentication headers");
669					}
670				}
671			}
672		};
673	}
674
675	fn validate_uniqueness(
676		instances: &[&Self],
677		current_instance: &Self,
678		file_path: &str,
679	) -> Result<(), ConfigError> {
680		// Check trigger name uniqueness before pushing
681		if instances.iter().any(|existing_trigger| {
682			normalize_string(&existing_trigger.name) == normalize_string(&current_instance.name)
683		}) {
684			Err(ConfigError::validation_error(
685				format!("Duplicate trigger name found: '{}'", current_instance.name),
686				None,
687				Some(HashMap::from([
688					(
689						"trigger_name".to_string(),
690						current_instance.name.to_string(),
691					),
692					("path".to_string(), file_path.to_string()),
693				])),
694			))
695		} else {
696			Ok(())
697		}
698	}
699}
700
701#[cfg(test)]
702mod tests {
703	use super::*;
704	use crate::models::NotificationMessage;
705	use crate::models::{core::Trigger, ScriptLanguage, SecretString};
706	use crate::utils::tests::builders::trigger::TriggerBuilder;
707	use crate::utils::RetryConfig;
708	use std::{fs::File, io::Write, os::unix::fs::PermissionsExt};
709	use tempfile::TempDir;
710	use tracing_test::traced_test;
711
712	#[test]
713	fn test_slack_trigger_validation() {
714		// Valid trigger
715		let valid_trigger = TriggerBuilder::new()
716			.name("test_slack")
717			.slack("https://hooks.slack.com/services/xxx")
718			.message("Alert", "Test message")
719			.build();
720		assert!(valid_trigger.validate().is_ok());
721
722		// Invalid webhook URL
723		let invalid_webhook = TriggerBuilder::new()
724			.name("test_slack")
725			.slack("https://invalid-url.com")
726			.build();
727		assert!(invalid_webhook.validate().is_err());
728
729		// Empty title
730		let empty_title = TriggerBuilder::new()
731			.name("test_slack")
732			.slack("https://hooks.slack.com/services/xxx")
733			.message("", "Test message")
734			.build();
735		assert!(empty_title.validate().is_err());
736
737		// Empty body
738		let empty_body = TriggerBuilder::new()
739			.name("test_slack")
740			.slack("https://hooks.slack.com/services/xxx")
741			.message("Alert", "")
742			.build();
743		assert!(empty_body.validate().is_err());
744	}
745
746	#[test]
747	fn test_email_trigger_validation() {
748		// Valid trigger
749		let valid_trigger = TriggerBuilder::new()
750			.name("test_email")
751			.email(
752				"smtp.example.com",
753				"user",
754				"pass",
755				"sender@example.com",
756				vec!["recipient@example.com"],
757			)
758			.build();
759		assert!(valid_trigger.validate().is_ok());
760
761		// Test invalid host
762		let invalid_host = TriggerBuilder::new()
763			.name("test_email")
764			.email(
765				"invalid@host",
766				"user",
767				"pass",
768				"sender@example.com",
769				vec!["recipient@example.com"],
770			)
771			.build();
772		assert!(invalid_host.validate().is_err());
773
774		// Test empty host
775		let empty_host = TriggerBuilder::new()
776			.name("test_email")
777			.email(
778				"",
779				"user",
780				"pass",
781				"sender@example.com",
782				vec!["recipient@example.com"],
783			)
784			.build();
785		assert!(empty_host.validate().is_err());
786
787		// Test invalid email address
788		let invalid_email = TriggerBuilder::new()
789			.name("test_email")
790			.email(
791				"smtp.example.com",
792				"user",
793				"pass",
794				"invalid-email",
795				vec!["recipient@example.com"],
796			)
797			.build();
798		assert!(invalid_email.validate().is_err());
799
800		// Test empty password
801		let invalid_password = TriggerBuilder::new()
802			.name("test_email")
803			.email(
804				"smtp.example.com",
805				"user",
806				"", // Invalid password
807				"sender@example.com",
808				vec!["recipient@example.com"],
809			)
810			.build();
811		assert!(invalid_password.validate().is_err());
812
813		// Test subject too long
814		let invalid_subject = TriggerBuilder::new()
815			.name("test_email")
816			.email(
817				"smtp.example.com",
818				"user",
819				"pass",
820				"sender@example.com",
821				vec!["recipient@example.com"],
822			)
823			.message(&"A".repeat(999), "Test Body")  // Exceeds max length
824			.build();
825		assert!(invalid_subject.validate().is_err());
826
827		// Test empty username
828		let empty_username = TriggerBuilder::new()
829			.name("test_email")
830			.email(
831				"smtp.example.com",
832				"",
833				"pass",
834				"sender@example.com",
835				vec!["recipient@example.com"],
836			)
837			.build();
838		assert!(empty_username.validate().is_err());
839
840		// Test invalid control characters in username
841		let invalid_control_chars = TriggerBuilder::new()
842			.name("test_email")
843			.email(
844				"smtp.example.com",
845				"\0",
846				"pass",
847				"sender@example.com",
848				vec!["recipient@example.com"],
849			)
850			.build();
851		assert!(invalid_control_chars.validate().is_err());
852
853		// Test invalid recipient
854		let invalid_recipient = TriggerBuilder::new()
855			.name("test_email")
856			.email(
857				"smtp.example.com",
858				"user",
859				"pass",
860				"sender@example.com",
861				vec!["invalid-email"],
862			)
863			.build();
864		assert!(invalid_recipient.validate().is_err());
865
866		// Test empty body
867		let empty_body = TriggerBuilder::new()
868			.name("test_email")
869			.email(
870				"smtp.example.com",
871				"user",
872				"pass",
873				"sender@example.com",
874				vec!["recipient@example.com"],
875			)
876			.message("Test Subject", "")
877			.build();
878		assert!(empty_body.validate().is_err());
879
880		// Test control characters in subject
881		let control_chars_subject = TriggerBuilder::new()
882			.name("test_email")
883			.email(
884				"smtp.example.com",
885				"user",
886				"pass",
887				"sender@example.com",
888				vec!["recipient@example.com"],
889			)
890			.message("Test \0 Subject", "Test Body")
891			.build();
892		assert!(control_chars_subject.validate().is_err());
893
894		// Test control characters in body
895		let control_chars_body = TriggerBuilder::new()
896			.name("test_email")
897			.email(
898				"smtp.example.com",
899				"user",
900				"pass",
901				"sender@example.com",
902				vec!["recipient@example.com"],
903			)
904			.message("Test Subject", "Test \0 Body")
905			.build();
906		assert!(control_chars_body.validate().is_err());
907	}
908
909	#[test]
910	fn test_webhook_trigger_validation() {
911		// Valid trigger
912		let valid_trigger = TriggerBuilder::new()
913			.name("test_webhook")
914			.webhook("https://api.example.com/webhook")
915			.message("Alert", "Test message")
916			.build();
917		assert!(valid_trigger.validate().is_ok());
918
919		// Invalid URL
920		let invalid_url = TriggerBuilder::new()
921			.name("test_webhook")
922			.webhook("invalid-url")
923			.build();
924		assert!(invalid_url.validate().is_err());
925
926		// Empty title
927		let invalid_title = TriggerBuilder::new()
928			.name("test_webhook")
929			.webhook("https://api.example.com/webhook")
930			.message("", "Test message")
931			.build();
932		assert!(invalid_title.validate().is_err());
933
934		// Empty body
935		let invalid_body = TriggerBuilder::new()
936			.name("test_webhook")
937			.webhook("https://api.example.com/webhook")
938			.message("Alert", "")
939			.build();
940		assert!(invalid_body.validate().is_err());
941	}
942
943	#[test]
944	fn test_discord_trigger_validation() {
945		// Valid trigger
946		let valid_trigger = TriggerBuilder::new()
947			.name("test_discord")
948			.discord("https://discord.com/api/webhooks/xxx")
949			.message("Alert", "Test message")
950			.build();
951		assert!(valid_trigger.validate().is_ok());
952
953		// Invalid webhook URL
954		let invalid_webhook = TriggerBuilder::new()
955			.name("test_discord")
956			.discord("https://invalid-url.com")
957			.build();
958		assert!(invalid_webhook.validate().is_err());
959
960		// Empty title
961		let invalid_title = TriggerBuilder::new()
962			.name("test_discord")
963			.discord("https://discord.com/api/webhooks/123")
964			.message("", "Test message")
965			.build();
966		assert!(invalid_title.validate().is_err());
967
968		// Empty body
969		let invalid_body = TriggerBuilder::new()
970			.name("test_discord")
971			.discord("https://discord.com/api/webhooks/123")
972			.message("Alert", "")
973			.build();
974		assert!(invalid_body.validate().is_err());
975	}
976
977	#[test]
978	fn test_telegram_trigger_validation() {
979		let valid_trigger = TriggerBuilder::new()
980			.name("test_telegram")
981			.telegram(
982				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
983				"1730223038",
984				true,
985			)
986			.build();
987		assert!(valid_trigger.validate().is_ok());
988
989		// Test invalid token
990		let invalid_token = TriggerBuilder::new()
991			.name("test_telegram")
992			.telegram("invalid-token", "1730223038", true)
993			.build();
994		assert!(invalid_token.validate().is_err());
995
996		// Test invalid chat ID
997		let invalid_chat_id = TriggerBuilder::new()
998			.name("test_telegram")
999			.telegram(
1000				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1001				"",
1002				true,
1003			)
1004			.build();
1005		assert!(invalid_chat_id.validate().is_err());
1006
1007		// Test invalid message
1008		let invalid_title_message = TriggerBuilder::new()
1009			.name("test_telegram")
1010			.telegram(
1011				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1012				"1730223038",
1013				true,
1014			)
1015			.message("", "Test Message")
1016			.build();
1017		assert!(invalid_title_message.validate().is_err());
1018
1019		let invalid_body_message = TriggerBuilder::new()
1020			.name("test_telegram")
1021			.telegram(
1022				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", // noboost
1023				"1730223038",
1024				true,
1025			)
1026			.message("Test Subject", "")
1027			.build();
1028		assert!(invalid_body_message.validate().is_err());
1029	}
1030
1031	#[test]
1032	fn test_script_trigger_validation() {
1033		let temp_dir = std::env::temp_dir();
1034		let script_path = temp_dir.join("test_script.sh");
1035		std::fs::write(&script_path, "#!/bin/bash\necho 'test'").unwrap();
1036
1037		// Valid trigger
1038		let valid_trigger = TriggerBuilder::new()
1039			.name("test_script")
1040			.script(script_path.to_str().unwrap(), ScriptLanguage::Bash)
1041			.build();
1042		assert!(valid_trigger.validate().is_ok());
1043
1044		// Non-existent script
1045		let invalid_path = TriggerBuilder::new()
1046			.name("test_script")
1047			.script("/non/existent/path", ScriptLanguage::Python)
1048			.build();
1049		assert!(invalid_path.validate().is_err());
1050
1051		std::fs::remove_file(script_path).unwrap();
1052	}
1053
1054	#[tokio::test]
1055	async fn test_invalid_load_from_path() {
1056		let path = Path::new("config/triggers/invalid.json");
1057		assert!(matches!(
1058			Trigger::load_from_path(path).await,
1059			Err(ConfigError::FileError(_))
1060		));
1061	}
1062
1063	#[tokio::test]
1064	async fn test_invalid_config_from_load_from_path() {
1065		use std::io::Write;
1066		use tempfile::NamedTempFile;
1067
1068		let mut temp_file = NamedTempFile::new().unwrap();
1069		write!(temp_file, "{{\"invalid\": \"json").unwrap();
1070
1071		let path = temp_file.path();
1072
1073		assert!(matches!(
1074			Trigger::load_from_path(path).await,
1075			Err(ConfigError::ParseError(_))
1076		));
1077	}
1078
1079	#[tokio::test]
1080	async fn test_load_all_directory_not_found() {
1081		let non_existent_path = Path::new("non_existent_directory");
1082
1083		let result: Result<HashMap<String, Trigger>, ConfigError> =
1084			Trigger::load_all(Some(non_existent_path)).await;
1085		assert!(matches!(result, Err(ConfigError::FileError(_))));
1086
1087		if let Err(ConfigError::FileError(err)) = result {
1088			assert!(err.message.contains("triggers directory not found"));
1089		}
1090	}
1091
1092	#[tokio::test]
1093	#[cfg(unix)] // This test is Unix-specific due to permission handling
1094	async fn test_load_all_unreadable_file() {
1095		// Create a temporary directory for our test
1096		let temp_dir = TempDir::new().unwrap();
1097		let config_dir = temp_dir.path().join("triggers");
1098		std::fs::create_dir(&config_dir).unwrap();
1099
1100		// Create a JSON file with valid content but unreadable permissions
1101		let file_path = config_dir.join("unreadable.json");
1102		{
1103			let mut file = File::create(&file_path).unwrap();
1104			writeln!(file, r#"{{ "test_trigger": {{ "name": "test", "trigger_type": "Slack", "config": {{ "slack_url": "https://hooks.slack.com/services/xxx", "message": {{ "title": "Alert", "body": "Test message" }} }} }} }}"#).unwrap();
1105		}
1106
1107		// Change permissions to make the file unreadable
1108		let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1109		perms.set_mode(0o000); // No permissions
1110		std::fs::set_permissions(&file_path, perms).unwrap();
1111
1112		// Try to load triggers from the directory
1113		let result: Result<HashMap<String, Trigger>, ConfigError> =
1114			Trigger::load_all(Some(&config_dir)).await;
1115
1116		// Verify we get the expected error
1117		assert!(matches!(result, Err(ConfigError::FileError(_))));
1118		if let Err(ConfigError::FileError(err)) = result {
1119			assert!(err.message.contains("failed to read trigger config file"));
1120		}
1121
1122		// Clean up by making the file deletable
1123		let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1124		perms.set_mode(0o644);
1125		std::fs::set_permissions(&file_path, perms).unwrap();
1126	}
1127
1128	#[test]
1129	#[traced_test]
1130	fn test_validate_protocol_slack() {
1131		let insecure_trigger = TriggerBuilder::new()
1132			.name("test_slack")
1133			.slack("http://hooks.slack.com/services/xxx")
1134			.build();
1135
1136		insecure_trigger.validate_protocol();
1137		assert!(logs_contain("Slack URL uses an insecure protocol"));
1138	}
1139
1140	#[test]
1141	#[traced_test]
1142	fn test_validate_protocol_discord() {
1143		let insecure_trigger = TriggerBuilder::new()
1144			.name("test_discord")
1145			.discord("http://discord.com/api/webhooks/xxx")
1146			.build();
1147
1148		insecure_trigger.validate_protocol();
1149		assert!(logs_contain("Discord URL uses an insecure protocol"));
1150	}
1151
1152	#[test]
1153	#[traced_test]
1154	fn test_validate_protocol_webhook() {
1155		let insecure_trigger = TriggerBuilder::new()
1156			.name("test_webhook")
1157			.webhook("http://api.example.com/webhook")
1158			.build();
1159
1160		insecure_trigger.validate_protocol();
1161		assert!(logs_contain("Webhook URL uses an insecure protocol"));
1162		assert!(logs_contain("Webhook lacks authentication headers"));
1163	}
1164
1165	#[test]
1166	#[traced_test]
1167	fn test_validate_protocol_email() {
1168		let insecure_trigger = TriggerBuilder::new()
1169			.name("test_email")
1170			.email(
1171				"smtp.example.com",
1172				"user",
1173				"pass",
1174				"sender@example.com",
1175				vec!["recipient@example.com"],
1176			)
1177			.email_port(25) // Insecure port
1178			.build();
1179
1180		insecure_trigger.validate_protocol();
1181		assert!(logs_contain("Email port is not using a secure protocol"));
1182	}
1183
1184	#[cfg(unix)]
1185	#[test]
1186	#[traced_test]
1187	fn test_validate_protocol_script() {
1188		use std::fs::File;
1189		use std::os::unix::fs::PermissionsExt;
1190		use tempfile::TempDir;
1191
1192		let temp_dir = TempDir::new().unwrap();
1193		let script_path = temp_dir.path().join("test_script.sh");
1194		File::create(&script_path).unwrap();
1195
1196		// Set overly permissive permissions (777)
1197		let metadata = std::fs::metadata(&script_path).unwrap();
1198		let mut permissions = metadata.permissions();
1199		permissions.set_mode(0o777);
1200		std::fs::set_permissions(&script_path, permissions).unwrap();
1201
1202		let trigger = TriggerBuilder::new()
1203			.name("test_script")
1204			.script(script_path.to_str().unwrap(), ScriptLanguage::Bash)
1205			.build();
1206
1207		trigger.validate_protocol();
1208		assert!(logs_contain(
1209			"Script file has overly permissive write permissions"
1210		));
1211	}
1212
1213	#[test]
1214	#[traced_test]
1215	fn test_validate_protocol_webhook_with_headers() {
1216		let mut headers = HashMap::new();
1217		headers.insert("Content-Type".to_string(), "application/json".to_string());
1218
1219		let insecure_trigger = TriggerBuilder::new()
1220			.name("test_webhook")
1221			.webhook("http://api.example.com/webhook")
1222			.webhook_headers(headers)
1223			.build();
1224
1225		insecure_trigger.validate_protocol();
1226		assert!(logs_contain("Webhook URL uses an insecure protocol"));
1227		assert!(logs_contain("Webhook lacks authentication headers"));
1228	}
1229
1230	#[tokio::test]
1231	async fn test_resolve_secrets_slack() {
1232		let trigger = TriggerBuilder::new()
1233			.name("slack")
1234			.slack("https://hooks.slack.com/xxx")
1235			.build();
1236
1237		let resolved = trigger.resolve_secrets().await.unwrap();
1238		if let TriggerTypeConfig::Slack { slack_url, .. } = &resolved.config {
1239			assert!(matches!(slack_url, SecretValue::Plain(_)));
1240		}
1241	}
1242
1243	#[tokio::test]
1244	async fn test_resolve_secrets_email() {
1245		let trigger = TriggerBuilder::new()
1246			.name("email")
1247			.email(
1248				"smtp.example.com",
1249				"user",
1250				"pass",
1251				"sender@example.com",
1252				vec!["recipient@example.com"],
1253			)
1254			.build();
1255
1256		let resolved = trigger.resolve_secrets().await.unwrap();
1257		if let TriggerTypeConfig::Email {
1258			username, password, ..
1259		} = &resolved.config
1260		{
1261			assert!(matches!(username, SecretValue::Plain(_)));
1262			assert!(matches!(password, SecretValue::Plain(_)));
1263		}
1264	}
1265
1266	#[tokio::test]
1267	async fn test_resolve_secrets_webhook_with_secret() {
1268		let trigger = TriggerBuilder::new()
1269			.name("webhook")
1270			.webhook("https://api.example.com")
1271			.webhook_secret(SecretValue::Plain(SecretString::new("secret".to_string())))
1272			.build();
1273
1274		let resolved = trigger.resolve_secrets().await.unwrap();
1275		if let TriggerTypeConfig::Webhook { url, secret, .. } = &resolved.config {
1276			assert!(matches!(url, SecretValue::Plain(_)));
1277			assert!(matches!(secret, Some(SecretValue::Plain(_))));
1278		}
1279	}
1280
1281	#[tokio::test]
1282	async fn test_resolve_secrets_telegram() {
1283		let trigger = TriggerBuilder::new()
1284			.name("telegram")
1285			.telegram(
1286				"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789",
1287				"1730223038",
1288				true,
1289			)
1290			.build();
1291
1292		let resolved = trigger.resolve_secrets().await.unwrap();
1293		if let TriggerTypeConfig::Telegram { token, .. } = &resolved.config {
1294			assert!(matches!(token, SecretValue::Plain(_)));
1295		}
1296	}
1297
1298	#[tokio::test]
1299	async fn test_resolve_secrets_discord() {
1300		let trigger = TriggerBuilder::new()
1301			.name("discord")
1302			.discord("https://discord.com/api/webhooks/xxx")
1303			.build();
1304
1305		let resolved = trigger.resolve_secrets().await.unwrap();
1306		if let TriggerTypeConfig::Discord { discord_url, .. } = &resolved.config {
1307			assert!(matches!(discord_url, SecretValue::Plain(_)));
1308		}
1309	}
1310
1311	#[tokio::test]
1312	async fn test_resolve_secrets_other_branch() {
1313		// For a config type not handled in the match (e.g., Script)
1314		let trigger = TriggerBuilder::new()
1315			.name("script")
1316			.script("/tmp/test.sh", ScriptLanguage::Bash)
1317			.build();
1318
1319		let resolved = trigger.resolve_secrets().await.unwrap();
1320		if let TriggerTypeConfig::Script { .. } = &resolved.config {
1321			// No secret resolution, just check it passes
1322		}
1323	}
1324
1325	#[tokio::test]
1326	async fn test_resolve_secrets_slack_env_error() {
1327		let trigger = TriggerBuilder::new()
1328			.name("slack")
1329			.slack("")
1330			.url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1331			.build();
1332
1333		let result = trigger.resolve_secrets().await;
1334		assert!(result.is_err());
1335		if let Err(e) = result {
1336			assert!(e.to_string().contains("failed to resolve Slack URL"));
1337		}
1338	}
1339
1340	#[tokio::test]
1341	async fn test_resolve_secrets_discord_env_error() {
1342		let trigger = TriggerBuilder::new()
1343			.name("discord")
1344			.discord("")
1345			.url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1346			.build();
1347
1348		let result = trigger.resolve_secrets().await;
1349		assert!(result.is_err());
1350		if let Err(e) = result {
1351			assert!(e.to_string().contains("failed to resolve Discord URL"));
1352		}
1353	}
1354
1355	#[tokio::test]
1356	async fn test_resolve_secrets_telegram_env_error() {
1357		let trigger = TriggerBuilder::new()
1358			.name("telegram")
1359			.telegram("", "1730223038", true)
1360			.telegram_token(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1361			.build();
1362
1363		let result = trigger.resolve_secrets().await;
1364		assert!(result.is_err());
1365		if let Err(e) = result {
1366			assert!(e.to_string().contains("failed to resolve Telegram token"));
1367		}
1368	}
1369
1370	#[tokio::test]
1371	async fn test_resolve_secrets_webhook_env_error() {
1372		let trigger = TriggerBuilder::new()
1373			.name("webhook")
1374			.webhook("")
1375			.url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1376			.build();
1377
1378		let result = trigger.resolve_secrets().await;
1379		assert!(result.is_err());
1380		if let Err(e) = result {
1381			assert!(e.to_string().contains("failed to resolve webhook URL"));
1382		}
1383
1384		let trigger = TriggerBuilder::new()
1385			.name("webhook")
1386			.webhook("https://api.example.com")
1387			.webhook_secret(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1388			.build();
1389
1390		let result = trigger.resolve_secrets().await;
1391		assert!(result.is_err());
1392		if let Err(e) = result {
1393			assert!(e.to_string().contains("failed to resolve webhook secret"));
1394		}
1395	}
1396
1397	#[tokio::test]
1398	async fn test_resolve_secrets_email_env_error() {
1399		let trigger = TriggerBuilder::new()
1400			.name("email")
1401			.email(
1402				"smtp.example.com",
1403				"",
1404				"pass",
1405				"sender@example.com",
1406				vec!["recipient@example.com"],
1407			)
1408			.email_username(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1409			.build();
1410
1411		let result = trigger.resolve_secrets().await;
1412		assert!(result.is_err());
1413		if let Err(e) = result {
1414			assert!(e.to_string().contains("failed to resolve SMTP username"));
1415		}
1416
1417		let trigger = TriggerBuilder::new()
1418			.name("email")
1419			.email(
1420				"smtp.example.com",
1421				"user",
1422				"",
1423				"sender@example.com",
1424				vec!["recipient@example.com"],
1425			)
1426			.email_password(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1427			.build();
1428
1429		let result = trigger.resolve_secrets().await;
1430		assert!(result.is_err());
1431		if let Err(e) = result {
1432			assert!(e.to_string().contains("failed to resolve SMTP password"));
1433		}
1434	}
1435	#[test]
1436	fn test_telegram_max_message_length() {
1437		let max_body_length = Trigger {
1438			name: "test_telegram".to_string(),
1439			trigger_type: TriggerType::Telegram,
1440			config: TriggerTypeConfig::Telegram {
1441				token: SecretValue::Plain(SecretString::new(
1442					"1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789".to_string(),
1443				)),
1444				chat_id: "1730223038".to_string(),
1445				disable_web_preview: Some(true),
1446				message: NotificationMessage {
1447					title: "Test".to_string(),
1448					body: "x".repeat(TELEGRAM_MAX_BODY_LENGTH + 1), // Exceeds max length
1449				},
1450				retry_policy: RetryConfig::default(),
1451			},
1452		};
1453		assert!(max_body_length.validate().is_err());
1454	}
1455
1456	#[test]
1457	fn test_discord_max_message_length() {
1458		let max_body_length = Trigger {
1459			name: "test_discord".to_string(),
1460			trigger_type: TriggerType::Discord,
1461			config: TriggerTypeConfig::Discord {
1462				discord_url: SecretValue::Plain(SecretString::new(
1463					"https://discord.com/api/webhooks/xxx".to_string(),
1464				)),
1465				message: NotificationMessage {
1466					title: "Test".to_string(),
1467					body: "z".repeat(DISCORD_MAX_BODY_LENGTH + 1), // Exceeds max length
1468				},
1469				retry_policy: RetryConfig::default(),
1470			},
1471		};
1472		assert!(max_body_length.validate().is_err());
1473	}
1474
1475	#[tokio::test]
1476	async fn test_load_all_duplicate_trigger_name() {
1477		let temp_dir = TempDir::new().unwrap();
1478		let file_path_1 = temp_dir.path().join("duplicate_trigger.json");
1479		let file_path_2 = temp_dir.path().join("duplicate_trigger_2.json");
1480
1481		let trigger_config_1 = r#"{
1482			"test_trigger_1": {
1483				"name": "TestTrigger",
1484				"trigger_type": "slack",
1485				"config": {
1486					"slack_url": {
1487						"type": "plain",
1488						"value": "https://hooks.slack.com/services/xxx"
1489					},
1490					"message": {
1491						"title": "Test",
1492						"body": "Test"
1493					}
1494				}
1495			}
1496		}"#;
1497
1498		let trigger_config_2 = r#"{
1499			"test_trigger_2": {
1500				"name": "testTrigger",
1501				"trigger_type": "discord",
1502				"config": {
1503					"discord_url": {
1504						"type": "plain",
1505						"value": "https://discord.com/api/webhooks/xxx"
1506					},
1507					"message": {
1508						"title": "Test",
1509						"body": "Test"
1510					}
1511				}
1512			}
1513		}"#;
1514
1515		fs::write(&file_path_1, trigger_config_1).unwrap();
1516		fs::write(&file_path_2, trigger_config_2).unwrap();
1517
1518		let result: Result<HashMap<String, Trigger>, ConfigError> =
1519			Trigger::load_all(Some(temp_dir.path())).await;
1520
1521		assert!(result.is_err());
1522		if let Err(ConfigError::ValidationError(err)) = result {
1523			assert!(err.message.contains("Duplicate trigger name found"));
1524		}
1525	}
1526}