1use async_trait::async_trait;
7use chrono::Utc;
8use hmac::{Hmac, Mac};
9use reqwest::{
10 header::{HeaderMap, HeaderName, HeaderValue},
11 Method,
12};
13use reqwest_middleware::ClientWithMiddleware;
14use serde::Serialize;
15use sha2::Sha256;
16use std::{collections::HashMap, sync::Arc};
17
18use crate::{
19 models::TriggerTypeConfig,
20 services::notification::{NotificationError, Notifier},
21};
22
23type HmacSha256 = Hmac<Sha256>;
25
26#[derive(Clone)]
28pub struct WebhookConfig {
29 pub url: String,
30 pub url_params: Option<HashMap<String, String>>,
31 pub title: String,
32 pub body_template: String,
33 pub method: Option<String>,
34 pub secret: Option<String>,
35 pub headers: Option<HashMap<String, String>>,
36 pub payload_fields: Option<HashMap<String, serde_json::Value>>,
37}
38
39#[derive(Debug)]
41pub struct WebhookNotifier {
42 pub url: String,
44 pub url_params: Option<HashMap<String, String>>,
46 pub title: String,
48 pub body_template: String,
50 pub client: Arc<ClientWithMiddleware>,
52 pub method: Option<String>,
54 pub secret: Option<String>,
56 pub headers: Option<HashMap<String, String>>,
58 pub payload_fields: Option<HashMap<String, serde_json::Value>>,
60}
61
62#[derive(Serialize, Debug)]
64pub struct WebhookMessage {
65 title: String,
67 body: String,
68}
69
70impl WebhookNotifier {
71 pub fn new(
80 config: WebhookConfig,
81 http_client: Arc<ClientWithMiddleware>,
82 ) -> Result<Self, NotificationError> {
83 let mut headers = config.headers.unwrap_or_default();
84 if !headers.contains_key("Content-Type") {
85 headers.insert("Content-Type".to_string(), "application/json".to_string());
86 }
87 Ok(Self {
88 url: config.url,
89 url_params: config.url_params,
90 title: config.title,
91 body_template: config.body_template,
92 client: http_client,
93 method: Some(config.method.unwrap_or("POST".to_string())),
94 secret: config.secret,
95 headers: Some(headers),
96 payload_fields: config.payload_fields,
97 })
98 }
99
100 pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
108 let mut message = self.body_template.clone();
109 for (key, value) in variables {
110 message = message.replace(&format!("${{{}}}", key), value);
111 }
112 message
113 }
114
115 pub fn from_config(
124 config: &TriggerTypeConfig,
125 http_client: Arc<ClientWithMiddleware>,
126 ) -> Result<Self, NotificationError> {
127 if let TriggerTypeConfig::Webhook {
128 url,
129 message,
130 method,
131 secret,
132 headers,
133 ..
134 } = config
135 {
136 let webhook_config = WebhookConfig {
137 url: url.as_ref().to_string(),
138 url_params: None,
139 title: message.title.clone(),
140 body_template: message.body.clone(),
141 method: method.clone(),
142 secret: secret.as_ref().map(|s| s.as_ref().to_string()),
143 headers: headers.clone(),
144 payload_fields: None,
145 };
146
147 WebhookNotifier::new(webhook_config, http_client)
148 } else {
149 let msg = format!("Invalid webhook configuration: {:?}", config);
150 Err(NotificationError::config_error(msg, None, None))
151 }
152 }
153
154 pub fn sign_request(
155 &self,
156 secret: &str,
157 payload: &WebhookMessage,
158 ) -> Result<(String, String), NotificationError> {
159 if secret.is_empty() {
161 return Err(NotificationError::notify_failed(
162 "Invalid secret: cannot be empty.".to_string(),
163 None,
164 None,
165 ));
166 }
167
168 let timestamp = Utc::now().timestamp_millis();
169
170 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
172 NotificationError::config_error(format!("Invalid secret: {}", e), None, None)
173 })?; let message = format!("{:?}{}", payload, timestamp);
177 mac.update(message.as_bytes());
178
179 let signature = hex::encode(mac.finalize().into_bytes());
181
182 Ok((signature, timestamp.to_string()))
183 }
184}
185
186#[async_trait]
187impl Notifier for WebhookNotifier {
188 async fn notify(&self, message: &str) -> Result<(), NotificationError> {
196 let mut payload_fields = HashMap::new();
198 payload_fields.insert("title".to_string(), serde_json::json!(self.title));
199 payload_fields.insert("body".to_string(), serde_json::json!(message));
200
201 self.notify_with_payload(message, payload_fields).await
202 }
203
204 async fn notify_with_payload(
213 &self,
214 message: &str,
215 mut payload_fields: HashMap<String, serde_json::Value>,
216 ) -> Result<(), NotificationError> {
217 let mut url = self.url.clone();
218 if let Some(params) = &self.url_params {
220 let params_str: Vec<String> = params
221 .iter()
222 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
223 .collect();
224 if !params_str.is_empty() {
225 url = format!("{}?{}", url, params_str.join("&"));
226 }
227 }
228
229 if let Some(default_fields) = &self.payload_fields {
231 for (key, value) in default_fields {
232 if !payload_fields.contains_key(key) {
233 payload_fields.insert(key.clone(), value.clone());
234 }
235 }
236 }
237
238 let method = if let Some(ref m) = self.method {
239 Method::from_bytes(m.as_bytes()).unwrap_or(Method::POST)
240 } else {
241 Method::POST
242 };
243
244 let mut headers = HeaderMap::new();
246 headers.insert(
247 HeaderName::from_static("content-type"),
248 HeaderValue::from_static("application/json"),
249 );
250
251 if let Some(secret) = &self.secret {
252 let payload_for_signing = WebhookMessage {
254 title: self.title.clone(),
255 body: message.to_string(),
256 };
257
258 let (signature, timestamp) =
259 self.sign_request(secret, &payload_for_signing)
260 .map_err(|e| {
261 NotificationError::internal_error(e.to_string(), Some(e.into()), None)
262 })?;
263
264 headers.insert(
266 HeaderName::from_static("x-signature"),
267 HeaderValue::from_str(&signature).map_err(|e| {
268 NotificationError::notify_failed(
269 "Invalid signature value".to_string(),
270 Some(e.into()),
271 None,
272 )
273 })?,
274 );
275 headers.insert(
276 HeaderName::from_static("x-timestamp"),
277 HeaderValue::from_str(×tamp).map_err(|e| {
278 NotificationError::notify_failed(
279 "Invalid timestamp value".to_string(),
280 Some(e.into()),
281 None,
282 )
283 })?,
284 );
285 }
286
287 if let Some(headers_map) = &self.headers {
289 for (key, value) in headers_map {
290 let header_name = HeaderName::from_bytes(key.as_bytes()).map_err(|e| {
291 NotificationError::notify_failed(
292 format!("Invalid header name: {}", key),
293 Some(e.into()),
294 None,
295 )
296 })?;
297 let header_value = HeaderValue::from_str(value).map_err(|e| {
298 NotificationError::notify_failed(
299 format!("Invalid header value for {}: {}", key, value),
300 Some(e.into()),
301 None,
302 )
303 })?;
304 headers.insert(header_name, header_value);
305 }
306 }
307
308 let response = self
310 .client
311 .request(method, url.as_str())
312 .headers(headers)
313 .json(&payload_fields)
314 .send()
315 .await
316 .map_err(|e| {
317 NotificationError::notify_failed(
318 format!("Failed to send webhook request: {}", e),
319 Some(e.into()),
320 None,
321 )
322 })?;
323
324 let status = response.status();
325
326 if !status.is_success() {
327 return Err(NotificationError::notify_failed(
328 format!("Webhook request failed with status: {}", status),
329 None,
330 None,
331 ));
332 }
333
334 Ok(())
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use crate::{
341 models::{NotificationMessage, SecretString, SecretValue},
342 utils::{tests::create_test_http_client, HttpRetryConfig},
343 };
344
345 use super::*;
346 use mockito::{Matcher, Mock};
347 use serde_json::json;
348
349 fn create_test_notifier(
350 url: &str,
351 body_template: &str,
352 secret: Option<&str>,
353 headers: Option<HashMap<String, String>>,
354 ) -> WebhookNotifier {
355 let http_client = create_test_http_client();
356 let config = WebhookConfig {
357 url: url.to_string(),
358 url_params: None,
359 title: "Alert".to_string(),
360 body_template: body_template.to_string(),
361 method: Some("POST".to_string()),
362 secret: secret.map(|s| s.to_string()),
363 headers,
364 payload_fields: None,
365 };
366 WebhookNotifier::new(config, http_client).unwrap()
367 }
368
369 fn create_test_webhook_config() -> TriggerTypeConfig {
370 TriggerTypeConfig::Webhook {
371 url: SecretValue::Plain(SecretString::new("https://webhook.example.com".to_string())),
372 method: Some("POST".to_string()),
373 secret: None,
374 headers: None,
375 message: NotificationMessage {
376 title: "Test Alert".to_string(),
377 body: "Test message ${value}".to_string(),
378 },
379 retry_policy: HttpRetryConfig::default(),
380 }
381 }
382
383 #[test]
388 fn test_format_message() {
389 let notifier = create_test_notifier(
390 "https://webhook.example.com",
391 "Value is ${value} and status is ${status}",
392 None,
393 None,
394 );
395
396 let mut variables = HashMap::new();
397 variables.insert("value".to_string(), "100".to_string());
398 variables.insert("status".to_string(), "critical".to_string());
399
400 let result = notifier.format_message(&variables);
401 assert_eq!(result, "Value is 100 and status is critical");
402 }
403
404 #[test]
405 fn test_format_message_with_missing_variables() {
406 let notifier = create_test_notifier(
407 "https://webhook.example.com",
408 "Value is ${value} and status is ${status}",
409 None,
410 None,
411 );
412
413 let mut variables = HashMap::new();
414 variables.insert("value".to_string(), "100".to_string());
415 let result = notifier.format_message(&variables);
418 assert_eq!(result, "Value is 100 and status is ${status}");
419 }
420
421 #[test]
422 fn test_format_message_with_empty_template() {
423 let notifier = create_test_notifier("https://webhook.example.com", "", None, None);
424
425 let variables = HashMap::new();
426 let result = notifier.format_message(&variables);
427 assert_eq!(result, "");
428 }
429
430 #[test]
435 fn test_sign_request() {
436 let notifier = create_test_notifier(
437 "https://webhook.example.com",
438 "Test message",
439 Some("test-secret"),
440 None,
441 );
442 let payload = WebhookMessage {
443 title: "Test Title".to_string(),
444 body: "Test message".to_string(),
445 };
446 let secret = "test-secret";
447
448 let result = notifier.sign_request(secret, &payload).unwrap();
449 let (signature, timestamp) = result;
450
451 assert!(!signature.is_empty());
452 assert!(!timestamp.is_empty());
453 }
454
455 #[test]
456 fn test_sign_request_fails_empty_secret() {
457 let notifier =
458 create_test_notifier("https://webhook.example.com", "Test message", None, None);
459 let payload = WebhookMessage {
460 title: "Test Title".to_string(),
461 body: "Test message".to_string(),
462 };
463 let empty_secret = "";
464
465 let result = notifier.sign_request(empty_secret, &payload);
466 assert!(result.is_err());
467
468 let error = result.unwrap_err();
469 assert!(matches!(error, NotificationError::NotifyFailed(_)));
470 }
471
472 #[test]
477 fn test_from_config_with_webhook_config() {
478 let config = create_test_webhook_config();
479 let http_client = create_test_http_client();
480 let notifier = WebhookNotifier::from_config(&config, http_client);
481 assert!(notifier.is_ok());
482
483 let notifier = notifier.unwrap();
484 assert_eq!(notifier.url, "https://webhook.example.com");
485 assert_eq!(notifier.title, "Test Alert");
486 assert_eq!(notifier.body_template, "Test message ${value}");
487 }
488
489 #[test]
490 fn test_from_config_invalid_type() {
491 let config = TriggerTypeConfig::Slack {
493 slack_url: SecretValue::Plain(SecretString::new(
494 "https://slack.example.com".to_string(),
495 )),
496 message: NotificationMessage {
497 title: "Test Alert".to_string(),
498 body: "Test message ${value}".to_string(),
499 },
500 retry_policy: HttpRetryConfig::default(),
501 };
502
503 let http_client = create_test_http_client();
504 let notifier = WebhookNotifier::from_config(&config, http_client);
505 assert!(notifier.is_err());
506
507 let error = notifier.unwrap_err();
508 assert!(matches!(error, NotificationError::ConfigError { .. }));
509 }
510
511 #[tokio::test]
516 async fn test_notify_failure() {
517 let notifier =
518 create_test_notifier("https://webhook.example.com", "Test message", None, None);
519 let result = notifier.notify("Test message").await;
520 assert!(result.is_err());
521 }
522
523 #[tokio::test]
524 async fn test_notify_includes_signature_and_timestamp() {
525 let mut server = mockito::Server::new_async().await;
526 let mock: Mock = server
527 .mock("POST", "/")
528 .match_header("X-Signature", Matcher::Regex("^[0-9a-f]{64}$".to_string()))
529 .match_header("X-Timestamp", Matcher::Regex("^[0-9]+$".to_string()))
530 .match_header("Content-Type", "text/plain")
531 .with_status(200)
532 .create_async()
533 .await;
534
535 let notifier = create_test_notifier(
536 server.url().as_str(),
537 "Test message",
538 Some("top-secret"),
539 Some(HashMap::from([(
540 "Content-Type".to_string(),
541 "text/plain".to_string(),
542 )])),
543 );
544
545 let response = notifier.notify("Test message").await;
546
547 assert!(response.is_ok());
548
549 mock.assert();
550 }
551
552 #[tokio::test]
557 async fn test_notify_with_invalid_header_name() {
558 let server = mockito::Server::new_async().await;
559 let invalid_headers =
560 HashMap::from([("Invalid Header!@#".to_string(), "value".to_string())]);
561
562 let notifier = create_test_notifier(
563 server.url().as_str(),
564 "Test message",
565 None,
566 Some(invalid_headers),
567 );
568
569 let result = notifier.notify("Test message").await;
570 let err = result.unwrap_err();
571 assert!(err.to_string().contains("Invalid header name"));
572 }
573
574 #[tokio::test]
575 async fn test_notify_with_invalid_header_value() {
576 let server = mockito::Server::new_async().await;
577 let invalid_headers =
578 HashMap::from([("X-Custom-Header".to_string(), "Invalid\nValue".to_string())]);
579
580 let notifier = create_test_notifier(
581 server.url().as_str(),
582 "Test message",
583 None,
584 Some(invalid_headers),
585 );
586
587 let result = notifier.notify("Test message").await;
588 let err = result.unwrap_err();
589 assert!(err.to_string().contains("Invalid header value"));
590 }
591
592 #[tokio::test]
593 async fn test_notify_with_valid_headers() {
594 let mut server = mockito::Server::new_async().await;
595 let valid_headers = HashMap::from([
596 ("X-Custom-Header".to_string(), "valid-value".to_string()),
597 ("Accept".to_string(), "application/json".to_string()),
598 ]);
599
600 let mock = server
601 .mock("POST", "/")
602 .match_header("X-Custom-Header", "valid-value")
603 .match_header("Accept", "application/json")
604 .with_status(200)
605 .create_async()
606 .await;
607
608 let notifier = create_test_notifier(
609 server.url().as_str(),
610 "Test message",
611 None,
612 Some(valid_headers),
613 );
614
615 let result = notifier.notify("Test message").await;
616 assert!(result.is_ok());
617 mock.assert();
618 }
619
620 #[tokio::test]
621 async fn test_notify_signature_header_cases() {
622 let mut server = mockito::Server::new_async().await;
623
624 let mock = server
625 .mock("POST", "/")
626 .match_header("X-Signature", Matcher::Any)
627 .match_header("X-Timestamp", Matcher::Any)
628 .with_status(200)
629 .create_async()
630 .await;
631
632 let notifier = create_test_notifier(
633 server.url().as_str(),
634 "Test message",
635 Some("test-secret"),
636 None,
637 );
638
639 let result = notifier.notify("Test message").await;
640 assert!(result.is_ok());
641 mock.assert();
642 }
643
644 #[test]
645 fn test_sign_request_validation() {
646 let notifier = create_test_notifier(
647 "https://webhook.example.com",
648 "Test message",
649 Some("test-secret"),
650 None,
651 );
652
653 let payload = WebhookMessage {
654 title: "Test Title".to_string(),
655 body: "Test message".to_string(),
656 };
657
658 let result = notifier.sign_request("test-secret", &payload).unwrap();
659 let (signature, timestamp) = result;
660
661 assert!(
663 hex::decode(&signature).is_ok(),
664 "Signature should be valid hex"
665 );
666
667 assert!(
669 timestamp.parse::<i64>().is_ok(),
670 "Timestamp should be valid i64"
671 );
672 }
673
674 #[tokio::test]
679 async fn test_notify_with_payload_success() {
680 let mut server = mockito::Server::new_async().await;
681 let expected_payload = json!({
682 "title": "Alert",
683 "body": "Test message",
684 "custom_field": "custom_value"
685 });
686
687 let mock = server
688 .mock("POST", "/")
689 .match_header("content-type", "application/json")
690 .match_body(Matcher::Json(expected_payload))
691 .with_header("content-type", "application/json")
692 .with_body("{}")
693 .with_status(200)
694 .expect(1) .create_async()
696 .await;
697
698 let notifier = create_test_notifier(server.url().as_str(), "Test message", None, None);
699 let mut payload = HashMap::new();
700 payload.insert("title".to_string(), serde_json::json!("Alert"));
702 payload.insert("body".to_string(), serde_json::json!("Test message"));
703 payload.insert(
704 "custom_field".to_string(),
705 serde_json::json!("custom_value"),
706 );
707
708 let result = notifier.notify_with_payload("Test message", payload).await;
709 assert!(result.is_ok());
710 mock.assert();
711 }
712
713 #[tokio::test]
714 async fn test_notify_with_payload_and_url_params() {
715 let mut server = mockito::Server::new_async().await;
716 let mock = server
717 .mock("POST", "/")
718 .match_query(mockito::Matcher::AllOf(vec![
719 mockito::Matcher::UrlEncoded("param1".into(), "value1".into()),
720 mockito::Matcher::UrlEncoded("param2".into(), "value2".into()),
721 ]))
722 .with_status(200)
723 .create_async()
724 .await;
725
726 let mut url_params = HashMap::new();
727 url_params.insert("param1".to_string(), "value1".to_string());
728 url_params.insert("param2".to_string(), "value2".to_string());
729
730 let config = WebhookConfig {
731 url: server.url(),
732 url_params: Some(url_params),
733 title: "Alert".to_string(),
734 body_template: "Test message".to_string(),
735 method: None,
736 secret: None,
737 headers: None,
738 payload_fields: None,
739 };
740 let http_client = create_test_http_client();
741 let notifier = WebhookNotifier::new(config, http_client).unwrap();
742
743 let result = notifier
744 .notify_with_payload("Test message", HashMap::new())
745 .await;
746 assert!(result.is_ok());
747 mock.assert();
748 }
749
750 #[tokio::test]
751 async fn test_notify_with_payload_and_method_override() {
752 let mut server = mockito::Server::new_async().await;
753 let mock = server
754 .mock("GET", "/")
755 .with_status(200)
756 .create_async()
757 .await;
758
759 let config = WebhookConfig {
760 url: server.url(),
761 url_params: None,
762 title: "Alert".to_string(),
763 body_template: "Test message".to_string(),
764 method: Some("GET".to_string()),
765 secret: None,
766 headers: None,
767 payload_fields: None,
768 };
769 let http_client = create_test_http_client();
770 let notifier = WebhookNotifier::new(config, http_client).unwrap();
771
772 let result = notifier
773 .notify_with_payload("Test message", HashMap::new())
774 .await;
775 assert!(result.is_ok());
776 mock.assert();
777 }
778
779 #[tokio::test]
780 async fn test_notify_with_payload_merges_default_fields() {
781 let mut server = mockito::Server::new_async().await;
782
783 let expected_payload = json!({
784 "default_field": "default_value",
785 "custom_field": "custom_value"
786 });
787
788 let mock = server
789 .mock("POST", "/")
790 .match_body(mockito::Matcher::Json(expected_payload))
791 .with_status(200)
792 .create_async()
793 .await;
794
795 let mut default_fields = HashMap::new();
796 default_fields.insert(
797 "default_field".to_string(),
798 serde_json::json!("default_value"),
799 );
800
801 let config = WebhookConfig {
802 url: server.url(),
803 url_params: None,
804 title: "Alert".to_string(),
805 body_template: "Test message".to_string(),
806 method: None,
807 secret: None,
808 headers: None,
809 payload_fields: Some(default_fields),
810 };
811 let http_client = create_test_http_client();
812 let notifier = WebhookNotifier::new(config, http_client).unwrap();
813
814 let mut payload = HashMap::new();
815 payload.insert(
816 "custom_field".to_string(),
817 serde_json::json!("custom_value"),
818 );
819
820 let result = notifier.notify_with_payload("Test message", payload).await;
821 assert!(result.is_ok());
822 mock.assert();
823 }
824
825 #[tokio::test]
826 async fn test_notify_with_payload_custom_fields_override_defaults() {
827 let mut server = mockito::Server::new_async().await;
828
829 let expected_payload = json!({
830 "custom_field": "custom_value"
831 });
832
833 let mock = server
834 .mock("POST", "/")
835 .match_body(mockito::Matcher::Json(expected_payload))
836 .with_status(200)
837 .create_async()
838 .await;
839
840 let mut default_fields = HashMap::new();
841 default_fields.insert(
842 "custom_field".to_string(),
843 serde_json::json!("default_value"),
844 );
845
846 let config = WebhookConfig {
847 url: server.url(),
848 url_params: None,
849 title: "Alert".to_string(),
850 body_template: "Test message".to_string(),
851 method: None,
852 secret: None,
853 headers: None,
854 payload_fields: Some(default_fields),
855 };
856 let http_client = create_test_http_client();
857 let notifier = WebhookNotifier::new(config, http_client).unwrap();
858
859 let mut payload = HashMap::new();
860 payload.insert(
861 "custom_field".to_string(),
862 serde_json::json!("custom_value"),
863 );
864
865 let result = notifier.notify_with_payload("Test message", payload).await;
866 assert!(result.is_ok());
867 mock.assert();
868 }
869
870 #[tokio::test]
871 async fn test_notify_with_payload_invalid_url() {
872 let notifier = create_test_notifier("invalid-url", "Test message", None, None);
873
874 let result = notifier
875 .notify_with_payload("Test message", HashMap::new())
876 .await;
877 assert!(result.is_err());
878
879 let error = result.unwrap_err();
880 assert!(matches!(error, NotificationError::NotifyFailed { .. }));
881 }
882
883 #[tokio::test]
884 async fn test_notify_with_payload_failure_with_retryable_error() {
885 let mut server = mockito::Server::new_async().await;
886 let default_retries_count = HttpRetryConfig::default().max_retries as usize;
887 let mock = server
888 .mock("POST", "/")
889 .with_status(500)
890 .with_body("Internal Server Error")
891 .expect(1 + default_retries_count)
892 .create_async()
893 .await;
894
895 let notifier = create_test_notifier(server.url().as_str(), "Test message", None, None);
896
897 let result = notifier
898 .notify_with_payload("Test message", HashMap::new())
899 .await;
900
901 assert!(result.is_err());
902 mock.assert();
903 }
904
905 #[tokio::test]
906 async fn test_notify_with_payload_failure_with_non_retryable_error() {
907 let mut server = mockito::Server::new_async().await;
908 let mock = server
909 .mock("POST", "/")
910 .with_status(400)
911 .with_body("Bad Request")
912 .expect(1)
913 .create_async()
914 .await;
915
916 let notifier = create_test_notifier(server.url().as_str(), "Test message", None, None);
917
918 let result = notifier
919 .notify_with_payload("Test message", HashMap::new())
920 .await;
921
922 assert!(result.is_err());
923 mock.assert();
924 }
925}