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},
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	////////////////////////////////////////////////////////////
327	// sign_request tests
328	////////////////////////////////////////////////////////////
329
330	#[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	////////////////////////////////////////////////////////////
364	// from_config tests
365	////////////////////////////////////////////////////////////
366
367	#[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		// Create a config that is not a Telegram type
382		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	////////////////////////////////////////////////////////////
402	// notify tests
403	////////////////////////////////////////////////////////////
404
405	#[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	////////////////////////////////////////////////////////////
443	// notify header validation tests
444	////////////////////////////////////////////////////////////
445
446	#[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		// Validate signature format (should be a hex string)
528		assert!(
529			hex::decode(&signature).is_ok(),
530			"Signature should be valid hex"
531		);
532
533		// Validate timestamp format (should be a valid i64)
534		assert!(
535			timestamp.parse::<i64>().is_ok(),
536			"Timestamp should be valid i64"
537		);
538	}
539}