Skip to main content

notedthat_server/
config.rs

1//! Configuration for `notedthat-server`.
2//!
3//! Every setting arrives from one of two places: the command line, or the
4//! process environment. [`crate::cli::ServerCli`] resolves which — the flag wins
5//! — and hands the raw values here; nothing in this module reads the
6//! environment itself. See `docs/CONFIGURATION.md` for the full reference.
7
8use crate::cli::ServerCli;
9use crate::oidc::OidcSettings;
10use notedthat_core::{Error, KbSlug, StagingConfig, TenantSlug, setting};
11use notedthat_events::{MemoryConfig, MemorySettings, NatsConfig, NatsSettings};
12use notedthat_storage_fs::FsSettings;
13use notedthat_storage_s3::S3Settings;
14use notedthat_write::MAX_UPLOAD_BYTES;
15use std::collections::BTreeMap;
16use std::ffi::OsStr;
17use std::net::SocketAddr;
18use std::time::Duration;
19
20/// Which storage backend the server runs on.
21///
22/// Parsed separately from its configuration so the selection can be named in an error
23/// message before any backend configuration is read.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum StorageBackendKind {
26    /// An S3-compatible object store.
27    S3,
28    /// A local filesystem tree.
29    Fs,
30}
31
32impl StorageBackendKind {
33    /// The `NOTEDTHAT_STORAGE_BACKEND` value that selects this backend.
34    #[must_use]
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::S3 => "s3",
38            Self::Fs => "fs",
39        }
40    }
41}
42
43impl std::fmt::Display for StorageBackendKind {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49/// The selected storage backend together with the configuration it needs.
50///
51/// An enum rather than one `Option` per backend, so "exactly one backend is configured"
52/// is a property of the type and [`crate::run`] has no unreachable error arm.
53#[derive(Debug, Clone)]
54pub enum StorageConfig {
55    /// S3-compatible object store (the default).
56    S3(notedthat_storage_s3::S3Config),
57    /// Local filesystem tree.
58    Fs(notedthat_storage_fs::FsConfig),
59}
60
61impl StorageConfig {
62    /// Which backend this is.
63    #[must_use]
64    pub fn kind(&self) -> StorageBackendKind {
65        match self {
66            Self::S3(_) => StorageBackendKind::S3,
67            Self::Fs(_) => StorageBackendKind::Fs,
68        }
69    }
70}
71
72/// Which object change event log the server publishes to (`NOTEDTHAT_EVENTS_BACKEND`).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
74pub enum EventsBackendKind {
75    /// No log: writes are not announced and the events route answers 404.
76    None,
77    /// A process-local ring — replay across reconnects, not restarts or replicas.
78    Memory,
79    /// A NATS `JetStream` stream shared by every replica.
80    Nats,
81}
82
83impl EventsBackendKind {
84    /// The `NOTEDTHAT_EVENTS_BACKEND` value that selects this backend.
85    #[must_use]
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::None => "none",
89            Self::Memory => "memory",
90            Self::Nats => "nats",
91        }
92    }
93}
94
95impl std::fmt::Display for EventsBackendKind {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// Whether `/mcp` admits a request that presents no credential (`NOTEDTHAT_MCP_ANONYMOUS`).
102///
103/// The MCP surface acts as its caller on the loopback API, so an anonymous caller is bound
104/// by the manifests' `anyone` rules exactly as a direct anonymous request is. What this
105/// setting decides is only whether such a request is let in at all, because the alternative
106/// — a `401` with the bearer challenge — is what an OAuth-capable MCP client needs to see
107/// before it will sign in.
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
109pub enum McpAnonymous {
110    /// Admit anonymous callers when at least one declared knowledge base grants `anyone`
111    /// something; otherwise `401`. The default.
112    #[default]
113    Auto,
114    /// Always `401` a missing credential, whatever the manifests grant — for a deployment
115    /// with public knowledge bases and an identity provider whose operator wants OAuth
116    /// clients challenged on connect rather than signed in by hand.
117    Never,
118}
119
120impl McpAnonymous {
121    /// The `NOTEDTHAT_MCP_ANONYMOUS` value that selects this mode.
122    #[must_use]
123    pub fn as_str(self) -> &'static str {
124        match self {
125            Self::Auto => "auto",
126            Self::Never => "never",
127        }
128    }
129}
130
131impl std::fmt::Display for McpAnonymous {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// The selected events backend together with the configuration it needs.
138#[derive(Debug, Clone)]
139pub enum EventsConfig {
140    /// No event log (the default).
141    None,
142    /// The in-process ring.
143    Memory(MemoryConfig),
144    /// A NATS `JetStream` stream.
145    Nats(NatsConfig),
146}
147
148impl EventsConfig {
149    /// Which backend this is.
150    #[must_use]
151    pub fn kind(&self) -> EventsBackendKind {
152        match self {
153            Self::None => EventsBackendKind::None,
154            Self::Memory(_) => EventsBackendKind::Memory,
155            Self::Nats(_) => EventsBackendKind::Nats,
156        }
157    }
158}
159
160/// Every setting owned by one storage backend, paired with its backend and with
161/// whether this run supplied it at all.
162///
163/// A setting whose owner is not the selected backend is a startup error rather than an
164/// ignored setting — silently ignoring `NOTEDTHAT_FS_ROOT` under the default `s3` backend
165/// is how an operator ends up believing their bytes are on a disk they are not on. Same
166/// reasoning as [`REMOVED_LISTENER_ENV_VARS`] (D39), applied to backend selection.
167///
168/// "Supplied" means presence, not value, and spans both sources: `--s3-region ""` and
169/// `NOTEDTHAT_S3_REGION=` both count, matching how an empty variable counted when the
170/// environment was the only source.
171///
172/// Deliberately confined to settings this server reads. `AWS_*` is not listed:
173/// `S3Config::build_client` uses a static credential provider and never consults the
174/// ambient credential chain, so rejecting an `AWS_ACCESS_KEY_ID` on a shared runner would
175/// be a pure false positive.
176///
177/// The names are asserted against each adapter's own inventory — `S3_ENV_VARS` and
178/// `FS_ENV_VARS` — by a test, so this table cannot drift from what those adapters read.
179fn backend_owned_settings(cli: &ServerCli) -> Vec<(&'static str, StorageBackendKind, bool)> {
180    use StorageBackendKind::{Fs, S3};
181    vec![
182        ("NOTEDTHAT_S3_REGION", S3, cli.s3_region.is_some()),
183        (
184            "NOTEDTHAT_S3_ACCESS_KEY_ID",
185            S3,
186            cli.s3_access_key_id.is_some(),
187        ),
188        (
189            "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
190            S3,
191            cli.s3_secret_access_key.is_some(),
192        ),
193        (
194            "NOTEDTHAT_S3_ENDPOINT_URL",
195            S3,
196            cli.s3_endpoint_url.is_some(),
197        ),
198        (
199            "NOTEDTHAT_S3_FORCE_PATH_STYLE",
200            S3,
201            cli.s3_force_path_style.is_some(),
202        ),
203        ("NOTEDTHAT_S3_RECONCILE", S3, cli.s3_reconcile.is_some()),
204        ("NOTEDTHAT_FS_ROOT", Fs, cli.fs_root.is_some()),
205        ("NOTEDTHAT_FS_METADATA", Fs, cli.fs_metadata.is_some()),
206        ("NOTEDTHAT_FS_FILE_MODE", Fs, cli.fs_file_mode.is_some()),
207        ("NOTEDTHAT_FS_DIR_MODE", Fs, cli.fs_dir_mode.is_some()),
208        (
209            "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
210            Fs,
211            cli.fs_allow_lossy_names.is_some(),
212        ),
213        ("NOTEDTHAT_FS_WATCH", Fs, cli.fs_watch.is_some()),
214        (
215            "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS",
216            Fs,
217            cli.fs_watch_debounce_ms.is_some(),
218        ),
219    ]
220}
221
222/// The `NOTEDTHAT_EVENTS_*` and `NOTEDTHAT_NATS_*` settings, each with the events
223/// backend that reads it. Same purpose and same guard as [`backend_owned_settings`].
224fn events_owned_settings(cli: &ServerCli) -> Vec<(&'static str, EventsBackendKind, bool)> {
225    use EventsBackendKind::{Memory, Nats};
226    vec![
227        (
228            "NOTEDTHAT_EVENTS_MEMORY_CAPACITY",
229            Memory,
230            cli.events_memory_capacity.is_some(),
231        ),
232        ("NOTEDTHAT_NATS_URL", Nats, cli.nats_url.is_some()),
233        ("NOTEDTHAT_NATS_STREAM", Nats, cli.nats_stream.is_some()),
234        (
235            "NOTEDTHAT_NATS_MAX_AGE_SECS",
236            Nats,
237            cli.nats_max_age_secs.is_some(),
238        ),
239    ]
240}
241
242/// Parse the backend selector, returning `None` when it was not supplied.
243///
244/// Strict, unlike `NOTEDTHAT_LOG_FORMAT`, which silently falls back on an unrecognised
245/// value. That one can afford leniency because a mis-parse announces itself immediately:
246/// the wrong log format is visible in the first line of output. A backend selector
247/// cannot: `NOTEDTHAT_STORAGE_BACKEND=fs3` would fall back to `s3`, start cleanly,
248/// provision buckets and serve a knowledge base that looks empty because the operator's
249/// data is on disk. Nothing later in the run would say so. Every `NOTEDTHAT_S3_*` switch
250/// is strict for the same reason — `NOTEDTHAT_S3_FORCE_PATH_STYLE=yes` used to become
251/// `false`, and the `SeaweedFS` or `MinIO` deployment it was set for then failed with DNS
252/// errors naming nothing.
253fn parse_storage_backend(supplied: Option<&OsStr>) -> Result<Option<StorageBackendKind>, Error> {
254    let Some(value) = supplied else {
255        return Ok(None);
256    };
257    let name = setting("NOTEDTHAT_STORAGE_BACKEND");
258    let value = value.to_str().ok_or_else(|| Error::Config {
259        message: format!("{name} must be valid UTF-8"),
260    })?;
261    if value.is_empty() {
262        return Err(Error::Config {
263            message: format!("{name} must not be empty"),
264        });
265    }
266    match value {
267        "s3" => Ok(Some(StorageBackendKind::S3)),
268        "fs" => Ok(Some(StorageBackendKind::Fs)),
269        other => Err(Error::Config {
270            message: format!("{name} is invalid: expected \"s3\" or \"fs\", got \"{other}\""),
271        }),
272    }
273}
274
275/// Parse the events backend selector, returning `None` when it was not supplied.
276///
277/// Strict for the same reason as [`parse_storage_backend`]: a mis-spelled selector
278/// that fell back to `none` would start cleanly and simply never announce anything.
279fn parse_events_backend(supplied: Option<&OsStr>) -> Result<Option<EventsBackendKind>, Error> {
280    let Some(value) = supplied else {
281        return Ok(None);
282    };
283    let name = setting("NOTEDTHAT_EVENTS_BACKEND");
284    let value = value.to_str().ok_or_else(|| Error::Config {
285        message: format!("{name} must be valid UTF-8"),
286    })?;
287    if value.is_empty() {
288        return Err(Error::Config {
289            message: format!("{name} must not be empty"),
290        });
291    }
292    match value {
293        "none" => Ok(Some(EventsBackendKind::None)),
294        "memory" => Ok(Some(EventsBackendKind::Memory)),
295        "nats" => Ok(Some(EventsBackendKind::Nats)),
296        other => Err(Error::Config {
297            message: format!(
298                "{name} is invalid: expected \"none\", \"memory\" or \"nats\", got \"{other}\""
299            ),
300        }),
301    }
302}
303
304/// Parse `NOTEDTHAT_MCP_ANONYMOUS`.
305///
306/// An empty or blank value is the default, as it is for the sibling
307/// `NOTEDTHAT_MCP_HTTP_*` settings: Compose passes every MCP variable through as `${VAR-}`,
308/// so an operator who never set it hands the server an empty string. Anything else that is
309/// not a mode is refused, as the backend selectors are — the wrong spelling of `never` must
310/// not quietly become `auto`.
311fn parse_mcp_anonymous(supplied: Option<&str>) -> Result<McpAnonymous, Error> {
312    let value = supplied.map(str::trim).unwrap_or_default();
313    if value.is_empty() {
314        return Ok(McpAnonymous::default());
315    }
316    match value.to_ascii_lowercase().as_str() {
317        "auto" => Ok(McpAnonymous::Auto),
318        "never" => Ok(McpAnonymous::Never),
319        other => Err(Error::Config {
320            message: format!(
321                "{} is invalid: expected \"auto\" or \"never\", got \"{other}\"",
322                setting("NOTEDTHAT_MCP_ANONYMOUS")
323            ),
324        }),
325    }
326}
327
328/// Refuse to start when settings belonging to an unselected backend are supplied.
329///
330/// Reports every offender at once: the realistic case is a whole `NOTEDTHAT_S3_*` family
331/// left behind by an operator switching to `fs`, and naming one per restart would take
332/// five restarts.
333///
334/// The check runs when the selector is unset too, and says so. That is the highest-value
335/// case: an operator who sets `NOTEDTHAT_FS_ROOT` and forgets the selector would
336/// otherwise get a perfectly healthy S3 deployment with an unread root.
337///
338/// Generic over the selector so the storage and events backends share one message
339/// shape. Offenders are grouped by the backend that owns them, since with three
340/// events backends the unselected ones are not a single "other".
341fn reject_unselected_settings<K: Copy + Eq + Ord + std::fmt::Display>(
342    selector: &'static str,
343    selected: Option<K>,
344    default: K,
345    table: Vec<(&'static str, K, bool)>,
346) -> Result<(), Error> {
347    let effective = selected.unwrap_or(default);
348    let mut by_owner: BTreeMap<K, Vec<String>> = BTreeMap::new();
349    for (name, owner, supplied) in table {
350        if supplied && owner != effective {
351            by_owner.entry(owner).or_default().push(setting(name));
352        }
353    }
354
355    if by_owner.is_empty() {
356        return Ok(());
357    }
358
359    let selection = match selected {
360        Some(kind) => format!("{} is {kind}", setting(selector)),
361        None => format!(
362            "{} is unset, so the default {default} backend is selected",
363            setting(selector)
364        ),
365    };
366    let complaints: Vec<String> = by_owner
367        .iter()
368        .map(|(owner, names)| {
369            format!(
370                "these settings belong to the {owner} backend and would be ignored: {}",
371                names.join(", ")
372            )
373        })
374        .collect();
375    let fixes: Vec<String> = by_owner
376        .keys()
377        .map(|owner| format!("{selector}={owner}"))
378        .collect();
379    Err(Error::Config {
380        message: format!(
381            "{selection}, but {}. Unset them or set {} to start the server.",
382            complaints.join("; "),
383            fixes.join(" or ")
384        ),
385    })
386}
387
388/// An S3 storage config pointed at an unroutable address.
389///
390/// For tests that inject their own [`crate::run::Backends`] and never build a client
391/// from it. A regression that *does* reach for it fails loudly rather than quietly
392/// talking to something real.
393#[cfg(any(test, feature = "test-support"))]
394#[must_use]
395pub fn unroutable_storage_placeholder() -> StorageConfig {
396    StorageConfig::S3(notedthat_storage_s3::S3Config {
397        endpoint_url: Some("http://127.0.0.1:1".to_string()),
398        region: "us-east-1".to_string(),
399        access_key_id: "any".to_string(),
400        secret_access_key: "any".to_string(),
401        force_path_style: true,
402        reconcile_on_startup: true,
403    })
404}
405
406/// Server-wide configuration.
407#[derive(Debug, Clone)]
408pub struct Config {
409    /// Static Bearer token for API authentication (`NOTEDTHAT_API_TOKEN`).
410    pub api_token: String,
411    /// Declared knowledge bases, as a sorted map of slug string → [`KbSlug`].
412    pub kbs: BTreeMap<String, KbSlug>,
413    /// Tenant slug — hardcoded to `"default"` per Metis directive.
414    pub tenant_slug: TenantSlug,
415    /// Socket address the HTTP server binds to (`NOTEDTHAT_LISTEN_ADDR`; default `0.0.0.0:8080`).
416    pub listen_addr: SocketAddr,
417    /// The selected storage backend and its configuration
418    /// (`NOTEDTHAT_STORAGE_BACKEND`; default `s3`).
419    pub storage: StorageConfig,
420    /// The selected object change event log and its configuration
421    /// (`NOTEDTHAT_EVENTS_BACKEND`; default `none`).
422    pub events: EventsConfig,
423    /// Log output format (`NOTEDTHAT_LOG_FORMAT`; `pretty` or `json`).
424    pub log_format: LogFormat,
425    /// Qdrant client configuration.
426    pub qdrant: ServerQdrantConfig,
427    /// Embedder configuration.
428    pub embedder: EmbedderConfig,
429    /// `WebDAV` Basic authentication username (`NOTEDTHAT_WEBDAV_USERNAME`; required).
430    pub webdav_username: String,
431    /// `WebDAV` Basic authentication password (`NOTEDTHAT_WEBDAV_PASSWORD`; required).
432    pub webdav_password: String,
433    /// Allowed origins for MCP HTTP CORS (`NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS`; empty → `["null"]`).
434    pub mcp_http_allowed_origins: Vec<String>,
435    /// Allowed hosts for MCP HTTP Host header validation (`NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS`; empty → `["127.0.0.1", "localhost", "::1"]`).
436    pub mcp_http_allowed_hosts: Vec<String>,
437    /// Whether `/mcp` admits anonymous callers (`NOTEDTHAT_MCP_ANONYMOUS`; default `auto`).
438    pub mcp_anonymous: McpAnonymous,
439    /// Maximum patchable object size in bytes (`NOTEDTHAT_MAX_PATCHABLE_SIZE`; default 100 MiB).
440    pub max_patchable_size: u64,
441    /// Most bytes one MCP object read may fetch (`NOTEDTHAT_MCP_MAX_READ_BYTES`; default 16 MiB,
442    /// the API body cap). Larger objects are read in slices.
443    pub mcp_max_read_bytes: u64,
444    /// How often `/readyz`'s poller probes the storage backend and Qdrant, in
445    /// milliseconds; also each probe's deadline (`NOTEDTHAT_READY_PROBE_INTERVAL_MS`;
446    /// default 5000).
447    pub ready_probe_interval_ms: u64,
448    /// Shared private staging directory for uploads and index snapshots (`NOTEDTHAT_UPLOAD_TMP_DIR`).
449    pub staging: StagingConfig,
450    /// Identity-provider settings (`NOTEDTHAT_OIDC_*`); `None` when no issuer is set.
451    pub oidc: Option<OidcSettings>,
452}
453
454/// Settings removed when the API, `WebDAV`, and MCP surfaces moved onto one
455/// listener, each paired with the setup that replaces it.
456///
457/// Leaving one of these set is a silent exposure change on upgrade — a
458/// `WebDAV` listener that was bound to loopback becomes reachable at `/webdav` on the
459/// public listener, and `NOTEDTHAT_MCP_HTTP_ENABLED=false` no longer disables
460/// `/mcp`. Per D39 the server refuses to start instead, naming the replacement.
461///
462/// [`ServerCli`] still accepts each one as a hidden flag for the same reason it is
463/// checked here: an operator who reaches for the removed setting deserves the
464/// replacement, not "unexpected argument".
465const REMOVED_LISTENER_ENV_VARS: [(&str, &str); 3] = [
466    (
467        "NOTEDTHAT_WEBDAV_LISTEN_ADDR",
468        "WebDAV is always served at /webdav on NOTEDTHAT_LISTEN_ADDR",
469    ),
470    (
471        "NOTEDTHAT_MCP_HTTP_BIND",
472        "MCP HTTP is always served at /mcp on NOTEDTHAT_LISTEN_ADDR",
473    ),
474    (
475        "NOTEDTHAT_MCP_HTTP_ENABLED",
476        "MCP HTTP is always served at /mcp on NOTEDTHAT_LISTEN_ADDR",
477    ),
478];
479
480/// Tracing output format.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum LogFormat {
483    /// Human-readable multi-line output (default).
484    Pretty,
485    /// Machine-readable JSON (one line per event).
486    Json,
487}
488
489impl Config {
490    /// Parse configuration from the environment alone.
491    ///
492    /// Equivalent to [`Config::from_cli`] over an empty `argv`, which is what a
493    /// container that passes no arguments gets.
494    ///
495    /// # Errors
496    ///
497    /// As [`Config::from_cli`], plus `Err(Error::Config { .. })` if a variable holds a
498    /// value the parser cannot accept at all.
499    pub fn from_env() -> Result<Self, Error> {
500        let cli = ServerCli::from_env().map_err(|error| Error::Config {
501            message: error.to_string(),
502        })?;
503        Self::from_cli(cli)
504    }
505
506    /// Validate the settings this run supplied, from either source.
507    ///
508    /// # Errors
509    ///
510    /// Returns `Err(Error::Config { .. })` if any required setting is missing,
511    /// if any value is invalid (empty token, bad slug, duplicate slug, etc.), or
512    /// if any [`REMOVED_LISTENER_ENV_VARS`] entry was supplied.
513    #[allow(clippy::too_many_lines)]
514    pub fn from_cli(mut cli: ServerCli) -> Result<Self, Error> {
515        let removed = [
516            cli.webdav_listen_addr.is_some(),
517            cli.mcp_http_bind.is_some(),
518            cli.mcp_http_enabled.is_some(),
519        ];
520        for ((key, replacement), supplied) in REMOVED_LISTENER_ENV_VARS.iter().zip(removed) {
521            if supplied {
522                return Err(Error::Config {
523                    message: format!(
524                        "{key} was removed: {replacement}. Unset {key} to start the server."
525                    ),
526                });
527            }
528        }
529
530        // Borrows the whole CLI, so it runs before the field-by-field moves below.
531        let oidc = parse_oidc(&cli)?;
532
533        let api_token = cli.api_token.take().ok_or_else(|| Error::Config {
534            message: format!("{} is required", setting("NOTEDTHAT_API_TOKEN")),
535        })?;
536        if api_token.trim().is_empty() {
537            return Err(Error::Config {
538                message: format!("{} must not be empty", setting("NOTEDTHAT_API_TOKEN")),
539            });
540        }
541
542        let kbs_raw = cli.kbs.take().ok_or_else(|| Error::Config {
543            message: format!("{} is required", setting("NOTEDTHAT_KBS")),
544        })?;
545        if kbs_raw.trim().is_empty() {
546            return Err(Error::Config {
547                message: format!(
548                    "{} must declare at least one knowledge base",
549                    setting("NOTEDTHAT_KBS")
550                ),
551            });
552        }
553
554        let mut kbs = BTreeMap::new();
555        for token in kbs_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
556            let slug = KbSlug::try_new(token).map_err(|e| Error::Config {
557                message: format!("invalid KB slug {token:?}: {e}"),
558            })?;
559            if kbs.insert(slug.as_str().to_string(), slug).is_some() {
560                return Err(Error::Config {
561                    message: format!(
562                        "duplicate KB slug in {}: {token:?}",
563                        setting("NOTEDTHAT_KBS")
564                    ),
565                });
566            }
567        }
568        if kbs.is_empty() {
569            return Err(Error::Config {
570                message: format!(
571                    "{} must declare at least one knowledge base",
572                    setting("NOTEDTHAT_KBS")
573                ),
574            });
575        }
576
577        // Tenant slug is hardcoded to "default" per Metis directive.
578        // NOTEDTHAT_TENANT_SLUG intentionally not read.
579        let tenant_slug = TenantSlug::default();
580
581        // Taken, not moved: the backend-rejection check below needs the whole
582        // `cli` by reference, and reordering the two would change which error an
583        // operator sees when both are wrong.
584        let listen_addr_str = cli
585            .listen_addr
586            .take()
587            .unwrap_or_else(|| "0.0.0.0:8080".to_string());
588        let listen_addr: SocketAddr = listen_addr_str.parse().map_err(|e| Error::Config {
589            message: format!("{} is invalid: {e}", setting("NOTEDTHAT_LISTEN_ADDR")),
590        })?;
591
592        let selected = parse_storage_backend(cli.storage_backend.as_deref())?;
593        reject_unselected_settings(
594            "NOTEDTHAT_STORAGE_BACKEND",
595            selected,
596            StorageBackendKind::S3,
597            backend_owned_settings(&cli),
598        )?;
599        let selected_events = parse_events_backend(cli.events_backend.as_deref())?;
600        reject_unselected_settings(
601            "NOTEDTHAT_EVENTS_BACKEND",
602            selected_events,
603            EventsBackendKind::None,
604            events_owned_settings(&cli),
605        )?;
606        let storage = match selected.unwrap_or(StorageBackendKind::S3) {
607            StorageBackendKind::S3 => {
608                StorageConfig::S3(notedthat_storage_s3::S3Config::from_settings(S3Settings {
609                    region: cli.s3_region,
610                    access_key_id: cli.s3_access_key_id,
611                    secret_access_key: cli.s3_secret_access_key,
612                    endpoint_url: cli.s3_endpoint_url,
613                    force_path_style: cli.s3_force_path_style,
614                    reconcile: cli.s3_reconcile,
615                })?)
616            }
617            StorageBackendKind::Fs => {
618                StorageConfig::Fs(notedthat_storage_fs::FsConfig::from_settings(FsSettings {
619                    root: cli.fs_root,
620                    metadata: cli.fs_metadata,
621                    file_mode: cli.fs_file_mode,
622                    dir_mode: cli.fs_dir_mode,
623                    allow_lossy_names: cli.fs_allow_lossy_names,
624                    watch: cli.fs_watch,
625                    watch_debounce_ms: cli.fs_watch_debounce_ms,
626                })?)
627            }
628        };
629
630        let events = match selected_events.unwrap_or(EventsBackendKind::None) {
631            EventsBackendKind::None => EventsConfig::None,
632            EventsBackendKind::Memory => {
633                EventsConfig::Memory(MemoryConfig::from_settings(&MemorySettings {
634                    capacity: cli.events_memory_capacity,
635                })?)
636            }
637            EventsBackendKind::Nats => {
638                EventsConfig::Nats(NatsConfig::from_settings(NatsSettings {
639                    url: cli.nats_url,
640                    stream: cli.nats_stream,
641                    max_age_secs: cli.nats_max_age_secs,
642                })?)
643            }
644        };
645
646        let log_format = match cli.log_format.as_deref() {
647            Some("json") => LogFormat::Json,
648            _ => LogFormat::Pretty,
649        };
650
651        let qdrant = ServerQdrantConfig::from_parts(
652            cli.qdrant_url,
653            cli.qdrant_api_key,
654            cli.qdrant_timeout_ms.as_deref(),
655            cli.qdrant_connect_timeout_ms.as_deref(),
656        )?;
657        let embedder = EmbedderConfig::from_parts(EmbedderParts {
658            endpoint_url: cli.embedding_endpoint_url,
659            model: cli.embedding_model,
660            api_key: cli.embedding_api_key,
661            dimensions: cli.embedding_dimensions,
662            batch_size: cli.embedding_batch_size,
663            timeout_ms: cli.embedding_timeout_ms,
664            max_retries: cli.embedding_max_retries,
665            max_input_tokens: cli.embedding_max_input_tokens,
666        })?;
667
668        let webdav_username = cli.webdav_username.ok_or_else(|| Error::Config {
669            message: format!("{} is required", setting("NOTEDTHAT_WEBDAV_USERNAME")),
670        })?;
671        if webdav_username.is_empty() {
672            return Err(Error::Config {
673                message: format!(
674                    "{} is required and must not be empty",
675                    setting("NOTEDTHAT_WEBDAV_USERNAME")
676                ),
677            });
678        }
679
680        let webdav_password = cli.webdav_password.ok_or_else(|| Error::Config {
681            message: format!("{} is required", setting("NOTEDTHAT_WEBDAV_PASSWORD")),
682        })?;
683        if webdav_password.is_empty() {
684            return Err(Error::Config {
685                message: format!(
686                    "{} is required and must not be empty",
687                    setting("NOTEDTHAT_WEBDAV_PASSWORD")
688                ),
689            });
690        }
691
692        let mcp_http_allowed_origins =
693            comma_list(cli.mcp_http_allowed_origins.as_deref(), &["null"]);
694        let mcp_http_allowed_hosts = comma_list(
695            cli.mcp_http_allowed_hosts.as_deref(),
696            &["127.0.0.1", "localhost", "::1"],
697        );
698        let mcp_anonymous = parse_mcp_anonymous(cli.mcp_anonymous.as_deref())?;
699
700        let max_patchable_size = cli
701            .max_patchable_size
702            .unwrap_or_else(|| (100 * 1024 * 1024u64).to_string())
703            .parse::<u64>()
704            .map_err(|_e: std::num::ParseIntError| Error::Config {
705                message: format!(
706                    "{} must be a valid u64 integer",
707                    setting("NOTEDTHAT_MAX_PATCHABLE_SIZE")
708                ),
709            })?;
710        if max_patchable_size == 0 {
711            return Err(Error::Config {
712                message: format!("{} must be > 0", setting("NOTEDTHAT_MAX_PATCHABLE_SIZE")),
713            });
714        }
715        if max_patchable_size > MAX_UPLOAD_BYTES {
716            return Err(Error::Config {
717                message: format!(
718                    "{} must not exceed MAX_UPLOAD_BYTES (5 GiB)",
719                    setting("NOTEDTHAT_MAX_PATCHABLE_SIZE")
720                ),
721            });
722        }
723
724        // Empty or blank is the default, as for every sibling NOTEDTHAT_MCP_*
725        // setting: Compose passes them through as `${VAR-}`, so a deployment
726        // that never set this hands the server an empty string.
727        let mcp_max_read_bytes = match cli
728            .mcp_max_read_bytes
729            .as_deref()
730            .map(str::trim)
731            .filter(|value| !value.is_empty())
732        {
733            None => notedthat_mcp::DEFAULT_MAX_READ_BYTES,
734            Some(value) => value.parse::<u64>().map_err(|_e| Error::Config {
735                message: format!(
736                    "{} must be a valid u64 integer",
737                    setting("NOTEDTHAT_MCP_MAX_READ_BYTES")
738                ),
739            })?,
740        };
741        if mcp_max_read_bytes == 0 {
742            return Err(Error::Config {
743                message: format!("{} must be > 0", setting("NOTEDTHAT_MCP_MAX_READ_BYTES")),
744            });
745        }
746        let ready_probe_interval_ms = parse_millis(
747            "NOTEDTHAT_READY_PROBE_INTERVAL_MS",
748            cli.ready_probe_interval_ms.as_deref(),
749            5_000,
750        )?;
751
752        let staging =
753            StagingConfig::from_setting(cli.upload_tmp_dir).map_err(|error| Error::Config {
754                message: error.to_string(),
755            })?;
756
757        Ok(Self {
758            api_token,
759            kbs,
760            tenant_slug,
761            listen_addr,
762            storage,
763            events,
764            log_format,
765            qdrant,
766            embedder,
767            webdav_username,
768            webdav_password,
769            mcp_http_allowed_origins,
770            mcp_http_allowed_hosts,
771            mcp_anonymous,
772            max_patchable_size,
773            mcp_max_read_bytes,
774            ready_probe_interval_ms,
775            staging,
776            oidc,
777        })
778    }
779}
780
781/// The `NOTEDTHAT_OIDC_*` settings that only mean something once an issuer is set.
782fn oidc_dependent_settings(cli: &ServerCli) -> [(&'static str, bool); 6] {
783    [
784        ("NOTEDTHAT_OIDC_AUDIENCE", cli.oidc_audience.is_some()),
785        (
786            "NOTEDTHAT_OIDC_USERNAME_CLAIM",
787            cli.oidc_username_claim.is_some(),
788        ),
789        (
790            "NOTEDTHAT_OIDC_GROUPS_CLAIM",
791            cli.oidc_groups_claim.is_some(),
792        ),
793        (
794            "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS",
795            cli.oidc_http_timeout_ms.is_some(),
796        ),
797        ("NOTEDTHAT_OIDC_RESOURCE", cli.oidc_resource.is_some()),
798        ("NOTEDTHAT_OIDC_CA_CERT", cli.oidc_ca_cert.is_some()),
799    ]
800}
801
802/// Parse the identity-provider settings.
803///
804/// `NOTEDTHAT_OIDC_ISSUER` is the switch. Without it, any other `NOTEDTHAT_OIDC_*`
805/// setting is refused rather than ignored, for the same reason a setting of
806/// the unselected storage backend is: a deployment that sets an audience and
807/// no issuer believed it had configured identity tokens, and silently running
808/// without them is the wrong way to find out.
809fn parse_oidc(cli: &ServerCli) -> Result<Option<OidcSettings>, Error> {
810    let Some(issuer) = cli.oidc_issuer.as_deref().map(str::trim) else {
811        let offenders: Vec<String> = oidc_dependent_settings(cli)
812            .into_iter()
813            .filter(|(_, supplied)| *supplied)
814            .map(|(name, _)| setting(name))
815            .collect();
816        if offenders.is_empty() {
817            return Ok(None);
818        }
819        return Err(Error::Config {
820            message: format!(
821                "{} is unset, so identity tokens are not accepted, but {} {} set; set the \
822                 issuer or unset {}",
823                setting("NOTEDTHAT_OIDC_ISSUER"),
824                offenders.join(", "),
825                if offenders.len() == 1 { "is" } else { "are" },
826                if offenders.len() == 1 { "it" } else { "them" },
827            ),
828        });
829    };
830
831    let issuer_url = absolute_http_url("NOTEDTHAT_OIDC_ISSUER", issuer)?;
832    let audiences = comma_list(cli.oidc_audience.as_deref(), &[]);
833    if audiences.is_empty() {
834        return Err(Error::Config {
835            message: format!(
836                "{} is required when {} is set: name the audience the provider puts in \
837                 its tokens, usually the client id",
838                setting("NOTEDTHAT_OIDC_AUDIENCE"),
839                setting("NOTEDTHAT_OIDC_ISSUER"),
840            ),
841        });
842    }
843    let claim = |var: &str, supplied: Option<&str>, default: &str| -> Result<String, Error> {
844        match supplied.map(str::trim) {
845            None => Ok(default.to_string()),
846            Some("") => Err(Error::Config {
847                message: format!("{} must not be empty", setting(var)),
848            }),
849            Some(name) => Ok(name.to_string()),
850        }
851    };
852    let username_claim = claim(
853        "NOTEDTHAT_OIDC_USERNAME_CLAIM",
854        cli.oidc_username_claim.as_deref(),
855        OidcSettings::DEFAULT_USERNAME_CLAIM,
856    )?;
857    let groups_claim = claim(
858        "NOTEDTHAT_OIDC_GROUPS_CLAIM",
859        cli.oidc_groups_claim.as_deref(),
860        OidcSettings::DEFAULT_GROUPS_CLAIM,
861    )?;
862    let http_timeout = Duration::from_millis(parse_millis(
863        "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS",
864        cli.oidc_http_timeout_ms.as_deref(),
865        OidcSettings::DEFAULT_HTTP_TIMEOUT_MS,
866    )?);
867    let resource = cli
868        .oidc_resource
869        .as_deref()
870        .map(str::trim)
871        .map(|resource| absolute_http_url("NOTEDTHAT_OIDC_RESOURCE", resource))
872        .transpose()?
873        .map(|url| url.to_string().trim_end_matches('/').to_string());
874
875    let ca_cert = match cli.oidc_ca_cert.as_deref() {
876        None => None,
877        Some(path) if path.is_empty() => {
878            return Err(Error::Config {
879                message: format!("{} must not be empty", setting("NOTEDTHAT_OIDC_CA_CERT")),
880            });
881        }
882        Some(path) => {
883            let path = std::path::PathBuf::from(path);
884            if !path.is_file() {
885                return Err(Error::Config {
886                    message: format!(
887                        "{} is not a readable file: {}",
888                        setting("NOTEDTHAT_OIDC_CA_CERT"),
889                        path.display()
890                    ),
891                });
892            }
893            Some(path)
894        }
895    };
896
897    Ok(Some(OidcSettings {
898        issuer: issuer_url.to_string(),
899        audiences,
900        username_claim,
901        groups_claim,
902        http_timeout,
903        resource,
904        ca_cert,
905    }))
906}
907
908/// Parse an `http(s)` URL setting, keeping the operator's spelling.
909///
910/// Returns the parsed URL only to prove it parses; the `Display` of a parsed
911/// URL can differ from the input (a bare origin gains a trailing slash), and
912/// the issuer has to be compared byte-for-byte with the provider's `iss`.
913fn absolute_http_url(var: &str, raw: &str) -> Result<UrlSpelling, Error> {
914    let parsed = url::Url::parse(raw).map_err(|error| Error::Config {
915        message: format!("{} is not an absolute URL: {error}", setting(var)),
916    })?;
917    if !matches!(parsed.scheme(), "http" | "https") {
918        return Err(Error::Config {
919            message: format!("{} must use http or https", setting(var)),
920        });
921    }
922    Ok(UrlSpelling(raw.to_string()))
923}
924
925/// A URL that parsed, kept in the operator's own spelling.
926struct UrlSpelling(String);
927
928impl std::fmt::Display for UrlSpelling {
929    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
930        f.write_str(&self.0)
931    }
932}
933
934/// Split a comma-separated allowlist, falling back to `default` when nothing usable
935/// was supplied.
936///
937/// An empty or whitespace-only value means "not configured" rather than "allow
938/// nothing", because both defaults here are the safe, loopback-only ones.
939fn comma_list(supplied: Option<&str>, default: &[&str]) -> Vec<String> {
940    match supplied {
941        Some(s) if !s.trim().is_empty() => s
942            .split(',')
943            .map(|v| v.trim().to_string())
944            .filter(|v| !v.is_empty())
945            .collect(),
946        _ => default.iter().map(|v| (*v).to_string()).collect(),
947    }
948}
949
950/// Qdrant client configuration.
951#[derive(Debug, Clone)]
952pub struct ServerQdrantConfig {
953    /// Qdrant gRPC/HTTP endpoint (`NOTEDTHAT_QDRANT_URL`; required).
954    pub url: String,
955    /// Optional Qdrant API key (`NOTEDTHAT_QDRANT_API_KEY`).
956    pub api_key: Option<String>,
957    /// Per-RPC timeout in milliseconds (`NOTEDTHAT_QDRANT_TIMEOUT_MS`; default 30 000).
958    ///
959    /// `qdrant-client`'s own default is 5 s, which is too tight for a full
960    /// embedding batch upserted with `wait(true)`.
961    pub timeout_ms: u64,
962    /// Connection-establishment timeout in milliseconds
963    /// (`NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS`; default 10 000).
964    pub connect_timeout_ms: u64,
965}
966
967impl ServerQdrantConfig {
968    /// Validate the Qdrant settings this run supplied.
969    ///
970    /// # Errors
971    ///
972    /// Returns `Err(Error::Config { .. })` if the URL is missing or a timeout is
973    /// not a positive integer.
974    fn from_parts(
975        url: Option<String>,
976        api_key: Option<String>,
977        timeout_ms: Option<&str>,
978        connect_timeout_ms: Option<&str>,
979    ) -> Result<Self, Error> {
980        let url = url.ok_or_else(|| Error::Config {
981            message: format!("{} is required", setting("NOTEDTHAT_QDRANT_URL")),
982        })?;
983        Ok(Self {
984            url,
985            api_key,
986            timeout_ms: parse_millis("NOTEDTHAT_QDRANT_TIMEOUT_MS", timeout_ms, 30_000)?,
987            connect_timeout_ms: parse_millis(
988                "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS",
989                connect_timeout_ms,
990                10_000,
991            )?,
992        })
993    }
994}
995
996/// Parse a millisecond duration, rejecting zero.
997fn parse_millis(var: &str, supplied: Option<&str>, default: u64) -> Result<u64, Error> {
998    let Some(raw) = supplied else {
999        return Ok(default);
1000    };
1001    let value = raw.parse::<u64>().map_err(|_| Error::Config {
1002        message: format!("{} must be a valid u64 integer", setting(var)),
1003    })?;
1004    if value == 0 {
1005        return Err(Error::Config {
1006            message: format!("{} must be > 0", setting(var)),
1007        });
1008    }
1009    Ok(value)
1010}
1011
1012/// The raw embedder settings, before validation.
1013///
1014/// A struct rather than eight positional arguments, because eight `Option<String>`
1015/// parameters in a row is a call site nothing can typecheck.
1016struct EmbedderParts {
1017    endpoint_url: Option<String>,
1018    model: Option<String>,
1019    api_key: Option<String>,
1020    dimensions: Option<String>,
1021    batch_size: Option<String>,
1022    timeout_ms: Option<String>,
1023    max_retries: Option<String>,
1024    max_input_tokens: Option<String>,
1025}
1026
1027/// Embedder configuration.
1028#[derive(Debug, Clone)]
1029pub struct EmbedderConfig {
1030    /// OpenAI-compatible embedding endpoint URL (`EMBEDDING_ENDPOINT_URL`; required).
1031    pub endpoint_url: String,
1032    /// Embedding model name (`EMBEDDING_MODEL`; required).
1033    pub model: String,
1034    /// API key for the embedding endpoint (`EMBEDDING_API_KEY`; required).
1035    pub api_key: String,
1036    /// Output vector dimensions (`EMBEDDING_DIMENSIONS`; required).
1037    pub dimensions: u32,
1038    /// Number of texts per embedding batch (`EMBEDDING_BATCH_SIZE`; default `32`).
1039    pub batch_size: usize,
1040    /// HTTP request timeout in milliseconds (`EMBEDDING_TIMEOUT_MS`; default `30000`).
1041    pub timeout_ms: u64,
1042    /// Maximum number of retries on transient failures (`EMBEDDING_MAX_RETRIES`; default `3`).
1043    pub max_retries: u32,
1044    /// Maximum tokens per input text (`EMBEDDING_MAX_INPUT_TOKENS`; default `8192`).
1045    pub max_input_tokens: usize,
1046}
1047
1048impl EmbedderConfig {
1049    /// Validate the embedder settings this run supplied.
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns `Err(Error::Config { .. })` if any required setting is missing or invalid.
1054    fn from_parts(parts: EmbedderParts) -> Result<Self, Error> {
1055        let endpoint_url = parts.endpoint_url.ok_or_else(|| Error::Config {
1056            message: format!("{} is required", setting("EMBEDDING_ENDPOINT_URL")),
1057        })?;
1058        let model = parts.model.ok_or_else(|| Error::Config {
1059            message: format!("{} is required", setting("EMBEDDING_MODEL")),
1060        })?;
1061        let api_key = parts.api_key.ok_or_else(|| Error::Config {
1062            message: format!("{} is required", setting("EMBEDDING_API_KEY")),
1063        })?;
1064        let dimensions = parse_number("EMBEDDING_DIMENSIONS", parts.dimensions.as_deref())?
1065            .ok_or_else(|| Error::Config {
1066                message: format!("{} is required", setting("EMBEDDING_DIMENSIONS")),
1067            })?;
1068        Ok(Self {
1069            endpoint_url,
1070            model,
1071            api_key,
1072            dimensions,
1073            batch_size: parse_number("EMBEDDING_BATCH_SIZE", parts.batch_size.as_deref())?
1074                .unwrap_or(32),
1075            timeout_ms: parse_number("EMBEDDING_TIMEOUT_MS", parts.timeout_ms.as_deref())?
1076                .unwrap_or(30_000),
1077            max_retries: parse_number("EMBEDDING_MAX_RETRIES", parts.max_retries.as_deref())?
1078                .unwrap_or(3),
1079            max_input_tokens: parse_number(
1080                "EMBEDDING_MAX_INPUT_TOKENS",
1081                parts.max_input_tokens.as_deref(),
1082            )?
1083            .unwrap_or(8192),
1084        })
1085    }
1086}
1087
1088/// Parse an optional integer setting, naming it on failure.
1089fn parse_number<T>(var: &str, supplied: Option<&str>) -> Result<Option<T>, Error>
1090where
1091    T: std::str::FromStr<Err = std::num::ParseIntError>,
1092{
1093    supplied
1094        .map(|raw| {
1095            raw.parse::<T>().map_err(|e| Error::Config {
1096                message: format!("{} is invalid: {e}", setting(var)),
1097            })
1098        })
1099        .transpose()
1100}
1101
1102#[cfg(test)]
1103pub(crate) mod tests {
1104    use super::*;
1105
1106    pub(crate) const ALL_ENV_KEYS: [&str; 54] = [
1107        "NOTEDTHAT_API_TOKEN",
1108        "NOTEDTHAT_KBS",
1109        "NOTEDTHAT_STORAGE_BACKEND",
1110        "NOTEDTHAT_FS_ROOT",
1111        "NOTEDTHAT_FS_METADATA",
1112        "NOTEDTHAT_FS_FILE_MODE",
1113        "NOTEDTHAT_FS_DIR_MODE",
1114        "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
1115        "NOTEDTHAT_FS_WATCH",
1116        "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS",
1117        "NOTEDTHAT_S3_REGION",
1118        "NOTEDTHAT_S3_ACCESS_KEY_ID",
1119        "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
1120        "NOTEDTHAT_LISTEN_ADDR",
1121        "NOTEDTHAT_LOG_FORMAT",
1122        "NOTEDTHAT_S3_ENDPOINT_URL",
1123        "NOTEDTHAT_S3_FORCE_PATH_STYLE",
1124        "NOTEDTHAT_S3_RECONCILE",
1125        "NOTEDTHAT_EVENTS_BACKEND",
1126        "NOTEDTHAT_EVENTS_MEMORY_CAPACITY",
1127        "NOTEDTHAT_NATS_URL",
1128        "NOTEDTHAT_NATS_STREAM",
1129        "NOTEDTHAT_NATS_MAX_AGE_SECS",
1130        "NOTEDTHAT_QDRANT_URL",
1131        "NOTEDTHAT_QDRANT_API_KEY",
1132        "NOTEDTHAT_QDRANT_TIMEOUT_MS",
1133        "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS",
1134        "NOTEDTHAT_WEBDAV_USERNAME",
1135        "NOTEDTHAT_WEBDAV_PASSWORD",
1136        "NOTEDTHAT_WEBDAV_LISTEN_ADDR",
1137        "NOTEDTHAT_MCP_HTTP_BIND",
1138        "NOTEDTHAT_MCP_HTTP_ENABLED",
1139        "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1140        "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
1141        "NOTEDTHAT_MCP_ANONYMOUS",
1142        "NOTEDTHAT_MCP_MAX_READ_BYTES",
1143        "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1144        "NOTEDTHAT_READY_PROBE_INTERVAL_MS",
1145        "NOTEDTHAT_UPLOAD_TMP_DIR",
1146        "NOTEDTHAT_OIDC_ISSUER",
1147        "NOTEDTHAT_OIDC_AUDIENCE",
1148        "NOTEDTHAT_OIDC_USERNAME_CLAIM",
1149        "NOTEDTHAT_OIDC_GROUPS_CLAIM",
1150        "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS",
1151        "NOTEDTHAT_OIDC_RESOURCE",
1152        "NOTEDTHAT_OIDC_CA_CERT",
1153        "EMBEDDING_ENDPOINT_URL",
1154        "EMBEDDING_MODEL",
1155        "EMBEDDING_API_KEY",
1156        "EMBEDDING_DIMENSIONS",
1157        "EMBEDDING_BATCH_SIZE",
1158        "EMBEDDING_TIMEOUT_MS",
1159        "EMBEDDING_MAX_RETRIES",
1160        "EMBEDDING_MAX_INPUT_TOKENS",
1161    ];
1162
1163    /// A configuration diagnostic has to be actionable from either direction, so
1164    /// it names the environment variable, the flag that overrides it, and what is
1165    /// wrong. Asserting on all three at once keeps the check readable while making
1166    /// it stricter than a single `contains`.
1167    fn names_setting(message: &str, env_var: &str, complaint: &str) -> bool {
1168        message.contains(env_var)
1169            && message.contains(&notedthat_core::flag_for(env_var))
1170            && message.contains(complaint)
1171    }
1172
1173    fn run_with_env<F: FnOnce() -> R, R>(overrides: &[(&str, Option<&str>)], f: F) -> R {
1174        let mut vars: Vec<(&str, Option<&str>)> = vec![
1175            ("NOTEDTHAT_API_TOKEN", Some("test-token")),
1176            ("NOTEDTHAT_KBS", Some("notes,docs")),
1177            ("NOTEDTHAT_STORAGE_BACKEND", None),
1178            ("NOTEDTHAT_FS_ROOT", None),
1179            ("NOTEDTHAT_FS_METADATA", None),
1180            ("NOTEDTHAT_FS_FILE_MODE", None),
1181            ("NOTEDTHAT_FS_DIR_MODE", None),
1182            ("NOTEDTHAT_FS_ALLOW_LOSSY_NAMES", None),
1183            ("NOTEDTHAT_S3_REGION", Some("us-east-1")),
1184            ("NOTEDTHAT_S3_ACCESS_KEY_ID", Some("key")),
1185            ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", Some("secret")),
1186            ("NOTEDTHAT_LISTEN_ADDR", None),
1187            ("NOTEDTHAT_LOG_FORMAT", None),
1188            ("NOTEDTHAT_S3_ENDPOINT_URL", None),
1189            ("NOTEDTHAT_S3_FORCE_PATH_STYLE", None),
1190            ("NOTEDTHAT_S3_RECONCILE", None),
1191            ("NOTEDTHAT_EVENTS_BACKEND", None),
1192            ("NOTEDTHAT_EVENTS_MEMORY_CAPACITY", None),
1193            ("NOTEDTHAT_NATS_URL", None),
1194            ("NOTEDTHAT_NATS_STREAM", None),
1195            ("NOTEDTHAT_NATS_MAX_AGE_SECS", None),
1196            ("NOTEDTHAT_QDRANT_URL", Some("http://localhost:6334")),
1197            ("NOTEDTHAT_QDRANT_API_KEY", None),
1198            ("NOTEDTHAT_QDRANT_TIMEOUT_MS", None),
1199            ("NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", None),
1200            ("NOTEDTHAT_WEBDAV_USERNAME", Some("webdav-user")),
1201            ("NOTEDTHAT_WEBDAV_PASSWORD", Some("webdav-pass")),
1202            ("NOTEDTHAT_WEBDAV_LISTEN_ADDR", None),
1203            ("NOTEDTHAT_MCP_HTTP_BIND", None),
1204            ("NOTEDTHAT_MCP_HTTP_ENABLED", None),
1205            ("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", None),
1206            ("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", None),
1207            ("NOTEDTHAT_MCP_ANONYMOUS", None),
1208            ("NOTEDTHAT_MCP_MAX_READ_BYTES", None),
1209            ("NOTEDTHAT_MAX_PATCHABLE_SIZE", None),
1210            ("NOTEDTHAT_READY_PROBE_INTERVAL_MS", None),
1211            ("NOTEDTHAT_UPLOAD_TMP_DIR", None),
1212            ("NOTEDTHAT_OIDC_ISSUER", None),
1213            ("NOTEDTHAT_OIDC_AUDIENCE", None),
1214            ("NOTEDTHAT_OIDC_USERNAME_CLAIM", None),
1215            ("NOTEDTHAT_OIDC_GROUPS_CLAIM", None),
1216            ("NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", None),
1217            ("NOTEDTHAT_OIDC_RESOURCE", None),
1218            ("NOTEDTHAT_OIDC_CA_CERT", None),
1219            ("EMBEDDING_ENDPOINT_URL", Some("https://api.openai.com")),
1220            ("EMBEDDING_MODEL", Some("text-embedding-3-small")),
1221            ("EMBEDDING_API_KEY", Some("sk-test")),
1222            ("EMBEDDING_DIMENSIONS", Some("1536")),
1223            ("EMBEDDING_BATCH_SIZE", None),
1224            ("EMBEDDING_TIMEOUT_MS", None),
1225            ("EMBEDDING_MAX_RETRIES", None),
1226            ("EMBEDDING_MAX_INPUT_TOKENS", None),
1227        ];
1228
1229        for (key, value) in overrides {
1230            if let Some((_, slot)) = vars.iter_mut().find(|(existing, _)| existing == key) {
1231                *slot = *value;
1232            }
1233        }
1234
1235        temp_env::with_vars(vars, f)
1236    }
1237
1238    #[test]
1239    fn test_empty_kbs_rejected() {
1240        let result = run_with_env(&[("NOTEDTHAT_KBS", Some(""))], Config::from_env);
1241        assert!(result.is_err());
1242        assert!(
1243            result
1244                .unwrap_err()
1245                .to_string()
1246                .contains("at least one knowledge base")
1247        );
1248    }
1249
1250    #[test]
1251    fn test_duplicate_slug_rejected() {
1252        let result = run_with_env(&[("NOTEDTHAT_KBS", Some("notes,notes"))], Config::from_env);
1253        assert!(result.is_err(), "duplicate slugs should fail");
1254        let msg = result.unwrap_err().to_string();
1255        assert!(
1256            msg.contains("duplicate"),
1257            "error should mention 'duplicate'"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_no_tenant_slug_env_var() {
1263        let cfg = run_with_env(&[], Config::from_env).unwrap();
1264        assert_eq!(cfg.tenant_slug.as_str(), "default");
1265    }
1266
1267    #[test]
1268    fn test_log_format_json() {
1269        let cfg =
1270            run_with_env(&[("NOTEDTHAT_LOG_FORMAT", Some("json"))], Config::from_env).unwrap();
1271        assert_eq!(cfg.log_format, LogFormat::Json);
1272    }
1273
1274    #[test]
1275    fn test_log_format_default_pretty() {
1276        let cfg = run_with_env(&[], Config::from_env).unwrap();
1277        assert_eq!(cfg.log_format, LogFormat::Pretty);
1278    }
1279
1280    #[test]
1281    fn test_default_listen_addr() {
1282        let cfg = run_with_env(&[], Config::from_env).unwrap();
1283        assert_eq!(cfg.listen_addr.to_string(), "0.0.0.0:8080");
1284    }
1285
1286    #[test]
1287    fn test_default_staging_directory() {
1288        let cfg = run_with_env(&[], Config::from_env).unwrap();
1289        assert_eq!(cfg.staging.directory(), std::env::temp_dir());
1290    }
1291
1292    #[test]
1293    fn test_invalid_listen_addr() {
1294        let result = run_with_env(
1295            &[("NOTEDTHAT_LISTEN_ADDR", Some("not-a-socket-addr"))],
1296            Config::from_env,
1297        );
1298        assert!(result.is_err());
1299    }
1300
1301    #[test]
1302    fn test_kbs_parsed_correctly() {
1303        let cfg = run_with_env(&[], Config::from_env).unwrap();
1304        assert_eq!(cfg.kbs.len(), 2);
1305        assert!(cfg.kbs.contains_key("notes"));
1306        assert!(cfg.kbs.contains_key("docs"));
1307    }
1308
1309    #[test]
1310    fn test_missing_api_token_rejected() {
1311        let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", None)], Config::from_env);
1312        assert!(result.is_err());
1313        assert!(
1314            result
1315                .unwrap_err()
1316                .to_string()
1317                .contains("NOTEDTHAT_API_TOKEN")
1318        );
1319    }
1320
1321    #[test]
1322    fn test_missing_webdav_username_rejected() {
1323        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_USERNAME", None)], Config::from_env);
1324        assert!(result.is_err());
1325        assert!(
1326            result
1327                .unwrap_err()
1328                .to_string()
1329                .contains("NOTEDTHAT_WEBDAV_USERNAME")
1330        );
1331    }
1332
1333    #[test]
1334    fn test_empty_webdav_username_rejected() {
1335        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_USERNAME", Some(""))], Config::from_env);
1336        assert!(result.is_err());
1337        assert!(
1338            result
1339                .unwrap_err()
1340                .to_string()
1341                .contains("NOTEDTHAT_WEBDAV_USERNAME")
1342        );
1343    }
1344
1345    #[test]
1346    fn test_missing_webdav_password_rejected() {
1347        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_PASSWORD", None)], Config::from_env);
1348        assert!(result.is_err());
1349        assert!(
1350            result
1351                .unwrap_err()
1352                .to_string()
1353                .contains("NOTEDTHAT_WEBDAV_PASSWORD")
1354        );
1355    }
1356
1357    #[test]
1358    fn test_empty_webdav_password_rejected() {
1359        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_PASSWORD", Some(""))], Config::from_env);
1360        assert!(result.is_err());
1361        assert!(
1362            result
1363                .unwrap_err()
1364                .to_string()
1365                .contains("NOTEDTHAT_WEBDAV_PASSWORD")
1366        );
1367    }
1368
1369    #[test]
1370    fn removed_listener_variables_are_rejected_with_their_replacement() {
1371        for (key, replacement) in REMOVED_LISTENER_ENV_VARS {
1372            let result = run_with_env(&[(key, Some("some-stale-value"))], Config::from_env);
1373            let message = result.map_or_else(
1374                |e| e.to_string(),
1375                |_| panic!("{key} must be rejected at startup"),
1376            );
1377
1378            assert!(message.contains(key), "{key} error must name the variable");
1379            assert!(
1380                message.contains(replacement),
1381                "{key} error must name its replacement"
1382            );
1383        }
1384    }
1385
1386    #[test]
1387    fn removed_listener_variables_are_rejected_even_when_empty() {
1388        let result = run_with_env(
1389            &[("NOTEDTHAT_MCP_HTTP_ENABLED", Some(""))],
1390            Config::from_env,
1391        );
1392
1393        assert!(
1394            result.is_err(),
1395            "an empty removed variable is still an explicit operator setting"
1396        );
1397    }
1398
1399    #[test]
1400    fn unset_removed_listener_variables_leave_the_default_listener() {
1401        let config = run_with_env(&[], Config::from_env)
1402            .expect("configuration must parse when no removed variable is set");
1403
1404        assert_eq!(config.listen_addr.to_string(), "0.0.0.0:8080");
1405    }
1406
1407    #[test]
1408    fn test_webdav_credentials_propagated() {
1409        let cfg = run_with_env(
1410            &[
1411                ("NOTEDTHAT_WEBDAV_USERNAME", Some("myuser")),
1412                ("NOTEDTHAT_WEBDAV_PASSWORD", Some("mypass")),
1413            ],
1414            Config::from_env,
1415        )
1416        .unwrap();
1417        assert_eq!(cfg.webdav_username, "myuser");
1418        assert_eq!(cfg.webdav_password, "mypass");
1419    }
1420
1421    /// The inventory is what `cli::tests::every_setting_has_both_a_flag_and_a_variable`
1422    /// checks the parser against, so a setting missing from here is a setting that
1423    /// can silently lose its flag.
1424    #[test]
1425    fn all_env_keys_are_accounted_for() {
1426        assert_eq!(ALL_ENV_KEYS.len(), 54);
1427    }
1428
1429    #[test]
1430    fn mcp_max_read_bytes_defaults_to_the_api_body_cap() {
1431        let cfg =
1432            run_with_env(&[("NOTEDTHAT_MCP_MAX_READ_BYTES", None)], Config::from_env).unwrap();
1433        assert_eq!(cfg.mcp_max_read_bytes, 16 * 1024 * 1024);
1434    }
1435
1436    #[test]
1437    fn mcp_max_read_bytes_accepts_explicit_bytes() {
1438        let cfg = run_with_env(
1439            &[("NOTEDTHAT_MCP_MAX_READ_BYTES", Some("4096"))],
1440            Config::from_env,
1441        )
1442        .unwrap();
1443        assert_eq!(cfg.mcp_max_read_bytes, 4096);
1444    }
1445
1446    #[test]
1447    fn mcp_max_read_bytes_empty_or_blank_is_the_default_like_its_siblings() {
1448        for value in ["", "   "] {
1449            let cfg = run_with_env(
1450                &[("NOTEDTHAT_MCP_MAX_READ_BYTES", Some(value))],
1451                Config::from_env,
1452            )
1453            .unwrap();
1454            assert_eq!(
1455                cfg.mcp_max_read_bytes,
1456                notedthat_mcp::DEFAULT_MAX_READ_BYTES,
1457                "{value:?}"
1458            );
1459        }
1460    }
1461
1462    #[test]
1463    fn mcp_max_read_bytes_rejects_zero_and_non_numbers() {
1464        for (value, fragment) in [("0", "must be > 0"), ("lots", "must be a valid u64")] {
1465            let result = run_with_env(
1466                &[("NOTEDTHAT_MCP_MAX_READ_BYTES", Some(value))],
1467                Config::from_env,
1468            );
1469            assert!(matches!(result, Err(Error::Config { .. })));
1470            assert!(names_setting(
1471                &result.unwrap_err().to_string(),
1472                "NOTEDTHAT_MCP_MAX_READ_BYTES",
1473                fragment
1474            ));
1475        }
1476    }
1477
1478    #[test]
1479    fn ready_probe_interval_defaults_to_five_seconds() {
1480        let cfg = run_with_env(
1481            &[("NOTEDTHAT_READY_PROBE_INTERVAL_MS", None)],
1482            Config::from_env,
1483        )
1484        .unwrap();
1485        assert_eq!(cfg.ready_probe_interval_ms, 5_000);
1486    }
1487
1488    #[test]
1489    fn ready_probe_interval_is_parsed() {
1490        let cfg = run_with_env(
1491            &[("NOTEDTHAT_READY_PROBE_INTERVAL_MS", Some("250"))],
1492            Config::from_env,
1493        )
1494        .unwrap();
1495        assert_eq!(cfg.ready_probe_interval_ms, 250);
1496    }
1497
1498    #[test]
1499    fn ready_probe_interval_rejects_zero_and_nonsense() {
1500        for (value, complaint) in [("0", "must be > 0"), ("soon", "must be a valid u64")] {
1501            let error = run_with_env(
1502                &[("NOTEDTHAT_READY_PROBE_INTERVAL_MS", Some(value))],
1503                Config::from_env,
1504            )
1505            .unwrap_err();
1506            assert!(
1507                names_setting(
1508                    &error.to_string(),
1509                    "NOTEDTHAT_READY_PROBE_INTERVAL_MS",
1510                    complaint
1511                ),
1512                "{value}: {error}"
1513            );
1514        }
1515    }
1516
1517    #[test]
1518    fn max_patchable_size_defaults_to_100_mib() {
1519        let cfg =
1520            run_with_env(&[("NOTEDTHAT_MAX_PATCHABLE_SIZE", None)], Config::from_env).unwrap();
1521        assert_eq!(cfg.max_patchable_size, 100 * 1024 * 1024);
1522    }
1523
1524    #[test]
1525    fn max_patchable_size_accepts_explicit_bytes() {
1526        let cfg = run_with_env(
1527            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("52428800"))],
1528            Config::from_env,
1529        )
1530        .unwrap();
1531        assert_eq!(cfg.max_patchable_size, 50 * 1024 * 1024);
1532    }
1533
1534    #[test]
1535    fn max_patchable_size_rejects_zero() {
1536        let result = run_with_env(
1537            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("0"))],
1538            Config::from_env,
1539        );
1540        assert!(matches!(result, Err(Error::Config { .. })));
1541        assert!(names_setting(
1542            &result.unwrap_err().to_string(),
1543            "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1544            "must be > 0"
1545        ));
1546    }
1547
1548    #[test]
1549    fn max_patchable_size_rejects_values_over_max_upload_bytes() {
1550        let result = run_with_env(
1551            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("6442450944"))],
1552            Config::from_env,
1553        );
1554        assert!(matches!(result, Err(Error::Config { .. })));
1555        assert!(names_setting(
1556            &result.unwrap_err().to_string(),
1557            "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1558            "must not exceed MAX_UPLOAD_BYTES (5 GiB)"
1559        ));
1560    }
1561
1562    #[test]
1563    fn max_patchable_size_rejects_non_numeric_values() {
1564        let result = run_with_env(
1565            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("not-a-number"))],
1566            Config::from_env,
1567        );
1568        assert!(matches!(result, Err(Error::Config { .. })));
1569        assert!(names_setting(
1570            &result.unwrap_err().to_string(),
1571            "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1572            "must be a valid u64 integer"
1573        ));
1574    }
1575
1576    #[test]
1577    fn qdrant_url_missing_returns_error() {
1578        let result = run_with_env(&[("NOTEDTHAT_QDRANT_URL", None)], Config::from_env);
1579        assert!(result.is_err());
1580        let msg = result.unwrap_err().to_string();
1581        assert!(
1582            msg.contains("NOTEDTHAT_QDRANT_URL"),
1583            "error should mention the missing var: {msg}"
1584        );
1585    }
1586
1587    #[test]
1588    fn qdrant_api_key_optional() {
1589        let cfg = run_with_env(&[("NOTEDTHAT_QDRANT_API_KEY", None)], Config::from_env).unwrap();
1590        assert!(
1591            cfg.qdrant.api_key.is_none(),
1592            "api_key should be None when env var is unset"
1593        );
1594    }
1595
1596    #[test]
1597    fn qdrant_api_key_set_when_present() {
1598        let cfg = run_with_env(
1599            &[("NOTEDTHAT_QDRANT_API_KEY", Some("my-secret-key"))],
1600            Config::from_env,
1601        )
1602        .unwrap();
1603        assert_eq!(cfg.qdrant.api_key.as_deref(), Some("my-secret-key"));
1604    }
1605
1606    #[test]
1607    fn qdrant_url_propagated_to_config() {
1608        let cfg = run_with_env(
1609            &[(
1610                "NOTEDTHAT_QDRANT_URL",
1611                Some("http://qdrant.example.com:6334"),
1612            )],
1613            Config::from_env,
1614        )
1615        .unwrap();
1616        assert_eq!(cfg.qdrant.url, "http://qdrant.example.com:6334");
1617    }
1618
1619    #[test]
1620    fn embedding_endpoint_url_missing() {
1621        let result = run_with_env(&[("EMBEDDING_ENDPOINT_URL", None)], Config::from_env);
1622        assert!(result.is_err());
1623        let msg = result.unwrap_err().to_string();
1624        assert!(
1625            msg.contains("EMBEDDING_ENDPOINT_URL"),
1626            "error should mention the missing var: {msg}"
1627        );
1628    }
1629
1630    #[test]
1631    fn embedding_model_missing() {
1632        let result = run_with_env(&[("EMBEDDING_MODEL", None)], Config::from_env);
1633        assert!(result.is_err());
1634        let msg = result.unwrap_err().to_string();
1635        assert!(
1636            msg.contains("EMBEDDING_MODEL"),
1637            "error should mention the missing var: {msg}"
1638        );
1639    }
1640
1641    #[test]
1642    fn embedding_api_key_missing() {
1643        let result = run_with_env(&[("EMBEDDING_API_KEY", None)], Config::from_env);
1644        assert!(result.is_err());
1645        let msg = result.unwrap_err().to_string();
1646        assert!(
1647            msg.contains("EMBEDDING_API_KEY"),
1648            "error should mention the missing var: {msg}"
1649        );
1650    }
1651
1652    #[test]
1653    fn embedding_dimensions_missing() {
1654        let result = run_with_env(&[("EMBEDDING_DIMENSIONS", None)], Config::from_env);
1655        assert!(result.is_err());
1656        let msg = result.unwrap_err().to_string();
1657        assert!(
1658            msg.contains("EMBEDDING_DIMENSIONS"),
1659            "error should mention the missing var: {msg}"
1660        );
1661    }
1662
1663    #[test]
1664    fn embedding_dimensions_invalid() {
1665        let result = run_with_env(
1666            &[("EMBEDDING_DIMENSIONS", Some("not-a-number"))],
1667            Config::from_env,
1668        );
1669        assert!(result.is_err());
1670        let msg = result.unwrap_err().to_string();
1671        assert!(
1672            msg.contains("EMBEDDING_DIMENSIONS"),
1673            "error should mention the invalid var: {msg}"
1674        );
1675    }
1676
1677    #[test]
1678    fn embedding_batch_size_default() {
1679        let cfg = run_with_env(&[("EMBEDDING_BATCH_SIZE", None)], Config::from_env).unwrap();
1680        assert_eq!(cfg.embedder.batch_size, 32);
1681    }
1682
1683    #[test]
1684    fn embedding_timeout_ms_default() {
1685        let cfg = run_with_env(&[("EMBEDDING_TIMEOUT_MS", None)], Config::from_env).unwrap();
1686        assert_eq!(cfg.embedder.timeout_ms, 30_000);
1687    }
1688
1689    #[test]
1690    fn embedding_max_retries_default() {
1691        let cfg = run_with_env(&[("EMBEDDING_MAX_RETRIES", None)], Config::from_env).unwrap();
1692        assert_eq!(cfg.embedder.max_retries, 3);
1693    }
1694
1695    #[test]
1696    fn embedding_max_input_tokens_default() {
1697        let cfg = run_with_env(&[("EMBEDDING_MAX_INPUT_TOKENS", None)], Config::from_env).unwrap();
1698        assert_eq!(cfg.embedder.max_input_tokens, 8192);
1699    }
1700
1701    #[test]
1702    fn embedder_fields_propagated_to_config() {
1703        let cfg = run_with_env(&[], Config::from_env).unwrap();
1704        assert_eq!(cfg.embedder.endpoint_url, "https://api.openai.com");
1705        assert_eq!(cfg.embedder.model, "text-embedding-3-small");
1706        assert_eq!(cfg.embedder.api_key, "sk-test");
1707        assert_eq!(cfg.embedder.dimensions, 1536);
1708    }
1709
1710    mod mcp_http {
1711        use super::*;
1712
1713        #[test]
1714        fn mcp_http_defaults() {
1715            let cfg = run_with_env(&[], Config::from_env).unwrap();
1716            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1717            assert_eq!(
1718                cfg.mcp_http_allowed_hosts,
1719                vec!["127.0.0.1", "localhost", "::1"]
1720            );
1721        }
1722
1723        #[test]
1724        fn mcp_http_empty_origins_defaults_to_null() {
1725            let cfg = run_with_env(
1726                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", Some(""))],
1727                Config::from_env,
1728            )
1729            .unwrap();
1730            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1731        }
1732
1733        #[test]
1734        fn mcp_http_whitespace_origins_defaults_to_null() {
1735            let cfg = run_with_env(
1736                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", Some("   "))],
1737                Config::from_env,
1738            )
1739            .unwrap();
1740            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1741        }
1742
1743        #[test]
1744        fn mcp_http_single_origin() {
1745            let cfg = run_with_env(
1746                &[(
1747                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1748                    Some("https://example.com"),
1749                )],
1750                Config::from_env,
1751            )
1752            .unwrap();
1753            assert_eq!(cfg.mcp_http_allowed_origins, vec!["https://example.com"]);
1754        }
1755
1756        #[test]
1757        fn mcp_http_multiple_origins_comma_separated() {
1758            let cfg = run_with_env(
1759                &[(
1760                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1761                    Some("https://example.com,https://other.com"),
1762                )],
1763                Config::from_env,
1764            )
1765            .unwrap();
1766            assert_eq!(
1767                cfg.mcp_http_allowed_origins,
1768                vec!["https://example.com", "https://other.com"]
1769            );
1770        }
1771
1772        #[test]
1773        fn mcp_http_origins_with_whitespace_trimmed() {
1774            let cfg = run_with_env(
1775                &[(
1776                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1777                    Some("  https://example.com  ,  https://other.com  "),
1778                )],
1779                Config::from_env,
1780            )
1781            .unwrap();
1782            assert_eq!(
1783                cfg.mcp_http_allowed_origins,
1784                vec!["https://example.com", "https://other.com"]
1785            );
1786        }
1787
1788        #[test]
1789        fn mcp_http_empty_hosts_defaults_to_loopback() {
1790            let cfg = run_with_env(
1791                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some(""))],
1792                Config::from_env,
1793            )
1794            .unwrap();
1795            assert_eq!(
1796                cfg.mcp_http_allowed_hosts,
1797                vec!["127.0.0.1", "localhost", "::1"]
1798            );
1799        }
1800
1801        #[test]
1802        fn mcp_http_whitespace_hosts_defaults_to_loopback() {
1803            let cfg = run_with_env(
1804                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some("   "))],
1805                Config::from_env,
1806            )
1807            .unwrap();
1808            assert_eq!(
1809                cfg.mcp_http_allowed_hosts,
1810                vec!["127.0.0.1", "localhost", "::1"]
1811            );
1812        }
1813
1814        #[test]
1815        fn mcp_http_single_host() {
1816            let cfg = run_with_env(
1817                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some("example.com"))],
1818                Config::from_env,
1819            )
1820            .unwrap();
1821            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com"]);
1822        }
1823
1824        #[test]
1825        fn mcp_http_multiple_hosts_comma_separated() {
1826            let cfg = run_with_env(
1827                &[(
1828                    "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
1829                    Some("example.com,other.com"),
1830                )],
1831                Config::from_env,
1832            )
1833            .unwrap();
1834            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com", "other.com"]);
1835        }
1836
1837        #[test]
1838        fn mcp_http_hosts_with_whitespace_trimmed() {
1839            let cfg = run_with_env(
1840                &[(
1841                    "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
1842                    Some("  example.com  ,  other.com  "),
1843                )],
1844                Config::from_env,
1845            )
1846            .unwrap();
1847            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com", "other.com"]);
1848        }
1849
1850        #[test]
1851        fn mcp_anonymous_defaults_to_auto() {
1852            let cfg = run_with_env(&[], Config::from_env).unwrap();
1853            assert_eq!(cfg.mcp_anonymous, McpAnonymous::Auto);
1854        }
1855
1856        #[test]
1857        fn mcp_anonymous_empty_or_blank_is_auto_like_its_siblings() {
1858            // Compose hands every MCP variable through as `${VAR-}`, so an
1859            // unset variable arrives as an empty string.
1860            for value in ["", "   "] {
1861                let cfg = run_with_env(
1862                    &[("NOTEDTHAT_MCP_ANONYMOUS", Some(value))],
1863                    Config::from_env,
1864                )
1865                .unwrap();
1866                assert_eq!(cfg.mcp_anonymous, McpAnonymous::Auto, "{value:?}");
1867            }
1868        }
1869
1870        #[test]
1871        fn mcp_anonymous_never_in_any_case() {
1872            for value in ["never", "NEVER", " Never "] {
1873                let cfg = run_with_env(
1874                    &[("NOTEDTHAT_MCP_ANONYMOUS", Some(value))],
1875                    Config::from_env,
1876                )
1877                .unwrap();
1878                assert_eq!(cfg.mcp_anonymous, McpAnonymous::Never, "{value:?}");
1879            }
1880        }
1881
1882        #[test]
1883        fn an_unknown_mcp_anonymous_mode_is_refused_rather_than_defaulted() {
1884            let err = run_with_env(
1885                &[("NOTEDTHAT_MCP_ANONYMOUS", Some("nevr"))],
1886                Config::from_env,
1887            )
1888            .unwrap_err()
1889            .to_string();
1890            assert!(
1891                err.contains("NOTEDTHAT_MCP_ANONYMOUS") && err.contains("nevr"),
1892                "names the setting and the value: {err}"
1893            );
1894        }
1895
1896        #[test]
1897        fn mcp_http_with_empty_token_fails() {
1898            let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", Some(""))], Config::from_env);
1899            assert!(result.is_err());
1900            let msg = result.unwrap_err().to_string();
1901            assert!(
1902                msg.contains("NOTEDTHAT_API_TOKEN"),
1903                "error should mention NOTEDTHAT_API_TOKEN: {msg}"
1904            );
1905        }
1906
1907        #[test]
1908        fn mcp_http_with_whitespace_token_fails() {
1909            let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", Some("   "))], Config::from_env);
1910            assert!(result.is_err());
1911            let msg = result.unwrap_err().to_string();
1912            assert!(
1913                msg.contains("NOTEDTHAT_API_TOKEN"),
1914                "error should mention NOTEDTHAT_API_TOKEN: {msg}"
1915            );
1916        }
1917    }
1918
1919    mod storage_backend {
1920        use super::*;
1921
1922        #[test]
1923        fn the_default_is_s3_so_existing_deployments_are_unaffected() {
1924            run_with_env(&[], || {
1925                let config = Config::from_env().expect("valid");
1926                assert_eq!(config.storage.kind(), StorageBackendKind::S3);
1927            });
1928        }
1929
1930        #[test]
1931        fn selecting_fs_reads_the_fs_variables_and_stops_requiring_s3() {
1932            run_with_env(
1933                &[
1934                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1935                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
1936                    ("NOTEDTHAT_S3_REGION", None),
1937                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
1938                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
1939                ],
1940                || {
1941                    let config = Config::from_env().expect("valid");
1942                    assert_eq!(config.storage.kind(), StorageBackendKind::Fs);
1943                },
1944            );
1945        }
1946
1947        /// Unlike `NOTEDTHAT_LOG_FORMAT`, a typo here must not fall back — it would
1948        /// silently point the server at a different store.
1949        #[test]
1950        fn an_unknown_backend_is_refused_rather_than_defaulted() {
1951            run_with_env(&[("NOTEDTHAT_STORAGE_BACKEND", Some("filesystem"))], || {
1952                let error = Config::from_env().unwrap_err().to_string();
1953                assert!(error.contains("expected \"s3\" or \"fs\""), "{error}");
1954                assert!(error.contains("filesystem"), "{error}");
1955            });
1956        }
1957
1958        #[test]
1959        fn an_empty_backend_selector_is_refused() {
1960            run_with_env(&[("NOTEDTHAT_STORAGE_BACKEND", Some(""))], || {
1961                let error = Config::from_env().unwrap_err().to_string();
1962                assert!(error.contains("must not be empty"), "{error}");
1963            });
1964        }
1965
1966        #[test]
1967        fn selecting_fs_without_a_root_names_the_variable() {
1968            run_with_env(
1969                &[
1970                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1971                    ("NOTEDTHAT_S3_REGION", None),
1972                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
1973                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
1974                ],
1975                || {
1976                    let error = Config::from_env().unwrap_err().to_string();
1977                    assert!(
1978                        names_setting(&error, "NOTEDTHAT_FS_ROOT", "is required"),
1979                        "{error}"
1980                    );
1981                },
1982            );
1983        }
1984
1985        #[test]
1986        fn leftover_s3_variables_under_fs_are_reported_together() {
1987            run_with_env(
1988                &[
1989                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1990                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
1991                ],
1992                || {
1993                    let error = Config::from_env().unwrap_err().to_string();
1994                    assert!(error.contains("belong to the s3 backend"), "{error}");
1995                    // All of them at once, not one per restart.
1996                    assert!(error.contains("NOTEDTHAT_S3_REGION"), "{error}");
1997                    assert!(error.contains("NOTEDTHAT_S3_ACCESS_KEY_ID"), "{error}");
1998                    assert!(error.contains("NOTEDTHAT_S3_SECRET_ACCESS_KEY"), "{error}");
1999                    assert!(error.contains("NOTEDTHAT_STORAGE_BACKEND=s3"), "{error}");
2000                },
2001            );
2002        }
2003
2004        #[test]
2005        fn s3_reconcile_defaults_on_and_is_parsed() {
2006            let cfg = run_with_env(&[("NOTEDTHAT_S3_RECONCILE", None)], Config::from_env).unwrap();
2007            let StorageConfig::S3(s3) = &cfg.storage else {
2008                panic!("the placeholder selects s3")
2009            };
2010            assert!(s3.reconcile_on_startup);
2011
2012            let cfg = run_with_env(
2013                &[("NOTEDTHAT_S3_RECONCILE", Some("false"))],
2014                Config::from_env,
2015            )
2016            .unwrap();
2017            let StorageConfig::S3(s3) = &cfg.storage else {
2018                panic!("the placeholder selects s3")
2019            };
2020            assert!(!s3.reconcile_on_startup);
2021
2022            let error = run_with_env(
2023                &[("NOTEDTHAT_S3_RECONCILE", Some("sometimes"))],
2024                Config::from_env,
2025            )
2026            .unwrap_err()
2027            .to_string();
2028            assert!(
2029                names_setting(
2030                    &error,
2031                    "NOTEDTHAT_S3_RECONCILE",
2032                    "expected \"true\" or \"false\""
2033                ),
2034                "{error}"
2035            );
2036        }
2037
2038        #[test]
2039        fn s3_reconcile_under_fs_is_refused() {
2040            run_with_env(
2041                &[
2042                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2043                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
2044                    ("NOTEDTHAT_S3_REGION", None),
2045                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
2046                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
2047                    ("NOTEDTHAT_S3_RECONCILE", Some("true")),
2048                ],
2049                || {
2050                    let error = Config::from_env().unwrap_err().to_string();
2051                    assert!(error.contains("belong to the s3 backend"), "{error}");
2052                    assert!(error.contains("NOTEDTHAT_S3_RECONCILE"), "{error}");
2053                },
2054            );
2055        }
2056
2057        /// The case this check exists for: the operator sets a root and forgets the
2058        /// selector, and would otherwise get a healthy S3 deployment with an unread root.
2059        #[test]
2060        fn an_fs_root_without_the_selector_is_refused_and_says_why() {
2061            run_with_env(&[("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat"))], || {
2062                let error = Config::from_env().unwrap_err().to_string();
2063                assert!(
2064                    names_setting(&error, "NOTEDTHAT_STORAGE_BACKEND", "is unset"),
2065                    "{error}"
2066                );
2067                assert!(error.contains("NOTEDTHAT_FS_ROOT"), "{error}");
2068                assert!(error.contains("NOTEDTHAT_STORAGE_BACKEND=fs"), "{error}");
2069            });
2070        }
2071
2072        /// An empty value is still a value — matching how removed variables are checked.
2073        #[test]
2074        fn an_empty_cross_backend_variable_still_counts() {
2075            run_with_env(
2076                &[
2077                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2078                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
2079                    ("NOTEDTHAT_S3_REGION", Some("")),
2080                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
2081                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
2082                ],
2083                || {
2084                    let error = Config::from_env().unwrap_err().to_string();
2085                    assert!(error.contains("NOTEDTHAT_S3_REGION"), "{error}");
2086                },
2087            );
2088        }
2089
2090        /// The rejection table is checked against each adapter's own inventory, so it
2091        /// cannot drift from what those adapters actually read.
2092        #[test]
2093        fn the_rejection_table_matches_each_adapter_inventory() {
2094            let table = backend_owned_settings(&ServerCli::default());
2095            let s3: Vec<&str> = table
2096                .iter()
2097                .filter(|(_, kind, _)| *kind == StorageBackendKind::S3)
2098                .map(|(name, _, _)| *name)
2099                .collect();
2100            let fs: Vec<&str> = table
2101                .iter()
2102                .filter(|(_, kind, _)| *kind == StorageBackendKind::Fs)
2103                .map(|(name, _, _)| *name)
2104                .collect();
2105            assert_eq!(s3, notedthat_storage_s3::S3_ENV_VARS.to_vec());
2106            assert_eq!(fs, notedthat_storage_fs::FS_ENV_VARS.to_vec());
2107
2108            for (name, _, _) in &table {
2109                assert!(
2110                    ALL_ENV_KEYS.contains(name),
2111                    "{name} is read but missing from ALL_ENV_KEYS"
2112                );
2113            }
2114        }
2115
2116        /// A default `ServerCli` supplies nothing, so nothing can be an offender —
2117        /// the guard against a field being wired to the wrong entry in the table.
2118        #[test]
2119        fn nothing_is_supplied_by_a_default_command_line() {
2120            assert!(
2121                backend_owned_settings(&ServerCli::default())
2122                    .iter()
2123                    .all(|(_, _, supplied)| !supplied)
2124            );
2125        }
2126    }
2127
2128    mod events_backend {
2129        use super::*;
2130
2131        #[test]
2132        fn the_default_is_none_so_existing_deployments_publish_nothing() {
2133            run_with_env(&[], || {
2134                let config = Config::from_env().expect("valid");
2135                assert_eq!(config.events.kind(), EventsBackendKind::None);
2136            });
2137        }
2138
2139        #[test]
2140        fn memory_reads_its_capacity_and_defaults_it() {
2141            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some("memory"))], || {
2142                let config = Config::from_env().expect("valid");
2143                match config.events {
2144                    EventsConfig::Memory(memory) => {
2145                        assert_eq!(memory.capacity, notedthat_events::DEFAULT_MEMORY_CAPACITY);
2146                    }
2147                    other => panic!("expected memory, got {other:?}"),
2148                }
2149            });
2150            run_with_env(
2151                &[
2152                    ("NOTEDTHAT_EVENTS_BACKEND", Some("memory")),
2153                    ("NOTEDTHAT_EVENTS_MEMORY_CAPACITY", Some("250")),
2154                ],
2155                || {
2156                    let config = Config::from_env().expect("valid");
2157                    match config.events {
2158                        EventsConfig::Memory(memory) => assert_eq!(memory.capacity, 250),
2159                        other => panic!("expected memory, got {other:?}"),
2160                    }
2161                },
2162            );
2163        }
2164
2165        #[test]
2166        fn nats_requires_its_url_and_names_the_variable() {
2167            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some("nats"))], || {
2168                let error = Config::from_env().unwrap_err().to_string();
2169                assert!(
2170                    names_setting(&error, "NOTEDTHAT_NATS_URL", "is required"),
2171                    "{error}"
2172                );
2173            });
2174            run_with_env(
2175                &[
2176                    ("NOTEDTHAT_EVENTS_BACKEND", Some("nats")),
2177                    ("NOTEDTHAT_NATS_URL", Some("nats://broker:4222")),
2178                    ("NOTEDTHAT_NATS_STREAM", Some("evt")),
2179                    ("NOTEDTHAT_NATS_MAX_AGE_SECS", Some("60")),
2180                ],
2181                || {
2182                    let config = Config::from_env().expect("valid");
2183                    match config.events {
2184                        EventsConfig::Nats(nats) => {
2185                            assert_eq!(nats.url, "nats://broker:4222");
2186                            assert_eq!(nats.stream, "evt");
2187                            assert_eq!(nats.max_age, Duration::from_secs(60));
2188                        }
2189                        other => panic!("expected nats, got {other:?}"),
2190                    }
2191                },
2192            );
2193        }
2194
2195        /// A typo must not fall back to `none`: the server would start and simply
2196        /// never announce a change.
2197        #[test]
2198        fn an_unknown_events_backend_is_refused_rather_than_defaulted() {
2199            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some("kafka"))], || {
2200                let error = Config::from_env().unwrap_err().to_string();
2201                assert!(
2202                    error.contains("expected \"none\", \"memory\" or \"nats\""),
2203                    "{error}"
2204                );
2205                assert!(error.contains("kafka"), "{error}");
2206            });
2207            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some(""))], || {
2208                let error = Config::from_env().unwrap_err().to_string();
2209                assert!(
2210                    names_setting(&error, "NOTEDTHAT_EVENTS_BACKEND", "must not be empty"),
2211                    "{error}"
2212                );
2213            });
2214        }
2215
2216        #[test]
2217        fn nats_variables_under_memory_are_reported_together_with_the_fix() {
2218            run_with_env(
2219                &[
2220                    ("NOTEDTHAT_EVENTS_BACKEND", Some("memory")),
2221                    ("NOTEDTHAT_NATS_URL", Some("nats://broker:4222")),
2222                    ("NOTEDTHAT_NATS_STREAM", Some("evt")),
2223                ],
2224                || {
2225                    let error = Config::from_env().unwrap_err().to_string();
2226                    assert!(
2227                        names_setting(&error, "NOTEDTHAT_EVENTS_BACKEND", "is memory"),
2228                        "{error}"
2229                    );
2230                    assert!(error.contains("belong to the nats backend"), "{error}");
2231                    assert!(error.contains("NOTEDTHAT_NATS_URL"), "{error}");
2232                    assert!(error.contains("NOTEDTHAT_NATS_STREAM"), "{error}");
2233                    assert!(error.contains("NOTEDTHAT_EVENTS_BACKEND=nats"), "{error}");
2234                },
2235            );
2236        }
2237
2238        /// The operator configures a broker and forgets the selector: the highest-value
2239        /// case, since the server would otherwise start healthy and silent.
2240        #[test]
2241        fn a_nats_url_without_the_selector_is_refused_and_says_why() {
2242            run_with_env(
2243                &[("NOTEDTHAT_NATS_URL", Some("nats://broker:4222"))],
2244                || {
2245                    let error = Config::from_env().unwrap_err().to_string();
2246                    assert!(
2247                        names_setting(&error, "NOTEDTHAT_EVENTS_BACKEND", "is unset"),
2248                        "{error}"
2249                    );
2250                    assert!(error.contains("default none backend"), "{error}");
2251                    assert!(error.contains("NOTEDTHAT_EVENTS_BACKEND=nats"), "{error}");
2252                },
2253            );
2254        }
2255
2256        /// Both unselected backends' variables at once: each owner is named, and
2257        /// each fix is offered.
2258        #[test]
2259        fn offenders_from_two_backends_are_grouped_by_owner() {
2260            run_with_env(
2261                &[
2262                    ("NOTEDTHAT_EVENTS_MEMORY_CAPACITY", Some("10")),
2263                    ("NOTEDTHAT_NATS_URL", Some("nats://broker:4222")),
2264                ],
2265                || {
2266                    let error = Config::from_env().unwrap_err().to_string();
2267                    assert!(error.contains("belong to the memory backend"), "{error}");
2268                    assert!(error.contains("belong to the nats backend"), "{error}");
2269                    assert!(
2270                        error.contains(
2271                            "NOTEDTHAT_EVENTS_BACKEND=memory or NOTEDTHAT_EVENTS_BACKEND=nats"
2272                        ),
2273                        "{error}"
2274                    );
2275                },
2276            );
2277        }
2278
2279        #[test]
2280        fn the_rejection_table_matches_each_adapter_inventory() {
2281            let table = events_owned_settings(&ServerCli::default());
2282            let memory: Vec<&str> = table
2283                .iter()
2284                .filter(|(_, kind, _)| *kind == EventsBackendKind::Memory)
2285                .map(|(name, _, _)| *name)
2286                .collect();
2287            let nats: Vec<&str> = table
2288                .iter()
2289                .filter(|(_, kind, _)| *kind == EventsBackendKind::Nats)
2290                .map(|(name, _, _)| *name)
2291                .collect();
2292            assert_eq!(memory, notedthat_events::MEMORY_ENV_VARS.to_vec());
2293            assert_eq!(nats, notedthat_events::NATS_ENV_VARS.to_vec());
2294
2295            for (name, _, _) in &table {
2296                assert!(
2297                    ALL_ENV_KEYS.contains(name),
2298                    "{name} is read but missing from ALL_ENV_KEYS"
2299                );
2300            }
2301            assert!(
2302                table.iter().all(|(_, _, supplied)| !supplied),
2303                "a default command line supplies nothing"
2304            );
2305        }
2306    }
2307
2308    mod oidc {
2309        use super::*;
2310
2311        const ISSUER: &str = "https://auth.example.com/application/o/notedthat/";
2312
2313        #[test]
2314        fn no_oidc_settings_means_no_verifier() {
2315            let cfg = run_with_env(&[], Config::from_env).expect("valid config");
2316            assert!(cfg.oidc.is_none());
2317        }
2318
2319        #[test]
2320        fn an_issuer_with_an_audience_enables_oidc_with_the_defaults() {
2321            let cfg = run_with_env(
2322                &[
2323                    ("NOTEDTHAT_OIDC_ISSUER", Some(ISSUER)),
2324                    ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat, mcp-client")),
2325                ],
2326                Config::from_env,
2327            )
2328            .expect("valid config");
2329            let oidc = cfg.oidc.expect("configured");
2330            assert_eq!(oidc.issuer, ISSUER, "the operator's spelling is kept");
2331            assert_eq!(oidc.audiences, vec!["notedthat", "mcp-client"]);
2332            assert_eq!(oidc.username_claim, "preferred_username");
2333            assert_eq!(oidc.groups_claim, "groups");
2334            assert_eq!(oidc.http_timeout, Duration::from_millis(5000));
2335            assert_eq!(oidc.resource, None);
2336            assert_eq!(oidc.ca_cert, None);
2337            assert_eq!(
2338                oidc.discovery_url(),
2339                "https://auth.example.com/application/o/notedthat/.well-known/openid-configuration"
2340            );
2341        }
2342
2343        #[test]
2344        fn every_oidc_setting_is_read() {
2345            let cfg = run_with_env(
2346                &[
2347                    ("NOTEDTHAT_OIDC_ISSUER", Some("https://auth.example.com")),
2348                    ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2349                    ("NOTEDTHAT_OIDC_USERNAME_CLAIM", Some("email")),
2350                    (
2351                        "NOTEDTHAT_OIDC_GROUPS_CLAIM",
2352                        Some("urn:zitadel:iam:org:project:roles"),
2353                    ),
2354                    ("NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", Some("250")),
2355                    (
2356                        "NOTEDTHAT_OIDC_RESOURCE",
2357                        Some("https://notes.example.com/"),
2358                    ),
2359                ],
2360                Config::from_env,
2361            )
2362            .expect("valid config");
2363            let oidc = cfg.oidc.expect("configured");
2364            assert_eq!(oidc.username_claim, "email");
2365            assert_eq!(oidc.groups_claim, "urn:zitadel:iam:org:project:roles");
2366            assert_eq!(oidc.http_timeout, Duration::from_millis(250));
2367            assert_eq!(
2368                oidc.resource.as_deref(),
2369                Some("https://notes.example.com"),
2370                "the resource is an origin, so its trailing slash is dropped"
2371            );
2372        }
2373
2374        #[test]
2375        fn oidc_settings_without_an_issuer_are_rejected() {
2376            let error = run_with_env(
2377                &[
2378                    ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2379                    ("NOTEDTHAT_OIDC_GROUPS_CLAIM", Some("roles")),
2380                ],
2381                Config::from_env,
2382            )
2383            .expect_err("refused");
2384            let message = error.to_string();
2385            assert!(
2386                names_setting(&message, "NOTEDTHAT_OIDC_ISSUER", "unset"),
2387                "{message}"
2388            );
2389            assert!(message.contains("NOTEDTHAT_OIDC_AUDIENCE"), "{message}");
2390            assert!(message.contains("NOTEDTHAT_OIDC_GROUPS_CLAIM"), "{message}");
2391        }
2392
2393        #[test]
2394        fn an_issuer_without_an_audience_is_rejected() {
2395            let error = run_with_env(&[("NOTEDTHAT_OIDC_ISSUER", Some(ISSUER))], Config::from_env)
2396                .expect_err("refused");
2397            assert!(
2398                names_setting(&error.to_string(), "NOTEDTHAT_OIDC_AUDIENCE", "required"),
2399                "{error}"
2400            );
2401        }
2402
2403        #[test]
2404        fn an_issuer_that_is_not_an_http_url_is_rejected() {
2405            for bad in ["auth.example.com", "ldap://auth.example.com", ""] {
2406                let error = run_with_env(
2407                    &[
2408                        ("NOTEDTHAT_OIDC_ISSUER", Some(bad)),
2409                        ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2410                    ],
2411                    Config::from_env,
2412                )
2413                .expect_err("refused");
2414                assert!(
2415                    error.to_string().contains("NOTEDTHAT_OIDC_ISSUER"),
2416                    "{bad}: {error}"
2417                );
2418            }
2419        }
2420
2421        #[test]
2422        fn an_empty_claim_name_and_a_zero_timeout_are_rejected() {
2423            for (var, value) in [
2424                ("NOTEDTHAT_OIDC_USERNAME_CLAIM", " "),
2425                ("NOTEDTHAT_OIDC_GROUPS_CLAIM", ""),
2426                ("NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", "0"),
2427                ("NOTEDTHAT_OIDC_RESOURCE", "notes.example.com"),
2428                ("NOTEDTHAT_OIDC_CA_CERT", "/nonexistent/ca.pem"),
2429            ] {
2430                let error = run_with_env(
2431                    &[
2432                        ("NOTEDTHAT_OIDC_ISSUER", Some(ISSUER)),
2433                        ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2434                        (var, Some(value)),
2435                    ],
2436                    Config::from_env,
2437                )
2438                .expect_err("refused");
2439                assert!(error.to_string().contains(var), "{var}: {error}");
2440            }
2441        }
2442    }
2443}