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