diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..de6f8a0 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,49 @@ +# Label Printer Hub — example environment configuration +# Copy this file to .env and fill in your values. +# NEVER commit .env with real secrets — .env is in .gitignore. + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +PRINTER_HUB_DATABASE_URL=sqlite:////data/printer-hub.db + +# --------------------------------------------------------------------------- +# Brother printers +# Fill in the IP address (or hostname) of each printer. +# Leave empty if the printer is not used. +# --------------------------------------------------------------------------- +PRINTER_HUB_QL820_HOST= +PRINTER_HUB_QL820_PORT=9100 + +PRINTER_HUB_PT750W_HOST= +PRINTER_HUB_PT750W_PORT=9100 + +# --------------------------------------------------------------------------- +# Webhook authentication +# Minimum 32 characters. Generate with: +# openssl rand -hex 32 +# --------------------------------------------------------------------------- +PRINTER_HUB_WEBHOOK_API_KEY= + +# --------------------------------------------------------------------------- +# Snipe-IT integration (optional) +# --------------------------------------------------------------------------- +PRINTER_HUB_SNIPEIT_URL= +PRINTER_HUB_SNIPEIT_API_KEY= + +# --------------------------------------------------------------------------- +# Grocy integration (optional) +# --------------------------------------------------------------------------- +PRINTER_HUB_GROCY_URL= +PRINTER_HUB_GROCY_API_KEY= + +# --------------------------------------------------------------------------- +# Spoolman integration (no API key required) +# --------------------------------------------------------------------------- +PRINTER_HUB_SPOOLMAN_URL= + +# --------------------------------------------------------------------------- +# Server +# --------------------------------------------------------------------------- +PRINTER_HUB_SERVER_PORT=8090 +PRINTER_HUB_LOG_LEVEL=INFO diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f3d8dc4 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,96 @@ +"""Runtime configuration via Pydantic Settings. + +All settings are read from environment variables prefixed with ``PRINTER_HUB_`` +(e.g. ``PRINTER_HUB_QL820_HOST``). A ``.env`` file in the working directory is +loaded automatically when present; values from the environment always take +precedence over ``.env`` values. + +Usage:: + + from app.config import get_settings + + settings = get_settings() + print(settings.ql820_host) + +:func:`get_settings` is cached with :func:`functools.lru_cache` so that +settings are only parsed once per process. Tests that instantiate +:class:`Settings` directly bypass the cache and get a fresh read each time — +this is intentional and safe. To keep tests hermetic and independent of any +local ``.env`` file, pass ``_env_file=None`` when constructing +:class:`Settings` in test code. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application-wide runtime configuration. + + Every field maps to an environment variable with the ``PRINTER_HUB_`` + prefix. See ``.env.example`` in the ``backend/`` directory for the full + list of supported variables and their defaults. + """ + + model_config = SettingsConfigDict( + env_prefix="PRINTER_HUB_", + env_file=".env", + extra="ignore", + ) + + # Database + database_url: str = "sqlite:////data/printer-hub.db" + + # Brother QL-820NWB — address label printer + ql820_host: str = "" + ql820_port: int = 9100 + + # Brother PT-750W — cable / panel label printer + pt750w_host: str = "" + pt750w_port: int = 9100 + + # Webhook authentication + webhook_api_key: SecretStr = SecretStr("") + + # Snipe-IT integration (optional) + snipeit_url: str = "" + snipeit_api_key: SecretStr = SecretStr("") + + # Grocy integration (optional) + grocy_url: str = "" + grocy_api_key: SecretStr = SecretStr("") + + # Spoolman integration (no API key needed) + spoolman_url: str = "" + + # Server + server_port: int = 8090 + log_level: str = "INFO" + + @field_validator("webhook_api_key") + @classmethod + def validate_api_key_length(cls, v: SecretStr) -> SecretStr: + """Reject keys shorter than 32 characters. + + An empty string is accepted so that the hub can start without + webhook authentication configured (the webhook endpoint will + refuse all requests at runtime, but startup succeeds). + """ + secret = v.get_secret_value() + if secret and len(secret) < 32: + raise ValueError("PRINTER_HUB_WEBHOOK_API_KEY must be at least 32 characters") + return v + + +@lru_cache +def get_settings() -> Settings: + """Return the application settings, cached for the process lifetime. + + Call ``get_settings.cache_clear()`` in tests that need a fresh read after + mutating environment variables. + """ + return Settings() diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py new file mode 100644 index 0000000..f6ec10a --- /dev/null +++ b/backend/tests/unit/test_config.py @@ -0,0 +1,38 @@ +"""Unit tests for app.config — Pydantic Settings.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from app.config import Settings + + +def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("PRINTER_HUB_DATABASE_URL", f"sqlite:///{tmp_path}/test.db") + monkeypatch.setenv("PRINTER_HUB_QL820_HOST", "192.0.2.10") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.0.2.11") + monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "test-key-32-bytes-long-enough-here") + monkeypatch.setenv("PRINTER_HUB_SNIPEIT_URL", "https://snipe-it.example") + monkeypatch.setenv("PRINTER_HUB_SNIPEIT_API_KEY", "snipeit-key") + monkeypatch.setenv("PRINTER_HUB_GROCY_URL", "https://grocy.example") + monkeypatch.setenv("PRINTER_HUB_GROCY_API_KEY", "grocy-key") + monkeypatch.setenv("PRINTER_HUB_SPOOLMAN_URL", "https://spoolman.example") + + settings = Settings(_env_file=None) + + assert settings.database_url == f"sqlite:///{tmp_path}/test.db" + assert settings.ql820_host == "192.0.2.10" + assert settings.pt750w_host == "192.0.2.11" + assert settings.webhook_api_key.get_secret_value() == "test-key-32-bytes-long-enough-here" + assert settings.snipeit_url == "https://snipe-it.example" + assert settings.snipeit_api_key.get_secret_value() == "snipeit-key" + assert settings.grocy_url == "https://grocy.example" + assert settings.grocy_api_key.get_secret_value() == "grocy-key" + assert settings.spoolman_url == "https://spoolman.example" + + +def test_settings_rejects_short_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "too-short") + with pytest.raises(ValueError, match="at least 32"): + Settings(_env_file=None)