All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m56s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
CI / Format (pull_request) Successful in 22s
CI / Clippy (pull_request) Successful in 2m51s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
Add unit tests across all model and server infrastructure layers, increasing test count from 7 to 92. Covers serde round-trips, enum methods, defaults, config parsing, error mapping, PKCE crypto (with RFC 7636 test vector), OAuth store, and SearXNG ranking/dedup logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
411 lines
14 KiB
Rust
411 lines
14 KiB
Rust
//! Configuration structs loaded once at startup from environment variables.
|
|
//!
|
|
//! Each struct provides a `from_env()` constructor that reads `std::env::var`
|
|
//! values. Required variables cause an `Error::ConfigError` on failure;
|
|
//! optional ones default to an empty string.
|
|
|
|
use secrecy::SecretString;
|
|
|
|
use super::Error;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Read a required environment variable or return `Error::ConfigError`.
|
|
fn required_env(name: &str) -> Result<String, Error> {
|
|
std::env::var(name).map_err(|_| Error::ConfigError(format!("{name} is required but not set")))
|
|
}
|
|
|
|
/// Read an optional environment variable, defaulting to an empty string.
|
|
fn optional_env(name: &str) -> String {
|
|
std::env::var(name).unwrap_or_default()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// KeycloakConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Keycloak OpenID Connect settings for the public (frontend) client.
|
|
///
|
|
/// Also carries the admin service-account credentials used for
|
|
/// server-to-server calls (e.g. user management APIs).
|
|
#[derive(Debug)]
|
|
pub struct KeycloakConfig {
|
|
/// Base URL of the Keycloak instance (e.g. `http://localhost:8080`).
|
|
pub url: String,
|
|
/// Keycloak realm name.
|
|
pub realm: String,
|
|
/// Public client ID used by the dashboard frontend.
|
|
pub client_id: String,
|
|
/// OAuth redirect URI registered in Keycloak.
|
|
pub redirect_uri: String,
|
|
/// Root URL of this application (used for post-logout redirect).
|
|
pub app_url: String,
|
|
/// Confidential client ID for admin/server-to-server calls.
|
|
pub admin_client_id: String,
|
|
/// Confidential client secret (wrapped for debug safety).
|
|
pub admin_client_secret: SecretString,
|
|
}
|
|
|
|
impl KeycloakConfig {
|
|
/// Load Keycloak configuration from environment variables.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `Error::ConfigError` if a required variable is missing.
|
|
pub fn from_env() -> Result<Self, Error> {
|
|
Ok(Self {
|
|
url: required_env("KEYCLOAK_URL")?,
|
|
realm: required_env("KEYCLOAK_REALM")?,
|
|
client_id: required_env("KEYCLOAK_CLIENT_ID")?,
|
|
redirect_uri: required_env("REDIRECT_URI")?,
|
|
app_url: required_env("APP_URL")?,
|
|
admin_client_id: optional_env("KEYCLOAK_ADMIN_CLIENT_ID"),
|
|
admin_client_secret: SecretString::from(optional_env("KEYCLOAK_ADMIN_CLIENT_SECRET")),
|
|
})
|
|
}
|
|
|
|
/// OpenID Connect authorization endpoint URL.
|
|
pub fn auth_endpoint(&self) -> String {
|
|
format!(
|
|
"{}/realms/{}/protocol/openid-connect/auth",
|
|
self.url, self.realm
|
|
)
|
|
}
|
|
|
|
/// OpenID Connect token endpoint URL.
|
|
pub fn token_endpoint(&self) -> String {
|
|
format!(
|
|
"{}/realms/{}/protocol/openid-connect/token",
|
|
self.url, self.realm
|
|
)
|
|
}
|
|
|
|
/// OpenID Connect userinfo endpoint URL.
|
|
pub fn userinfo_endpoint(&self) -> String {
|
|
format!(
|
|
"{}/realms/{}/protocol/openid-connect/userinfo",
|
|
self.url, self.realm
|
|
)
|
|
}
|
|
|
|
/// OpenID Connect end-session (logout) endpoint URL.
|
|
pub fn logout_endpoint(&self) -> String {
|
|
format!(
|
|
"{}/realms/{}/protocol/openid-connect/logout",
|
|
self.url, self.realm
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SmtpConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// SMTP mail settings for transactional emails (invites, alerts, etc.).
|
|
#[derive(Debug)]
|
|
pub struct SmtpConfig {
|
|
/// SMTP server hostname.
|
|
pub host: String,
|
|
/// SMTP server port (as string for flexibility, e.g. "587").
|
|
pub port: String,
|
|
/// SMTP username.
|
|
pub username: String,
|
|
/// SMTP password (wrapped for debug safety).
|
|
pub password: SecretString,
|
|
/// Sender address shown in the `From:` header.
|
|
pub from_address: String,
|
|
}
|
|
|
|
impl SmtpConfig {
|
|
/// Load SMTP configuration from environment variables.
|
|
///
|
|
/// All fields are optional; defaults to empty strings when absent.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Currently infallible but returns `Result` for consistency.
|
|
pub fn from_env() -> Result<Self, Error> {
|
|
Ok(Self {
|
|
host: optional_env("SMTP_HOST"),
|
|
port: optional_env("SMTP_PORT"),
|
|
username: optional_env("SMTP_USERNAME"),
|
|
password: SecretString::from(optional_env("SMTP_PASSWORD")),
|
|
from_address: optional_env("SMTP_FROM_ADDRESS"),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ServiceUrls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
|
|
#[derive(Debug)]
|
|
pub struct ServiceUrls {
|
|
/// Ollama LLM instance base URL.
|
|
pub ollama_url: String,
|
|
/// Default Ollama model to use.
|
|
pub ollama_model: String,
|
|
/// SearXNG meta-search engine base URL.
|
|
pub searxng_url: String,
|
|
/// LangChain service URL.
|
|
pub langchain_url: String,
|
|
/// LangGraph service URL.
|
|
pub langgraph_url: String,
|
|
/// Langfuse observability URL.
|
|
pub langfuse_url: String,
|
|
/// Vector database URL.
|
|
pub vectordb_url: String,
|
|
/// S3-compatible object storage URL.
|
|
pub s3_url: String,
|
|
/// S3 access key.
|
|
pub s3_access_key: String,
|
|
/// S3 secret key (wrapped for debug safety).
|
|
pub s3_secret_key: SecretString,
|
|
}
|
|
|
|
impl ServiceUrls {
|
|
/// Load service URLs from environment variables.
|
|
///
|
|
/// All fields are optional with sensible defaults where applicable.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Currently infallible but returns `Result` for consistency.
|
|
pub fn from_env() -> Result<Self, Error> {
|
|
Ok(Self {
|
|
ollama_url: std::env::var("OLLAMA_URL")
|
|
.unwrap_or_else(|_| "http://localhost:11434".into()),
|
|
ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()),
|
|
searxng_url: std::env::var("SEARXNG_URL")
|
|
.unwrap_or_else(|_| "http://localhost:8888".into()),
|
|
langchain_url: optional_env("LANGCHAIN_URL"),
|
|
langgraph_url: optional_env("LANGGRAPH_URL"),
|
|
langfuse_url: optional_env("LANGFUSE_URL"),
|
|
vectordb_url: optional_env("VECTORDB_URL"),
|
|
s3_url: optional_env("S3_URL"),
|
|
s3_access_key: optional_env("S3_ACCESS_KEY"),
|
|
s3_secret_key: SecretString::from(optional_env("S3_SECRET_KEY")),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// StripeConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stripe billing configuration.
|
|
#[derive(Debug)]
|
|
pub struct StripeConfig {
|
|
/// Stripe secret API key (wrapped for debug safety).
|
|
pub secret_key: SecretString,
|
|
/// Stripe webhook signing secret (wrapped for debug safety).
|
|
pub webhook_secret: SecretString,
|
|
/// Stripe publishable key (safe to expose to the frontend).
|
|
pub publishable_key: String,
|
|
}
|
|
|
|
impl StripeConfig {
|
|
/// Load Stripe configuration from environment variables.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Currently infallible but returns `Result` for consistency.
|
|
pub fn from_env() -> Result<Self, Error> {
|
|
Ok(Self {
|
|
secret_key: SecretString::from(optional_env("STRIPE_SECRET_KEY")),
|
|
webhook_secret: SecretString::from(optional_env("STRIPE_WEBHOOK_SECRET")),
|
|
publishable_key: optional_env("STRIPE_PUBLISHABLE_KEY"),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LlmProvidersConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Comma-separated list of enabled LLM provider identifiers.
|
|
///
|
|
/// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
|
|
#[derive(Debug)]
|
|
pub struct LlmProvidersConfig {
|
|
/// Parsed provider names.
|
|
pub providers: Vec<String>,
|
|
}
|
|
|
|
impl LlmProvidersConfig {
|
|
/// Load the provider list from `LLM_PROVIDERS`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Currently infallible but returns `Result` for consistency.
|
|
pub fn from_env() -> Result<Self, Error> {
|
|
let raw = optional_env("LLM_PROVIDERS");
|
|
let providers: Vec<String> = raw
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
Ok(Self { providers })
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use serial_test::serial;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// KeycloakConfig endpoint methods (no env vars needed)
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn sample_keycloak() -> KeycloakConfig {
|
|
KeycloakConfig {
|
|
url: "https://auth.example.com".into(),
|
|
realm: "myrealm".into(),
|
|
client_id: "dashboard".into(),
|
|
redirect_uri: "https://app.example.com/callback".into(),
|
|
app_url: "https://app.example.com".into(),
|
|
admin_client_id: String::new(),
|
|
admin_client_secret: SecretString::from(String::new()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keycloak_auth_endpoint() {
|
|
let kc = sample_keycloak();
|
|
assert_eq!(
|
|
kc.auth_endpoint(),
|
|
"https://auth.example.com/realms/myrealm/protocol/openid-connect/auth"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keycloak_token_endpoint() {
|
|
let kc = sample_keycloak();
|
|
assert_eq!(
|
|
kc.token_endpoint(),
|
|
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keycloak_userinfo_endpoint() {
|
|
let kc = sample_keycloak();
|
|
assert_eq!(
|
|
kc.userinfo_endpoint(),
|
|
"https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keycloak_logout_endpoint() {
|
|
let kc = sample_keycloak();
|
|
assert_eq!(
|
|
kc.logout_endpoint(),
|
|
"https://auth.example.com/realms/myrealm/protocol/openid-connect/logout"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// LlmProvidersConfig::from_env()
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn llm_providers_empty_string() {
|
|
std::env::set_var("LLM_PROVIDERS", "");
|
|
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
assert!(cfg.providers.is_empty());
|
|
std::env::remove_var("LLM_PROVIDERS");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn llm_providers_single() {
|
|
std::env::set_var("LLM_PROVIDERS", "ollama");
|
|
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
assert_eq!(cfg.providers, vec!["ollama"]);
|
|
std::env::remove_var("LLM_PROVIDERS");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn llm_providers_multiple() {
|
|
std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic");
|
|
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]);
|
|
std::env::remove_var("LLM_PROVIDERS");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn llm_providers_trims_whitespace() {
|
|
std::env::set_var("LLM_PROVIDERS", " ollama , openai ");
|
|
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
|
|
std::env::remove_var("LLM_PROVIDERS");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn llm_providers_filters_empty_entries() {
|
|
std::env::set_var("LLM_PROVIDERS", "ollama,,openai,");
|
|
let cfg = LlmProvidersConfig::from_env().unwrap();
|
|
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
|
|
std::env::remove_var("LLM_PROVIDERS");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// ServiceUrls::from_env() defaults
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn service_urls_default_ollama_url() {
|
|
std::env::remove_var("OLLAMA_URL");
|
|
let svc = ServiceUrls::from_env().unwrap();
|
|
assert_eq!(svc.ollama_url, "http://localhost:11434");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn service_urls_default_ollama_model() {
|
|
std::env::remove_var("OLLAMA_MODEL");
|
|
let svc = ServiceUrls::from_env().unwrap();
|
|
assert_eq!(svc.ollama_model, "llama3.1:8b");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn service_urls_default_searxng_url() {
|
|
std::env::remove_var("SEARXNG_URL");
|
|
let svc = ServiceUrls::from_env().unwrap();
|
|
assert_eq!(svc.searxng_url, "http://localhost:8888");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn service_urls_custom_ollama_url() {
|
|
std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
|
|
let svc = ServiceUrls::from_env().unwrap();
|
|
assert_eq!(svc.ollama_url, "http://gpu-host:11434");
|
|
std::env::remove_var("OLLAMA_URL");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn required_env_missing_returns_config_error() {
|
|
std::env::remove_var("__TEST_REQUIRED_MISSING__");
|
|
let result = required_env("__TEST_REQUIRED_MISSING__");
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("__TEST_REQUIRED_MISSING__"));
|
|
}
|
|
}
|