Skip to main content

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