5435b60b80
Introduced a `test_default` method to `Config` to provide sane defaults for integration tests. This refactors several test files to use this new method, reducing boilerplate and improving maintainability. Added `LLM_TIMEOUT_SECONDS` to the environment variables and `Config` struct.
302 lines
10 KiB
Rust
302 lines
10 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// Per-user Whisper transcription settings from users.toml.
|
|
/// All fields optional — missing `[user.whisper]` block yields defaults (None).
|
|
#[derive(Debug, Default, Clone, Deserialize)]
|
|
pub struct WhisperUserSettings {
|
|
pub language: Option<String>,
|
|
pub hotwords: Option<String>,
|
|
pub initial_prompt: Option<String>,
|
|
}
|
|
|
|
/// A single user entry from users.toml.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct User {
|
|
pub slug: String,
|
|
pub api_key: String,
|
|
pub web_password: String,
|
|
pub role: String,
|
|
#[serde(default)]
|
|
pub whisper: WhisperUserSettings,
|
|
}
|
|
|
|
/// Wrapper for deserializing the [[user]] array from TOML.
|
|
#[derive(Deserialize)]
|
|
struct UsersFile {
|
|
user: Vec<User>,
|
|
}
|
|
|
|
pub struct Config {
|
|
// Phase 1 — required
|
|
pub server_port: u16,
|
|
pub data_path: PathBuf,
|
|
pub log_level: String,
|
|
pub log_path: PathBuf,
|
|
pub log_max_days: u32,
|
|
pub users: Vec<User>,
|
|
pub api_keys: HashMap<String, String>, // api_key_value → slug
|
|
|
|
// Phase 2+ — optional with defaults
|
|
pub retention_audio_days: u32,
|
|
pub retention_transcript_days: u32,
|
|
pub retention_document_days: u32,
|
|
pub whisper_url: String,
|
|
pub whisper_timeout_seconds: u64,
|
|
pub ollama_url: String,
|
|
pub ollama_model: String,
|
|
pub ollama_keep_alive: u32,
|
|
pub llm_url: String,
|
|
pub llm_api_key: String,
|
|
pub llm_model: String,
|
|
pub llm_temperature: f32,
|
|
pub llm_timeout_seconds: u64,
|
|
pub session_timeout_hours: u32,
|
|
/// Whether to set the `Secure` flag on the session cookie. Default `true`.
|
|
/// Set `COOKIE_SECURE=false` only for plain-HTTP local development —
|
|
/// browsers refuse `Secure` cookies on non-HTTPS origins.
|
|
pub cookie_secure: bool,
|
|
}
|
|
|
|
impl Config {
|
|
pub fn from_env() -> Self {
|
|
let users_file = required_env("USERS_FILE");
|
|
let (users, api_keys) = load_users(&users_file);
|
|
|
|
Self {
|
|
server_port: required_env_parsed("SERVER_PORT"),
|
|
data_path: PathBuf::from(required_env("DATA_PATH")),
|
|
log_level: optional_env("LOG_LEVEL", "info"),
|
|
log_path: PathBuf::from(optional_env("LOG_PATH", "/var/log/recorder")),
|
|
log_max_days: optional_env_parsed("LOG_MAX_DAYS", 90),
|
|
users,
|
|
api_keys,
|
|
|
|
retention_audio_days: optional_env_parsed("RETENTION_AUDIO_DAYS", 30),
|
|
retention_transcript_days: optional_env_parsed("RETENTION_TRANSCRIPT_DAYS", 30),
|
|
retention_document_days: optional_env_parsed("RETENTION_DOCUMENT_DAYS", 0),
|
|
whisper_url: optional_env("WHISPER_URL", "http://localhost:10300"),
|
|
whisper_timeout_seconds: optional_env_parsed("WHISPER_TIMEOUT_SECONDS", 120),
|
|
ollama_url: optional_env("OLLAMA_URL", "http://localhost:11434"),
|
|
ollama_model: optional_env("OLLAMA_MODEL", "gemma3:4b"),
|
|
ollama_keep_alive: optional_env_parsed("OLLAMA_KEEP_ALIVE", 0),
|
|
llm_url: optional_env("LLM_URL", ""),
|
|
llm_api_key: optional_env("LLM_API_KEY", ""),
|
|
llm_model: optional_env("LLM_MODEL", ""),
|
|
llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0),
|
|
llm_timeout_seconds: optional_env_parsed("LLM_TIMEOUT_SECONDS", 180),
|
|
session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8),
|
|
cookie_secure: optional_env_parsed("COOKIE_SECURE", true),
|
|
}
|
|
}
|
|
|
|
/// True iff all three required fields for the external analysis LLM are
|
|
/// non-empty. Used to gate the "Fall abschließen" UI and handler — if
|
|
/// no LLM is configured, the close action must not be reachable.
|
|
pub fn llm_configured(&self) -> bool {
|
|
!self.llm_url.is_empty() && !self.llm_api_key.is_empty() && !self.llm_model.is_empty()
|
|
}
|
|
|
|
/// Sane defaults for integration tests. Not a `Default` impl on purpose:
|
|
/// production code must go through `from_env()`, and `Default::default()`
|
|
/// carries an implicit "safe fallback" connotation this value does not
|
|
/// satisfy (empty users, `/tmp` paths). Override fields via
|
|
/// struct-update syntax:
|
|
///
|
|
/// ```ignore
|
|
/// let cfg = Config {
|
|
/// data_path: tmp_dir,
|
|
/// users: vec![my_user],
|
|
/// ..Config::test_default()
|
|
/// };
|
|
/// ```
|
|
#[doc(hidden)]
|
|
pub fn test_default() -> Self {
|
|
Self {
|
|
server_port: 3000,
|
|
data_path: PathBuf::from("/tmp"),
|
|
log_level: "info".into(),
|
|
log_path: PathBuf::from("/tmp"),
|
|
log_max_days: 90,
|
|
users: Vec::new(),
|
|
api_keys: HashMap::new(),
|
|
retention_audio_days: 30,
|
|
retention_transcript_days: 30,
|
|
retention_document_days: 0,
|
|
whisper_url: "http://localhost:10300".into(),
|
|
whisper_timeout_seconds: 120,
|
|
ollama_url: "http://localhost:11434".into(),
|
|
ollama_model: "gemma3:4b".into(),
|
|
ollama_keep_alive: 0,
|
|
llm_url: String::new(),
|
|
llm_api_key: String::new(),
|
|
llm_model: String::new(),
|
|
llm_temperature: 0.0,
|
|
llm_timeout_seconds: 180,
|
|
session_timeout_hours: 8,
|
|
cookie_secure: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load users from a TOML file and build the API key lookup map.
|
|
fn load_users(path: &str) -> (Vec<User>, HashMap<String, String>) {
|
|
let content = std::fs::read_to_string(path)
|
|
.unwrap_or_else(|e| panic!("Failed to read users file {path}: {e}"));
|
|
|
|
let parsed: UsersFile = toml::from_str(&content)
|
|
.unwrap_or_else(|e| panic!("Failed to parse users file {path}: {e}"));
|
|
|
|
if parsed.user.is_empty() {
|
|
panic!("No users defined in {path}. At least one is required.");
|
|
}
|
|
|
|
let api_keys = validate_and_index_users(&parsed.user)
|
|
.unwrap_or_else(|e| panic!("Invalid users file {path}: {e}"));
|
|
|
|
(parsed.user, api_keys)
|
|
}
|
|
|
|
/// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on
|
|
/// data directories) and duplicate api_keys (ambiguous authentication).
|
|
pub fn validate_and_index_users(users: &[User]) -> Result<HashMap<String, String>, String> {
|
|
let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
let mut api_keys: HashMap<String, String> = HashMap::new();
|
|
|
|
for u in users {
|
|
if !slugs.insert(u.slug.as_str()) {
|
|
return Err(format!("duplicate slug: {}", u.slug));
|
|
}
|
|
if let Some(existing) = api_keys.insert(u.api_key.clone(), u.slug.clone()) {
|
|
return Err(format!(
|
|
"duplicate api_key shared by users {existing} and {}",
|
|
u.slug
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(api_keys)
|
|
}
|
|
|
|
/// Read a required environment variable. Panics with a clear message if missing.
|
|
fn required_env(name: &str) -> String {
|
|
std::env::var(name)
|
|
.unwrap_or_else(|_| panic!("Missing required environment variable: {name}"))
|
|
}
|
|
|
|
/// Read a required environment variable and parse it. Panics if missing or unparseable.
|
|
fn required_env_parsed<T: std::str::FromStr>(name: &str) -> T
|
|
where
|
|
T::Err: std::fmt::Display,
|
|
{
|
|
let raw = required_env(name);
|
|
raw.parse()
|
|
.unwrap_or_else(|e| panic!("Invalid value for {name}: {e}"))
|
|
}
|
|
|
|
/// Read an optional environment variable, falling back to a default.
|
|
fn optional_env(name: &str, default: &str) -> String {
|
|
std::env::var(name).unwrap_or_else(|_| default.to_owned())
|
|
}
|
|
|
|
/// Read an optional environment variable and parse it, falling back to a default.
|
|
fn optional_env_parsed<T: std::str::FromStr>(name: &str, default: T) -> T
|
|
where
|
|
T::Err: std::fmt::Display,
|
|
{
|
|
match std::env::var(name) {
|
|
Ok(raw) => raw
|
|
.parse()
|
|
.unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")),
|
|
Err(_) => default,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn u(slug: &str, api_key: &str) -> User {
|
|
User {
|
|
slug: slug.into(),
|
|
api_key: api_key.into(),
|
|
web_password: String::new(),
|
|
role: "doctor".into(),
|
|
whisper: WhisperUserSettings::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_whisper_block() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let u = &parsed.user[0];
|
|
assert!(u.whisper.language.is_none());
|
|
assert!(u.whisper.hotwords.is_none());
|
|
assert!(u.whisper.initial_prompt.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_full_whisper_block() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
[user.whisper]
|
|
language = "de"
|
|
hotwords = "HOCM Valsalva"
|
|
initial_prompt = "Kardiologie"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let w = &parsed.user[0].whisper;
|
|
assert_eq!(w.language.as_deref(), Some("de"));
|
|
assert_eq!(w.hotwords.as_deref(), Some("HOCM Valsalva"));
|
|
assert_eq!(w.initial_prompt.as_deref(), Some("Kardiologie"));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_admin_role() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "root"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "admin"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(parsed.user[0].role, "admin");
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_distinct_users() {
|
|
let users = vec![u("a", "k1"), u("b", "k2")];
|
|
let map = validate_and_index_users(&users).unwrap();
|
|
assert_eq!(map.len(), 2);
|
|
assert_eq!(map.get("k1").unwrap(), "a");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_api_keys() {
|
|
let users = vec![u("a", "same"), u("b", "same")];
|
|
let err = validate_and_index_users(&users).unwrap_err();
|
|
assert!(err.contains("duplicate api_key"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_slugs() {
|
|
let users = vec![u("same", "k1"), u("same", "k2")];
|
|
let err = validate_and_index_users(&users).unwrap_err();
|
|
assert!(err.contains("duplicate slug"), "got: {err}");
|
|
}
|
|
}
|