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, WebhookPayloadMode,
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 payload_mode,
437 ..
438 } = &self.config
439 {
440 if !url.starts_with("http://") && !url.starts_with("https://") {
442 return Err(ConfigError::validation_error(
443 "Invalid webhook URL format",
444 None,
445 None,
446 ));
447 }
448 if let Some(method) = method {
450 match method.to_uppercase().as_str() {
451 "GET" | "POST" | "PUT" | "DELETE" => {}
452 _ => {
453 return Err(ConfigError::validation_error(
454 "Invalid HTTP method",
455 None,
456 None,
457 ));
458 }
459 }
460 }
461 if *payload_mode == WebhookPayloadMode::Template {
464 if message.title.trim().is_empty() {
465 return Err(ConfigError::validation_error(
466 "Title cannot be empty",
467 None,
468 None,
469 ));
470 }
471 if message.body.trim().is_empty() {
472 return Err(ConfigError::validation_error(
473 "Body cannot be empty",
474 None,
475 None,
476 ));
477 }
478 }
479 }
480 }
481 TriggerType::Telegram => {
482 if let TriggerTypeConfig::Telegram {
483 token,
484 chat_id,
485 message,
486 ..
487 } = &self.config
488 {
489 if token.trim().is_empty() {
492 return Err(ConfigError::validation_error(
493 "Token cannot be empty",
494 None,
495 None,
496 ));
497 }
498
499 match regex::Regex::new(r"^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$") {
501 Ok(re) => {
502 if !re.is_match(token.as_str()) {
503 return Err(ConfigError::validation_error(
504 "Invalid token format",
505 None,
506 None,
507 ));
508 }
509 }
510 Err(e) => {
511 return Err(ConfigError::validation_error(
512 format!("Failed to validate token format: {}", e),
513 None,
514 None,
515 ));
516 }
517 }
518
519 if chat_id.trim().is_empty() {
521 return Err(ConfigError::validation_error(
522 "Chat ID cannot be empty",
523 None,
524 None,
525 ));
526 }
527 if message.title.trim().is_empty() {
529 return Err(ConfigError::validation_error(
530 "Title cannot be empty",
531 None,
532 None,
533 ));
534 }
535 if message.body.trim().is_empty() {
536 return Err(ConfigError::validation_error(
537 "Body cannot be empty",
538 None,
539 None,
540 ));
541 }
542 if message.body.len() > TELEGRAM_MAX_BODY_LENGTH {
544 return Err(ConfigError::validation_error(
545 format!(
546 "Message body should not exceed {} characters",
547 TELEGRAM_MAX_BODY_LENGTH
548 ),
549 None,
550 None,
551 ));
552 }
553 }
554 }
555 TriggerType::Discord => {
556 if let TriggerTypeConfig::Discord {
557 discord_url,
558 message,
559 ..
560 } = &self.config
561 {
562 if !discord_url.starts_with("https://discord.com/api/webhooks/") {
564 return Err(ConfigError::validation_error(
565 "Invalid Discord webhook URL format",
566 None,
567 None,
568 ));
569 }
570 if message.title.trim().is_empty() {
572 return Err(ConfigError::validation_error(
573 "Title cannot be empty",
574 None,
575 None,
576 ));
577 }
578 if message.body.trim().is_empty() {
579 return Err(ConfigError::validation_error(
580 "Body cannot be empty",
581 None,
582 None,
583 ));
584 }
585 if message.body.len() > DISCORD_MAX_BODY_LENGTH {
587 return Err(ConfigError::validation_error(
588 format!(
589 "Message body should not exceed {} characters",
590 DISCORD_MAX_BODY_LENGTH
591 ),
592 None,
593 None,
594 ));
595 }
596 }
597 }
598 TriggerType::Script => {
599 if let TriggerTypeConfig::Script {
600 script_path,
601 language,
602 timeout_ms,
603 ..
604 } = &self.config
605 {
606 validate_script_config(script_path, language, timeout_ms)?;
607 }
608 }
609 }
610
611 self.validate_protocol();
613
614 Ok(())
615 }
616
617 fn validate_protocol(&self) {
621 match &self.config {
622 TriggerTypeConfig::Slack { slack_url, .. } => {
623 if !slack_url.starts_with("https://") {
624 tracing::warn!("Slack URL uses an insecure protocol: {}", slack_url);
625 }
626 }
627 TriggerTypeConfig::Discord { discord_url, .. } => {
628 if !discord_url.starts_with("https://") {
629 tracing::warn!("Discord URL uses an insecure protocol: {}", discord_url);
630 }
631 }
632 TriggerTypeConfig::Telegram { .. } => {}
633 TriggerTypeConfig::Script { script_path, .. } => {
634 #[cfg(unix)]
636 {
637 use std::os::unix::fs::PermissionsExt;
638 if let Ok(metadata) = std::fs::metadata(script_path) {
639 let permissions = metadata.permissions();
640 let mode = permissions.mode();
641 if mode & 0o022 != 0 {
642 tracing::warn!(
643 "Script file has overly permissive write permissions: {}.The recommended permissions are `644` (`rw-r--r--`)",
644 script_path
645 );
646 }
647 }
648 }
649 }
650 TriggerTypeConfig::Email { port, .. } => {
651 let secure_ports = [993, 587, 465];
652 if let Some(port) = port {
653 if !secure_ports.contains(port) {
654 tracing::warn!("Email port is not using a secure protocol: {}", port);
655 }
656 }
657 }
658 TriggerTypeConfig::Webhook { url, headers, .. } => {
659 if !url.starts_with("https://") {
660 tracing::warn!("Webhook URL uses an insecure protocol: {}", url);
661 }
662 match headers {
664 Some(headers) => {
665 if !headers.contains_key("X-API-Key")
666 && !headers.contains_key("Authorization")
667 {
668 tracing::warn!("Webhook lacks authentication headers");
669 }
670 }
671 None => {
672 tracing::warn!("Webhook lacks authentication headers");
673 }
674 }
675 }
676 };
677 }
678
679 fn validate_uniqueness(
680 instances: &[&Self],
681 current_instance: &Self,
682 file_path: &str,
683 ) -> Result<(), ConfigError> {
684 if instances.iter().any(|existing_trigger| {
686 normalize_string(&existing_trigger.name) == normalize_string(¤t_instance.name)
687 }) {
688 Err(ConfigError::validation_error(
689 format!("Duplicate trigger name found: '{}'", current_instance.name),
690 None,
691 Some(HashMap::from([
692 (
693 "trigger_name".to_string(),
694 current_instance.name.to_string(),
695 ),
696 ("path".to_string(), file_path.to_string()),
697 ])),
698 ))
699 } else {
700 Ok(())
701 }
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use crate::models::NotificationMessage;
709 use crate::models::{core::Trigger, ScriptLanguage, SecretString};
710 use crate::utils::tests::builders::trigger::TriggerBuilder;
711 use crate::utils::RetryConfig;
712 use std::{fs::File, io::Write, os::unix::fs::PermissionsExt};
713 use tempfile::TempDir;
714 use tracing_test::traced_test;
715
716 #[test]
717 fn test_slack_trigger_validation() {
718 let valid_trigger = TriggerBuilder::new()
720 .name("test_slack")
721 .slack("https://hooks.slack.com/services/xxx")
722 .message("Alert", "Test message")
723 .build();
724 assert!(valid_trigger.validate().is_ok());
725
726 let invalid_webhook = TriggerBuilder::new()
728 .name("test_slack")
729 .slack("https://invalid-url.com")
730 .build();
731 assert!(invalid_webhook.validate().is_err());
732
733 let empty_title = TriggerBuilder::new()
735 .name("test_slack")
736 .slack("https://hooks.slack.com/services/xxx")
737 .message("", "Test message")
738 .build();
739 assert!(empty_title.validate().is_err());
740
741 let empty_body = TriggerBuilder::new()
743 .name("test_slack")
744 .slack("https://hooks.slack.com/services/xxx")
745 .message("Alert", "")
746 .build();
747 assert!(empty_body.validate().is_err());
748 }
749
750 #[test]
751 fn test_email_trigger_validation() {
752 let valid_trigger = TriggerBuilder::new()
754 .name("test_email")
755 .email(
756 "smtp.example.com",
757 "user",
758 "pass",
759 "sender@example.com",
760 vec!["recipient@example.com"],
761 )
762 .build();
763 assert!(valid_trigger.validate().is_ok());
764
765 let invalid_host = TriggerBuilder::new()
767 .name("test_email")
768 .email(
769 "invalid@host",
770 "user",
771 "pass",
772 "sender@example.com",
773 vec!["recipient@example.com"],
774 )
775 .build();
776 assert!(invalid_host.validate().is_err());
777
778 let empty_host = TriggerBuilder::new()
780 .name("test_email")
781 .email(
782 "",
783 "user",
784 "pass",
785 "sender@example.com",
786 vec!["recipient@example.com"],
787 )
788 .build();
789 assert!(empty_host.validate().is_err());
790
791 let invalid_email = TriggerBuilder::new()
793 .name("test_email")
794 .email(
795 "smtp.example.com",
796 "user",
797 "pass",
798 "invalid-email",
799 vec!["recipient@example.com"],
800 )
801 .build();
802 assert!(invalid_email.validate().is_err());
803
804 let invalid_password = TriggerBuilder::new()
806 .name("test_email")
807 .email(
808 "smtp.example.com",
809 "user",
810 "", "sender@example.com",
812 vec!["recipient@example.com"],
813 )
814 .build();
815 assert!(invalid_password.validate().is_err());
816
817 let invalid_subject = TriggerBuilder::new()
819 .name("test_email")
820 .email(
821 "smtp.example.com",
822 "user",
823 "pass",
824 "sender@example.com",
825 vec!["recipient@example.com"],
826 )
827 .message(&"A".repeat(999), "Test Body") .build();
829 assert!(invalid_subject.validate().is_err());
830
831 let empty_username = TriggerBuilder::new()
833 .name("test_email")
834 .email(
835 "smtp.example.com",
836 "",
837 "pass",
838 "sender@example.com",
839 vec!["recipient@example.com"],
840 )
841 .build();
842 assert!(empty_username.validate().is_err());
843
844 let invalid_control_chars = TriggerBuilder::new()
846 .name("test_email")
847 .email(
848 "smtp.example.com",
849 "\0",
850 "pass",
851 "sender@example.com",
852 vec!["recipient@example.com"],
853 )
854 .build();
855 assert!(invalid_control_chars.validate().is_err());
856
857 let invalid_recipient = TriggerBuilder::new()
859 .name("test_email")
860 .email(
861 "smtp.example.com",
862 "user",
863 "pass",
864 "sender@example.com",
865 vec!["invalid-email"],
866 )
867 .build();
868 assert!(invalid_recipient.validate().is_err());
869
870 let empty_body = TriggerBuilder::new()
872 .name("test_email")
873 .email(
874 "smtp.example.com",
875 "user",
876 "pass",
877 "sender@example.com",
878 vec!["recipient@example.com"],
879 )
880 .message("Test Subject", "")
881 .build();
882 assert!(empty_body.validate().is_err());
883
884 let control_chars_subject = TriggerBuilder::new()
886 .name("test_email")
887 .email(
888 "smtp.example.com",
889 "user",
890 "pass",
891 "sender@example.com",
892 vec!["recipient@example.com"],
893 )
894 .message("Test \0 Subject", "Test Body")
895 .build();
896 assert!(control_chars_subject.validate().is_err());
897
898 let control_chars_body = TriggerBuilder::new()
900 .name("test_email")
901 .email(
902 "smtp.example.com",
903 "user",
904 "pass",
905 "sender@example.com",
906 vec!["recipient@example.com"],
907 )
908 .message("Test Subject", "Test \0 Body")
909 .build();
910 assert!(control_chars_body.validate().is_err());
911 }
912
913 #[test]
914 fn test_webhook_trigger_validation() {
915 let valid_trigger = TriggerBuilder::new()
917 .name("test_webhook")
918 .webhook("https://api.example.com/webhook")
919 .message("Alert", "Test message")
920 .build();
921 assert!(valid_trigger.validate().is_ok());
922
923 let invalid_url = TriggerBuilder::new()
925 .name("test_webhook")
926 .webhook("invalid-url")
927 .build();
928 assert!(invalid_url.validate().is_err());
929
930 let invalid_title = TriggerBuilder::new()
932 .name("test_webhook")
933 .webhook("https://api.example.com/webhook")
934 .message("", "Test message")
935 .build();
936 assert!(invalid_title.validate().is_err());
937
938 let invalid_body = TriggerBuilder::new()
940 .name("test_webhook")
941 .webhook("https://api.example.com/webhook")
942 .message("Alert", "")
943 .build();
944 assert!(invalid_body.validate().is_err());
945 }
946
947 #[test]
948 fn test_webhook_trigger_validation_raw_mode() {
949 use crate::models::WebhookPayloadMode;
950
951 let valid_raw_trigger = TriggerBuilder::new()
953 .name("test_webhook_raw")
954 .webhook("https://api.example.com/webhook")
955 .webhook_payload_mode(WebhookPayloadMode::Raw)
956 .message("", "") .build();
958 assert!(valid_raw_trigger.validate().is_ok());
959
960 let invalid_url_raw = TriggerBuilder::new()
962 .name("test_webhook_raw")
963 .webhook("invalid-url")
964 .webhook_payload_mode(WebhookPayloadMode::Raw)
965 .build();
966 assert!(invalid_url_raw.validate().is_err());
967
968 let valid_raw_with_message = TriggerBuilder::new()
970 .name("test_webhook_raw")
971 .webhook("https://api.example.com/webhook")
972 .webhook_payload_mode(WebhookPayloadMode::Raw)
973 .message("Alert", "Test message")
974 .build();
975 assert!(valid_raw_with_message.validate().is_ok());
976 }
977
978 #[test]
979 fn test_webhook_payload_mode_serialization() {
980 use crate::models::WebhookPayloadMode;
981
982 let template_mode = WebhookPayloadMode::Template;
984 let raw_mode = WebhookPayloadMode::Raw;
985
986 assert_eq!(
987 serde_json::to_string(&template_mode).unwrap(),
988 "\"template\""
989 );
990 assert_eq!(serde_json::to_string(&raw_mode).unwrap(), "\"raw\"");
991
992 let deserialized_template: WebhookPayloadMode =
994 serde_json::from_str("\"template\"").unwrap();
995 let deserialized_raw: WebhookPayloadMode = serde_json::from_str("\"raw\"").unwrap();
996
997 assert_eq!(deserialized_template, WebhookPayloadMode::Template);
998 assert_eq!(deserialized_raw, WebhookPayloadMode::Raw);
999
1000 assert_eq!(WebhookPayloadMode::default(), WebhookPayloadMode::Template);
1002 }
1003
1004 #[test]
1005 fn test_discord_trigger_validation() {
1006 let valid_trigger = TriggerBuilder::new()
1008 .name("test_discord")
1009 .discord("https://discord.com/api/webhooks/xxx")
1010 .message("Alert", "Test message")
1011 .build();
1012 assert!(valid_trigger.validate().is_ok());
1013
1014 let invalid_webhook = TriggerBuilder::new()
1016 .name("test_discord")
1017 .discord("https://invalid-url.com")
1018 .build();
1019 assert!(invalid_webhook.validate().is_err());
1020
1021 let invalid_title = TriggerBuilder::new()
1023 .name("test_discord")
1024 .discord("https://discord.com/api/webhooks/123")
1025 .message("", "Test message")
1026 .build();
1027 assert!(invalid_title.validate().is_err());
1028
1029 let invalid_body = TriggerBuilder::new()
1031 .name("test_discord")
1032 .discord("https://discord.com/api/webhooks/123")
1033 .message("Alert", "")
1034 .build();
1035 assert!(invalid_body.validate().is_err());
1036 }
1037
1038 #[test]
1039 fn test_telegram_trigger_validation() {
1040 let valid_trigger = TriggerBuilder::new()
1041 .name("test_telegram")
1042 .telegram(
1043 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "1730223038",
1045 true,
1046 )
1047 .build();
1048 assert!(valid_trigger.validate().is_ok());
1049
1050 let invalid_token = TriggerBuilder::new()
1052 .name("test_telegram")
1053 .telegram("invalid-token", "1730223038", true)
1054 .build();
1055 assert!(invalid_token.validate().is_err());
1056
1057 let invalid_chat_id = TriggerBuilder::new()
1059 .name("test_telegram")
1060 .telegram(
1061 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "",
1063 true,
1064 )
1065 .build();
1066 assert!(invalid_chat_id.validate().is_err());
1067
1068 let invalid_title_message = TriggerBuilder::new()
1070 .name("test_telegram")
1071 .telegram(
1072 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "1730223038",
1074 true,
1075 )
1076 .message("", "Test Message")
1077 .build();
1078 assert!(invalid_title_message.validate().is_err());
1079
1080 let invalid_body_message = TriggerBuilder::new()
1081 .name("test_telegram")
1082 .telegram(
1083 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789", "1730223038",
1085 true,
1086 )
1087 .message("Test Subject", "")
1088 .build();
1089 assert!(invalid_body_message.validate().is_err());
1090 }
1091
1092 #[test]
1093 fn test_script_trigger_validation() {
1094 let temp_dir = std::env::temp_dir();
1095 let script_path = temp_dir.join("test_script.sh");
1096 std::fs::write(&script_path, "#!/bin/bash\necho 'test'").unwrap();
1097
1098 let valid_trigger = TriggerBuilder::new()
1100 .name("test_script")
1101 .script(script_path.to_str().unwrap(), ScriptLanguage::Bash)
1102 .build();
1103 assert!(valid_trigger.validate().is_ok());
1104
1105 let invalid_path = TriggerBuilder::new()
1107 .name("test_script")
1108 .script("/non/existent/path", ScriptLanguage::Python)
1109 .build();
1110 assert!(invalid_path.validate().is_err());
1111
1112 std::fs::remove_file(script_path).unwrap();
1113 }
1114
1115 #[tokio::test]
1116 async fn test_invalid_load_from_path() {
1117 let path = Path::new("config/triggers/invalid.json");
1118 assert!(matches!(
1119 Trigger::load_from_path(path).await,
1120 Err(ConfigError::FileError(_))
1121 ));
1122 }
1123
1124 #[tokio::test]
1125 async fn test_invalid_config_from_load_from_path() {
1126 use std::io::Write;
1127 use tempfile::NamedTempFile;
1128
1129 let mut temp_file = NamedTempFile::new().unwrap();
1130 write!(temp_file, "{{\"invalid\": \"json").unwrap();
1131
1132 let path = temp_file.path();
1133
1134 assert!(matches!(
1135 Trigger::load_from_path(path).await,
1136 Err(ConfigError::ParseError(_))
1137 ));
1138 }
1139
1140 #[tokio::test]
1141 async fn test_load_all_directory_not_found() {
1142 let non_existent_path = Path::new("non_existent_directory");
1143
1144 let result: Result<HashMap<String, Trigger>, ConfigError> =
1145 Trigger::load_all(Some(non_existent_path)).await;
1146 assert!(matches!(result, Err(ConfigError::FileError(_))));
1147
1148 if let Err(ConfigError::FileError(err)) = result {
1149 assert!(err.message.contains("triggers directory not found"));
1150 }
1151 }
1152
1153 #[tokio::test]
1154 #[cfg(unix)] async fn test_load_all_unreadable_file() {
1156 let temp_dir = TempDir::new().unwrap();
1158 let config_dir = temp_dir.path().join("triggers");
1159 std::fs::create_dir(&config_dir).unwrap();
1160
1161 let file_path = config_dir.join("unreadable.json");
1163 {
1164 let mut file = File::create(&file_path).unwrap();
1165 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();
1166 }
1167
1168 let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1170 perms.set_mode(0o000); std::fs::set_permissions(&file_path, perms).unwrap();
1172
1173 let result: Result<HashMap<String, Trigger>, ConfigError> =
1175 Trigger::load_all(Some(&config_dir)).await;
1176
1177 assert!(matches!(result, Err(ConfigError::FileError(_))));
1179 if let Err(ConfigError::FileError(err)) = result {
1180 assert!(err.message.contains("failed to read trigger config file"));
1181 }
1182
1183 let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
1185 perms.set_mode(0o644);
1186 std::fs::set_permissions(&file_path, perms).unwrap();
1187 }
1188
1189 #[test]
1190 #[traced_test]
1191 fn test_validate_protocol_slack() {
1192 let insecure_trigger = TriggerBuilder::new()
1193 .name("test_slack")
1194 .slack("http://hooks.slack.com/services/xxx")
1195 .build();
1196
1197 insecure_trigger.validate_protocol();
1198 assert!(logs_contain("Slack URL uses an insecure protocol"));
1199 }
1200
1201 #[test]
1202 #[traced_test]
1203 fn test_validate_protocol_discord() {
1204 let insecure_trigger = TriggerBuilder::new()
1205 .name("test_discord")
1206 .discord("http://discord.com/api/webhooks/xxx")
1207 .build();
1208
1209 insecure_trigger.validate_protocol();
1210 assert!(logs_contain("Discord URL uses an insecure protocol"));
1211 }
1212
1213 #[test]
1214 #[traced_test]
1215 fn test_validate_protocol_webhook() {
1216 let insecure_trigger = TriggerBuilder::new()
1217 .name("test_webhook")
1218 .webhook("http://api.example.com/webhook")
1219 .build();
1220
1221 insecure_trigger.validate_protocol();
1222 assert!(logs_contain("Webhook URL uses an insecure protocol"));
1223 assert!(logs_contain("Webhook lacks authentication headers"));
1224 }
1225
1226 #[test]
1227 #[traced_test]
1228 fn test_validate_protocol_email() {
1229 let insecure_trigger = TriggerBuilder::new()
1230 .name("test_email")
1231 .email(
1232 "smtp.example.com",
1233 "user",
1234 "pass",
1235 "sender@example.com",
1236 vec!["recipient@example.com"],
1237 )
1238 .email_port(25) .build();
1240
1241 insecure_trigger.validate_protocol();
1242 assert!(logs_contain("Email port is not using a secure protocol"));
1243 }
1244
1245 #[cfg(unix)]
1246 #[test]
1247 #[traced_test]
1248 fn test_validate_protocol_script() {
1249 use std::fs::File;
1250 use std::os::unix::fs::PermissionsExt;
1251 use tempfile::TempDir;
1252
1253 let temp_dir = TempDir::new().unwrap();
1254 let script_path = temp_dir.path().join("test_script.sh");
1255 File::create(&script_path).unwrap();
1256
1257 let metadata = std::fs::metadata(&script_path).unwrap();
1259 let mut permissions = metadata.permissions();
1260 permissions.set_mode(0o777);
1261 std::fs::set_permissions(&script_path, permissions).unwrap();
1262
1263 let trigger = TriggerBuilder::new()
1264 .name("test_script")
1265 .script(script_path.to_str().unwrap(), ScriptLanguage::Bash)
1266 .build();
1267
1268 trigger.validate_protocol();
1269 assert!(logs_contain(
1270 "Script file has overly permissive write permissions"
1271 ));
1272 }
1273
1274 #[test]
1275 #[traced_test]
1276 fn test_validate_protocol_webhook_with_headers() {
1277 let mut headers = HashMap::new();
1278 headers.insert("Content-Type".to_string(), "application/json".to_string());
1279
1280 let insecure_trigger = TriggerBuilder::new()
1281 .name("test_webhook")
1282 .webhook("http://api.example.com/webhook")
1283 .webhook_headers(headers)
1284 .build();
1285
1286 insecure_trigger.validate_protocol();
1287 assert!(logs_contain("Webhook URL uses an insecure protocol"));
1288 assert!(logs_contain("Webhook lacks authentication headers"));
1289 }
1290
1291 #[tokio::test]
1292 async fn test_resolve_secrets_slack() {
1293 let trigger = TriggerBuilder::new()
1294 .name("slack")
1295 .slack("https://hooks.slack.com/xxx")
1296 .build();
1297
1298 let resolved = trigger.resolve_secrets().await.unwrap();
1299 if let TriggerTypeConfig::Slack { slack_url, .. } = &resolved.config {
1300 assert!(matches!(slack_url, SecretValue::Plain(_)));
1301 }
1302 }
1303
1304 #[tokio::test]
1305 async fn test_resolve_secrets_email() {
1306 let trigger = TriggerBuilder::new()
1307 .name("email")
1308 .email(
1309 "smtp.example.com",
1310 "user",
1311 "pass",
1312 "sender@example.com",
1313 vec!["recipient@example.com"],
1314 )
1315 .build();
1316
1317 let resolved = trigger.resolve_secrets().await.unwrap();
1318 if let TriggerTypeConfig::Email {
1319 username, password, ..
1320 } = &resolved.config
1321 {
1322 assert!(matches!(username, SecretValue::Plain(_)));
1323 assert!(matches!(password, SecretValue::Plain(_)));
1324 }
1325 }
1326
1327 #[tokio::test]
1328 async fn test_resolve_secrets_webhook_with_secret() {
1329 let trigger = TriggerBuilder::new()
1330 .name("webhook")
1331 .webhook("https://api.example.com")
1332 .webhook_secret(SecretValue::Plain(SecretString::new("secret".to_string())))
1333 .build();
1334
1335 let resolved = trigger.resolve_secrets().await.unwrap();
1336 if let TriggerTypeConfig::Webhook { url, secret, .. } = &resolved.config {
1337 assert!(matches!(url, SecretValue::Plain(_)));
1338 assert!(matches!(secret, Some(SecretValue::Plain(_))));
1339 }
1340 }
1341
1342 #[tokio::test]
1343 async fn test_resolve_secrets_telegram() {
1344 let trigger = TriggerBuilder::new()
1345 .name("telegram")
1346 .telegram(
1347 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789",
1348 "1730223038",
1349 true,
1350 )
1351 .build();
1352
1353 let resolved = trigger.resolve_secrets().await.unwrap();
1354 if let TriggerTypeConfig::Telegram { token, .. } = &resolved.config {
1355 assert!(matches!(token, SecretValue::Plain(_)));
1356 }
1357 }
1358
1359 #[tokio::test]
1360 async fn test_resolve_secrets_discord() {
1361 let trigger = TriggerBuilder::new()
1362 .name("discord")
1363 .discord("https://discord.com/api/webhooks/xxx")
1364 .build();
1365
1366 let resolved = trigger.resolve_secrets().await.unwrap();
1367 if let TriggerTypeConfig::Discord { discord_url, .. } = &resolved.config {
1368 assert!(matches!(discord_url, SecretValue::Plain(_)));
1369 }
1370 }
1371
1372 #[tokio::test]
1373 async fn test_resolve_secrets_other_branch() {
1374 let trigger = TriggerBuilder::new()
1376 .name("script")
1377 .script("/tmp/test.sh", ScriptLanguage::Bash)
1378 .build();
1379
1380 let resolved = trigger.resolve_secrets().await.unwrap();
1381 if let TriggerTypeConfig::Script { .. } = &resolved.config {
1382 }
1384 }
1385
1386 #[tokio::test]
1387 async fn test_resolve_secrets_slack_env_error() {
1388 let trigger = TriggerBuilder::new()
1389 .name("slack")
1390 .slack("")
1391 .url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1392 .build();
1393
1394 let result = trigger.resolve_secrets().await;
1395 assert!(result.is_err());
1396 if let Err(e) = result {
1397 assert!(e.to_string().contains("failed to resolve Slack URL"));
1398 }
1399 }
1400
1401 #[tokio::test]
1402 async fn test_resolve_secrets_discord_env_error() {
1403 let trigger = TriggerBuilder::new()
1404 .name("discord")
1405 .discord("")
1406 .url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1407 .build();
1408
1409 let result = trigger.resolve_secrets().await;
1410 assert!(result.is_err());
1411 if let Err(e) = result {
1412 assert!(e.to_string().contains("failed to resolve Discord URL"));
1413 }
1414 }
1415
1416 #[tokio::test]
1417 async fn test_resolve_secrets_telegram_env_error() {
1418 let trigger = TriggerBuilder::new()
1419 .name("telegram")
1420 .telegram("", "1730223038", true)
1421 .telegram_token(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1422 .build();
1423
1424 let result = trigger.resolve_secrets().await;
1425 assert!(result.is_err());
1426 if let Err(e) = result {
1427 assert!(e.to_string().contains("failed to resolve Telegram token"));
1428 }
1429 }
1430
1431 #[tokio::test]
1432 async fn test_resolve_secrets_webhook_env_error() {
1433 let trigger = TriggerBuilder::new()
1434 .name("webhook")
1435 .webhook("")
1436 .url(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1437 .build();
1438
1439 let result = trigger.resolve_secrets().await;
1440 assert!(result.is_err());
1441 if let Err(e) = result {
1442 assert!(e.to_string().contains("failed to resolve webhook URL"));
1443 }
1444
1445 let trigger = TriggerBuilder::new()
1446 .name("webhook")
1447 .webhook("https://api.example.com")
1448 .webhook_secret(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1449 .build();
1450
1451 let result = trigger.resolve_secrets().await;
1452 assert!(result.is_err());
1453 if let Err(e) = result {
1454 assert!(e.to_string().contains("failed to resolve webhook secret"));
1455 }
1456 }
1457
1458 #[tokio::test]
1459 async fn test_resolve_secrets_email_env_error() {
1460 let trigger = TriggerBuilder::new()
1461 .name("email")
1462 .email(
1463 "smtp.example.com",
1464 "",
1465 "pass",
1466 "sender@example.com",
1467 vec!["recipient@example.com"],
1468 )
1469 .email_username(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1470 .build();
1471
1472 let result = trigger.resolve_secrets().await;
1473 assert!(result.is_err());
1474 if let Err(e) = result {
1475 assert!(e.to_string().contains("failed to resolve SMTP username"));
1476 }
1477
1478 let trigger = TriggerBuilder::new()
1479 .name("email")
1480 .email(
1481 "smtp.example.com",
1482 "user",
1483 "",
1484 "sender@example.com",
1485 vec!["recipient@example.com"],
1486 )
1487 .email_password(SecretValue::Environment("NON_EXISTENT_ENV_VAR".to_string()))
1488 .build();
1489
1490 let result = trigger.resolve_secrets().await;
1491 assert!(result.is_err());
1492 if let Err(e) = result {
1493 assert!(e.to_string().contains("failed to resolve SMTP password"));
1494 }
1495 }
1496 #[test]
1497 fn test_telegram_max_message_length() {
1498 let max_body_length = Trigger {
1499 name: "test_telegram".to_string(),
1500 trigger_type: TriggerType::Telegram,
1501 config: TriggerTypeConfig::Telegram {
1502 token: SecretValue::Plain(SecretString::new(
1503 "1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ123456789".to_string(),
1504 )),
1505 chat_id: "1730223038".to_string(),
1506 disable_web_preview: Some(true),
1507 message: NotificationMessage {
1508 title: "Test".to_string(),
1509 body: "x".repeat(TELEGRAM_MAX_BODY_LENGTH + 1), },
1511 retry_policy: RetryConfig::default(),
1512 },
1513 };
1514 assert!(max_body_length.validate().is_err());
1515 }
1516
1517 #[test]
1518 fn test_discord_max_message_length() {
1519 let max_body_length = Trigger {
1520 name: "test_discord".to_string(),
1521 trigger_type: TriggerType::Discord,
1522 config: TriggerTypeConfig::Discord {
1523 discord_url: SecretValue::Plain(SecretString::new(
1524 "https://discord.com/api/webhooks/xxx".to_string(),
1525 )),
1526 message: NotificationMessage {
1527 title: "Test".to_string(),
1528 body: "z".repeat(DISCORD_MAX_BODY_LENGTH + 1), },
1530 retry_policy: RetryConfig::default(),
1531 },
1532 };
1533 assert!(max_body_length.validate().is_err());
1534 }
1535
1536 #[tokio::test]
1537 async fn test_load_all_duplicate_trigger_name() {
1538 let temp_dir = TempDir::new().unwrap();
1539 let file_path_1 = temp_dir.path().join("duplicate_trigger.json");
1540 let file_path_2 = temp_dir.path().join("duplicate_trigger_2.json");
1541
1542 let trigger_config_1 = r#"{
1543 "test_trigger_1": {
1544 "name": "TestTrigger",
1545 "trigger_type": "slack",
1546 "config": {
1547 "slack_url": {
1548 "type": "plain",
1549 "value": "https://hooks.slack.com/services/xxx"
1550 },
1551 "message": {
1552 "title": "Test",
1553 "body": "Test"
1554 }
1555 }
1556 }
1557 }"#;
1558
1559 let trigger_config_2 = r#"{
1560 "test_trigger_2": {
1561 "name": "testTrigger",
1562 "trigger_type": "discord",
1563 "config": {
1564 "discord_url": {
1565 "type": "plain",
1566 "value": "https://discord.com/api/webhooks/xxx"
1567 },
1568 "message": {
1569 "title": "Test",
1570 "body": "Test"
1571 }
1572 }
1573 }
1574 }"#;
1575
1576 fs::write(&file_path_1, trigger_config_1).unwrap();
1577 fs::write(&file_path_2, trigger_config_2).unwrap();
1578
1579 let result: Result<HashMap<String, Trigger>, ConfigError> =
1580 Trigger::load_all(Some(temp_dir.path())).await;
1581
1582 assert!(result.is_err());
1583 if let Err(ConfigError::ValidationError(err)) = result {
1584 assert!(err.message.contains("Duplicate trigger name found"));
1585 }
1586 }
1587}