openzeppelin_relayer/models/notification/
config.rs

1//! Configuration file representation and parsing for notifications.
2//!
3//! This module handles the configuration file format for notifications, providing:
4//!
5//! - **Config Models**: Structures that match the configuration file schema
6//! - **Validation**: Config-specific validation rules and constraints
7//! - **Conversions**: Bidirectional mapping between config and domain models
8//! - **Collections**: Container types for managing multiple notification configurations
9//!
10//! Used primarily during application startup to parse notification settings from config files.
11use 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/// Configuration file representation of a notification
22#[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        // Create core notification
38        let notification = Notification::new(config.id, config.r#type, config.url, signing_key);
39
40        // Validate using core validation logic
41        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 {min_len} characters long"
57                ))
58            }
59        })?;
60
61        Ok(notification)
62    }
63}
64
65impl NotificationConfig {
66    /// Validates the notification configuration by converting to core model
67    pub fn validate(&self) -> Result<(), ConfigFileError> {
68        let _notification = Notification::try_from(self.clone())?;
69        Ok(())
70    }
71
72    /// Converts to core notification model
73    pub fn to_core_notification(&self) -> Result<Notification, ConfigFileError> {
74        Notification::try_from(self.clone())
75    }
76
77    /// Gets the resolved signing key with config-specific error handling
78    pub fn get_signing_key(&self) -> Result<Option<SecretString>, ConfigFileError> {
79        match &self.signing_key {
80            Some(signing_key) => match signing_key {
81                PlainOrEnvValue::Env { value } => {
82                    if value.is_empty() {
83                        return Err(ConfigFileError::MissingField(
84                            "Signing key environment variable name cannot be empty".into(),
85                        ));
86                    }
87
88                    match std::env::var(value) {
89                        Ok(key_value) => {
90                            let secret = SecretString::new(&key_value);
91                            Ok(Some(secret))
92                        }
93                        Err(e) => Err(ConfigFileError::MissingEnvVar(format!(
94                            "Environment variable '{value}' not found: {e}"
95                        ))),
96                    }
97                }
98                PlainOrEnvValue::Plain { value } => {
99                    let is_empty = value.as_str(|s| s.is_empty());
100                    if is_empty {
101                        return Err(ConfigFileError::InvalidFormat(
102                            "Signing key value cannot be empty".into(),
103                        ));
104                    }
105                    Ok(Some(value.clone()))
106                }
107            },
108            None => Ok(None),
109        }
110    }
111}
112
113/// Collection of notification configurations
114#[derive(Debug, Serialize, Deserialize, Clone)]
115#[serde(deny_unknown_fields)]
116pub struct NotificationConfigs {
117    pub notifications: Vec<NotificationConfig>,
118}
119
120impl NotificationConfigs {
121    /// Creates a new collection of notification configurations
122    pub fn new(notifications: Vec<NotificationConfig>) -> Self {
123        Self { notifications }
124    }
125
126    /// Validates all notification configurations
127    pub fn validate(&self) -> Result<(), ConfigFileError> {
128        if self.notifications.is_empty() {
129            return Ok(());
130        }
131
132        let mut ids = HashSet::new();
133        for notification in &self.notifications {
134            // Validate each notification using core validation
135            notification.validate()?;
136
137            // Check for duplicate IDs
138            if !ids.insert(notification.id.clone()) {
139                return Err(ConfigFileError::DuplicateId(notification.id.clone()));
140            }
141        }
142        Ok(())
143    }
144
145    /// Converts all configurations to core notification models
146    pub fn to_core_notifications(&self) -> Result<Vec<Notification>, ConfigFileError> {
147        self.notifications
148            .iter()
149            .map(|config| Notification::try_from(config.clone()))
150            .collect()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_valid_notification_config_conversion() {
160        let config = NotificationConfig {
161            id: "test-webhook".to_string(),
162            r#type: NotificationType::Webhook,
163            url: "https://example.com/webhook".to_string(),
164            signing_key: Some(PlainOrEnvValue::Plain {
165                value: SecretString::new(&"a".repeat(32)),
166            }),
167        };
168
169        let result = Notification::try_from(config);
170        assert!(result.is_ok());
171
172        let notification = result.unwrap();
173        assert_eq!(notification.id, "test-webhook");
174        assert_eq!(notification.notification_type, NotificationType::Webhook);
175        assert_eq!(notification.url, "https://example.com/webhook");
176        assert!(notification.signing_key.is_some());
177    }
178
179    #[test]
180    fn test_invalid_notification_config_conversion() {
181        let config = NotificationConfig {
182            id: "invalid@id".to_string(), // Invalid ID format
183            r#type: NotificationType::Webhook,
184            url: "https://example.com/webhook".to_string(),
185            signing_key: None,
186        };
187
188        let result = Notification::try_from(config);
189        assert!(result.is_err());
190
191        if let Err(ConfigFileError::InvalidFormat(msg)) = result {
192            assert!(msg.contains("Invalid notification ID format"));
193        } else {
194            panic!("Expected InvalidFormat error");
195        }
196    }
197
198    #[test]
199    fn test_to_core_notification() {
200        let config = NotificationConfig {
201            id: "test-webhook".to_string(),
202            r#type: NotificationType::Webhook,
203            url: "https://example.com/webhook".to_string(),
204            signing_key: Some(PlainOrEnvValue::Plain {
205                value: SecretString::new(&"a".repeat(32)),
206            }),
207        };
208
209        let core = config.to_core_notification().unwrap();
210        assert_eq!(core.id, "test-webhook");
211        assert_eq!(core.notification_type, NotificationType::Webhook);
212        assert_eq!(core.url, "https://example.com/webhook");
213        assert!(core.signing_key.is_some());
214    }
215
216    #[test]
217    fn test_notification_configs_validation() {
218        let configs = NotificationConfigs::new(vec![
219            NotificationConfig {
220                id: "webhook1".to_string(),
221                r#type: NotificationType::Webhook,
222                url: "https://example.com/webhook1".to_string(),
223                signing_key: None,
224            },
225            NotificationConfig {
226                id: "webhook2".to_string(),
227                r#type: NotificationType::Webhook,
228                url: "https://example.com/webhook2".to_string(),
229                signing_key: None,
230            },
231        ]);
232
233        assert!(configs.validate().is_ok());
234    }
235
236    #[test]
237    fn test_duplicate_ids() {
238        let configs = NotificationConfigs::new(vec![
239            NotificationConfig {
240                id: "webhook1".to_string(),
241                r#type: NotificationType::Webhook,
242                url: "https://example.com/webhook1".to_string(),
243                signing_key: None,
244            },
245            NotificationConfig {
246                id: "webhook1".to_string(), // Duplicate ID
247                r#type: NotificationType::Webhook,
248                url: "https://example.com/webhook2".to_string(),
249                signing_key: None,
250            },
251        ]);
252
253        assert!(matches!(
254            configs.validate(),
255            Err(ConfigFileError::DuplicateId(_))
256        ));
257    }
258
259    #[test]
260    fn test_config_with_short_signing_key() {
261        let config = NotificationConfig {
262            id: "test-webhook".to_string(),
263            r#type: NotificationType::Webhook,
264            url: "https://example.com/webhook".to_string(),
265            signing_key: Some(PlainOrEnvValue::Plain {
266                value: SecretString::new("short"), // Too short
267            }),
268        };
269
270        let result = Notification::try_from(config);
271        assert!(result.is_err());
272
273        if let Err(ConfigFileError::InvalidFormat(msg)) = result {
274            assert!(msg.contains("Signing key must be at least"));
275        } else {
276            panic!("Expected InvalidFormat error for short key");
277        }
278    }
279
280    // Additional tests for JSON deserialization and environment handling
281    #[test]
282    fn test_valid_webhook_notification_json() {
283        use serde_json::json;
284
285        let config = json!({
286            "id": "notification-test",
287            "type": "webhook",
288            "url": "https://api.example.com/notifications"
289        });
290
291        let notification: NotificationConfig = serde_json::from_value(config).unwrap();
292        assert!(notification.validate().is_ok());
293        assert_eq!(notification.id, "notification-test");
294        assert_eq!(notification.r#type, NotificationType::Webhook);
295    }
296
297    #[test]
298    fn test_invalid_webhook_url_json() {
299        use serde_json::json;
300
301        let config = json!({
302            "id": "notification-test",
303            "type": "webhook",
304            "url": "invalid-url"
305        });
306
307        let notification: NotificationConfig = serde_json::from_value(config).unwrap();
308        assert!(notification.validate().is_err());
309    }
310
311    #[test]
312    fn test_webhook_notification_with_signing_key_json() {
313        use serde_json::json;
314
315        let config = json!({
316            "id": "notification-test",
317            "type": "webhook",
318            "url": "https://api.example.com/notifications",
319            "signing_key": {
320                "type": "plain",
321                "value": "a".repeat(32)
322            }
323        });
324
325        let notification: NotificationConfig = serde_json::from_value(config).unwrap();
326        assert!(notification.validate().is_ok());
327        assert!(notification.get_signing_key().unwrap().is_some());
328    }
329
330    #[test]
331    fn test_webhook_notification_with_env_signing_key_json() {
332        use serde_json::json;
333        use std::sync::Mutex;
334
335        static ENV_MUTEX: Mutex<()> = Mutex::new(());
336        let _lock = ENV_MUTEX.lock().unwrap();
337
338        // Set environment variable
339        std::env::set_var("TEST_SIGNING_KEY", "a".repeat(32));
340
341        let config = json!({
342            "id": "notification-test",
343            "type": "webhook",
344            "url": "https://api.example.com/notifications",
345            "signing_key": {
346                "type": "env",
347                "value": "TEST_SIGNING_KEY"
348            }
349        });
350
351        let notification: NotificationConfig = serde_json::from_value(config).unwrap();
352        assert!(notification.validate().is_ok());
353        assert!(notification.get_signing_key().unwrap().is_some());
354
355        // Clean up
356        std::env::remove_var("TEST_SIGNING_KEY");
357    }
358}