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 {} characters long",
57                    min_len
58                ))
59            }
60        })?;
61
62        Ok(notification)
63    }
64}
65
66impl NotificationConfig {
67    /// Validates the notification configuration by converting to core model
68    pub fn validate(&self) -> Result<(), ConfigFileError> {
69        let _notification = Notification::try_from(self.clone())?;
70        Ok(())
71    }
72
73    /// Converts to core notification model
74    pub fn to_core_notification(&self) -> Result<Notification, ConfigFileError> {
75        Notification::try_from(self.clone())
76    }
77
78    /// Gets the resolved signing key with config-specific error handling
79    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/// Collection of notification configurations
116#[derive(Debug, Serialize, Deserialize, Clone)]
117#[serde(deny_unknown_fields)]
118pub struct NotificationConfigs {
119    pub notifications: Vec<NotificationConfig>,
120}
121
122impl NotificationConfigs {
123    /// Creates a new collection of notification configurations
124    pub fn new(notifications: Vec<NotificationConfig>) -> Self {
125        Self { notifications }
126    }
127
128    /// Validates all notification configurations
129    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            // Validate each notification using core validation
137            notification.validate()?;
138
139            // Check for duplicate IDs
140            if !ids.insert(notification.id.clone()) {
141                return Err(ConfigFileError::DuplicateId(notification.id.clone()));
142            }
143        }
144        Ok(())
145    }
146
147    /// Converts all configurations to core notification models
148    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(), // Invalid ID format
185            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(), // Duplicate ID
249                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"), // Too short
269            }),
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    // Additional tests for JSON deserialization and environment handling
283    #[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        // Set environment variable
341        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        // Clean up
358        std::env::remove_var("TEST_SIGNING_KEY");
359    }
360}