Skip to main content

torrust_tracker_deployer_lib/domain/https/
config.rs

1//! HTTPS configuration domain type
2//!
3//! This module defines the domain-level HTTPS configuration that is stored
4//! in the environment and used to configure Caddy TLS termination.
5//!
6//! ## Domain vs DTO
7//!
8//! This is the domain type. The DTO version (`HttpsSection`) is in the
9//! application layer at `src/application/command_handlers/create/config/https.rs`.
10//!
11//! The domain type is validated when created from the DTO and carries
12//! the configuration through the environment lifecycle.
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::shared::Email;
18
19/// Error type for `HttpsConfig` construction failures
20///
21/// Contains validation errors that can occur when constructing an `HttpsConfig`.
22#[derive(Debug, Clone, Error, PartialEq, Eq)]
23pub enum HttpsConfigError {
24    /// The admin email address is invalid
25    #[error("Invalid admin email '{email}': {reason}")]
26    InvalidEmail {
27        /// The invalid email that was provided
28        email: String,
29        /// The reason why the email is invalid
30        reason: String,
31    },
32}
33
34impl HttpsConfigError {
35    /// Returns actionable help text for this error
36    ///
37    /// Provides detailed guidance on how to fix the configuration issue.
38    #[must_use]
39    pub fn help(&self) -> &'static str {
40        match self {
41            Self::InvalidEmail { .. } => {
42                "Invalid admin email format.\n\
43                 \n\
44                 The admin email is used by Let's Encrypt to send:\n\
45                 - Certificate expiration warnings\n\
46                 - Renewal failure notifications\n\
47                 - Important security updates\n\
48                 \n\
49                 Requirements:\n\
50                 - Must be a valid email format (e.g., admin@example.com)\n\
51                 - Should be a monitored mailbox for security alerts\n\
52                 \n\
53                 Fix:\n\
54                 Update the admin_email in your HTTPS configuration:\n\
55                 \n\
56                 \"https\": {\n\
57                   \"admin_email\": \"admin@yourdomain.com\",\n\
58                   \"use_staging\": false\n\
59                 }"
60            }
61        }
62    }
63}
64
65/// Domain-level HTTPS configuration for TLS termination
66///
67/// Contains validated HTTPS settings used for Caddy reverse proxy configuration.
68/// This type is created from the application-layer DTO (`HttpsSection`) after
69/// validation and stored in the environment.
70///
71/// # Let's Encrypt Environments
72///
73/// - **Production** (default): Trusted certificates, rate-limited
74/// - **Staging**: Untrusted test certificates, higher rate limits
75///
76/// # Example
77///
78/// ```rust
79/// use torrust_tracker_deployer_lib::domain::https::HttpsConfig;
80///
81/// let config = HttpsConfig::new("admin@example.com", false).unwrap();
82/// assert_eq!(config.admin_email(), "admin@example.com");
83/// assert!(!config.use_staging());
84/// ```
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct HttpsConfig {
87    /// Admin email for Let's Encrypt notifications
88    ///
89    /// Receives certificate expiration warnings and renewal failure notifications.
90    admin_email: String,
91
92    /// Whether to use Let's Encrypt staging environment
93    ///
94    /// - `true`: Use staging CA (for testing, certificates not trusted)
95    /// - `false`: Use production CA (trusted certificates)
96    use_staging: bool,
97}
98
99impl HttpsConfig {
100    /// Creates a new HTTPS configuration with validated email
101    ///
102    /// Validates the admin email format at construction time, ensuring
103    /// the configuration is always valid.
104    ///
105    /// # Arguments
106    ///
107    /// * `admin_email` - Admin email for Let's Encrypt notifications
108    /// * `use_staging` - Whether to use staging environment
109    ///
110    /// # Errors
111    ///
112    /// Returns `HttpsConfigError::InvalidEmail` if the email format is invalid.
113    ///
114    /// # Examples
115    ///
116    /// ```rust
117    /// use torrust_tracker_deployer_lib::domain::https::HttpsConfig;
118    ///
119    /// // Production configuration
120    /// let config = HttpsConfig::new("admin@example.com", false).unwrap();
121    /// assert!(!config.use_staging());
122    ///
123    /// // Staging configuration (for testing)
124    /// let staging = HttpsConfig::new("admin@example.com", true).unwrap();
125    /// assert!(staging.use_staging());
126    ///
127    /// // Invalid email is rejected
128    /// let result = HttpsConfig::new("invalid-email", false);
129    /// assert!(result.is_err());
130    /// ```
131    pub fn new(
132        admin_email: impl Into<String>,
133        use_staging: bool,
134    ) -> Result<Self, HttpsConfigError> {
135        let email_str = admin_email.into();
136
137        // Validate email format using the shared Email type
138        Email::new(&email_str).map_err(|e| HttpsConfigError::InvalidEmail {
139            email: email_str.clone(),
140            reason: e.to_string(),
141        })?;
142
143        Ok(Self {
144            admin_email: email_str,
145            use_staging,
146        })
147    }
148
149    /// Creates an HTTPS config from a validated email
150    ///
151    /// This is the preferred factory method when working with validated
152    /// email addresses from the application layer. Since the email is
153    /// already validated, this method is infallible.
154    ///
155    /// # Arguments
156    ///
157    /// * `email` - Validated email address
158    /// * `use_staging` - Whether to use staging environment
159    #[must_use]
160    pub fn from_validated_email(email: &Email, use_staging: bool) -> Self {
161        Self {
162            admin_email: email.to_string(),
163            use_staging,
164        }
165    }
166
167    /// Returns the admin email address
168    #[must_use]
169    pub fn admin_email(&self) -> &str {
170        &self.admin_email
171    }
172
173    /// Returns whether to use Let's Encrypt staging environment
174    #[must_use]
175    pub fn use_staging(&self) -> bool {
176        self.use_staging
177    }
178}
179
180impl Default for HttpsConfig {
181    /// Creates a default HTTPS configuration
182    ///
183    /// Uses a placeholder email that should be replaced before deployment.
184    fn default() -> Self {
185        Self {
186            admin_email: "admin@example.com".to_string(),
187            use_staging: false,
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn it_should_create_https_config_with_production_ca() {
198        let config = HttpsConfig::new("admin@tracker.example.com", false)
199            .expect("valid email should succeed");
200
201        assert_eq!(config.admin_email(), "admin@tracker.example.com");
202        assert!(!config.use_staging());
203    }
204
205    #[test]
206    fn it_should_create_https_config_with_staging_ca() {
207        let config = HttpsConfig::new("admin@tracker.example.com", true)
208            .expect("valid email should succeed");
209
210        assert_eq!(config.admin_email(), "admin@tracker.example.com");
211        assert!(config.use_staging());
212    }
213
214    #[test]
215    fn it_should_reject_invalid_email() {
216        let result = HttpsConfig::new("invalid-email", false);
217
218        assert!(result.is_err());
219        let err = result.unwrap_err();
220        assert!(matches!(err, HttpsConfigError::InvalidEmail { .. }));
221    }
222
223    #[test]
224    fn it_should_reject_email_without_at_symbol() {
225        let result = HttpsConfig::new("admin.example.com", false);
226
227        assert!(result.is_err());
228    }
229
230    #[test]
231    fn it_should_create_default_https_config() {
232        let config = HttpsConfig::default();
233
234        assert_eq!(config.admin_email(), "admin@example.com");
235        assert!(!config.use_staging());
236    }
237
238    #[test]
239    fn it_should_serialize_to_json() {
240        let config =
241            HttpsConfig::new("admin@example.com", true).expect("valid email should succeed");
242
243        let json = serde_json::to_string(&config).expect("serialization should succeed");
244
245        assert!(json.contains("\"admin_email\":\"admin@example.com\""));
246        assert!(json.contains("\"use_staging\":true"));
247    }
248
249    #[test]
250    fn it_should_deserialize_from_json() {
251        let json = r#"{"admin_email":"test@example.com","use_staging":false}"#;
252
253        let config: HttpsConfig =
254            serde_json::from_str(json).expect("deserialization should succeed");
255
256        assert_eq!(config.admin_email(), "test@example.com");
257        assert!(!config.use_staging());
258    }
259
260    #[test]
261    fn it_should_be_cloneable() {
262        let config =
263            HttpsConfig::new("admin@example.com", true).expect("valid email should succeed");
264        let cloned = config.clone();
265
266        assert_eq!(config, cloned);
267    }
268
269    #[test]
270    fn it_should_create_from_validated_email() {
271        let email = Email::new("admin@example.com").expect("valid email");
272        let config = HttpsConfig::from_validated_email(&email, false);
273
274        assert_eq!(config.admin_email(), "admin@example.com");
275        assert!(!config.use_staging());
276    }
277
278    #[test]
279    fn it_should_provide_help_for_invalid_email_error() {
280        let err = HttpsConfigError::InvalidEmail {
281            email: "bad".to_string(),
282            reason: "missing @".to_string(),
283        };
284
285        let help = err.help();
286        assert!(help.contains("Invalid admin email format"));
287        assert!(help.contains("Let's Encrypt"));
288    }
289}