openzeppelin_monitor/services/notification/
webhook.rs

1//! Webhook notification implementation.
2//!
3//! Provides functionality to send formatted messages to webhooks
4//! via incoming webhooks, supporting message templates with variable substitution.
5
6use 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
18/// HMAC SHA256 type alias
19type HmacSha256 = Hmac<Sha256>;
20
21/// Represents a webhook configuration
22#[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/// Implementation of webhook notifications via webhooks
35#[derive(Debug)]
36pub struct WebhookNotifier {
37	/// Webhook URL for message delivery
38	pub url: String,
39	/// URL parameters to use for the webhook request
40	pub url_params: Option<HashMap<String, String>>,
41	/// Title to display in the message
42	pub title: String,
43	/// Configured HTTP client for webhook requests with retry capabilities
44	pub client: Arc<ClientWithMiddleware>,
45	/// HTTP method to use for the webhook request
46	pub method: Option<String>,
47	/// Secret to use for the webhook request
48	pub secret: Option<String>,
49	/// Headers to use for the webhook request
50	pub headers: Option<HashMap<String, String>>,
51	/// Payload fields to use for the webhook request
52	pub payload_fields: Option<HashMap<String, serde_json::Value>>,
53}
54
55impl WebhookNotifier {
56	/// Creates a new Webhook notifier instance
57	///
58	/// # Arguments
59	/// * `config` - Webhook configuration
60	/// * `http_client` - HTTP client with middleware for retries
61	///
62	/// # Returns
63	/// * `Result<Self, NotificationError>` - Notifier instance if config is valid
64	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	/// Creates a Webhook notifier from a trigger configuration
85	///
86	/// # Arguments
87	/// * `config` - Trigger configuration containing Webhook parameters
88	/// * `http_client` - HTTP client with middleware for retries
89	///
90	/// # Returns
91	/// * `Result<Self>` - Notifier instance if config is Webhook type
92	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		// Explicitly reject empty secret, because `HmacSha256::new_from_slice` currently allows empty secrets
129		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		// Create HMAC instance
140		let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
141			NotificationError::config_error(format!("Invalid secret: {}", e), None, None)
142		})?; // Handle error if secret is invalid
143
144		// Create the message to sign
145		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		// Get the HMAC result
156		let signature = hex::encode(mac.finalize().into_bytes());
157
158		Ok((signature, timestamp.to_string()))
159	}
160
161	/// Sends a JSON payload to Webhook
162	///
163	/// # Arguments
164	/// * `payload` - The JSON payload to send
165	///
166	/// # Returns
167	/// * `Result<(), NotificationError>` - Success or error
168	pub async fn notify_json(&self, payload: &serde_json::Value) -> Result<(), NotificationError> {
169		let mut url = self.url.clone();
170		// Add URL parameters if present
171		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		// Add default headers
188		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			// Add signature headers
200			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(&timestamp).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		// Add custom headers
223		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		// Send request with custom payload
244		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	////////////////////////////////////////////////////////////
328	// sign_request tests
329	////////////////////////////////////////////////////////////
330
331	#[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	////////////////////////////////////////////////////////////
365	// from_config tests
366	////////////////////////////////////////////////////////////
367
368	#[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		// Create a config that is not a Telegram type
383		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	////////////////////////////////////////////////////////////
403	// notify tests
404	////////////////////////////////////////////////////////////
405
406	#[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	////////////////////////////////////////////////////////////
444	// notify header validation tests
445	////////////////////////////////////////////////////////////
446
447	#[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		// Validate signature format (should be a hex string)
529		assert!(
530			hex::decode(&signature).is_ok(),
531			"Signature should be valid hex"
532		);
533
534		// Validate timestamp format (should be a valid i64)
535		assert!(
536			timestamp.parse::<i64>().is_ok(),
537			"Timestamp should be valid i64"
538		);
539	}
540}