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 } = &self.config
299 {
300 if host.trim().is_empty() {
302 return Err(ConfigError::validation_error(
303 "Host cannot be empty",
304 None,
305 None,
306 ));
307 }
308 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 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 if password.trim().is_empty() {
338 return Err(ConfigError::validation_error(
339 "Password cannot be empty",
340 None,
341 None,
342 ));
343 }
344 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 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 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 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 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 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 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 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 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 if token.trim().is_empty() {
487 return Err(ConfigError::validation_error(
488 "Token cannot be empty",
489 None,
490 None,
491 ));
492 }
493
494 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 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 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 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 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 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 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 self.validate_protocol();
608
609 Ok(())
610 }
611
612 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 #[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 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 if instances.iter().any(|existing_trigger| {
681 normalize_string(&existing_trigger.name) == normalize_string(¤t_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 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 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 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 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 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 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 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 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 let invalid_password = TriggerBuilder::new()
801 .name("test_email")
802 .email(
803 "smtp.example.com",
804 "user",
805 "", "sender@example.com",
807 vec!["recipient@example.com"],
808 )
809 .build();
810 assert!(invalid_password.validate().is_err());
811
812 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") .build();
824 assert!(invalid_subject.validate().is_err());
825
826 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 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 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 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 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 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 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 let invalid_url = TriggerBuilder::new()
920 .name("test_webhook")
921 .webhook("invalid-url")
922 .build();
923 assert!(invalid_url.validate().is_err());
924
925 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 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 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 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 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 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", "1730223038",
983 true,
984 )
985 .build();
986 assert!(valid_trigger.validate().is_ok());
987
988 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 let invalid_chat_id = TriggerBuilder::new()
997 .name("test_telegram")
998 .telegram(
999 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "",
1001 true,
1002 )
1003 .build();
1004 assert!(invalid_chat_id.validate().is_err());
1005
1006 let invalid_title_message = TriggerBuilder::new()
1008 .name("test_telegram")
1009 .telegram(
1010 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "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", "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 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 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)] async fn test_load_all_unreadable_file() {
1094 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 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 let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1108 perms.set_mode(0o000); std::fs::set_permissions(&file_path, perms).unwrap();
1110
1111 let result: Result<HashMap<String, Trigger>, ConfigError> =
1113 Trigger::load_all(Some(&config_dir)).await;
1114
1115 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 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) .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 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 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 }
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), },
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), },
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}