1use chrono::Utc;
7use hmac::{Hmac, Mac};
8use reqwest::{
9 header::{HeaderMap, HeaderName, HeaderValue},
10 Method,
11};
12use reqwest_middleware::ClientWithMiddleware;
13use sha2::Sha256;
14use std::{collections::HashMap, sync::Arc};
15
16use crate::{models::TriggerTypeConfig, services::notification::NotificationError};
17
18type HmacSha256 = Hmac<Sha256>;
20
21#[derive(Clone)]
23pub struct WebhookConfig {
24 pub url: String,
25 pub url_params: Option<HashMap<String, String>>,
26 pub title: String,
27 pub body_template: String,
28 pub method: Option<String>,
29 pub secret: Option<String>,
30 pub headers: Option<HashMap<String, String>>,
31 pub payload_fields: Option<HashMap<String, serde_json::Value>>,
32}
33
34#[derive(Debug)]
36pub struct WebhookNotifier {
37 pub url: String,
39 pub url_params: Option<HashMap<String, String>>,
41 pub title: String,
43 pub client: Arc<ClientWithMiddleware>,
45 pub method: Option<String>,
47 pub secret: Option<String>,
49 pub headers: Option<HashMap<String, String>>,
51 pub payload_fields: Option<HashMap<String, serde_json::Value>>,
53}
54
55impl WebhookNotifier {
56 pub fn new(
65 config: WebhookConfig,
66 http_client: Arc<ClientWithMiddleware>,
67 ) -> Result<Self, NotificationError> {
68 let mut headers = config.headers.unwrap_or_default();
69 if !headers.contains_key("Content-Type") {
70 headers.insert("Content-Type".to_string(), "application/json".to_string());
71 }
72 Ok(Self {
73 url: config.url,
74 url_params: config.url_params,
75 title: config.title,
76 client: http_client,
77 method: Some(config.method.unwrap_or("POST".to_string())),
78 secret: config.secret,
79 headers: Some(headers),
80 payload_fields: config.payload_fields,
81 })
82 }
83
84 pub fn from_config(
93 config: &TriggerTypeConfig,
94 http_client: Arc<ClientWithMiddleware>,
95 ) -> Result<Self, NotificationError> {
96 if let TriggerTypeConfig::Webhook {
97 url,
98 message,
99 method,
100 secret,
101 headers,
102 ..
103 } = config
104 {
105 let webhook_config = WebhookConfig {
106 url: url.as_ref().to_string(),
107 url_params: None,
108 title: message.title.clone(),
109 body_template: message.body.clone(),
110 method: method.clone(),
111 secret: secret.as_ref().map(|s| s.as_ref().to_string()),
112 headers: headers.clone(),
113 payload_fields: None,
114 };
115
116 WebhookNotifier::new(webhook_config, http_client)
117 } else {
118 let msg = format!("Invalid webhook configuration: {:?}", config);
119 Err(NotificationError::config_error(msg, None, None))
120 }
121 }
122
123 pub fn sign_payload(
124 &self,
125 secret: &str,
126 payload: &serde_json::Value,
127 ) -> Result<(String, String), NotificationError> {
128 if secret.is_empty() {
130 return Err(NotificationError::notify_failed(
131 "Invalid secret: cannot be empty.".to_string(),
132 None,
133 None,
134 ));
135 }
136
137 let timestamp = Utc::now().timestamp_millis();
138
139 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
141 NotificationError::config_error(format!("Invalid secret: {}", e), None, None)
142 })?; let serialized_payload = serde_json::to_string(payload).map_err(|e| {
146 NotificationError::internal_error(
147 format!("Failed to serialize payload: {}", e),
148 Some(e.into()),
149 None,
150 )
151 })?;
152 let message = format!("{}{}", serialized_payload, timestamp);
153 mac.update(message.as_bytes());
154
155 let signature = hex::encode(mac.finalize().into_bytes());
157
158 Ok((signature, timestamp.to_string()))
159 }
160
161 pub async fn notify_json(&self, payload: &serde_json::Value) -> Result<(), NotificationError> {
169 let mut url = self.url.clone();
170 if let Some(params) = &self.url_params {
172 let params_str: Vec<String> = params
173 .iter()
174 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
175 .collect();
176 if !params_str.is_empty() {
177 url = format!("{}?{}", url, params_str.join("&"));
178 }
179 }
180
181 let method = if let Some(ref m) = self.method {
182 Method::from_bytes(m.as_bytes()).unwrap_or(Method::POST)
183 } else {
184 Method::POST
185 };
186
187 let mut headers = HeaderMap::new();
189 headers.insert(
190 HeaderName::from_static("content-type"),
191 HeaderValue::from_static("application/json"),
192 );
193
194 if let Some(secret) = &self.secret {
195 let (signature, timestamp) = self.sign_payload(secret, payload).map_err(|e| {
196 NotificationError::internal_error(e.to_string(), Some(e.into()), None)
197 })?;
198
199 headers.insert(
201 HeaderName::from_static("x-signature"),
202 HeaderValue::from_str(&signature).map_err(|e| {
203 NotificationError::notify_failed(
204 "Invalid signature value".to_string(),
205 Some(e.into()),
206 None,
207 )
208 })?,
209 );
210 headers.insert(
211 HeaderName::from_static("x-timestamp"),
212 HeaderValue::from_str(×tamp).map_err(|e| {
213 NotificationError::notify_failed(
214 "Invalid timestamp value".to_string(),
215 Some(e.into()),
216 None,
217 )
218 })?,
219 );
220 }
221
222 if let Some(headers_map) = &self.headers {
224 for (key, value) in headers_map {
225 let header_name = HeaderName::from_bytes(key.as_bytes()).map_err(|e| {
226 NotificationError::notify_failed(
227 format!("Invalid header name: {}", key),
228 Some(e.into()),
229 None,
230 )
231 })?;
232 let header_value = HeaderValue::from_str(value).map_err(|e| {
233 NotificationError::notify_failed(
234 format!("Invalid header value for {}: {}", key, value),
235 Some(e.into()),
236 None,
237 )
238 })?;
239 headers.insert(header_name, header_value);
240 }
241 }
242
243 let response = self
245 .client
246 .request(method, url.as_str())
247 .headers(headers)
248 .json(payload)
249 .send()
250 .await
251 .map_err(|e| {
252 NotificationError::notify_failed(
253 format!("Failed to send webhook request: {}", e),
254 Some(e.into()),
255 None,
256 )
257 })?;
258
259 let status = response.status();
260
261 if !status.is_success() {
262 return Err(NotificationError::notify_failed(
263 format!("Webhook request failed with status: {}", status),
264 None,
265 None,
266 ));
267 }
268
269 Ok(())
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use crate::{
276 models::{NotificationMessage, SecretString, SecretValue, WebhookPayloadMode},
277 services::notification::{GenericWebhookPayloadBuilder, WebhookPayloadBuilder},
278 utils::{tests::create_test_http_client, RetryConfig},
279 };
280
281 use super::*;
282 use mockito::{Matcher, Mock};
283 use serde_json::json;
284
285 fn create_test_notifier(
286 url: &str,
287 secret: Option<&str>,
288 headers: Option<HashMap<String, String>>,
289 ) -> WebhookNotifier {
290 let http_client = create_test_http_client();
291 let config = WebhookConfig {
292 url: url.to_string(),
293 url_params: None,
294 title: "Alert".to_string(),
295 body_template: "Test message".to_string(),
296 method: Some("POST".to_string()),
297 secret: secret.map(|s| s.to_string()),
298 headers,
299 payload_fields: None,
300 };
301 WebhookNotifier::new(config, http_client).unwrap()
302 }
303
304 fn create_test_webhook_config() -> TriggerTypeConfig {
305 TriggerTypeConfig::Webhook {
306 url: SecretValue::Plain(SecretString::new("https://webhook.example.com".to_string())),
307 method: Some("POST".to_string()),
308 secret: None,
309 headers: None,
310 message: NotificationMessage {
311 title: "Test Alert".to_string(),
312 body: "Test message ${value}".to_string(),
313 },
314 payload_mode: WebhookPayloadMode::default(),
315 retry_policy: RetryConfig::default(),
316 }
317 }
318
319 fn create_test_payload() -> serde_json::Value {
320 GenericWebhookPayloadBuilder.build_payload(
321 "Test Alert",
322 "Test message with value ${value}",
323 &HashMap::from([("value".to_string(), "42".to_string())]),
324 )
325 }
326
327 #[test]
332 fn test_sign_request() {
333 let notifier =
334 create_test_notifier("https://webhook.example.com", Some("test-secret"), None);
335 let payload = json!({
336 "title": "Test Title",
337 "body": "Test message"
338 });
339 let secret = "test-secret";
340
341 let result = notifier.sign_payload(secret, &payload).unwrap();
342 let (signature, timestamp) = result;
343
344 assert!(!signature.is_empty());
345 assert!(!timestamp.is_empty());
346 }
347
348 #[test]
349 fn test_sign_request_fails_empty_secret() {
350 let notifier = create_test_notifier("https://webhook.example.com", None, None);
351 let payload = json!({
352 "title": "Test Title",
353 "body": "Test message"
354 });
355 let empty_secret = "";
356
357 let result = notifier.sign_payload(empty_secret, &payload);
358 assert!(result.is_err());
359
360 let error = result.unwrap_err();
361 assert!(matches!(error, NotificationError::NotifyFailed(_)));
362 }
363
364 #[test]
369 fn test_from_config_with_webhook_config() {
370 let config = create_test_webhook_config();
371 let http_client = create_test_http_client();
372 let notifier = WebhookNotifier::from_config(&config, http_client);
373 assert!(notifier.is_ok());
374
375 let notifier = notifier.unwrap();
376 assert_eq!(notifier.url, "https://webhook.example.com");
377 assert_eq!(notifier.title, "Test Alert");
378 }
379
380 #[test]
381 fn test_from_config_invalid_type() {
382 let config = TriggerTypeConfig::Slack {
384 slack_url: SecretValue::Plain(SecretString::new(
385 "https://slack.example.com".to_string(),
386 )),
387 message: NotificationMessage {
388 title: "Test Alert".to_string(),
389 body: "Test message ${value}".to_string(),
390 },
391 retry_policy: RetryConfig::default(),
392 };
393
394 let http_client = create_test_http_client();
395 let notifier = WebhookNotifier::from_config(&config, http_client);
396 assert!(notifier.is_err());
397
398 let error = notifier.unwrap_err();
399 assert!(matches!(error, NotificationError::ConfigError { .. }));
400 }
401
402 #[tokio::test]
407 async fn test_notify_failure() {
408 let notifier = create_test_notifier("https://webhook.example.com", None, None);
409 let payload = create_test_payload();
410 let result = notifier.notify_json(&payload).await;
411 assert!(result.is_err());
412 }
413
414 #[tokio::test]
415 async fn test_notify_includes_signature_and_timestamp() {
416 let mut server = mockito::Server::new_async().await;
417 let mock: Mock = server
418 .mock("POST", "/")
419 .match_header("X-Signature", Matcher::Regex("^[0-9a-f]{64}$".to_string()))
420 .match_header("X-Timestamp", Matcher::Regex("^[0-9]+$".to_string()))
421 .match_header("Content-Type", "application/json")
422 .with_status(200)
423 .create_async()
424 .await;
425
426 let notifier = create_test_notifier(
427 server.url().as_str(),
428 Some("top-secret"),
429 Some(HashMap::from([(
430 "Content-Type".to_string(),
431 "application/json".to_string(),
432 )])),
433 );
434
435 let payload = create_test_payload();
436 let result = notifier.notify_json(&payload).await;
437
438 assert!(result.is_ok());
439
440 mock.assert();
441 }
442
443 #[tokio::test]
448 async fn test_notify_with_invalid_header_name() {
449 let server = mockito::Server::new_async().await;
450 let invalid_headers =
451 HashMap::from([("Invalid Header!@#".to_string(), "value".to_string())]);
452
453 let notifier = create_test_notifier(server.url().as_str(), None, Some(invalid_headers));
454 let payload = create_test_payload();
455 let result = notifier.notify_json(&payload).await;
456 let err = result.unwrap_err();
457 assert!(err.to_string().contains("Invalid header name"));
458 }
459
460 #[tokio::test]
461 async fn test_notify_with_invalid_header_value() {
462 let server = mockito::Server::new_async().await;
463 let invalid_headers =
464 HashMap::from([("X-Custom-Header".to_string(), "Invalid\nValue".to_string())]);
465
466 let notifier = create_test_notifier(server.url().as_str(), None, Some(invalid_headers));
467
468 let payload = create_test_payload();
469 let result = notifier.notify_json(&payload).await;
470 let err = result.unwrap_err();
471 assert!(err.to_string().contains("Invalid header value"));
472 }
473
474 #[tokio::test]
475 async fn test_notify_with_valid_headers() {
476 let mut server = mockito::Server::new_async().await;
477 let valid_headers = HashMap::from([
478 ("X-Custom-Header".to_string(), "valid-value".to_string()),
479 ("Accept".to_string(), "application/json".to_string()),
480 ]);
481
482 let mock = server
483 .mock("POST", "/")
484 .match_header("X-Custom-Header", "valid-value")
485 .match_header("Accept", "application/json")
486 .with_status(200)
487 .create_async()
488 .await;
489
490 let notifier = create_test_notifier(server.url().as_str(), None, Some(valid_headers));
491
492 let payload = create_test_payload();
493 let result = notifier.notify_json(&payload).await;
494 assert!(result.is_ok());
495 mock.assert();
496 }
497
498 #[tokio::test]
499 async fn test_notify_signature_header_cases() {
500 let mut server = mockito::Server::new_async().await;
501
502 let mock = server
503 .mock("POST", "/")
504 .match_header("X-Signature", Matcher::Any)
505 .match_header("X-Timestamp", Matcher::Any)
506 .with_status(200)
507 .create_async()
508 .await;
509
510 let notifier = create_test_notifier(server.url().as_str(), Some("test-secret"), None);
511
512 let payload = create_test_payload();
513 let result = notifier.notify_json(&payload).await;
514 assert!(result.is_ok());
515 mock.assert();
516 }
517
518 #[test]
519 fn test_sign_request_validation() {
520 let notifier =
521 create_test_notifier("https://webhook.example.com", Some("test-secret"), None);
522
523 let payload = create_test_payload();
524
525 let result = notifier.sign_payload("test-secret", &payload).unwrap();
526 let (signature, timestamp) = result;
527
528 assert!(
530 hex::decode(&signature).is_ok(),
531 "Signature should be valid hex"
532 );
533
534 assert!(
536 timestamp.parse::<i64>().is_ok(),
537 "Timestamp should be valid i64"
538 );
539 }
540}