1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum StorageBackendKind {
26 S3,
28 Fs,
30}
31
32impl StorageBackendKind {
33 #[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#[derive(Debug, Clone)]
54pub enum StorageConfig {
55 S3(notedthat_storage_s3::S3Config),
57 Fs(notedthat_storage_fs::FsConfig),
59}
60
61impl StorageConfig {
62 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
74pub enum EventsBackendKind {
75 None,
77 Memory,
79 Nats,
81}
82
83impl EventsBackendKind {
84 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
109pub enum McpAnonymous {
110 #[default]
113 Auto,
114 Never,
118}
119
120impl McpAnonymous {
121 #[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#[derive(Debug, Clone)]
139pub enum EventsConfig {
140 None,
142 Memory(MemoryConfig),
144 Nats(NatsConfig),
146}
147
148impl EventsConfig {
149 #[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
160fn 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
222fn 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
242fn 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
275fn 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
304fn 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
328fn 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#[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#[derive(Debug, Clone)]
408pub struct Config {
409 pub api_token: String,
411 pub kbs: BTreeMap<String, KbSlug>,
413 pub tenant_slug: TenantSlug,
415 pub listen_addr: SocketAddr,
417 pub storage: StorageConfig,
420 pub events: EventsConfig,
423 pub log_format: LogFormat,
425 pub qdrant: ServerQdrantConfig,
427 pub embedder: EmbedderConfig,
429 pub webdav_username: String,
431 pub webdav_password: String,
433 pub mcp_http_allowed_origins: Vec<String>,
435 pub mcp_http_allowed_hosts: Vec<String>,
437 pub mcp_anonymous: McpAnonymous,
439 pub max_patchable_size: u64,
441 pub mcp_max_read_bytes: u64,
444 pub ready_probe_interval_ms: u64,
448 pub staging: StagingConfig,
450 pub oidc: Option<OidcSettings>,
452}
453
454const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum LogFormat {
483 Pretty,
485 Json,
487}
488
489impl Config {
490 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 #[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 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 let tenant_slug = TenantSlug::default();
580
581 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 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
781fn 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
802fn 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
908fn 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
925struct 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
934fn 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#[derive(Debug, Clone)]
952pub struct ServerQdrantConfig {
953 pub url: String,
955 pub api_key: Option<String>,
957 pub timeout_ms: u64,
962 pub connect_timeout_ms: u64,
965}
966
967impl ServerQdrantConfig {
968 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
996fn 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
1012struct 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#[derive(Debug, Clone)]
1029pub struct EmbedderConfig {
1030 pub endpoint_url: String,
1032 pub model: String,
1034 pub api_key: String,
1036 pub dimensions: u32,
1038 pub batch_size: usize,
1040 pub timeout_ms: u64,
1042 pub max_retries: u32,
1044 pub max_input_tokens: usize,
1046}
1047
1048impl EmbedderConfig {
1049 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
1088fn 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 fn names_setting(message: &str, env_var: &str, complaint: &str) -> bool {
1168 message.contains(env_var)
1169 && message.contains(¬edthat_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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}