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}