hammerwork_web/config.rs
1//! Configuration for the Hammerwork web dashboard.
2//!
3//! This module provides comprehensive configuration options for the web dashboard,
4//! including server settings, authentication, WebSocket configuration, and more.
5//!
6//! # Examples
7//!
8//! ## Basic Configuration
9//!
10//! ```rust
11//! use hammerwork_web::config::DashboardConfig;
12//!
13//! let config = DashboardConfig::new()
14//! .with_bind_address("127.0.0.1", 8080)
15//! .with_database_url("postgresql://localhost/hammerwork");
16//!
17//! assert_eq!(config.bind_addr(), "127.0.0.1:8080");
18//! ```
19//!
20//! ## Configuration with Authentication
21//!
22//! ```rust
23//! use hammerwork_web::config::{DashboardConfig, AuthConfig};
24//! use std::time::Duration;
25//!
26//! let config = DashboardConfig::new()
27//! .with_auth("admin", "$2b$12$hash...")
28//! .with_cors(true);
29//!
30//! assert!(config.auth.enabled);
31//! assert_eq!(config.auth.username, "admin");
32//! assert!(config.enable_cors);
33//! ```
34//!
35//! ## Loading from File
36//!
37//! ```rust,no_run
38//! use hammerwork_web::config::DashboardConfig;
39//!
40//! // Create a configuration file (dashboard.toml)
41//! let config_content = r#"
42//! bind_address = "0.0.0.0"
43//! port = 9090
44//! database_url = "postgresql://localhost/hammerwork"
45//! enable_cors = true
46//!
47//! [auth]
48//! enabled = true
49//! username = "admin"
50//! "#;
51//!
52//! std::fs::write("dashboard.toml", config_content)?;
53//!
54//! // Load the configuration
55//! let config = DashboardConfig::from_file("dashboard.toml")?;
56//! assert_eq!(config.port, 9090);
57//! assert!(config.enable_cors);
58//!
59//! // Clean up
60//! std::fs::remove_file("dashboard.toml")?;
61//! # Ok::<(), Box<dyn std::error::Error>>(())
62//! ```
63
64use serde::{Deserialize, Serialize};
65use std::path::PathBuf;
66use std::time::Duration;
67
68/// Main configuration for the web dashboard.
69///
70/// This struct contains all configuration options for the Hammerwork web dashboard,
71/// including server settings, database connection, authentication, and WebSocket options.
72///
73/// # Examples
74///
75/// ```rust
76/// use hammerwork_web::config::DashboardConfig;
77/// use std::path::PathBuf;
78///
79/// // Create with defaults
80/// let config = DashboardConfig::default();
81/// assert_eq!(config.bind_address, "127.0.0.1");
82/// assert_eq!(config.port, 8080);
83///
84/// // Use builder pattern
85/// let config = DashboardConfig::new()
86/// .with_bind_address("0.0.0.0", 9090)
87/// .with_database_url("postgresql://localhost/hammerwork")
88/// .with_cors(true);
89///
90/// assert_eq!(config.bind_addr(), "0.0.0.0:9090");
91/// assert!(config.enable_cors);
92/// ```
93///
94/// `Debug` shows `database_url` with its password replaced by `***`.
95#[derive(Clone, Serialize, Deserialize)]
96pub struct DashboardConfig {
97 /// Server bind address
98 pub bind_address: String,
99
100 /// Server port
101 pub port: u16,
102
103 /// Database connection URL
104 pub database_url: String,
105
106 /// Database connection pool size
107 pub pool_size: u32,
108
109 /// Directory containing static assets (HTML, CSS, JS)
110 pub static_dir: PathBuf,
111
112 /// Authentication configuration
113 pub auth: AuthConfig,
114
115 /// WebSocket configuration
116 pub websocket: WebSocketConfig,
117
118 /// Enable CORS for cross-origin requests from [`allowed_origins`](Self::allowed_origins).
119 ///
120 /// CORS is only ever granted to the listed origins, never to any origin, so enabling it
121 /// requires at least one entry there.
122 pub enable_cors: bool,
123
124 /// Origins other than the dashboard's own (`scheme://host[:port]`) that may send
125 /// state-changing requests and WebSocket connections, and, with
126 /// [`enable_cors`](Self::enable_cors), read API responses. Browsers on any other origin
127 /// are refused (see [`crate::security`]).
128 #[serde(default)]
129 pub allowed_origins: Vec<String>,
130}
131
132impl Default for DashboardConfig {
133 fn default() -> Self {
134 Self {
135 bind_address: "127.0.0.1".to_string(),
136 port: 8080,
137 database_url: "postgresql://localhost/hammerwork".to_string(),
138 pool_size: 5,
139 static_dir: PathBuf::from("./assets"),
140 auth: AuthConfig::default(),
141 websocket: WebSocketConfig::default(),
142 enable_cors: false,
143 allowed_origins: Vec::new(),
144 }
145 }
146}
147
148impl DashboardConfig {
149 /// Create a new configuration with defaults.
150 ///
151 /// # Examples
152 ///
153 /// ```rust
154 /// use hammerwork_web::config::DashboardConfig;
155 ///
156 /// let config = DashboardConfig::new();
157 /// assert_eq!(config.bind_address, "127.0.0.1");
158 /// assert_eq!(config.port, 8080);
159 /// assert_eq!(config.database_url, "postgresql://localhost/hammerwork");
160 /// ```
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 /// Set the server bind address and port.
166 ///
167 /// # Examples
168 ///
169 /// ```rust
170 /// use hammerwork_web::config::DashboardConfig;
171 ///
172 /// let config = DashboardConfig::new()
173 /// .with_bind_address("0.0.0.0", 9090);
174 ///
175 /// assert_eq!(config.bind_address, "0.0.0.0");
176 /// assert_eq!(config.port, 9090);
177 /// assert_eq!(config.bind_addr(), "0.0.0.0:9090");
178 /// ```
179 pub fn with_bind_address(mut self, address: &str, port: u16) -> Self {
180 self.bind_address = address.to_string();
181 self.port = port;
182 self
183 }
184
185 /// Set the database URL.
186 ///
187 /// Supports both PostgreSQL and MySQL database URLs.
188 ///
189 /// # Examples
190 ///
191 /// ```rust
192 /// use hammerwork_web::config::DashboardConfig;
193 ///
194 /// // PostgreSQL
195 /// let pg_config = DashboardConfig::new()
196 /// .with_database_url("postgresql://user:pass@localhost/hammerwork");
197 /// assert_eq!(pg_config.database_url, "postgresql://user:pass@localhost/hammerwork");
198 ///
199 /// // MySQL
200 /// let mysql_config = DashboardConfig::new()
201 /// .with_database_url("mysql://root:password@localhost/hammerwork");
202 /// assert_eq!(mysql_config.database_url, "mysql://root:password@localhost/hammerwork");
203 /// ```
204 pub fn with_database_url(mut self, url: &str) -> Self {
205 self.database_url = url.to_string();
206 self
207 }
208
209 /// Set the static assets directory.
210 ///
211 /// # Examples
212 ///
213 /// ```rust
214 /// use hammerwork_web::config::DashboardConfig;
215 /// use std::path::PathBuf;
216 ///
217 /// let config = DashboardConfig::new()
218 /// .with_static_dir(PathBuf::from("/var/www/dashboard"));
219 ///
220 /// assert_eq!(config.static_dir, PathBuf::from("/var/www/dashboard"));
221 /// ```
222 pub fn with_static_dir(mut self, dir: PathBuf) -> Self {
223 self.static_dir = dir;
224 self
225 }
226
227 /// Enable authentication with username and password hash.
228 ///
229 /// The password should be a bcrypt hash for security. When authentication is enabled,
230 /// all API endpoints and WebSocket connections will require basic authentication.
231 ///
232 /// # Examples
233 ///
234 /// ```rust
235 /// use hammerwork_web::config::DashboardConfig;
236 ///
237 /// let config = DashboardConfig::new()
238 /// .with_auth("admin", "$2b$12$hash...");
239 ///
240 /// assert!(config.auth.enabled);
241 /// assert_eq!(config.auth.username, "admin");
242 /// assert_eq!(config.auth.password_hash, "$2b$12$hash...");
243 /// ```
244 pub fn with_auth(mut self, username: &str, password_hash: &str) -> Self {
245 self.auth.enabled = true;
246 self.auth.username = username.to_string();
247 self.auth.password_hash = password_hash.to_string();
248 self
249 }
250
251 /// Enable or disable CORS support.
252 ///
253 /// When enabled, browsers on the origins in [`allowed_origins`](Self::allowed_origins)
254 /// may call the API; [`validate`](Self::validate) requires at least one of them.
255 ///
256 /// # Examples
257 ///
258 /// ```rust
259 /// use hammerwork_web::config::DashboardConfig;
260 ///
261 /// let config = DashboardConfig::new()
262 /// .with_cors(true)
263 /// .with_allowed_origin("https://ops.example.com");
264 ///
265 /// assert!(config.enable_cors);
266 /// assert_eq!(config.allowed_origins, ["https://ops.example.com"]);
267 ///
268 /// let config = DashboardConfig::new()
269 /// .with_cors(false);
270 ///
271 /// assert!(!config.enable_cors);
272 /// ```
273 pub fn with_cors(mut self, enabled: bool) -> Self {
274 self.enable_cors = enabled;
275 self
276 }
277
278 /// Allow requests from another origin (`scheme://host[:port]`); see
279 /// [`allowed_origins`](Self::allowed_origins).
280 pub fn with_allowed_origin(mut self, origin: &str) -> Self {
281 self.allowed_origins.push(origin.to_string());
282 self
283 }
284
285 /// Load configuration from a TOML file
286 pub fn from_file(path: &str) -> crate::Result<Self> {
287 let content = std::fs::read_to_string(path)?;
288 let config: Self = toml::from_str(&content)?;
289 config.validate()?;
290 Ok(config)
291 }
292
293 /// Check settings that would make the server unsafe or make it fail at runtime:
294 ///
295 /// - authentication enabled in a build without the `auth` feature, which cannot verify
296 /// bcrypt password hashes;
297 /// - an entry of `allowed_origins` that is not an origin, or `enable_cors` without any;
298 /// - a zero `websocket.ping_interval` (`tokio::time::interval` panics on a zero period),
299 /// `websocket.message_buffer_size` or `websocket.max_message_size`;
300 /// - a zero `websocket.live_update_max_jobs` while live updates are enabled.
301 pub fn validate(&self) -> crate::Result<()> {
302 if self.auth.enabled && !cfg!(feature = "auth") {
303 anyhow::bail!(
304 "Authentication is enabled, but hammerwork-web was built without the `auth` \
305 feature and cannot verify bcrypt password hashes. Rebuild with \
306 `--features auth` (a default feature), or disable authentication"
307 );
308 }
309 crate::security::AllowedOrigins::new(&self.allowed_origins)?;
310 if self.enable_cors && self.allowed_origins.is_empty() {
311 anyhow::bail!(
312 "enable_cors requires at least one entry in allowed_origins: CORS is never \
313 granted to every origin"
314 );
315 }
316 if self.websocket.ping_interval.is_zero() {
317 anyhow::bail!("websocket.ping_interval must be greater than zero");
318 }
319 if self.websocket.message_buffer_size == 0 {
320 anyhow::bail!("websocket.message_buffer_size must be greater than zero");
321 }
322 if self.websocket.max_message_size == 0 {
323 anyhow::bail!("websocket.max_message_size must be greater than zero");
324 }
325 if !self.websocket.live_update_interval.is_zero()
326 && self.websocket.live_update_max_jobs == 0
327 {
328 anyhow::bail!(
329 "websocket.live_update_max_jobs must be greater than zero (set \
330 websocket.live_update_interval to zero to disable live updates)"
331 );
332 }
333 Ok(())
334 }
335
336 /// Save configuration to a TOML file
337 pub fn save_to_file(&self, path: &str) -> crate::Result<()> {
338 let content = toml::to_string_pretty(self)?;
339 std::fs::write(path, content)?;
340 Ok(())
341 }
342
343 /// Get the full bind address (address:port)
344 pub fn bind_addr(&self) -> String {
345 format!("{}:{}", self.bind_address, self.port)
346 }
347}
348
349/// Authentication configuration for the web dashboard.
350///
351/// Controls authentication behavior including credentials, session management,
352/// and security policies like rate limiting and account lockout.
353///
354/// # Examples
355///
356/// ```rust
357/// use hammerwork_web::config::AuthConfig;
358/// use std::time::Duration;
359///
360/// // Default configuration (authentication enabled)
361/// let auth_config = AuthConfig::default();
362/// assert!(auth_config.enabled);
363/// assert_eq!(auth_config.username, "admin");
364/// assert_eq!(auth_config.max_failed_attempts, 5);
365///
366/// // Custom configuration
367/// let auth_config = AuthConfig {
368/// enabled: true,
369/// username: "dashboard_admin".to_string(),
370/// password_hash: "$2b$12$hash...".to_string(),
371/// session_timeout: Duration::from_secs(4 * 60 * 60), // 4 hours
372/// max_failed_attempts: 3,
373/// lockout_duration: Duration::from_secs(30 * 60), // 30 minutes
374/// };
375///
376/// assert_eq!(auth_config.username, "dashboard_admin");
377/// assert_eq!(auth_config.max_failed_attempts, 3);
378/// ```
379///
380/// `Debug` never shows `password_hash`.
381#[derive(Clone, Serialize, Deserialize)]
382pub struct AuthConfig {
383 /// Whether authentication is enabled
384 pub enabled: bool,
385
386 /// Username for basic authentication
387 pub username: String,
388
389 /// Bcrypt hash of the password. It is only ever verified with bcrypt (the `auth`
390 /// feature), never compared to the password as text.
391 pub password_hash: String,
392
393 /// Upper bound on how long a successful credential check is remembered (at most
394 /// [`crate::auth::VERIFIED_CREDENTIALS_TTL`]); zero re-verifies every request.
395 #[serde(with = "hammerwork::config::serde_duration")]
396 pub session_timeout: Duration,
397
398 /// Failed attempts from one client address before it is locked out
399 pub max_failed_attempts: u32,
400
401 /// How long a locked-out client is refused. A failure older than this no longer counts.
402 #[serde(with = "hammerwork::config::serde_duration")]
403 pub lockout_duration: Duration,
404}
405
406impl Default for AuthConfig {
407 fn default() -> Self {
408 Self {
409 enabled: true, // Enable auth by default for security
410 username: "admin".to_string(),
411 password_hash: String::new(),
412 session_timeout: Duration::from_secs(8 * 60 * 60), // 8 hours
413 max_failed_attempts: 5,
414 lockout_duration: Duration::from_secs(15 * 60), // 15 minutes
415 }
416 }
417}
418
419/// WebSocket configuration
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct WebSocketConfig {
422 /// Ping interval to keep connections alive
423 #[serde(with = "hammerwork::config::serde_duration")]
424 pub ping_interval: Duration,
425
426 /// Maximum number of concurrent WebSocket connections
427 pub max_connections: usize,
428
429 /// Outgoing messages queued per connection; further messages for a client that does
430 /// not keep up are dropped
431 pub message_buffer_size: usize,
432
433 /// Maximum size in bytes of a message (and of a frame) received from a client
434 pub max_message_size: usize,
435
436 /// How often the dashboard polls the database for job state changes and queue
437 /// statistics to push to connected clients (`JobUpdate` and `QueueUpdate` messages).
438 /// Polling only happens while at least one client is connected. Zero disables live
439 /// updates. Optional in configuration files (default 2 seconds).
440 #[serde(
441 default = "default_live_update_interval",
442 with = "hammerwork::config::serde_duration"
443 )]
444 pub live_update_interval: Duration,
445
446 /// The most changed jobs one poll reads and pushes; further changes in the same
447 /// interval are not pushed individually (the queue statistics still reflect them).
448 /// Optional in configuration files (default 100).
449 #[serde(default = "default_live_update_max_jobs")]
450 pub live_update_max_jobs: u32,
451}
452
453fn default_live_update_interval() -> Duration {
454 Duration::from_secs(2)
455}
456
457fn default_live_update_max_jobs() -> u32 {
458 100
459}
460
461impl Default for WebSocketConfig {
462 fn default() -> Self {
463 Self {
464 ping_interval: Duration::from_secs(30),
465 max_connections: 100,
466 message_buffer_size: 1024,
467 max_message_size: 64 * 1024, // 64KB
468 live_update_interval: default_live_update_interval(),
469 live_update_max_jobs: default_live_update_max_jobs(),
470 }
471 }
472}
473
474impl std::fmt::Debug for DashboardConfig {
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 f.debug_struct("DashboardConfig")
477 .field("bind_address", &self.bind_address)
478 .field("port", &self.port)
479 .field(
480 "database_url",
481 &hammerwork::config::redact_url(&self.database_url),
482 )
483 .field("pool_size", &self.pool_size)
484 .field("static_dir", &self.static_dir)
485 .field("auth", &self.auth)
486 .field("websocket", &self.websocket)
487 .field("enable_cors", &self.enable_cors)
488 .finish()
489 }
490}
491
492impl std::fmt::Debug for AuthConfig {
493 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494 // Fields added later are left out rather than risk printing a secret.
495 f.debug_struct("AuthConfig")
496 .field("enabled", &self.enabled)
497 .field("username", &self.username)
498 .field("password_hash", &"[REDACTED]")
499 .field("session_timeout", &self.session_timeout)
500 .field("max_failed_attempts", &self.max_failed_attempts)
501 .field("lockout_duration", &self.lockout_duration)
502 .finish_non_exhaustive()
503 }
504}
505
506#[cfg(test)]
507mod debug_redaction_tests {
508 use super::*;
509
510 #[test]
511 fn debug_does_not_print_secrets() {
512 let mut config = DashboardConfig {
513 database_url: "postgres://app:hunter2-db@db.internal/jobs".to_string(),
514 ..Default::default()
515 };
516 config.auth.password_hash = "$2b$12$hunter2hashhunter2hashhu".to_string();
517 let debug = format!("{config:?}");
518 assert!(!debug.contains("hunter2"), "{debug}");
519 assert!(
520 debug.contains("postgres://app:***@db.internal/jobs"),
521 "{debug}"
522 );
523 assert!(debug.contains("[REDACTED]"), "{debug}");
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use tempfile::tempdir;
531
532 /// The configuration file example in the README must load as written.
533 #[test]
534 fn readme_configuration_example_loads() {
535 let readme = include_str!("../README.md");
536 let start = readme
537 .find("```toml\nbind_address")
538 .expect("README has a configuration example");
539 let body = &readme[start + "```toml\n".len()..];
540 let example = &body[..body.find("```").unwrap()];
541 let config: DashboardConfig = toml::from_str(example).unwrap();
542 assert_eq!(config.auth.session_timeout, Duration::from_secs(8 * 3600));
543 assert_eq!(config.auth.lockout_duration, Duration::from_secs(15 * 60));
544 assert_eq!(config.websocket.ping_interval, Duration::from_secs(30));
545 assert_eq!(
546 config.websocket.live_update_interval,
547 Duration::from_secs(2)
548 );
549 }
550
551 /// Files written before durations became strings (serde's `{ secs, nanos }` tables)
552 /// still load, and saved files use the readable form.
553 #[test]
554 fn durations_accept_the_old_table_form_and_save_as_strings() {
555 let mut value = toml::Value::try_from(DashboardConfig::new()).unwrap();
556 value["websocket"]["ping_interval"] =
557 toml::from_str::<toml::Table>("v = { secs = 45, nanos = 0 }").unwrap()["v"].clone();
558 let config: DashboardConfig = toml::from_str(&toml::to_string(&value).unwrap()).unwrap();
559 assert_eq!(config.websocket.ping_interval, Duration::from_secs(45));
560
561 let saved = toml::to_string(&DashboardConfig::new()).unwrap();
562 assert!(saved.contains("ping_interval = \"30s\""), "{saved}");
563 let reloaded: DashboardConfig = toml::from_str(&saved).unwrap();
564 assert_eq!(reloaded.auth.session_timeout, Duration::from_secs(8 * 3600));
565 }
566
567 /// The defaults, with authentication only where this build can verify passwords.
568 fn defaults() -> DashboardConfig {
569 let mut config = DashboardConfig::new();
570 config.auth.enabled = cfg!(feature = "auth");
571 config
572 }
573
574 #[test]
575 fn test_config_creation() {
576 let config = DashboardConfig::new()
577 .with_bind_address("0.0.0.0", 9090)
578 .with_database_url("mysql://localhost/test")
579 .with_cors(true);
580
581 assert_eq!(config.bind_address, "0.0.0.0");
582 assert_eq!(config.port, 9090);
583 assert_eq!(config.database_url, "mysql://localhost/test");
584 assert!(config.enable_cors);
585 assert_eq!(config.bind_addr(), "0.0.0.0:9090");
586 }
587
588 #[test]
589 fn test_validate_rejects_zero_ping_interval() {
590 let mut config = defaults();
591 assert!(config.validate().is_ok());
592 config.websocket.ping_interval = Duration::ZERO;
593 let err = config.validate().unwrap_err().to_string();
594 assert!(err.contains("ping_interval"), "{err}");
595 }
596
597 #[test]
598 fn test_from_file_rejects_zero_ping_interval() {
599 let dir = tempdir().unwrap();
600 let path = dir.path().join("bad.toml");
601 let mut config = defaults();
602 config.websocket.ping_interval = Duration::ZERO;
603 config.save_to_file(path.to_str().unwrap()).unwrap();
604 assert!(DashboardConfig::from_file(path.to_str().unwrap()).is_err());
605 }
606
607 #[test]
608 fn test_config_file_operations() {
609 let dir = tempdir().unwrap();
610 let config_path = dir.path().join("config.toml");
611
612 let config = defaults()
613 .with_bind_address("192.168.1.100", 8888)
614 .with_database_url("postgresql://test/db");
615
616 // Save config
617 config.save_to_file(config_path.to_str().unwrap()).unwrap();
618
619 // Load config
620 let loaded_config = DashboardConfig::from_file(config_path.to_str().unwrap()).unwrap();
621
622 assert_eq!(loaded_config.bind_address, "192.168.1.100");
623 assert_eq!(loaded_config.port, 8888);
624 assert_eq!(loaded_config.database_url, "postgresql://test/db");
625 }
626
627 #[test]
628 fn test_auth_config_defaults() {
629 let auth = AuthConfig::default();
630 assert!(auth.enabled); // Auth is enabled by default for security
631 assert_eq!(auth.username, "admin");
632 assert_eq!(auth.max_failed_attempts, 5);
633 assert_eq!(auth.lockout_duration.as_secs(), 15 * 60); // 15 minutes
634 assert_eq!(auth.session_timeout.as_secs(), 8 * 60 * 60); // 8 hours
635 }
636
637 #[test]
638 fn test_validate_rejects_unusable_websocket_limits() {
639 let mut config = defaults();
640 config.websocket.message_buffer_size = 0;
641 let err = config.validate().unwrap_err().to_string();
642 assert!(err.contains("message_buffer_size"), "{err}");
643 let mut config = defaults();
644 config.websocket.max_message_size = 0;
645 let err = config.validate().unwrap_err().to_string();
646 assert!(err.contains("max_message_size"), "{err}");
647 }
648
649 #[test]
650 fn cors_is_only_granted_to_listed_origins() {
651 // M16: CORS used to allow any origin.
652 let err = defaults()
653 .with_cors(true)
654 .validate()
655 .unwrap_err()
656 .to_string();
657 assert!(err.contains("allowed_origins"), "{err}");
658 let config = defaults()
659 .with_cors(true)
660 .with_allowed_origin("https://ops.example.com");
661 assert!(config.validate().is_ok());
662 let err = defaults()
663 .with_allowed_origin("*")
664 .validate()
665 .unwrap_err()
666 .to_string();
667 assert!(err.contains("invalid origin"), "{err}");
668 }
669
670 #[test]
671 fn config_files_without_allowed_origins_still_load() {
672 let text = toml::to_string(&DashboardConfig::new()).unwrap();
673 let text: String = text
674 .lines()
675 .filter(|line| !line.starts_with("allowed_origins"))
676 .collect::<Vec<_>>()
677 .join("\n");
678 let config: DashboardConfig = toml::from_str(&text).unwrap();
679 assert!(config.allowed_origins.is_empty());
680 }
681
682 /// `request_timeout` was removed in 2.0; files that still set it (as 1.x wrote it)
683 /// load and ignore it.
684 #[test]
685 fn config_files_with_removed_request_timeout_still_load() {
686 let text = format!(
687 "{}\n[request_timeout]\nsecs = 30\nnanos = 0\n",
688 toml::to_string(&DashboardConfig::new()).unwrap()
689 );
690 let config: DashboardConfig = toml::from_str(&text).unwrap();
691 assert_eq!(config.port, DashboardConfig::new().port);
692 }
693
694 #[cfg(feature = "auth")]
695 #[test]
696 fn auth_builds_accept_authentication() {
697 assert!(DashboardConfig::new().validate().is_ok());
698 }
699
700 #[cfg(not(feature = "auth"))]
701 #[test]
702 fn builds_without_bcrypt_refuse_to_enable_authentication() {
703 // H5: without bcrypt the stored hash must never be compared as a plaintext password.
704 let err = DashboardConfig::new().validate().unwrap_err().to_string();
705 assert!(err.contains("`auth`"), "{err}");
706 let mut config = DashboardConfig::new();
707 config.auth.enabled = false;
708 assert!(config.validate().is_ok());
709 }
710
711 #[test]
712 fn test_websocket_config_defaults() {
713 let ws_config = WebSocketConfig::default();
714 assert_eq!(ws_config.ping_interval, Duration::from_secs(30));
715 assert_eq!(ws_config.max_connections, 100);
716 assert_eq!(ws_config.max_message_size, 64 * 1024);
717 assert_eq!(ws_config.live_update_interval, Duration::from_secs(2));
718 assert_eq!(ws_config.live_update_max_jobs, 100);
719 }
720
721 #[test]
722 fn test_live_update_settings_are_optional_and_validated() {
723 // A configuration written before live updates existed still loads.
724 let toml = r#"
725 ping_interval = { secs = 30, nanos = 0 }
726 max_connections = 10
727 message_buffer_size = 16
728 max_message_size = 1024
729 "#;
730 let ws: WebSocketConfig = toml::from_str(toml).unwrap();
731 assert_eq!(ws.live_update_interval, Duration::from_secs(2));
732 assert_eq!(ws.live_update_max_jobs, 100);
733
734 let mut config = DashboardConfig::new();
735 config.auth.enabled = false;
736 config.websocket.live_update_max_jobs = 0;
737 let err = config.validate().unwrap_err().to_string();
738 assert!(err.contains("live_update_max_jobs"), "{err}");
739 // Disabled live updates do not need a limit.
740 config.websocket.live_update_interval = Duration::ZERO;
741 assert!(config.validate().is_ok());
742 }
743}