1use 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#[derive(Debug, Deserialize)]
25pub struct TriggerConfigFile {
26 #[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 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 for (name, mut trigger) in file_triggers.triggers {
187 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 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 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 config = config.resolve_secrets().await?;
230
231 config.validate()?;
233
234 Ok(config)
235 }
236
237 fn validate(&self) -> Result<(), ConfigError> {
246 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 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 if message.title.trim().is_empty() {
273 return Err(ConfigError::validation_error(
274 "Title cannot be empty",
275 None,
276 None,
277 ));
278 }
279 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 if host.trim().is_empty() {
303 return Err(ConfigError::validation_error(
304 "Host cannot be empty",
305 None,
306 None,
307 ));
308 }
309 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 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 if password.trim().is_empty() {
339 return Err(ConfigError::validation_error(
340 "Password cannot be empty",
341 None,
342 None,
343 ));
344 }
345 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 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 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 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 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 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 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 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 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 if token.trim().is_empty() {
488 return Err(ConfigError::validation_error(
489 "Token cannot be empty",
490 None,
491 None,
492 ));
493 }
494
495 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 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 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 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 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 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 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 self.validate_protocol();
609
610 Ok(())
611 }
612
613 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 #[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 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 if instances.iter().any(|existing_trigger| {
682 normalize_string(&existing_trigger.name) == normalize_string(¤t_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 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 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 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 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 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 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 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 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 let invalid_password = TriggerBuilder::new()
802 .name("test_email")
803 .email(
804 "smtp.example.com",
805 "user",
806 "", "sender@example.com",
808 vec!["recipient@example.com"],
809 )
810 .build();
811 assert!(invalid_password.validate().is_err());
812
813 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") .build();
825 assert!(invalid_subject.validate().is_err());
826
827 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 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 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 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 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 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 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 let invalid_url = TriggerBuilder::new()
921 .name("test_webhook")
922 .webhook("invalid-url")
923 .build();
924 assert!(invalid_url.validate().is_err());
925
926 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 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 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 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 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 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", "1730223038",
984 true,
985 )
986 .build();
987 assert!(valid_trigger.validate().is_ok());
988
989 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 let invalid_chat_id = TriggerBuilder::new()
998 .name("test_telegram")
999 .telegram(
1000 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "",
1002 true,
1003 )
1004 .build();
1005 assert!(invalid_chat_id.validate().is_err());
1006
1007 let invalid_title_message = TriggerBuilder::new()
1009 .name("test_telegram")
1010 .telegram(
1011 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "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", "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 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 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)] async fn test_load_all_unreadable_file() {
1095 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 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 let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1109 perms.set_mode(0o000); std::fs::set_permissions(&file_path, perms).unwrap();
1111
1112 let result: Result<HashMap<String, Trigger>, ConfigError> =
1114 Trigger::load_all(Some(&config_dir)).await;
1115
1116 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 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) .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 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 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 }
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), },
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), },
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}