Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand (Clean Architecture: domain → application → infrastructure → api). Wesentliche Bereiche: - ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben) - Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen, Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe - Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services - Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API - Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte Jüngste Änderungen dieser Session: - Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/ Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/ E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails) - Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde) umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/ Kundendaten), demo.mp4, .claude/, variocontrol-ai/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
629 lines
21 KiB
Rust
629 lines
21 KiB
Rust
//! Konfiguration aus einer `config.toml`-Datei.
|
|
//!
|
|
//! [`load`] liest die TOML-Datei (Pfad via `HOLZLEITNER_CONFIG`-Env
|
|
//! überschreibbar, sonst `config.toml` im Arbeitsverzeichnis) und
|
|
//! deserialisiert sie über `serde` in [`Config`]. Die Struktur ist nach
|
|
//! Bereichen in TOML-Sections gruppiert (`[server]`, `[database]`,
|
|
//! `[keycloak]`, …); fehlende optionale Sections/Felder fallen auf die
|
|
//! `#[serde(default = …)]`-Werte zurück.
|
|
//!
|
|
//! Die Vorlage liegt in `config.example.toml`; für lokale Entwicklung
|
|
//! `cp config.example.toml config.toml` und Werte anpassen. Die echte
|
|
//! `config.toml` enthält Secrets und gehört nicht in Git.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use chrono::NaiveDate;
|
|
use serde::Deserialize;
|
|
|
|
/// Env-Variable, mit der der Pfad zur Config-Datei überschrieben werden
|
|
/// kann (z. B. im Deployment). Ohne sie wird `config.toml` im
|
|
/// Arbeitsverzeichnis erwartet.
|
|
const CONFIG_PATH_ENV: &str = "HOLZLEITNER_CONFIG";
|
|
const DEFAULT_CONFIG_PATH: &str = "config.toml";
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Config {
|
|
pub server: ServerConfig,
|
|
pub database: DatabaseConfig,
|
|
#[allow(dead_code)] // wird in der Keycloak-Phase verdrahtet
|
|
pub keycloak: KeycloakConfig,
|
|
pub gsd: GsdConfig,
|
|
#[serde(default)]
|
|
pub signature: SignatureConfig,
|
|
#[serde(default)]
|
|
pub attachment: AttachmentConfig,
|
|
#[serde(default)]
|
|
pub report: ReportConfig,
|
|
#[serde(default)]
|
|
pub erp: ErpConfig,
|
|
#[serde(default)]
|
|
pub import: ImportConfig,
|
|
/// DEV-ONLY-Schalter (`today_override`, `sync_enabled`).
|
|
#[serde(default)]
|
|
pub dev: DevConfig,
|
|
/// Admin-API-Key-Gate auf `/admin`.
|
|
#[serde(default)]
|
|
pub admin: AdminConfig,
|
|
/// Log-Filter (Override via `RUST_LOG`-Env hat Vorrang, siehe main.rs).
|
|
#[serde(default)]
|
|
pub logging: LoggingConfig,
|
|
}
|
|
|
|
impl Config {
|
|
/// Loggt eine Zusammenfassung der geladenen Konfiguration beim Start.
|
|
/// Secrets (Passwörter, Client-Secrets, Hashes) werden **maskiert** —
|
|
/// nur „gesetzt/leer" ist sichtbar, nie der Klartext. Hilft, beim
|
|
/// Binary-Start auf einen Blick Fehlkonfigurationen zu erkennen (z. B.
|
|
/// einen aktiven `today_override`).
|
|
pub fn log_summary(&self) {
|
|
tracing::info!("════════════ Konfiguration (Start) ════════════");
|
|
tracing::info!(host = %self.server.host, port = self.server.port, "cfg.server");
|
|
tracing::info!(
|
|
url = %mask_db_url(&self.database.url),
|
|
max_connections = self.database.max_connections,
|
|
"cfg.database"
|
|
);
|
|
tracing::info!(
|
|
issuer_url = %self.keycloak.issuer_url,
|
|
audience = %self.keycloak.audience,
|
|
realm = %self.keycloak.realm,
|
|
admin_url = %display_or_unset(&self.keycloak.admin_url),
|
|
provisioning_enabled = self.keycloak.provisioning_enabled,
|
|
provisioner_client_id = %display_or_unset(&self.keycloak.provisioner_client_id),
|
|
provisioner_client_secret = %mask(&self.keycloak.provisioner_client_secret),
|
|
driver_role = %self.keycloak.driver_role,
|
|
driver_default_password = %mask(&self.keycloak.driver_default_password),
|
|
jwks_cache_ttl_seconds = self.keycloak.jwks_cache_ttl_seconds,
|
|
"cfg.keycloak"
|
|
);
|
|
tracing::info!(
|
|
host = %display_or_unset(&self.erp.host),
|
|
port = self.erp.port,
|
|
database = %display_or_unset(&self.erp.database),
|
|
user = %display_or_unset(&self.erp.user),
|
|
password = %mask(&self.erp.password),
|
|
trust_cert = self.erp.trust_cert,
|
|
writeback_enabled = self.erp.writeback_enabled,
|
|
"cfg.erp"
|
|
);
|
|
tracing::info!(
|
|
enabled = self.import.enabled,
|
|
cron = %self.import.cron,
|
|
date_offset_days = self.import.date_offset_days,
|
|
"cfg.import (scheduler)"
|
|
);
|
|
tracing::info!(
|
|
rest_url = %self.gsd.rest_url,
|
|
user = %self.gsd.user,
|
|
app_key = %mask(&self.gsd.app_key),
|
|
password_md5 = %mask(&self.gsd.password_md5),
|
|
app_names = ?self.gsd.app_names,
|
|
"cfg.gsd"
|
|
);
|
|
tracing::info!(storage_dir = %self.signature.storage_dir, "cfg.signature");
|
|
tracing::info!(storage_dir = %self.attachment.storage_dir, "cfg.attachment (Bild-Notizen lokal)");
|
|
tracing::info!(
|
|
storage_dir = %self.report.storage_dir,
|
|
upload_enabled = self.report.upload_enabled,
|
|
retry_cron = %self.report.retry_cron,
|
|
"cfg.report (PDF-Report → DOCUframe)"
|
|
);
|
|
if self.report.upload_enabled {
|
|
tracing::warn!("report.upload_enabled=true: Reports werden an DOCUframe übertragen + lokale Dateien danach gelöscht");
|
|
}
|
|
match &self.dev.today_override {
|
|
Some(date) => tracing::warn!(
|
|
today_override = %date,
|
|
"cfg.DEV: dev.today_override AKTIV — 'heute' ist überschrieben! \
|
|
In Produktion entfernen."
|
|
),
|
|
None => tracing::info!("cfg.dev.today_override = (echte Uhr)"),
|
|
}
|
|
if self.dev.sync_enabled {
|
|
tracing::warn!(
|
|
"cfg.DEV: dev.sync_enabled AKTIV — ungeschützter Endpoint \
|
|
POST /dev/resync löscht Postgres-Tourdaten! In Produktion aus."
|
|
);
|
|
}
|
|
tracing::info!(
|
|
admin_api_key = %mask(&self.admin.api_key),
|
|
"cfg.admin (X-Admin-Api-Key Gate auf /admin)"
|
|
);
|
|
if self.admin.api_key.is_empty() {
|
|
tracing::warn!(
|
|
"cfg.admin: admin.api_key LEER — alle /admin-Endpunkte sind \
|
|
GESPERRT (fail-closed). Zum Aktivieren admin.api_key setzen."
|
|
);
|
|
}
|
|
tracing::info!("═══════════════════════════════════════════════");
|
|
}
|
|
}
|
|
|
|
/// Maskiert ein Secret: zeigt nur, ob gesetzt — nie den Klartext.
|
|
fn mask(secret: &str) -> &'static str {
|
|
if secret.is_empty() {
|
|
"(leer)"
|
|
} else {
|
|
"***gesetzt***"
|
|
}
|
|
}
|
|
|
|
/// Zeigt den Wert oder `(leer)` für optionale, nicht-geheime Felder.
|
|
fn display_or_unset(value: &str) -> String {
|
|
if value.is_empty() {
|
|
"(leer)".to_string()
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}
|
|
|
|
/// Maskiert das Passwort in einer DB-URL: `scheme://user:pass@host` →
|
|
/// `scheme://user:***@host`. Lässt alles andere unverändert.
|
|
fn mask_db_url(url: &str) -> String {
|
|
let Some(scheme_end) = url.find("://") else {
|
|
return url.to_string();
|
|
};
|
|
let (scheme, rest) = url.split_at(scheme_end + 3);
|
|
let Some(at) = rest.find('@') else {
|
|
return url.to_string();
|
|
};
|
|
let (creds, host) = rest.split_at(at); // host beginnt mit '@'
|
|
let Some(colon) = creds.find(':') else {
|
|
return url.to_string();
|
|
};
|
|
let user = &creds[..colon];
|
|
format!("{scheme}{user}:***{host}")
|
|
}
|
|
|
|
/// Lese-Zugang zur ERPframe-MS-SQL-DB (für den täglichen Touren-Pull).
|
|
/// Alle Felder optional defaultet — der Server bootet auch ohne ERP-Config,
|
|
/// solange `import.enabled = false` (Default) ist.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ErpConfig {
|
|
#[serde(default)]
|
|
pub host: String,
|
|
#[serde(default = "default_mssql_port")]
|
|
pub port: u16,
|
|
#[serde(default)]
|
|
pub database: String,
|
|
#[serde(default)]
|
|
pub user: String,
|
|
#[serde(default)]
|
|
pub password: String,
|
|
/// Selbstsigniertes Server-Zertifikat akzeptieren (Intranet-DB).
|
|
#[serde(default = "default_true")]
|
|
pub trust_cert: bool,
|
|
/// ERP-**Rückschreiben** beim Lieferabschluss aktiv? Default `false` —
|
|
/// dann bleibt der Abschluss rein lokal (Dev/Seed ohne ERP-Beleg).
|
|
#[serde(default)]
|
|
pub writeback_enabled: bool,
|
|
}
|
|
|
|
impl Default for ErpConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
host: String::new(),
|
|
port: default_mssql_port(),
|
|
database: String::new(),
|
|
user: String::new(),
|
|
password: String::new(),
|
|
trust_cert: default_true(),
|
|
writeback_enabled: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_mssql_port() -> u16 {
|
|
1433
|
|
}
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Steuert den geplanten ERP-Import.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ImportConfig {
|
|
/// Default `false` — der Scheduler startet nur, wenn explizit aktiviert.
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
/// Cron-Ausdruck (tokio-cron-scheduler, 6-stellig inkl. Sekunden).
|
|
/// Default: täglich 03:00:00.
|
|
#[serde(default = "default_import_cron")]
|
|
pub cron: String,
|
|
/// Versatz in Tagen relativ zu „heute" für das Ziel-Tourdatum.
|
|
/// Default `1` = morgen.
|
|
#[serde(default = "default_import_offset")]
|
|
pub date_offset_days: i64,
|
|
}
|
|
|
|
impl Default for ImportConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
cron: default_import_cron(),
|
|
date_offset_days: default_import_offset(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_import_cron() -> String {
|
|
"0 0 3 * * *".to_string()
|
|
}
|
|
fn default_import_offset() -> i64 {
|
|
1
|
|
}
|
|
|
|
/// Lokaler Speicher für Unterschriften-PNGs (Kunde + Fahrer). Bewusst NICHT
|
|
/// DOCUframe — die Bilder bleiben auf der Backend-Maschine.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SignatureConfig {
|
|
/// Basis-Verzeichnis für die PNG-Dateien (wird beim Start angelegt).
|
|
#[serde(default = "default_signature_dir")]
|
|
pub storage_dir: String,
|
|
}
|
|
|
|
impl Default for SignatureConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
storage_dir: default_signature_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_signature_dir() -> String {
|
|
"./data/signatures".to_string()
|
|
}
|
|
|
|
/// Lokaler Speicher für hochgeladene Bild-Notizen. Statt DOCUframe landen die
|
|
/// Bilder pro Belegnummer in einem Unterordner (`<dir>/<Belegnummer>/…`).
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct AttachmentConfig {
|
|
/// Basis-Verzeichnis (wird beim Start angelegt).
|
|
#[serde(default = "default_attachment_dir")]
|
|
pub storage_dir: String,
|
|
}
|
|
|
|
impl Default for AttachmentConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
storage_dir: default_attachment_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_attachment_dir() -> String {
|
|
"./data/attachments".to_string()
|
|
}
|
|
|
|
/// Lokaler (temporärer) Speicher für die PDF-Lieferreports. Später wird der
|
|
/// Blob direkt an ein DOCUframe-Makro gesendet (Sink-Stub vorhanden).
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ReportConfig {
|
|
/// Basis-Verzeichnis (wird beim Start angelegt).
|
|
#[serde(default = "default_report_dir")]
|
|
pub storage_dir: String,
|
|
/// Schaltet die DOCUframe-Übertragung des Reports beim Abschluss + den
|
|
/// Retry-Cron frei. Aus = nur lokale Ablage (Dev / DOCUframe nicht erreichbar).
|
|
#[serde(default)]
|
|
pub upload_enabled: bool,
|
|
/// Cron-Ausdruck (6-stellig, mit Sekunden) für den Retry der offenen
|
|
/// Report-Jobs. Default: alle 5 Minuten.
|
|
#[serde(default = "default_report_retry_cron")]
|
|
pub retry_cron: String,
|
|
}
|
|
|
|
impl Default for ReportConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
storage_dir: default_report_dir(),
|
|
upload_enabled: false,
|
|
retry_cron: default_report_retry_cron(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_report_dir() -> String {
|
|
"./data/reports".to_string()
|
|
}
|
|
|
|
fn default_report_retry_cron() -> String {
|
|
"0 */5 * * * *".to_string()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ServerConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DatabaseConfig {
|
|
pub url: String,
|
|
#[serde(default = "default_max_connections")]
|
|
pub max_connections: u32,
|
|
}
|
|
|
|
fn default_max_connections() -> u32 {
|
|
10
|
|
}
|
|
|
|
/// Felder werden in der Keycloak-Phase angefasst — bis dahin Dead-Code-
|
|
/// Warnings unterdrücken.
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct KeycloakConfig {
|
|
pub issuer_url: String,
|
|
pub audience: String,
|
|
#[serde(default = "default_jwks_cache_ttl")]
|
|
pub jwks_cache_ttl_seconds: u64,
|
|
|
|
// --- Provisioning via Admin-API (ERP-Sync legt Fahrer-Konten an) ------
|
|
/// Basis-URL der Keycloak-Instanz **ohne** `/realms/...`, z. B.
|
|
/// `http://localhost:8080`. Leer ⇒ Provisionierung nicht möglich.
|
|
#[serde(default)]
|
|
pub admin_url: String,
|
|
/// Realm-Name. Default `holzleitner`.
|
|
#[serde(default = "default_realm")]
|
|
pub realm: String,
|
|
/// Service-Account-Client (confidential) für die Admin-API.
|
|
#[serde(default)]
|
|
pub provisioner_client_id: String,
|
|
#[serde(default)]
|
|
pub provisioner_client_secret: String,
|
|
/// Temporäres Default-Passwort für neu angelegte Fahrer-Konten (muss beim
|
|
/// ersten Login geändert werden).
|
|
#[serde(default = "default_driver_password")]
|
|
pub driver_default_password: String,
|
|
/// Realm-Rolle, die jedem Fahrer zugewiesen wird. Default `driver`.
|
|
#[serde(default = "default_driver_role")]
|
|
pub driver_role: String,
|
|
/// Konto-Provisionierung beim Sync aktiv? Default `false`.
|
|
#[serde(default)]
|
|
pub provisioning_enabled: bool,
|
|
}
|
|
|
|
fn default_jwks_cache_ttl() -> u64 {
|
|
3600
|
|
}
|
|
fn default_realm() -> String {
|
|
"holzleitner".to_string()
|
|
}
|
|
fn default_driver_password() -> String {
|
|
"Holzleitner-Start1!".to_string()
|
|
}
|
|
fn default_driver_role() -> String {
|
|
"driver".to_string()
|
|
}
|
|
|
|
/// Anbindung an die GSD/DOCUframe-REST-API (für Datei-Uploads).
|
|
///
|
|
/// Login läuft über einen technischen Service-Account; `password_md5` ist
|
|
/// bereits der MD5-Hash des Passworts (GSD erwartet `pass` als MD5) — so
|
|
/// liegt kein Klartext-Secret in der Konfiguration.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct GsdConfig {
|
|
/// Basis-URL der GSD-REST-API (ohne abschließenden Slash), z. B.
|
|
/// `http://192.168.1.9:8334`.
|
|
pub rest_url: String,
|
|
pub app_key: String,
|
|
pub user: String,
|
|
/// MD5-Hash des Service-Account-Passworts.
|
|
pub password_md5: String,
|
|
/// App-Namen als TOML-Array, z. B. `["GSD-RestApi"]`.
|
|
pub app_names: Vec<String>,
|
|
}
|
|
|
|
/// DEV-ONLY-Schalter. Beide Felder defaulten auf „aus".
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DevConfig {
|
|
/// Überschreibt das serverseitige „heute" für `GET /me/tours/today`.
|
|
/// Als **quotierter** String `"YYYY-MM-DD"` angeben — ein bloßes
|
|
/// `2026-06-01` würde TOML als nativen Datums-Typ lesen, den chrono
|
|
/// nicht direkt akzeptiert. `None`/weglassen = echte Uhr. Nur zum
|
|
/// Testen mit historischen/importierten Touren.
|
|
#[serde(default)]
|
|
pub today_override: Option<NaiveDate>,
|
|
/// Schaltet den ungeschützten Dev-Resync-Endpoint (`POST /dev/resync`)
|
|
/// frei, der Postgres platt macht und neu importiert. Default `false`
|
|
/// → Endpoint wird gar nicht erst gemountet.
|
|
#[serde(default)]
|
|
pub sync_enabled: bool,
|
|
}
|
|
|
|
/// Admin-API-Key-Gate auf `/admin`.
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct AdminConfig {
|
|
/// Statischer API-Key für die `/admin`-Endpunkte (Maschinen-Zugang per
|
|
/// Header `X-Admin-Api-Key`, KEIN Keycloak/JWT). **Leer ⇒ alle
|
|
/// Admin-Routen sind gesperrt** (fail-closed). In Produktion einen
|
|
/// hochentropischen Zufallswert setzen, nie committen.
|
|
#[serde(default)]
|
|
pub api_key: String,
|
|
}
|
|
|
|
/// Logging-Filter (tracing-subscriber `EnvFilter`-Syntax).
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct LoggingConfig {
|
|
/// Default-Filter. Die `RUST_LOG`-Env-Variable hat Vorrang (siehe
|
|
/// `main.rs`), damit Ad-hoc-Debugging ohne Datei-Edit möglich bleibt.
|
|
#[serde(default = "default_log_filter")]
|
|
pub filter: String,
|
|
}
|
|
|
|
impl Default for LoggingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
filter: default_log_filter(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_log_filter() -> String {
|
|
// WICHTIG: Der Binary-Crate heißt `holzleitner_server` (vom `[[bin]]
|
|
// name`), NICHT `holzleitner_api` — sonst werden alle Logs aus
|
|
// main.rs/config.rs (Startup + Config-Übersicht) herausgefiltert.
|
|
"holzleitner_server=info,holzleitner_api=info,\
|
|
holzleitner_application=info,holzleitner_infrastructure=info,\
|
|
tower_http=info"
|
|
.to_string()
|
|
}
|
|
|
|
/// Pfad zur Config-Datei: `HOLZLEITNER_CONFIG`-Env, sonst `config.toml`.
|
|
fn config_path() -> PathBuf {
|
|
match std::env::var(CONFIG_PATH_ENV) {
|
|
Ok(p) if !p.trim().is_empty() => PathBuf::from(p.trim()),
|
|
_ => PathBuf::from(DEFAULT_CONFIG_PATH),
|
|
}
|
|
}
|
|
|
|
/// Lädt die Konfiguration aus der TOML-Datei.
|
|
///
|
|
/// Reihenfolge:
|
|
/// 1. Pfad bestimmen (`HOLZLEITNER_CONFIG`-Env oder `config.toml`).
|
|
/// 2. Datei lesen und mit `toml`/`serde` in [`Config`] deserialisieren.
|
|
/// Fehlende optionale Sections/Felder fallen auf die `serde`-Defaults.
|
|
pub fn load() -> Result<Config, ConfigError> {
|
|
let path = config_path();
|
|
let raw = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
parse(&raw).map_err(|source| ConfigError::Parse { path, source })
|
|
}
|
|
|
|
/// Deserialisiert TOML-Text in [`Config`]. Ausgelagert, damit er in Tests
|
|
/// ohne Dateizugriff aufgerufen werden kann.
|
|
fn parse(raw: &str) -> Result<Config, toml::de::Error> {
|
|
toml::from_str::<Config>(raw)
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ConfigError {
|
|
// PathBuf implementiert kein Display → Debug-Format `{path:?}`.
|
|
#[error("config-Datei {path:?} konnte nicht gelesen werden: {source}")]
|
|
Read {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
#[error("config-Datei {path:?} ist ungültig: {source}")]
|
|
Parse {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: toml::de::Error,
|
|
},
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Minimal-Config mit nur den Pflicht-Sections — optionale Sections
|
|
/// fallen auf Defaults zurück.
|
|
#[test]
|
|
fn parses_minimal_config() {
|
|
let raw = r#"
|
|
[server]
|
|
host = "0.0.0.0"
|
|
port = 3000
|
|
|
|
[database]
|
|
url = "postgres://u:p@localhost/db"
|
|
|
|
[keycloak]
|
|
issuer_url = "http://localhost:8080/realms/holzleitner"
|
|
audience = "holzleitner-api"
|
|
|
|
[gsd]
|
|
rest_url = "http://localhost:8334"
|
|
app_key = "k"
|
|
user = "u"
|
|
password_md5 = "abc"
|
|
app_names = ["GSD-RestApi"]
|
|
"#;
|
|
let cfg = parse(raw).expect("minimal config should parse");
|
|
assert_eq!(cfg.server.port, 3000);
|
|
assert_eq!(cfg.database.max_connections, 10); // default
|
|
assert_eq!(cfg.report.storage_dir, "./data/reports"); // default section
|
|
assert!(!cfg.dev.sync_enabled); // default
|
|
assert!(cfg.dev.today_override.is_none());
|
|
assert!(cfg.admin.api_key.is_empty());
|
|
assert_eq!(cfg.erp.port, 1433); // default
|
|
}
|
|
|
|
/// `today_override` wird als `YYYY-MM-DD` geparst.
|
|
#[test]
|
|
fn parses_today_override_and_arrays() {
|
|
let raw = r#"
|
|
[server]
|
|
host = "0.0.0.0"
|
|
port = 3000
|
|
|
|
[database]
|
|
url = "postgres://u:p@localhost/db"
|
|
|
|
[keycloak]
|
|
issuer_url = "iss"
|
|
audience = "aud"
|
|
|
|
[gsd]
|
|
rest_url = "http://localhost:8334"
|
|
app_key = "k"
|
|
user = "u"
|
|
password_md5 = "abc"
|
|
app_names = ["A", "B"]
|
|
|
|
[dev]
|
|
today_override = "2026-06-01"
|
|
sync_enabled = true
|
|
"#;
|
|
let cfg = parse(raw).expect("config should parse");
|
|
assert_eq!(
|
|
cfg.dev.today_override,
|
|
Some(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap())
|
|
);
|
|
assert!(cfg.dev.sync_enabled);
|
|
assert_eq!(cfg.gsd.app_names, vec!["A".to_string(), "B".to_string()]);
|
|
}
|
|
|
|
/// Unbekannte Schlüssel werden abgewiesen — schützt vor Tippfehlern.
|
|
#[test]
|
|
fn rejects_unknown_keys() {
|
|
let raw = r#"
|
|
[server]
|
|
host = "0.0.0.0"
|
|
port = 3000
|
|
bogus = true
|
|
|
|
[database]
|
|
url = "postgres://u:p@localhost/db"
|
|
|
|
[keycloak]
|
|
issuer_url = "iss"
|
|
audience = "aud"
|
|
|
|
[gsd]
|
|
rest_url = "r"
|
|
app_key = "k"
|
|
user = "u"
|
|
password_md5 = "abc"
|
|
app_names = []
|
|
"#;
|
|
assert!(parse(raw).is_err());
|
|
}
|
|
}
|