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},
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 retry_policy: RetryConfig::default(),
315 }
316 }
317
318 fn create_test_payload() -> serde_json::Value {
319 GenericWebhookPayloadBuilder.build_payload(
320 "Test Alert",
321 "Test message with value ${value}",
322 &HashMap::from([("value".to_string(), "42".to_string())]),
323 )
324 }
325
326 #[test]
331 fn test_sign_request() {
332 let notifier =
333 create_test_notifier("https://webhook.example.com", Some("test-secret"), None);
334 let payload = json!({
335 "title": "Test Title",
336 "body": "Test message"
337 });
338 let secret = "test-secret";
339
340 let result = notifier.sign_payload(secret, &payload).unwrap();
341 let (signature, timestamp) = result;
342
343 assert!(!signature.is_empty());
344 assert!(!timestamp.is_empty());
345 }
346
347 #[test]
348 fn test_sign_request_fails_empty_secret() {
349 let notifier = create_test_notifier("https://webhook.example.com", None, None);
350 let payload = json!({
351 "title": "Test Title",
352 "body": "Test message"
353 });
354 let empty_secret = "";
355
356 let result = notifier.sign_payload(empty_secret, &payload);
357 assert!(result.is_err());
358
359 let error = result.unwrap_err();
360 assert!(matches!(error, NotificationError::NotifyFailed(_)));
361 }
362
363 #[test]
368 fn test_from_config_with_webhook_config() {
369 let config = create_test_webhook_config();
370 let http_client = create_test_http_client();
371 let notifier = WebhookNotifier::from_config(&config, http_client);
372 assert!(notifier.is_ok());
373
374 let notifier = notifier.unwrap();
375 assert_eq!(notifier.url, "https://webhook.example.com");
376 assert_eq!(notifier.title, "Test Alert");
377 }
378
379 #[test]
380 fn test_from_config_invalid_type() {
381 let config = TriggerTypeConfig::Slack {
383 slack_url: SecretValue::Plain(SecretString::new(
384 "https://slack.example.com".to_string(),
385 )),
386 message: NotificationMessage {
387 title: "Test Alert".to_string(),
388 body: "Test message ${value}".to_string(),
389 },
390 retry_policy: RetryConfig::default(),
391 };
392
393 let http_client = create_test_http_client();
394 let notifier = WebhookNotifier::from_config(&config, http_client);
395 assert!(notifier.is_err());
396
397 let error = notifier.unwrap_err();
398 assert!(matches!(error, NotificationError::ConfigError { .. }));
399 }
400
401 #[tokio::test]
406 async fn test_notify_failure() {
407 let notifier = create_test_notifier("https://webhook.example.com", None, None);
408 let payload = create_test_payload();
409 let result = notifier.notify_json(&payload).await;
410 assert!(result.is_err());
411 }
412
413 #[tokio::test]
414 async fn test_notify_includes_signature_and_timestamp() {
415 let mut server = mockito::Server::new_async().await;
416 let mock: Mock = server
417 .mock("POST", "/")
418 .match_header("X-Signature", Matcher::Regex("^[0-9a-f]{64}$".to_string()))
419 .match_header("X-Timestamp", Matcher::Regex("^[0-9]+$".to_string()))
420 .match_header("Content-Type", "application/json")
421 .with_status(200)
422 .create_async()
423 .await;
424
425 let notifier = create_test_notifier(
426 server.url().as_str(),
427 Some("top-secret"),
428 Some(HashMap::from([(
429 "Content-Type".to_string(),
430 "application/json".to_string(),
431 )])),
432 );
433
434 let payload = create_test_payload();
435 let result = notifier.notify_json(&payload).await;
436
437 assert!(result.is_ok());
438
439 mock.assert();
440 }
441
442 #[tokio::test]
447 async fn test_notify_with_invalid_header_name() {
448 let server = mockito::Server::new_async().await;
449 let invalid_headers =
450 HashMap::from([("Invalid Header!@#".to_string(), "value".to_string())]);
451
452 let notifier = create_test_notifier(server.url().as_str(), None, Some(invalid_headers));
453 let payload = create_test_payload();
454 let result = notifier.notify_json(&payload).await;
455 let err = result.unwrap_err();
456 assert!(err.to_string().contains("Invalid header name"));
457 }
458
459 #[tokio::test]
460 async fn test_notify_with_invalid_header_value() {
461 let server = mockito::Server::new_async().await;
462 let invalid_headers =
463 HashMap::from([("X-Custom-Header".to_string(), "Invalid\nValue".to_string())]);
464
465 let notifier = create_test_notifier(server.url().as_str(), None, Some(invalid_headers));
466
467 let payload = create_test_payload();
468 let result = notifier.notify_json(&payload).await;
469 let err = result.unwrap_err();
470 assert!(err.to_string().contains("Invalid header value"));
471 }
472
473 #[tokio::test]
474 async fn test_notify_with_valid_headers() {
475 let mut server = mockito::Server::new_async().await;
476 let valid_headers = HashMap::from([
477 ("X-Custom-Header".to_string(), "valid-value".to_string()),
478 ("Accept".to_string(), "application/json".to_string()),
479 ]);
480
481 let mock = server
482 .mock("POST", "/")
483 .match_header("X-Custom-Header", "valid-value")
484 .match_header("Accept", "application/json")
485 .with_status(200)
486 .create_async()
487 .await;
488
489 let notifier = create_test_notifier(server.url().as_str(), None, Some(valid_headers));
490
491 let payload = create_test_payload();
492 let result = notifier.notify_json(&payload).await;
493 assert!(result.is_ok());
494 mock.assert();
495 }
496
497 #[tokio::test]
498 async fn test_notify_signature_header_cases() {
499 let mut server = mockito::Server::new_async().await;
500
501 let mock = server
502 .mock("POST", "/")
503 .match_header("X-Signature", Matcher::Any)
504 .match_header("X-Timestamp", Matcher::Any)
505 .with_status(200)
506 .create_async()
507 .await;
508
509 let notifier = create_test_notifier(server.url().as_str(), Some("test-secret"), None);
510
511 let payload = create_test_payload();
512 let result = notifier.notify_json(&payload).await;
513 assert!(result.is_ok());
514 mock.assert();
515 }
516
517 #[test]
518 fn test_sign_request_validation() {
519 let notifier =
520 create_test_notifier("https://webhook.example.com", Some("test-secret"), None);
521
522 let payload = create_test_payload();
523
524 let result = notifier.sign_payload("test-secret", &payload).unwrap();
525 let (signature, timestamp) = result;
526
527 assert!(
529 hex::decode(&signature).is_ok(),
530 "Signature should be valid hex"
531 );
532
533 assert!(
535 timestamp.parse::<i64>().is_ok(),
536 "Timestamp should be valid i64"
537 );
538 }
539}