openzeppelin_relayer/models/notification/
config.rs1use crate::{
12 config::ConfigFileError,
13 models::{
14 notification::Notification, NotificationType, NotificationValidationError, PlainOrEnvValue,
15 SecretString,
16 },
17};
18use serde::{Deserialize, Serialize};
19use std::collections::HashSet;
20
21#[derive(Debug, Serialize, Deserialize, Clone)]
23#[serde(deny_unknown_fields)]
24pub struct NotificationConfig {
25 pub id: String,
26 pub r#type: NotificationType,
27 pub url: String,
28 pub signing_key: Option<PlainOrEnvValue>,
29}
30
31impl TryFrom<NotificationConfig> for Notification {
32 type Error = ConfigFileError;
33
34 fn try_from(config: NotificationConfig) -> Result<Self, Self::Error> {
35 let signing_key = config.get_signing_key()?;
36
37 let notification = Notification::new(config.id, config.r#type, config.url, signing_key);
39
40 notification.validate().map_err(|e| match e {
42 NotificationValidationError::EmptyId => {
43 ConfigFileError::MissingField("notification id".into())
44 }
45 NotificationValidationError::InvalidIdFormat => {
46 ConfigFileError::InvalidFormat("Invalid notification ID format".into())
47 }
48 NotificationValidationError::EmptyUrl => {
49 ConfigFileError::MissingField("Webhook URL is required".into())
50 }
51 NotificationValidationError::InvalidUrl => {
52 ConfigFileError::InvalidFormat("Invalid Webhook URL".into())
53 }
54 NotificationValidationError::SigningKeyTooShort(min_len) => {
55 ConfigFileError::InvalidFormat(format!(
56 "Signing key must be at least {} characters long",
57 min_len
58 ))
59 }
60 })?;
61
62 Ok(notification)
63 }
64}
65
66impl NotificationConfig {
67 pub fn validate(&self) -> Result<(), ConfigFileError> {
69 let _notification = Notification::try_from(self.clone())?;
70 Ok(())
71 }
72
73 pub fn to_core_notification(&self) -> Result<Notification, ConfigFileError> {
75 Notification::try_from(self.clone())
76 }
77
78 pub fn get_signing_key(&self) -> Result<Option<SecretString>, ConfigFileError> {
80 match &self.signing_key {
81 Some(signing_key) => match signing_key {
82 PlainOrEnvValue::Env { value } => {
83 if value.is_empty() {
84 return Err(ConfigFileError::MissingField(
85 "Signing key environment variable name cannot be empty".into(),
86 ));
87 }
88
89 match std::env::var(value) {
90 Ok(key_value) => {
91 let secret = SecretString::new(&key_value);
92 Ok(Some(secret))
93 }
94 Err(e) => Err(ConfigFileError::MissingEnvVar(format!(
95 "Environment variable '{}' not found: {}",
96 value, e
97 ))),
98 }
99 }
100 PlainOrEnvValue::Plain { value } => {
101 let is_empty = value.as_str(|s| s.is_empty());
102 if is_empty {
103 return Err(ConfigFileError::InvalidFormat(
104 "Signing key value cannot be empty".into(),
105 ));
106 }
107 Ok(Some(value.clone()))
108 }
109 },
110 None => Ok(None),
111 }
112 }
113}
114
115#[derive(Debug, Serialize, Deserialize, Clone)]
117#[serde(deny_unknown_fields)]
118pub struct NotificationConfigs {
119 pub notifications: Vec<NotificationConfig>,
120}
121
122impl NotificationConfigs {
123 pub fn new(notifications: Vec<NotificationConfig>) -> Self {
125 Self { notifications }
126 }
127
128 pub fn validate(&self) -> Result<(), ConfigFileError> {
130 if self.notifications.is_empty() {
131 return Ok(());
132 }
133
134 let mut ids = HashSet::new();
135 for notification in &self.notifications {
136 notification.validate()?;
138
139 if !ids.insert(notification.id.clone()) {
141 return Err(ConfigFileError::DuplicateId(notification.id.clone()));
142 }
143 }
144 Ok(())
145 }
146
147 pub fn to_core_notifications(&self) -> Result<Vec<Notification>, ConfigFileError> {
149 self.notifications
150 .iter()
151 .map(|config| Notification::try_from(config.clone()))
152 .collect()
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn test_valid_notification_config_conversion() {
162 let config = NotificationConfig {
163 id: "test-webhook".to_string(),
164 r#type: NotificationType::Webhook,
165 url: "https://example.com/webhook".to_string(),
166 signing_key: Some(PlainOrEnvValue::Plain {
167 value: SecretString::new(&"a".repeat(32)),
168 }),
169 };
170
171 let result = Notification::try_from(config);
172 assert!(result.is_ok());
173
174 let notification = result.unwrap();
175 assert_eq!(notification.id, "test-webhook");
176 assert_eq!(notification.notification_type, NotificationType::Webhook);
177 assert_eq!(notification.url, "https://example.com/webhook");
178 assert!(notification.signing_key.is_some());
179 }
180
181 #[test]
182 fn test_invalid_notification_config_conversion() {
183 let config = NotificationConfig {
184 id: "invalid@id".to_string(), r#type: NotificationType::Webhook,
186 url: "https://example.com/webhook".to_string(),
187 signing_key: None,
188 };
189
190 let result = Notification::try_from(config);
191 assert!(result.is_err());
192
193 if let Err(ConfigFileError::InvalidFormat(msg)) = result {
194 assert!(msg.contains("Invalid notification ID format"));
195 } else {
196 panic!("Expected InvalidFormat error");
197 }
198 }
199
200 #[test]
201 fn test_to_core_notification() {
202 let config = NotificationConfig {
203 id: "test-webhook".to_string(),
204 r#type: NotificationType::Webhook,
205 url: "https://example.com/webhook".to_string(),
206 signing_key: Some(PlainOrEnvValue::Plain {
207 value: SecretString::new(&"a".repeat(32)),
208 }),
209 };
210
211 let core = config.to_core_notification().unwrap();
212 assert_eq!(core.id, "test-webhook");
213 assert_eq!(core.notification_type, NotificationType::Webhook);
214 assert_eq!(core.url, "https://example.com/webhook");
215 assert!(core.signing_key.is_some());
216 }
217
218 #[test]
219 fn test_notification_configs_validation() {
220 let configs = NotificationConfigs::new(vec![
221 NotificationConfig {
222 id: "webhook1".to_string(),
223 r#type: NotificationType::Webhook,
224 url: "https://example.com/webhook1".to_string(),
225 signing_key: None,
226 },
227 NotificationConfig {
228 id: "webhook2".to_string(),
229 r#type: NotificationType::Webhook,
230 url: "https://example.com/webhook2".to_string(),
231 signing_key: None,
232 },
233 ]);
234
235 assert!(configs.validate().is_ok());
236 }
237
238 #[test]
239 fn test_duplicate_ids() {
240 let configs = NotificationConfigs::new(vec![
241 NotificationConfig {
242 id: "webhook1".to_string(),
243 r#type: NotificationType::Webhook,
244 url: "https://example.com/webhook1".to_string(),
245 signing_key: None,
246 },
247 NotificationConfig {
248 id: "webhook1".to_string(), r#type: NotificationType::Webhook,
250 url: "https://example.com/webhook2".to_string(),
251 signing_key: None,
252 },
253 ]);
254
255 assert!(matches!(
256 configs.validate(),
257 Err(ConfigFileError::DuplicateId(_))
258 ));
259 }
260
261 #[test]
262 fn test_config_with_short_signing_key() {
263 let config = NotificationConfig {
264 id: "test-webhook".to_string(),
265 r#type: NotificationType::Webhook,
266 url: "https://example.com/webhook".to_string(),
267 signing_key: Some(PlainOrEnvValue::Plain {
268 value: SecretString::new("short"), }),
270 };
271
272 let result = Notification::try_from(config);
273 assert!(result.is_err());
274
275 if let Err(ConfigFileError::InvalidFormat(msg)) = result {
276 assert!(msg.contains("Signing key must be at least"));
277 } else {
278 panic!("Expected InvalidFormat error for short key");
279 }
280 }
281
282 #[test]
284 fn test_valid_webhook_notification_json() {
285 use serde_json::json;
286
287 let config = json!({
288 "id": "notification-test",
289 "type": "webhook",
290 "url": "https://api.example.com/notifications"
291 });
292
293 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
294 assert!(notification.validate().is_ok());
295 assert_eq!(notification.id, "notification-test");
296 assert_eq!(notification.r#type, NotificationType::Webhook);
297 }
298
299 #[test]
300 fn test_invalid_webhook_url_json() {
301 use serde_json::json;
302
303 let config = json!({
304 "id": "notification-test",
305 "type": "webhook",
306 "url": "invalid-url"
307 });
308
309 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
310 assert!(notification.validate().is_err());
311 }
312
313 #[test]
314 fn test_webhook_notification_with_signing_key_json() {
315 use serde_json::json;
316
317 let config = json!({
318 "id": "notification-test",
319 "type": "webhook",
320 "url": "https://api.example.com/notifications",
321 "signing_key": {
322 "type": "plain",
323 "value": "a".repeat(32)
324 }
325 });
326
327 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
328 assert!(notification.validate().is_ok());
329 assert!(notification.get_signing_key().unwrap().is_some());
330 }
331
332 #[test]
333 fn test_webhook_notification_with_env_signing_key_json() {
334 use serde_json::json;
335 use std::sync::Mutex;
336
337 static ENV_MUTEX: Mutex<()> = Mutex::new(());
338 let _lock = ENV_MUTEX.lock().unwrap();
339
340 std::env::set_var("TEST_SIGNING_KEY", "a".repeat(32));
342
343 let config = json!({
344 "id": "notification-test",
345 "type": "webhook",
346 "url": "https://api.example.com/notifications",
347 "signing_key": {
348 "type": "env",
349 "value": "TEST_SIGNING_KEY"
350 }
351 });
352
353 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
354 assert!(notification.validate().is_ok());
355 assert!(notification.get_signing_key().unwrap().is_some());
356
357 std::env::remove_var("TEST_SIGNING_KEY");
359 }
360}