openzeppelin_relayer/config/config_file/
notification.rs1use 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#[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 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#[derive(Debug, Serialize, Deserialize, Clone)]
119#[serde(deny_unknown_fields)]
120pub struct NotificationsFileConfig {
121 pub notifications: Vec<NotificationFileConfig>,
122}
123
124impl NotificationsFileConfig {
125 pub fn new(notifications: Vec<NotificationFileConfig>) -> Self {
127 Self { notifications }
128 }
129
130 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"; 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}