Skip to main content

webx_core/
config.rs

1//! Built-in configuration support: appsettings.json + AppOptions pattern.
2//!
3//! The framework automatically loads `appsettings.json` (merged with
4//! `appsettings.Development.json` in dev mode) and binds it to the
5//! built-in `AppOptions` struct.  Users customize options via
6//! `HostBuilder::configure(|app| app.useOptions(|o| { ... }))`.
7
8use crate::mode::AppMode;
9use serde::Deserialize;
10use std::path::Path;
11
12// ---------------------------------------------------------------------------
13// IAppOptions trait (for user-defined option types)
14// ---------------------------------------------------------------------------
15
16/// Application options — binds to a section of appsettings.json.
17///
18/// Users define their own structs implementing this trait,
19/// then call `AppConfig::bind()` to bind values.
20pub trait IAppOptions: for<'de> Deserialize<'de> + Default + Send + Sync + 'static {}
21
22impl<T> IAppOptions for T where T: for<'de> Deserialize<'de> + Default + Send + Sync + 'static {}
23
24// ---------------------------------------------------------------------------
25// Built-in option types (matching standard appsettings.json layout)
26// ---------------------------------------------------------------------------
27
28/// Top-level application section.
29#[derive(Debug, Clone, Deserialize)]
30pub struct AppSection {
31    /// Application display name.
32    #[serde(default, rename = "Name")]
33    pub name: String,
34    /// Listen addresses as full URLs (e.g., "http://0.0.0.0:5000").
35    /// ASP.NET Core compatible. Default: ["http://0.0.0.0:5000"].
36    #[serde(default = "default_urls", rename = "Urls")]
37    pub urls: Vec<String>,
38    /// Maximum request body size in bytes. Default: 10 MB.
39    #[serde(default = "default_max_body_size", rename = "MaxBodySize")]
40    pub max_body_size: usize,
41    /// Maximum concurrent connections. Default: 10000.
42    #[serde(default = "default_max_connections", rename = "MaxConnections")]
43    pub max_connections: usize,
44}
45
46impl Default for AppSection {
47    fn default() -> Self {
48        Self {
49            name: String::new(),
50            urls: default_urls(),
51            max_body_size: default_max_body_size(),
52            max_connections: default_max_connections(),
53        }
54    }
55}
56
57fn default_urls() -> Vec<String> {
58    vec!["http://0.0.0.0:5000".to_string()]
59}
60
61fn default_max_body_size() -> usize {
62    10 * 1024 * 1024 // 10 MB
63}
64
65fn default_max_connections() -> usize {
66    10_000
67}
68
69/// JWT authentication section.
70///
71/// appsettings keys are PascalCase: `Jwt.Secret` and `Jwt.ExpirationMinutes`
72/// (or `APP__Jwt__ExpirationMinutes`). `ExpirationMinutes` drives the lifetime
73/// of tokens minted by the default `ITokenIssuer` that
74/// `HostBuilder::add_authentication()` registers.
75///
76/// # Where the signing secret comes from (XR-2)
77///
78/// First non-empty source wins, evaluated at host startup:
79///
80/// 1. `APP__Jwt__Secret` / `JWT_SECRET` env vars — applied over the JSON during
81///    `load_appsettings`, so they arrive here as `Secret`;
82/// 2. `Jwt.Secret` in `appsettings.json` (or a code-level `useOptions`
83///    override);
84/// 3. `Jwt.SecretFile` — a path to a file holding the secret, read by
85///    `rust_webx_host::auth_jwt::apply_jwt_secret_file`. The file must be at
86///    least 32 bytes of non-whitespace content and, on Unix, must not grant
87///    group/other permissions (`chmod 600`); a missing, unreadable, too short
88///    or over-permissive file fails host startup instead of running with a
89///    broken key.
90#[derive(Debug, Clone, Deserialize)]
91pub struct JwtSection {
92    /// HMAC secret for signing/verifying JWT tokens.
93    #[serde(default, rename = "Secret")]
94    pub secret: String,
95
96    /// Optional path to a file holding the HMAC secret.
97    ///
98    /// Only used when `Secret` is empty, so an explicit secret (env or JSON)
99    /// always wins and existing appsettings keep working unchanged. The
100    /// framework does not create the file: provisioning the secret is an
101    /// operator/installer concern. DPAPI-wrapped secrets are not supported by
102    /// this release.
103    #[serde(default, rename = "SecretFile")]
104    pub secret_file: Option<String>,
105
106    /// Lifetime of freshly issued tokens, in minutes. Default: 60.
107    ///
108    /// `0` falls back to the default: a zero lifetime would only mint tokens
109    /// that are already expired.
110    #[serde(
111        default = "default_jwt_expiration_minutes",
112        rename = "ExpirationMinutes"
113    )]
114    pub expiration_minutes: u64,
115}
116
117fn default_jwt_expiration_minutes() -> u64 {
118    60
119}
120
121impl Default for JwtSection {
122    fn default() -> Self {
123        Self {
124            secret: String::new(),
125            secret_file: None,
126            expiration_minutes: default_jwt_expiration_minutes(),
127        }
128    }
129}
130
131/// CORS (Cross-Origin Resource Sharing) section.
132///
133/// JSON keys use PascalCase (`Origins`, `Methods`, …) to match appsettings
134/// and ASP.NET Core conventions — same as `App` / `Jwt` / `RateLimit`.
135#[derive(Debug, Clone, Deserialize)]
136#[serde(rename_all = "PascalCase")]
137pub struct CorsSection {
138    /// Allowed origins. Default: ["*"].
139    #[serde(default = "default_origins")]
140    pub origins: Vec<String>,
141    /// Allowed methods. Default: GET, POST, PUT, DELETE, PATCH, OPTIONS.
142    #[serde(default = "default_cors_methods")]
143    pub methods: Vec<String>,
144    /// Allowed headers. Default: Content-Type, Authorization.
145    #[serde(default = "default_cors_headers")]
146    pub headers: Vec<String>,
147    /// Response headers exposed to the client. Default: empty.
148    #[serde(default)]
149    pub expose_headers: Vec<String>,
150    /// Allow credentials. Default: false.
151    #[serde(default)]
152    pub allow_credentials: bool,
153    /// Preflight cache max-age in seconds. Default: 86400.
154    #[serde(default = "default_max_age")]
155    pub max_age: u32,
156}
157
158impl Default for CorsSection {
159    fn default() -> Self {
160        Self {
161            origins: default_origins(),
162            methods: default_cors_methods(),
163            headers: default_cors_headers(),
164            expose_headers: Vec::new(),
165            allow_credentials: false,
166            max_age: default_max_age(),
167        }
168    }
169}
170
171fn default_origins() -> Vec<String> {
172    vec!["*".to_string()]
173}
174
175fn default_cors_methods() -> Vec<String> {
176    vec![
177        "GET".to_string(),
178        "POST".to_string(),
179        "PUT".to_string(),
180        "DELETE".to_string(),
181        "PATCH".to_string(),
182        "OPTIONS".to_string(),
183    ]
184}
185
186fn default_cors_headers() -> Vec<String> {
187    vec!["Content-Type".to_string(), "Authorization".to_string()]
188}
189
190fn default_max_age() -> u32 {
191    86400
192}
193
194/// Culture / localization section.
195///
196/// The default UI culture is a host-wide setting: endpoint names, OpenAPI, and
197/// permission synchronization use it unless a caller explicitly passes another
198/// culture. Request-header negotiation is deliberately not part of this:
199/// administrative output must not change with the caller's browser language.
200#[derive(Debug, Clone, Deserialize)]
201pub struct CultureSection {
202    /// Default BCP-47 culture, e.g. `"zh-CN"` or `"en"`. Default: `"en"`.
203    #[serde(default = "default_culture", rename = "Default")]
204    pub default: String,
205}
206
207impl Default for CultureSection {
208    fn default() -> Self {
209        Self {
210            default: default_culture(),
211        }
212    }
213}
214
215fn default_culture() -> String {
216    "en".to_string()
217}
218
219/// Per-IP rate limiting (token bucket).
220#[derive(Debug, Clone, Deserialize)]
221pub struct RateLimitSection {
222    /// Enable rate-limit middleware. Default: false (enable in Production appsettings).
223    #[serde(default, rename = "Enabled")]
224    pub enabled: bool,
225    /// Sustained requests per second per client IP.
226    #[serde(default = "default_rate_limit_rps", rename = "RequestsPerSecond")]
227    pub requests_per_second: f64,
228    /// Burst capacity before throttling.
229    #[serde(default = "default_rate_limit_burst", rename = "BurstSize")]
230    pub burst_size: u32,
231    /// Maximum distinct client IPs tracked (LRU eviction when exceeded).
232    #[serde(default = "default_rate_limit_max_ips", rename = "MaxTrackedIps")]
233    pub max_tracked_ips: usize,
234    /// Trust X-Forwarded-For / X-Real-IP headers for client IP extraction.
235    /// Only enable when behind a trusted reverse proxy. Default: false.
236    #[serde(default, rename = "TrustProxy")]
237    pub trust_proxy: bool,
238    /// CIDR ranges of the trusted proxies. When `TrustProxy` is enabled, the
239    /// forwarded headers are only consulted if the direct peer address belongs
240    /// to one of these ranges; the client IP is then the right-most address in
241    /// `X-Forwarded-For` that is not itself a trusted proxy.
242    ///
243    /// An empty list with `TrustProxy = true` is treated as "trust nothing":
244    /// the headers are ignored (a client can otherwise rotate
245    /// `X-Forwarded-For` to bypass the limiter).
246    #[serde(default, rename = "TrustedProxies")]
247    pub trusted_proxies: Vec<String>,
248}
249
250impl Default for RateLimitSection {
251    fn default() -> Self {
252        Self {
253            enabled: false,
254            requests_per_second: default_rate_limit_rps(),
255            burst_size: default_rate_limit_burst(),
256            max_tracked_ips: default_rate_limit_max_ips(),
257            trust_proxy: false,
258            trusted_proxies: Vec::new(),
259        }
260    }
261}
262
263fn default_rate_limit_rps() -> f64 {
264    100.0
265}
266
267fn default_rate_limit_burst() -> u32 {
268    200
269}
270
271fn default_rate_limit_max_ips() -> usize {
272    10_000
273}
274
275/// HTTP request metrics (`GET /metrics` Prometheus text when enabled).
276#[derive(Debug, Clone, Deserialize, Default)]
277pub struct MetricsSection {
278    #[serde(default, rename = "Enabled")]
279    pub enabled: bool,
280}
281
282/// TLS (Transport Layer Security) section.
283///
284/// TLS is activated automatically when the `App.Urls` array
285/// contains one or more `https://` entries. The certificate
286/// and key paths are read from this section.
287#[derive(Debug, Clone, Deserialize, Default)]
288pub struct TlsSection {
289    /// Path to TLS certificate PEM file.
290    #[serde(default, rename = "CertPath")]
291    pub cert_path: String,
292    /// Path to TLS private key PEM file.
293    #[serde(default, rename = "KeyPath")]
294    pub key_path: String,
295}
296
297/// `multipart/form-data` upload settings.
298///
299/// These are deliberately separate from `App.MaxBodySize`: a JSON endpoint
300/// should stay tightly bounded while an upload endpoint legitimately accepts
301/// far more. A request is measured against `MaxRequestSize` when it declares
302/// `Content-Type: multipart/form-data`, and against `App.MaxBodySize`
303/// otherwise.
304#[derive(Debug, Clone, Deserialize)]
305pub struct FormSection {
306    /// Largest accepted multipart request body, in bytes. Default: 256 MiB.
307    #[serde(default = "default_max_request_size", rename = "MaxRequestSize")]
308    pub max_request_size: usize,
309    /// Largest accepted individual file part, in bytes. Default: 128 MiB.
310    #[serde(default = "default_max_file_size", rename = "MaxFileSize")]
311    pub max_file_size: u64,
312    /// Largest accepted individual text field, in bytes. Default: 1 MiB.
313    #[serde(default = "default_max_field_size", rename = "MaxFieldSize")]
314    pub max_field_size: usize,
315    /// Bytes buffered in memory per file before spooling to disk. Default: 1 MiB.
316    ///
317    /// Set to `0` to spool every upload, or very high to keep everything in
318    /// memory.
319    #[serde(default = "default_memory_threshold", rename = "MemoryThreshold")]
320    pub memory_threshold: usize,
321    /// Directory for spooled uploads. Default: `webx-uploads` under the system
322    /// temporary directory.
323    #[serde(default, rename = "TempDir")]
324    pub temp_dir: Option<String>,
325}
326
327impl Default for FormSection {
328    fn default() -> Self {
329        Self {
330            max_request_size: default_max_request_size(),
331            max_file_size: default_max_file_size(),
332            max_field_size: default_max_field_size(),
333            memory_threshold: default_memory_threshold(),
334            temp_dir: None,
335        }
336    }
337}
338
339fn default_max_request_size() -> usize {
340    256 * 1024 * 1024
341}
342
343fn default_max_file_size() -> u64 {
344    128 * 1024 * 1024
345}
346
347fn default_max_field_size() -> usize {
348    1024 * 1024
349}
350
351fn default_memory_threshold() -> usize {
352    1024 * 1024
353}
354
355/// Standard application options loaded from appsettings.json.
356///
357/// Bound automatically by the framework.  Access via `host.options()`
358/// or customize via `app.useOptions(|o| { ... })`.
359#[derive(Debug, Clone, Deserialize, Default)]
360pub struct AppOptions {
361    /// Application settings.
362    #[serde(default, rename = "App")]
363    pub app: AppSection,
364    /// JWT authentication settings.
365    #[serde(default, rename = "Jwt")]
366    pub jwt: JwtSection,
367    /// CORS settings.
368    #[serde(default, rename = "Cors")]
369    pub cors: CorsSection,
370    /// TLS settings.
371    #[serde(default, rename = "Tls")]
372    pub tls: TlsSection,
373    /// Rate limiting settings.
374    #[serde(default, rename = "RateLimit")]
375    pub rate_limit: RateLimitSection,
376    /// Prometheus-style metrics endpoint settings.
377    #[serde(default, rename = "Metrics")]
378    pub metrics: MetricsSection,
379    /// `multipart/form-data` upload settings.
380    #[serde(default, rename = "Form")]
381    pub form: FormSection,
382    /// Default culture for endpoint text and OpenAPI.
383    #[serde(default, rename = "Culture")]
384    pub culture: CultureSection,
385}
386
387// ---------------------------------------------------------------------------
388// Config loading helpers
389// ---------------------------------------------------------------------------
390
391/// Load the merged appsettings JSON (base + environment overlay + env overrides).
392///
393/// 按运行模式自动合并对应的 `appsettings.{Mode}.json` overlay:
394/// - `Development` → `appsettings.Development.json`
395/// - `Production`  → `appsettings.Production.json`
396///
397/// Environment variables prefixed with `APP__` override the corresponding JSON values.
398/// For example, `APP__App__Address=0.0.0.0:8080` overrides `{"App": {"Address": "..."}}`.
399pub fn load_appsettings(mode: AppMode) -> Option<serde_json::Value> {
400    let mut base = read_json_file("appsettings.json")?;
401
402    let overlay_name = mode.overlay_filename();
403    if let Some(overlay) = read_json_file(overlay_name) {
404        merge_json(&mut base, overlay);
405    }
406
407    // Well-known env vars (JWT_SECRET) — applied before APP__ so APP__Jwt__Secret wins.
408    apply_well_known_env_overrides(&mut base);
409
410    // Apply environment variable overrides (APP__Section__Key pattern)
411    apply_env_overrides(&mut base);
412
413    Some(base)
414}
415
416/// Apply conventional environment variables that map to config sections.
417///
418/// `JWT_SECRET` → `Jwt.Secret` (overridden by `APP__Jwt__Secret` if set).
419fn apply_well_known_env_overrides(config: &mut serde_json::Value) {
420    if let Ok(secret) = std::env::var("JWT_SECRET") {
421        if !secret.is_empty() {
422            set_json_value(config, &["Jwt", "Secret"], &secret);
423        }
424    }
425}
426
427/// Apply environment variable overrides following the `APP__Section__Key` pattern.
428fn apply_env_overrides(config: &mut serde_json::Value) {
429    for (key, value) in std::env::vars() {
430        if let Some(path) = key.strip_prefix("APP__") {
431            // Split by double underscore to get path segments
432            let segments: Vec<&str> = path.split("__").collect();
433            if segments.is_empty() {
434                continue;
435            }
436            set_json_value(config, &segments, &value);
437        }
438    }
439}
440
441/// Set a value in a JSON object following the given path segments.
442fn set_json_value(obj: &mut serde_json::Value, segments: &[&str], value: &str) {
443    if segments.is_empty() {
444        return;
445    }
446
447    let key = segments[0];
448
449    if let serde_json::Value::Object(map) = obj {
450        if segments.len() == 1 {
451            // Leaf: set the value, attempting to parse as JSON first
452            let parsed =
453                serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()));
454            map.insert(key.to_string(), parsed);
455        } else if let Some(child) = map.get_mut(key) {
456            // Recurse into child
457            set_json_value(child, &segments[1..], value);
458        } else {
459            // Create intermediate objects as needed
460            let mut child = serde_json::json!({});
461            set_json_value(&mut child, &segments[1..], value);
462            map.insert(key.to_string(), child);
463        }
464    }
465}
466
467/// Bind a section of the config JSON to a deserializable type.
468pub fn bind_config<T: for<'de> Deserialize<'de> + Default>(
469    config: &serde_json::Value,
470    section: &str,
471) -> T {
472    if section.is_empty() || section == "." {
473        serde_json::from_value(config.clone()).unwrap_or_else(|e| {
474            tracing::warn!(
475                "[Config] Failed to bind root config to {}: {}, using defaults",
476                std::any::type_name::<T>(),
477                e
478            );
479            T::default()
480        })
481    } else {
482        config
483            .get(section)
484            .map(|v| {
485                serde_json::from_value(v.clone()).unwrap_or_else(|e| {
486                    tracing::warn!(
487                        "[Config] Failed to bind section '{}' to {}: {}, using defaults",
488                        section,
489                        std::any::type_name::<T>(),
490                        e
491                    );
492                    T::default()
493                })
494            })
495            .unwrap_or_default()
496    }
497}
498
499/// Bind the entire config JSON to a type (for root-level deserialization).
500pub fn bind_root<T: for<'de> Deserialize<'de> + Default>(config: &serde_json::Value) -> T {
501    serde_json::from_value(config.clone()).unwrap_or_else(|e| {
502        tracing::warn!(
503            "[Config] Failed to bind root config to {}: {}, using defaults",
504            std::any::type_name::<T>(),
505            e
506        );
507        T::default()
508    })
509}
510
511// ---------------------------------------------------------------------------
512// Internal helpers
513// ---------------------------------------------------------------------------
514
515fn read_json_file(path: impl AsRef<Path>) -> Option<serde_json::Value> {
516    let path = path.as_ref();
517
518    /// Try to read and parse a JSON file at the given path.
519    fn try_read(path: &Path) -> Option<serde_json::Value> {
520        let content = std::fs::read_to_string(path).ok()?;
521        serde_json::from_str(&content).ok()
522    }
523
524    // 1. 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
525    //    部署与开发期均能定位到 appsettings.json 所在目录。
526    let base = crate::paths::app_base();
527    let candidate = base.join(path);
528    if let Some(value) = try_read(&candidate) {
529        return Some(value);
530    }
531
532    // 2. Try as-is (relative to current working directory) — 兜底
533    if let Some(value) = try_read(path) {
534        return Some(value);
535    }
536
537    None
538}
539
540fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
541    match (base, overlay) {
542        (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
543            for (k, v) in b {
544                merge_json(a.entry(k).or_insert(serde_json::Value::Null), v);
545            }
546        }
547        (a, b) => *a = b,
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn config_env_jwt_and_app_override_precedence() {
557        std::env::set_var("JWT_SECRET", "env-jwt-secret-that-is-long-enough-32");
558        let mut config = serde_json::json!({ "Jwt": { "Secret": "from-file" } });
559        apply_well_known_env_overrides(&mut config);
560        assert_eq!(
561            config["Jwt"]["Secret"],
562            "env-jwt-secret-that-is-long-enough-32"
563        );
564
565        std::env::set_var(
566            "APP__Jwt__Secret",
567            "from-app-override-secret-32chars-minimum",
568        );
569        apply_env_overrides(&mut config);
570        std::env::remove_var("JWT_SECRET");
571        std::env::remove_var("APP__Jwt__Secret");
572        assert_eq!(
573            config["Jwt"]["Secret"],
574            "from-app-override-secret-32chars-minimum"
575        );
576    }
577
578    #[test]
579    fn cors_section_binds_pascal_case_from_appsettings() {
580        // Regression: without rename_all=PascalCase, Origins fell back to ["*"]
581        // and Production fail-fast panicked even when Production overlay set explicit origins.
582        let json = serde_json::json!({
583            "Cors": {
584                "Origins": ["https://www.lusida.net", "https://lusida.net"],
585                "Methods": ["GET", "POST"],
586                "Headers": ["Content-Type", "Authorization"],
587                "AllowCredentials": true,
588                "MaxAge": 3600
589            }
590        });
591        let opts: AppOptions = bind_root(&json);
592        assert_eq!(
593            opts.cors.origins,
594            vec![
595                "https://www.lusida.net".to_string(),
596                "https://lusida.net".to_string()
597            ]
598        );
599        assert_eq!(
600            opts.cors.methods,
601            vec!["GET".to_string(), "POST".to_string()]
602        );
603        assert!(opts.cors.allow_credentials);
604        assert_eq!(opts.cors.max_age, 3600);
605        assert!(!opts.cors.origins.iter().any(|o| o == "*"));
606    }
607
608    #[test]
609    fn culture_section_binds_default_and_falls_back_to_en() {
610        let json = serde_json::json!({ "Culture": { "Default": "zh-CN" } });
611        let opts: AppOptions = bind_root(&json);
612        assert_eq!(opts.culture.default, "zh-CN");
613
614        let opts: AppOptions = bind_root(&serde_json::json!({}));
615        assert_eq!(opts.culture.default, "en");
616    }
617
618    #[test]
619    fn jwt_section_binds_expiration_minutes_with_60_minute_default() {
620        let opts: AppOptions = bind_root(&serde_json::json!({}));
621        assert_eq!(opts.jwt.expiration_minutes, 60);
622        assert!(opts.jwt.secret.is_empty());
623
624        let opts: AppOptions = bind_root(&serde_json::json!({
625            "Jwt": { "Secret": "s", "ExpirationMinutes": 15 }
626        }));
627        assert_eq!(opts.jwt.expiration_minutes, 15);
628    }
629
630    #[test]
631    fn jwt_section_binds_secret_file_and_defaults_to_none() {
632        // XR-2: the new key must be optional so existing appsettings bind
633        // exactly as before.
634        let opts: AppOptions = bind_root(&serde_json::json!({}));
635        assert_eq!(opts.jwt.secret_file, None);
636        assert_eq!(JwtSection::default().secret_file, None);
637
638        let opts: AppOptions = bind_root(&serde_json::json!({
639            "Jwt": { "SecretFile": "data/jwt.key" }
640        }));
641        assert_eq!(opts.jwt.secret_file.as_deref(), Some("data/jwt.key"));
642        assert!(
643            opts.jwt.secret.is_empty(),
644            "binding records the path; the host reads the file"
645        );
646
647        // Both keys may coexist: resolution picks `Secret` first.
648        let opts: AppOptions = bind_root(&serde_json::json!({
649            "Jwt": { "Secret": "explicit-secret", "SecretFile": "data/jwt.key" }
650        }));
651        assert_eq!(opts.jwt.secret, "explicit-secret");
652        assert_eq!(opts.jwt.secret_file.as_deref(), Some("data/jwt.key"));
653    }
654
655    #[test]
656    fn production_overlay_merge_keeps_explicit_cors_origins() {
657        let mut base = serde_json::json!({
658            "App": { "Urls": ["http://localhost:5000"] },
659            "Cors": { "Origins": ["*"] },
660            "Jwt": { "Secret": "docbit-dev-secret-change-in-production" }
661        });
662        let overlay = serde_json::json!({
663            "App": { "Urls": ["http://0.0.0.0:8100"] },
664            "Cors": {
665                "Origins": [
666                    "https://www.lusida.net",
667                    "https://lusida.net"
668                ]
669            },
670            "Jwt": { "Secret": "" }
671        });
672        merge_json(&mut base, overlay);
673        let opts: AppOptions = bind_root(&base);
674        assert_eq!(opts.app.urls, vec!["http://0.0.0.0:8100".to_string()]);
675        assert_eq!(
676            opts.cors.origins,
677            vec![
678                "https://www.lusida.net".to_string(),
679                "https://lusida.net".to_string()
680            ]
681        );
682        assert!(opts.jwt.secret.is_empty());
683    }
684}