openzeppelin_relayer/config/config_file/
notification.rs

1//! This module defines the configuration structures and validation logic for notifications.
2//!
3//! It includes:
4//! - `NotificationFileConfigType`: An enum representing the type of notification configuration.
5//! - `SigningKeyConfig`: An enum for specifying signing key configurations, either from an
6//!   environment variable or a plain value.
7//! - `NotificationFileConfig`: A struct representing a single notification configuration, with
8//!   methods for validation and signing key retrieval.
9//! - `NotificationsFileConfig`: A struct for managing a collection of notification configurations,
10//!   with validation to ensure uniqueness and completeness.
11use crate::{
12    constants::MINIMUM_SECRET_VALUE_LENGTH,
13    models::{PlainOrEnvValue, SecretString},
14};
15
16use super::ConfigFileError;
17use reqwest::Url;
18use serde::{Deserialize, Serialize};
19use std::collections::HashSet;
20
21#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
22#[serde(rename_all = "lowercase")]
23pub enum NotificationFileConfigType {
24    Webhook,
25}
26
27/// Represents the type of notification configuration.
28#[derive(Debug, Serialize, Deserialize, Clone)]
29#[serde(deny_unknown_fields)]
30pub struct NotificationFileConfig {
31    pub id: String,
32    pub r#type: NotificationFileConfigType,
33    pub url: String,
34    pub signing_key: Option<PlainOrEnvValue>,
35}
36
37impl NotificationFileConfig {
38    fn validate_signing_key(&self) -> Result<(), ConfigFileError> {
39        match &self.signing_key {
40            Some(signing_key) => {
41                match signing_key {
42                    PlainOrEnvValue::Env { value } => {
43                        if value.is_empty() {
44                            return Err(ConfigFileError::MissingField(
45                                "Signing key environment variable name cannot be empty".into(),
46                            ));
47                        }
48
49                        match std::env::var(value) {
50                            Ok(key_value) => {
51                                // Validate the key length
52                                if key_value.len() < MINIMUM_SECRET_VALUE_LENGTH {
53                                    return Err(ConfigFileError::InvalidFormat(
54                                    format!("Signing key must be at least {} characters long (found {})",
55                                        MINIMUM_SECRET_VALUE_LENGTH, key_value.len()),
56                                ));
57                                }
58                            }
59                            Err(e) => {
60                                return Err(ConfigFileError::MissingEnvVar(format!(
61                                    "Environment variable '{}' not found: {}",
62                                    value, e
63                                )));
64                            }
65                        }
66                    }
67                    PlainOrEnvValue::Plain { value } => {
68                        if value.is_empty() {
69                            return Err(ConfigFileError::InvalidFormat(
70                                "Signing key value cannot be empty".into(),
71                            ));
72                        }
73
74                        if !value.has_minimum_length(MINIMUM_SECRET_VALUE_LENGTH) {
75                            return Err(ConfigFileError::InvalidFormat(
76                            format!("Security error: Signing key value must be at least {} characters long", MINIMUM_SECRET_VALUE_LENGTH)
77                        ));
78                        }
79                    }
80                }
81            }
82            None => return Ok(()),
83        }
84
85        Ok(())
86    }
87
88    pub fn get_signing_key(&self) -> Option<SecretString> {
89        self.signing_key
90            .as_ref()
91            .and_then(|key| key.get_value().ok())
92    }
93
94    pub fn validate(&self) -> Result<(), ConfigFileError> {
95        if self.id.is_empty() {
96            return Err(ConfigFileError::MissingField("notification id".into()));
97        }
98
99        match &self.r#type {
100            NotificationFileConfigType::Webhook => {
101                if self.url.is_empty() {
102                    return Err(ConfigFileError::MissingField(
103                        "Webhook URL is required".into(),
104                    ));
105                }
106                Url::parse(&self.url)
107                    .map_err(|_| ConfigFileError::InvalidFormat("Invalid Webhook URL".into()))?;
108            }
109        }
110
111        self.validate_signing_key()?;
112
113        Ok(())
114    }
115}
116
117/// Manages a collection of notification configurations.
118#[derive(Debug, Serialize, Deserialize, Clone)]
119#[serde(deny_unknown_fields)]
120pub struct NotificationsFileConfig {
121    pub notifications: Vec<NotificationFileConfig>,
122}
123
124impl NotificationsFileConfig {
125    /// Creates a new `NotificationsFileConfig` with the given notifications.
126    pub fn new(notifications: Vec<NotificationFileConfig>) -> Self {
127        Self { notifications }
128    }
129
130    /// Validates the collection of notification configurations.
131    ///
132    /// Ensures that each notification is valid and that there are no duplicate IDs.
133    pub fn validate(&self) -> Result<(), ConfigFileError> {
134        if self.notifications.is_empty() {
135            return Err(ConfigFileError::MissingField("notifications".into()));
136        }
137
138        let mut ids = HashSet::new();
139        for notification in &self.notifications {
140            notification.validate()?;
141            if !ids.insert(notification.id.clone()) {
142                return Err(ConfigFileError::DuplicateId(notification.id.clone()));
143            }
144        }
145        Ok(())
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use serde_json::json;
153    use std::sync::Mutex;
154
155    static ENV_MUTEX: Mutex<()> = Mutex::new(());
156
157    #[test]
158    fn test_valid_webhook_notification() {
159        let config = json!({
160            "id": "notification-test",
161            "type": "webhook",
162            "url": "https://api.example.com/notifications"
163        });
164
165        let notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
166        assert!(notification.validate().is_ok());
167        assert_eq!(notification.id, "notification-test");
168        assert_eq!(notification.r#type, NotificationFileConfigType::Webhook);
169    }
170
171    #[test]
172    #[should_panic(expected = "missing field `url`")]
173    fn test_missing_webhook_url() {
174        let config = json!({
175            "id": "notification-test",
176            "type": "webhook"
177        });
178
179        let _notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
180    }
181
182    #[test]
183    fn test_invalid_webhook_url() {
184        let config = json!({
185            "id": "notification-test",
186            "type": "webhook",
187            "url": "invalid-url"
188        });
189
190        let notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
191
192        assert!(matches!(
193            notification.validate(),
194            Err(ConfigFileError::InvalidFormat(_))
195        ));
196    }
197
198    #[test]
199    fn test_duplicate_notification_ids() {
200        let config = json!({
201            "notifications": [
202                {
203                    "id": "notification-test",
204                    "type": "webhook",
205                    "url": "https://api.example.com/notifications"
206                },
207                {
208                    "id": "notification-test",
209                    "type": "webhook",
210                    "url": "https://api.example.com/notifications"
211                }
212            ]
213        });
214
215        let notifications_config: NotificationsFileConfig = serde_json::from_value(config).unwrap();
216        assert!(matches!(
217            notifications_config.validate(),
218            Err(ConfigFileError::DuplicateId(_))
219        ));
220    }
221
222    #[test]
223    fn test_empty_notification_id() {
224        let config = json!({
225            "notifications": [
226                {
227                    "id": "",
228                    "type": "webhook",
229                    "url": "https://api.example.com/notifications"
230                }
231            ]
232        });
233
234        let notifications_config: NotificationsFileConfig = serde_json::from_value(config).unwrap();
235        assert!(matches!(
236            notifications_config.validate(),
237            Err(ConfigFileError::MissingField(_))
238        ));
239    }
240
241    #[test]
242    fn test_valid_webhook_signing_notification_configuration() {
243        let config = json!({
244            "id": "notification-test",
245            "type": "webhook",
246            "url": "https://api.example.com/notifications",
247            "signing_key": {
248                "type": "plain",
249                "value": "C6D72367-EB3A-4D34-8900-DFF794A633F9"
250            }
251        });
252
253        let notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
254        assert!(notification.validate().is_ok());
255        assert_eq!(notification.id, "notification-test");
256        assert_eq!(notification.r#type, NotificationFileConfigType::Webhook);
257    }
258
259    #[test]
260    fn test_invalid_webhook_signing_notification_configuration() {
261        let config = json!({
262            "id": "notification-test",
263            "type": "webhook",
264            "url": "https://api.example.com/notifications",
265            "signing_key": {
266                "type": "plain",
267                "value": "insufficient_length"
268            }
269        });
270
271        let notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
272
273        let validation_result = notification.validate();
274        assert!(validation_result.is_err());
275
276        if let Err(ConfigFileError::InvalidFormat(message)) = validation_result {
277            assert!(message.contains("32 characters long"));
278        } else {
279            panic!("Expected InvalidFormat error about key length");
280        }
281    }
282
283    #[test]
284    fn test_webhook_signing_key_from_env() {
285        use std::env;
286
287        let _guard = ENV_MUTEX
288            .lock()
289            .unwrap_or_else(|poisoned| poisoned.into_inner());
290
291        let env_var_name = "TEST_WEBHOOK_SIGNING_KEY";
292        let valid_key = "C6D72367-EB3A-4D34-8900-DFF794A633F9"; // noboost
293        env::set_var(env_var_name, valid_key);
294
295        let config = json!({
296            "id": "notification-test",
297            "type": "webhook",
298            "url": "https://api.example.com/notifications",
299            "signing_key": {
300                "type": "env",
301                "value": env_var_name
302            }
303        });
304
305        let notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
306
307        assert!(notification.validate().is_ok());
308
309        let signing_key = notification.get_signing_key();
310        assert!(signing_key.is_some());
311
312        env::remove_var(env_var_name);
313    }
314
315    #[test]
316    fn test_webhook_signing_key_from_env_insufficient_length() {
317        use std::env;
318
319        let _guard = ENV_MUTEX
320            .lock()
321            .unwrap_or_else(|poisoned| poisoned.into_inner());
322
323        let env_var_name = "TEST_WEBHOOK_SIGNING_KEY";
324        let valid_key = "insufficient_length";
325        env::set_var(env_var_name, valid_key);
326
327        let config = json!({
328            "id": "notification-test",
329            "type": "webhook",
330            "url": "https://api.example.com/notifications",
331            "signing_key": {
332                "type": "env",
333                "value": env_var_name
334            }
335        });
336
337        let notification: NotificationFileConfig = serde_json::from_value(config).unwrap();
338
339        let validation_result = notification.validate();
340
341        assert!(validation_result.is_err());
342
343        if let Err(ConfigFileError::InvalidFormat(message)) = validation_result {
344            assert!(message.contains("32 characters long"));
345        } else {
346            panic!("Expected InvalidFormat error about key length");
347        }
348    }
349}