Skip to main content

assay_engine/
config.rs

1//! Engine configuration loaded from TOML.
2//!
3//! Phase 8 wires in `AuthConfig` so the engine binary can compose an
4//! `assay_auth::AuthCtx` per-deployment (issuer, OIDC provider toggle,
5//! session/cookie shape). When `auth` isn't compiled in (Cargo feature
6//! off) the auth section is parsed but never read — keeping the TOML
7//! shape stable across feature configurations.
8//!
9//! Env-var substitution: `${VAR}` and `${VAR:-default}` references in
10//! the TOML are expanded against the process environment before parsing
11//! (added in 0.3.1). This keeps secrets out of config files when the
12//! engine runs under K8s/systemd/etc. — the typical pattern is
13//! `url = "${DATABASE_URL}"` with `DATABASE_URL` injected from a
14//! Secret/EnvironmentFile.
15
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
20#[non_exhaustive]
21pub struct EngineConfig {
22    pub server: ServerConfig,
23    pub backend: BackendConfig,
24    #[serde(default)]
25    pub workflow: WorkflowConfig,
26    #[serde(default)]
27    pub auth: AuthConfig,
28    #[serde(default)]
29    pub vault: VaultConfig,
30    #[serde(default)]
31    pub dashboard: DashboardConfig,
32    #[serde(default)]
33    pub logging: LoggingConfig,
34    /// TTL in seconds for the engine_events outbox. Rows older than this
35    /// are pruned hourly by the cleanup loop. Default 3 days.
36    #[serde(default = "default_engine_events_ttl_secs")]
37    pub engine_events_ttl_secs: u64,
38    /// Modules to flip from `enabled = FALSE` to `enabled = TRUE` on
39    /// first boot when they're compiled in. Empty by default — operators
40    /// of existing v0.1.2 deployments shouldn't get unexpected auth
41    /// migrations on upgrade. Local-dev convenience: set to
42    /// `["auth"]` in `engine.local.toml` to flip auth on without an
43    /// extra step.
44    #[serde(default)]
45    pub auto_enable_modules: Vec<String>,
46}
47
48fn default_engine_events_ttl_secs() -> u64 {
49    3 * 86_400
50}
51
52#[derive(Clone, Debug, Deserialize, Serialize)]
53#[non_exhaustive]
54pub struct ServerConfig {
55    #[serde(default = "default_bind_addr")]
56    pub bind_addr: String,
57    /// Operator-supplied canonical URL for the engine API and dashboard.
58    /// Auth defaults to this origin too, but can use a dedicated hostname
59    /// through `auth.public_url`. Defaults to the bind address over plain
60    /// HTTP for local development; production deployments MUST override it
61    /// with the public HTTPS URL.
62    #[serde(default = "default_public_url")]
63    pub public_url: String,
64    /// Host header values accepted by the public server. Empty keeps the
65    /// embedded/local-development behavior and accepts every host. Health
66    /// checks remain reachable when this allowlist is populated.
67    #[serde(default)]
68    pub allowed_hosts: Vec<String>,
69}
70
71impl Default for ServerConfig {
72    fn default() -> Self {
73        Self {
74            bind_addr: default_bind_addr(),
75            public_url: default_public_url(),
76            allowed_hosts: Vec::new(),
77        }
78    }
79}
80
81fn default_bind_addr() -> String {
82    "0.0.0.0:3000".to_string()
83}
84
85fn default_public_url() -> String {
86    "http://localhost:3000".to_string()
87}
88
89#[derive(Clone, Debug, Deserialize, Serialize)]
90#[serde(tag = "type", rename_all = "lowercase")]
91#[non_exhaustive]
92pub enum BackendConfig {
93    Postgres {
94        /// Postgres connection URL, e.g. `postgres://user:pass@host:5432/db`.
95        /// PostgreSQL 18 is the minimum supported version.
96        url: String,
97    },
98    Sqlite {
99        /// Directory holding the per-module SQLite files
100        /// (`<data_dir>/engine.db`, `<data_dir>/workflow.db`, …). Created
101        /// on startup if missing. Defaults to `./data`. Use `:memory:`
102        /// in `path` (legacy) or set `data_dir = ":memory:"` to keep the
103        /// engine purely in-memory for tests.
104        #[serde(default = "default_data_dir")]
105        data_dir: String,
106        /// Legacy single-file SQLite path. Deprecated in v0.1.2 — when
107        /// set, the engine logs a deprecation notice and treats it as
108        /// `data_dir = parent(path)` so existing configs keep working
109        /// during the transition.
110        #[serde(default)]
111        path: Option<String>,
112    },
113}
114
115fn default_data_dir() -> String {
116    "./data".to_string()
117}
118
119impl BackendConfig {
120    /// Resolve the effective data directory for SQLite. PG returns `None`.
121    pub fn sqlite_data_dir(&self) -> Option<String> {
122        match self {
123            Self::Sqlite { data_dir, path } => {
124                // Legacy `path` wins for backwards compat — treat the
125                // parent dir as the new data_dir so existing v0.1.1
126                // configs migrate without surprise.
127                if let Some(p) = path {
128                    let parent = std::path::Path::new(p)
129                        .parent()
130                        .map(|p| p.display().to_string())
131                        .filter(|s| !s.is_empty());
132                    Some(parent.unwrap_or_else(|| data_dir.clone()))
133                } else {
134                    Some(data_dir.clone())
135                }
136            }
137            Self::Postgres { .. } => None,
138        }
139    }
140}
141
142#[derive(Clone, Debug, Default, Deserialize, Serialize)]
143#[non_exhaustive]
144pub struct WorkflowConfig {
145    #[serde(default = "default_true")]
146    pub enabled: bool,
147}
148
149/// Auth-module deployment shape. Read by the engine binary when the
150/// `auth` Cargo feature is compiled in AND `engine.modules.auth.enabled`
151/// is TRUE; otherwise the defaults are harmless.
152#[derive(Clone, Debug, Default, Deserialize, Serialize)]
153#[non_exhaustive]
154pub struct AuthConfig {
155    /// Canonical browser-facing origin for the auth surface. Defaults to
156    /// `server.public_url` when unset. This allows one engine deployment to
157    /// expose auth on a dedicated hostname without changing its API origin.
158    pub public_url: Option<String>,
159    /// JWT issuer + OIDC `iss` claim. Defaults to
160    /// `<auth.public_url>/auth` when unset, which matches the route
161    /// mount point.
162    pub issuer: Option<String>,
163    /// JWT audience list — also used by the OIDC provider when minting
164    /// access_tokens for resource servers. Defaults to `[issuer]`.
165    #[serde(default)]
166    pub audience: Vec<String>,
167    #[serde(default)]
168    pub session: AuthSessionConfig,
169    #[serde(default)]
170    pub passkey: AuthPasskeyConfig,
171    #[serde(default)]
172    pub recovery: AuthRecoveryConfig,
173    #[serde(default)]
174    pub oidc_provider: AuthOidcProviderConfig,
175    /// Admin API keys — comma-separated bearer tokens that grant access
176    /// to `/admin/*` routes. Operators rotate these via the engine
177    /// config. Per-token, no expiry; for fancier admin auth (Zanzibar
178    /// roles, session-based admin) see plan 12c § 6.7. Empty list locks
179    /// admin routes entirely (404 → 401).
180    #[serde(default)]
181    pub admin_api_keys: Vec<String>,
182    /// External OIDC issuers trusted to mint JWTs the engine accepts
183    /// pass-through (v0.3.2). Each entry's JWKS is discovered via
184    /// `<issuer_url>/.well-known/openid-configuration` at boot and
185    /// refreshed periodically thereafter. Tokens whose `iss` claim
186    /// matches a configured issuer are verified against that issuer's
187    /// keys; everything else falls through to the engine's internal
188    /// JWT path. When this list is non-empty, the engine boots without
189    /// requiring operator users / `admin_api_keys` — the upstream IdP
190    /// is the source of truth for identity.
191    ///
192    /// Mirrors the v0.12.1 `--auth-issuer` / `--auth-audience` CLI
193    /// flags in the new TOML config shape. Multiple issuers are allowed
194    /// for deployments that span more than one IdP.
195    ///
196    /// Field is private so future entries (per-issuer policy, claim
197    /// mappers, etc.) can be added without breaking downstream
198    /// construction. Read via [`AuthConfig::external_issuers`].
199    #[serde(default)]
200    external_issuers: Vec<ExternalIssuerConfig>,
201}
202
203impl AuthConfig {
204    /// Read access to the parsed `[[auth.external_issuers]]` blocks.
205    pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
206        &self.external_issuers
207    }
208}
209
210/// One trusted external OIDC issuer for pass-through JWT validation.
211#[derive(Clone, Debug, Default, Deserialize, Serialize)]
212#[non_exhaustive]
213pub struct ExternalIssuerConfig {
214    /// Issuer URL — the value the JWT's `iss` claim is matched against
215    /// and the base for `<issuer_url>/.well-known/openid-configuration`
216    /// discovery. Trailing slashes are normalized.
217    pub issuer_url: String,
218    /// Accepted `aud` claim values. A token whose `aud` isn't in this
219    /// list is rejected. Empty list = audience check disabled (NOT
220    /// recommended; set explicitly per deployment).
221    #[serde(default)]
222    pub audience: Vec<String>,
223    /// JWKS refresh interval in seconds (background task). Default 3600
224    /// (1 hour). Minimum effective value 60 seconds — anything smaller
225    /// is clamped to avoid hammering the upstream's JWKS endpoint.
226    #[serde(default = "default_jwks_refresh_secs")]
227    pub jwks_refresh_secs: u64,
228}
229
230fn default_jwks_refresh_secs() -> u64 {
231    3600
232}
233
234/// Session module knobs.
235#[derive(Clone, Debug, Default, Deserialize, Serialize)]
236#[non_exhaustive]
237pub struct AuthSessionConfig {
238    /// Default session lifetime in seconds. `None` ⇒ uses the
239    /// `assay_auth::session::DEFAULT_SESSION_DURATION` (30 days).
240    pub ttl_seconds: Option<u64>,
241}
242
243/// WebAuthn / passkey module knobs.
244#[derive(Clone, Debug, Default, Deserialize, Serialize)]
245#[non_exhaustive]
246pub struct AuthPasskeyConfig {
247    /// Relying-party id — the host (no scheme/port) the browser will
248    /// scope passkeys to. Defaults to the host of `auth.public_url`, or
249    /// `server.public_url` when no dedicated auth origin is configured.
250    pub rp_id: Option<String>,
251    /// Human-readable label browsers show. Defaults to `"Assay"`.
252    pub rp_name: Option<String>,
253}
254
255/// Self-service password-recovery deployment knobs.
256#[derive(Clone, Debug, Deserialize, Serialize)]
257#[non_exhaustive]
258pub struct AuthRecoveryConfig {
259    /// Mount the public recovery endpoints and send reset emails.
260    #[serde(default)]
261    pub enabled: bool,
262    /// Lifetime of a single-use recovery token. Defaults to 15 minutes.
263    #[serde(default = "default_recovery_token_ttl_seconds")]
264    pub token_ttl_seconds: u64,
265    /// Minimum time between recovery emails for one account.
266    #[serde(default = "default_recovery_cooldown_seconds")]
267    pub request_cooldown_seconds: u64,
268    /// SMTP delivery settings. Required when recovery is enabled.
269    pub smtp: Option<AuthSmtpConfig>,
270}
271
272impl Default for AuthRecoveryConfig {
273    fn default() -> Self {
274        Self {
275            enabled: false,
276            token_ttl_seconds: default_recovery_token_ttl_seconds(),
277            request_cooldown_seconds: default_recovery_cooldown_seconds(),
278            smtp: None,
279        }
280    }
281}
282
283fn default_recovery_token_ttl_seconds() -> u64 {
284    15 * 60
285}
286
287fn default_recovery_cooldown_seconds() -> u64 {
288    60
289}
290
291/// SMTP settings used only by password recovery.
292#[derive(Clone, Debug, Deserialize, Serialize)]
293#[non_exhaustive]
294pub struct AuthSmtpConfig {
295    pub host: String,
296    #[serde(default = "default_smtp_port")]
297    pub port: u16,
298    pub username: String,
299    pub password: String,
300    pub from: String,
301    #[serde(default = "default_true")]
302    pub starttls: bool,
303}
304
305fn default_smtp_port() -> u16 {
306    587
307}
308
309/// OIDC provider knobs.
310#[derive(Clone, Debug, Default, Deserialize, Serialize)]
311#[non_exhaustive]
312pub struct AuthOidcProviderConfig {
313    /// Whether the OIDC provider routes (/authorize /token /userinfo …)
314    /// are mounted. Defaults to `true` when the Cargo feature is on.
315    #[serde(default = "default_true")]
316    pub enabled: bool,
317    /// Override the issuer URL used by the OIDC provider. Defaults to
318    /// the parent [`AuthConfig::issuer`] when unset.
319    pub issuer_override: Option<String>,
320    /// `true`  → federation callback creates an `auth.users` row on
321    ///           first sign-in for a new upstream identity (open
322    ///           signup; legacy library default — kept as the default
323    ///           here so omitted config does not silently flip
324    ///           existing deployments into invite-only).
325    /// `false` → callback looks up by email (and requires the
326    ///           upstream `email_verified` claim); missing rows
327    ///           return 403. Operators pre-populate `auth.users` via
328    ///           the admin API or the sysops `/auth/users` page.
329    ///           Recommended for shared / multi-tenant deployments —
330    ///           must be set explicitly.
331    #[serde(default = "default_true")]
332    pub auto_provision: bool,
333}
334
335#[derive(Clone, Debug, Default, Deserialize, Serialize)]
336#[non_exhaustive]
337pub struct VaultConfig {
338    #[serde(default)]
339    pub hashicorp_compat: HashicorpCompatConfig,
340}
341
342/// Vault / OpenBao KV2 read facade at `/v1/*`. Off unless an operator asks
343/// for it: serving a second dialect of the secret store at the engine root is
344/// a deliberate act, not a default.
345#[derive(Clone, Debug, Deserialize, Serialize)]
346#[non_exhaustive]
347pub struct HashicorpCompatConfig {
348    #[serde(default)]
349    pub enabled: bool,
350    /// Set this to the mount the estate's OpenBao used and consumers keep
351    /// their existing paths.
352    #[serde(default = "default_vault_compat_mount")]
353    pub mount: String,
354}
355
356impl Default for HashicorpCompatConfig {
357    fn default() -> Self {
358        Self {
359            enabled: false,
360            mount: default_vault_compat_mount(),
361        }
362    }
363}
364
365fn default_vault_compat_mount() -> String {
366    "secrets".to_string()
367}
368
369#[derive(Clone, Debug, Deserialize, Serialize)]
370#[non_exhaustive]
371pub struct DashboardConfig {
372    #[serde(default = "default_true")]
373    pub enabled: bool,
374    /// Operator consoles (`/workflow`, `/engine`, `/vault`, and
375    /// `/auth/console`). Defaults to the legacy `enabled` value.
376    pub operator_enabled: Option<bool>,
377    /// Public browser authentication UI (`/auth/login`, recovery, and the
378    /// auth landing page). Defaults to the legacy `enabled` value.
379    pub auth_ui_enabled: Option<bool>,
380}
381
382impl Default for DashboardConfig {
383    fn default() -> Self {
384        // When the `[dashboard]` section is omitted entirely from
385        // engine.toml, serde calls Default::default() — and bool's
386        // derived default is `false`. We want `enabled: true` here so
387        // a fresh engine.toml without a [dashboard] section still
388        // mounts the SPAs out of the box.
389        Self {
390            enabled: true,
391            operator_enabled: None,
392            auth_ui_enabled: None,
393        }
394    }
395}
396
397impl DashboardConfig {
398    pub fn operator_enabled(&self) -> bool {
399        self.operator_enabled.unwrap_or(self.enabled)
400    }
401
402    pub fn auth_ui_enabled(&self) -> bool {
403        self.auth_ui_enabled.unwrap_or(self.enabled)
404    }
405}
406
407#[derive(Clone, Debug, Deserialize, Serialize)]
408#[non_exhaustive]
409pub struct LoggingConfig {
410    #[serde(default = "default_log_level")]
411    pub level: String,
412    #[serde(default = "default_log_format")]
413    pub format: String,
414}
415
416impl Default for LoggingConfig {
417    fn default() -> Self {
418        Self {
419            level: default_log_level(),
420            format: default_log_format(),
421        }
422    }
423}
424
425fn default_true() -> bool {
426    true
427}
428
429fn default_log_level() -> String {
430    "info".to_string()
431}
432
433fn default_log_format() -> String {
434    "pretty".to_string()
435}
436
437impl EngineConfig {
438    /// Load `engine.toml`. String fields support `${VAR}` and
439    /// `${VAR:-default}` env-var references; references with no default
440    /// error out at load time when the variable is unset. Bracket-less
441    /// `$VAR` is left untouched, and `${...}` whose contents aren't a
442    /// valid identifier are passed through verbatim.
443    pub fn from_file(path: &Path) -> anyhow::Result<Self> {
444        let raw = std::fs::read_to_string(path)
445            .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
446        let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
447            .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
448        let cfg: Self = toml::from_str(&expanded)
449            .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
450        Ok(cfg)
451    }
452}
453
454/// Expand `${VAR}` and `${VAR:-default}` references in `raw` using
455/// `lookup` to resolve names. The lookup-by-closure shape keeps this
456/// pure for unit tests (the binary path uses `std::env::var`).
457///
458/// Behavior:
459/// - `${VAR}` → value if set, error if unset.
460/// - `${VAR:-default}` → value if set, else the default (which may be empty).
461/// - Bracket-less `$VAR` is untouched.
462/// - `${...}` whose contents aren't a valid identifier are passed
463///   through verbatim — keeps non-substitution `${...}` literals usable
464///   in odd field values without false positives.
465fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
466where
467    F: Fn(&str) -> Option<String>,
468{
469    let mut out = String::with_capacity(raw.len());
470    let mut rest = raw;
471    while let Some(idx) = rest.find("${") {
472        out.push_str(&rest[..idx]);
473        let after_open = &rest[idx + 2..];
474        let close_idx = after_open
475            .find('}')
476            .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
477        let inner = &after_open[..close_idx];
478        let (var_name, default) = match inner.split_once(":-") {
479            Some((n, d)) => (n, Some(d)),
480            None => (inner, None),
481        };
482        if !is_valid_var_name(var_name) {
483            // Not a valid identifier — pass the whole `${...}` through.
484            out.push_str("${");
485            out.push_str(inner);
486            out.push('}');
487        } else {
488            match lookup(var_name) {
489                Some(val) => out.push_str(&val),
490                None => match default {
491                    Some(def) => out.push_str(def),
492                    None => {
493                        return Err(anyhow::anyhow!(
494                            "env var `{}` is not set and has no default",
495                            var_name
496                        ));
497                    }
498                },
499            }
500        }
501        rest = &after_open[close_idx + 1..];
502    }
503    out.push_str(rest);
504    Ok(out)
505}
506
507fn is_valid_var_name(s: &str) -> bool {
508    let mut chars = s.chars();
509    match chars.next() {
510        Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
511        _ => return false,
512    }
513    chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
521        move |name: &str| {
522            map.iter()
523                .find(|(k, _)| *k == name)
524                .map(|(_, v)| (*v).to_string())
525        }
526    }
527
528    #[test]
529    fn no_substitution_passes_through() {
530        let s = "plain string with $literal but no expansion markers";
531        assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
532    }
533
534    #[test]
535    fn substitutes_set_var() {
536        let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
537        assert_eq!(out, "value=hello");
538    }
539
540    #[test]
541    fn errors_on_unset_var_with_no_default() {
542        let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
543        assert!(err.to_string().contains("MISSING"));
544    }
545
546    #[test]
547    fn falls_back_to_default_when_unset() {
548        let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
549        assert_eq!(out, "fallback");
550    }
551
552    #[test]
553    fn ignores_default_when_var_set() {
554        let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
555        assert_eq!(out, "actual");
556    }
557
558    #[test]
559    fn empty_default_yields_empty_string() {
560        let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
561        assert_eq!(out, "[]");
562    }
563
564    #[test]
565    fn substitutes_multiple_vars_in_one_string() {
566        let out = expand_env_vars(
567            "postgres://u:p@${HOST}:${PORT}/x",
568            lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
569        )
570        .unwrap();
571        assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
572    }
573
574    #[test]
575    fn dollar_without_braces_passes_through() {
576        // Bracket-less `$IDENT` is intentionally left alone — only the
577        // `${...}` form is treated as an env reference.
578        let s = "$HOME and $USER stay literal";
579        let out = expand_env_vars(s, lookup_from(&[])).unwrap();
580        assert_eq!(out, s);
581    }
582
583    #[test]
584    fn invalid_identifier_passes_through_verbatim() {
585        // Digit-leading is not a valid identifier; `${1NOT_VALID}` stays literal.
586        let s = "${1NOT_VALID}";
587        assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
588    }
589
590    #[test]
591    fn unclosed_brace_errors() {
592        let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
593        assert!(err.to_string().contains("unclosed"));
594    }
595
596    #[test]
597    fn substitutes_inside_toml_string_values() {
598        let toml_input = r#"
599[backend]
600type = "postgres"
601url = "${DB}"
602"#;
603        let expanded =
604            expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
605        assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
606    }
607
608    #[test]
609    fn is_valid_var_name_accepts_typical_names() {
610        assert!(is_valid_var_name("DATABASE_URL"));
611        assert!(is_valid_var_name("_PRIVATE"));
612        assert!(is_valid_var_name("X"));
613        assert!(is_valid_var_name("X1"));
614    }
615
616    #[test]
617    fn is_valid_var_name_rejects_bad_names() {
618        assert!(!is_valid_var_name(""));
619        assert!(!is_valid_var_name("1LEADING_DIGIT"));
620        assert!(!is_valid_var_name("HAS SPACE"));
621        assert!(!is_valid_var_name("HAS-DASH"));
622        assert!(!is_valid_var_name("HAS.DOT"));
623    }
624
625    #[test]
626    fn from_file_loads_static_toml() {
627        // Integration sanity that the from_file path still works after the
628        // expansion step is wired in. Uses a config with no env-var
629        // references to keep the test hermetic.
630        let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
631        std::fs::write(
632            &path,
633            r#"
634[server]
635bind_addr = "127.0.0.1:3000"
636
637[backend]
638type = "sqlite"
639data_dir = "/tmp/assay-engine-test-data-static"
640"#,
641        )
642        .unwrap();
643        let cfg = EngineConfig::from_file(&path).unwrap();
644        let _ = std::fs::remove_file(&path);
645        match cfg.backend {
646            BackendConfig::Sqlite { ref data_dir, .. } => {
647                assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
648            }
649            _ => panic!("expected sqlite backend"),
650        }
651    }
652
653    fn minimal_config_with(sections: &str) -> EngineConfig {
654        let base = r#"
655[server]
656bind_addr = "127.0.0.1:3000"
657
658[backend]
659type = "sqlite"
660data_dir = ":memory:"
661"#;
662        toml::from_str(&format!("{base}{sections}")).unwrap()
663    }
664
665    #[test]
666    fn the_vault_compat_facade_is_off_until_an_operator_asks_for_it() {
667        let cfg = minimal_config_with("");
668
669        assert!(!cfg.vault.hashicorp_compat.enabled);
670        assert_eq!(cfg.vault.hashicorp_compat.mount, "secrets");
671    }
672
673    #[test]
674    fn the_vault_compat_mount_is_operator_selectable() {
675        let cfg = minimal_config_with(
676            r#"
677[vault.hashicorp_compat]
678enabled = true
679mount = "kv"
680"#,
681        );
682
683        assert!(cfg.vault.hashicorp_compat.enabled);
684        assert_eq!(cfg.vault.hashicorp_compat.mount, "kv");
685    }
686
687    #[test]
688    fn password_recovery_is_disabled_by_default() {
689        let cfg: EngineConfig = toml::from_str(
690            r#"
691[server]
692bind_addr = "127.0.0.1:3000"
693
694[backend]
695type = "sqlite"
696data_dir = ":memory:"
697"#,
698        )
699        .unwrap();
700
701        assert!(!cfg.auth.recovery.enabled);
702        assert_eq!(cfg.auth.recovery.token_ttl_seconds, 900);
703        assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 60);
704        assert!(cfg.auth.recovery.smtp.is_none());
705    }
706
707    #[test]
708    fn password_recovery_smtp_configuration_deserializes() {
709        let cfg: EngineConfig = toml::from_str(
710            r#"
711[server]
712bind_addr = "127.0.0.1:3000"
713
714[backend]
715type = "sqlite"
716data_dir = ":memory:"
717
718[auth.recovery]
719enabled = true
720token_ttl_seconds = 1200
721request_cooldown_seconds = 90
722
723[auth.recovery.smtp]
724host = "smtp.example.com"
725port = 587
726username = "mailer"
727password = "secret"
728from = "Example Auth <noreply@example.com>"
729starttls = true
730"#,
731        )
732        .unwrap();
733
734        assert!(cfg.auth.recovery.enabled);
735        assert_eq!(cfg.auth.recovery.token_ttl_seconds, 1200);
736        assert_eq!(cfg.auth.recovery.request_cooldown_seconds, 90);
737        let smtp = cfg.auth.recovery.smtp.unwrap();
738        assert_eq!(smtp.host, "smtp.example.com");
739        assert_eq!(smtp.port, 587);
740        assert_eq!(smtp.username, "mailer");
741        assert_eq!(smtp.password, "secret");
742        assert_eq!(smtp.from, "Example Auth <noreply@example.com>");
743        assert!(smtp.starttls);
744    }
745
746    #[test]
747    fn flagship_host_and_dashboard_boundaries_deserialize() {
748        let cfg: EngineConfig = toml::from_str(
749            r#"
750[server]
751bind_addr = "127.0.0.1:3000"
752allowed_hosts = ["auth.assay.rs", "engine.assay.rs"]
753
754[backend]
755type = "sqlite"
756data_dir = ":memory:"
757
758[dashboard]
759enabled = true
760operator_enabled = false
761auth_ui_enabled = true
762"#,
763        )
764        .unwrap();
765
766        assert_eq!(
767            cfg.server.allowed_hosts,
768            ["auth.assay.rs", "engine.assay.rs"]
769        );
770        assert!(!cfg.dashboard.operator_enabled());
771        assert!(cfg.dashboard.auth_ui_enabled());
772    }
773
774    #[test]
775    fn dashboard_surface_flags_preserve_the_legacy_enabled_default() {
776        let dashboard = DashboardConfig::default();
777        assert!(dashboard.operator_enabled());
778        assert!(dashboard.auth_ui_enabled());
779        assert!(ServerConfig::default().allowed_hosts.is_empty());
780    }
781}