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 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
23/// HMAC SHA256 type alias
24type HmacSha256 = Hmac<Sha256>;
25
26/// Represents a webhook configuration
27#[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/// Implementation of webhook notifications via webhooks
40#[derive(Debug)]
41pub struct WebhookNotifier {
42	/// Webhook URL for message delivery
43	pub url: String,
44	/// URL parameters to use for the webhook request
45	pub url_params: Option<HashMap<String, String>>,
46	/// Title to display in the message
47	pub title: String,
48	/// Message template with variable placeholders
49	pub body_template: String,
50	/// Configured HTTP client for webhook requests with retry capabilities
51	pub client: Arc<ClientWithMiddleware>,
52	/// HTTP method to use for the webhook request
53	pub method: Option<String>,
54	/// Secret to use for the webhook request
55	pub secret: Option<String>,
56	/// Headers to use for the webhook request
57	pub headers: Option<HashMap<String, String>>,
58	/// Payload fields to use for the webhook request
59	pub payload_fields: Option<HashMap<String, serde_json::Value>>,
60}
61
62/// Represents a formatted webhook message
63#[derive(Serialize, Debug)]
64pub struct WebhookMessage {
65	/// The content of the message
66	title: String,
67	body: String,
68}
69
70impl WebhookNotifier {
71	/// Creates a new Webhook notifier instance
72	///
73	/// # Arguments
74	/// * `config` - Webhook configuration
75	/// * `http_client` - HTTP client with middleware for retries
76	///
77	/// # Returns
78	/// * `Result<Self, NotificationError>` - Notifier instance if config is valid
79	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	/// Formats a message by substituting variables in the template
101	///
102	/// # Arguments
103	/// * `variables` - Map of variable names to values
104	///
105	/// # Returns
106	/// * `String` - Formatted message with variables replaced
107	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	/// Creates a Webhook notifier from a trigger configuration
116	///
117	/// # Arguments
118	/// * `config` - Trigger configuration containing Webhook parameters
119	/// * `http_client` - HTTP client with middleware for retries
120	///
121	/// # Returns
122	/// * `Result<Self>` - Notifier instance if config is Webhook type
123	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		// Explicitly reject empty secret, because `HmacSha256::new_from_slice` currently allows empty secrets
160		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		// Create HMAC instance
171		let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
172			NotificationError::config_error(format!("Invalid secret: {}", e), None, None)
173		})?; // Handle error if secret is invalid
174
175		// Create the message to sign
176		let message = format!("{:?}{}", payload, timestamp);
177		mac.update(message.as_bytes());
178
179		// Get the HMAC result
180		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	/// Sends a formatted message to Webhook
189	///
190	/// # Arguments
191	/// * `message` - The formatted message to send
192	///
193	/// # Returns
194	/// * `Result<(), NotificationError>` - Success or error
195	async fn notify(&self, message: &str) -> Result<(), NotificationError> {
196		// Default payload with title and body
197		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	/// Sends a formatted message to Webhook with custom payload fields
205	///
206	/// # Arguments
207	/// * `message` - The formatted message to send
208	/// * `payload_fields` - Additional fields to include in the payload
209	///
210	/// # Returns
211	/// * `Result<(), NotificationError>` - Success or error
212	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		// Add URL parameters if present
219		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		// Merge with default payload fields if they exist
230		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		// Add default headers
245		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			// Create a WebhookMessage for signing
253			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			// Add signature headers
265			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(&timestamp).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		// Add custom headers
288		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		// Send request with custom payload
309		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	////////////////////////////////////////////////////////////
384	// format_message tests
385	////////////////////////////////////////////////////////////
386
387	#[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		// status variable is not provided
416
417		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	////////////////////////////////////////////////////////////
431	// sign_request tests
432	////////////////////////////////////////////////////////////
433
434	#[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	////////////////////////////////////////////////////////////
473	// from_config tests
474	////////////////////////////////////////////////////////////
475
476	#[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		// Create a config that is not a Telegram type
492		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	////////////////////////////////////////////////////////////
512	// notify tests
513	////////////////////////////////////////////////////////////
514
515	#[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	////////////////////////////////////////////////////////////
553	// notify header validation tests
554	////////////////////////////////////////////////////////////
555
556	#[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		// Validate signature format (should be a hex string)
662		assert!(
663			hex::decode(&signature).is_ok(),
664			"Signature should be valid hex"
665		);
666
667		// Validate timestamp format (should be a valid i64)
668		assert!(
669			timestamp.parse::<i64>().is_ok(),
670			"Timestamp should be valid i64"
671		);
672	}
673
674	////////////////////////////////////////////////////////////
675	// notify_with_payload tests
676	////////////////////////////////////////////////////////////
677
678	#[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)  // Expect exactly one request
695			.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		// Insert fields in the same order as they appear in expected_payload
701		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}