diff --git a/backend/app/config.py b/backend/app/config.py index ee451db..f786675 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,10 +34,13 @@ from __future__ import annotations from functools import lru_cache +from typing import Literal from pydantic import Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + class Settings(BaseSettings): """Application-wide runtime configuration. @@ -79,7 +82,10 @@ class Settings(BaseSettings): # Server server_port: int = 8090 - log_level: str = "INFO" + log_level: LogLevel = "INFO" + """Python-Loglevel — auf die fünf dokumentierten Werte beschränkt + (Issue #46). Ungültige Werte (Tippfehler, lowercase) erzeugen einen + ValidationError beim Startup statt lautlos defaulten.""" # SSE EventBus — configurable resource limits sse_queue_size: int = Field(default=32, gt=0) @@ -130,16 +136,32 @@ class Settings(BaseSettings): @field_validator("webhook_api_key") @classmethod def validate_api_key_length(cls, v: SecretStr) -> SecretStr: - """Reject keys shorter than 32 characters. + """Reject keys whose effective length (after stripping whitespace) is < 32. 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). + + Issue #46: whitespace-only keys (e.g. accidentally pasted as + ``\" \"``) sind faktisch leere Keys — + sie schützen nichts und sollten beim Startup fehlschlagen statt + eine trügerische Sicherheit zu suggerieren. + + Gemini-Review (#116): die alte Implementierung verglich `len(secret) < 32` + gegen die **Rohlänge**. Das liess ``" " * 31 + "a"`` durch — 32 Zeichen + formal, aber effektiv 1 Zeichen Auth-Material. Wir messen die Stripped- + Länge und geben den getrimmten Wert als neuen SecretStr zurück, damit + spätere Vergleiche konsistent gegen den effektiven Key laufen. """ 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 + if not secret: + return v + stripped = secret.strip() + if len(stripped) < 32: + raise ValueError( + "PRINTER_HUB_WEBHOOK_API_KEY must be at least 32 non-whitespace characters" + ) + return SecretStr(stripped) @lru_cache diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py index b5dccbe..395d23f 100644 --- a/backend/tests/unit/test_config.py +++ b/backend/tests/unit/test_config.py @@ -163,3 +163,102 @@ def test_settings_printers_config_env_override(monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv("PRINTER_HUB_PRINTERS_CONFIG", "/custom/path/printers.yaml") s = Settings(_env_file=None) assert s.printers_config == "/custom/path/printers.yaml" + + +# --------------------------------------------------------------------------- +# Issue #46 — Stricter validation for log_level and webhook_api_key +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "level", + ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], +) +def test_settings_log_level_accepts_valid(level: str) -> None: + """Alle dokumentierten Python-Loglevels muessen akzeptiert werden.""" + s = Settings(log_level=level, _env_file=None) # type: ignore[call-arg] + assert s.log_level == level + + +@pytest.mark.parametrize( + "invalid", + ["debug", "info", "TRACE", "VERBOSE", "", "INFOO"], +) +def test_settings_log_level_rejects_invalid(invalid: str) -> None: + """log_level=Literal: ungueltige Werte muessen einen ValidationError werfen.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Settings(log_level=invalid, _env_file=None) # type: ignore[call-arg] + + +def test_settings_log_level_default_is_info() -> None: + """Default-Loglevel bleibt INFO (keine Regression).""" + s = Settings(_env_file=None) + assert s.log_level == "INFO" + + +@pytest.mark.parametrize( + "whitespace_key", + [ + " " * 32, + "\t" * 33, + " \t\n" * 12 + " ", + ], +) +def test_settings_webhook_api_key_rejects_whitespace_only(whitespace_key: str) -> None: + """Whitespace-only Keys mit >=32 Zeichen muessen abgelehnt werden. + + Ohne Validation wuerden Tippfehler wie ein versehentlicher Space-Wall + im .env durchgehen — und der Webhook-Endpunkt akzeptiert dann jedes + Header-Token das whitespace ist. + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="whitespace"): + Settings(webhook_api_key=whitespace_key, _env_file=None) # type: ignore[call-arg] + + +def test_settings_webhook_api_key_accepts_real_key() -> None: + """Echte Keys (32+ Zeichen, kein whitespace-only) bleiben akzeptiert.""" + real_key = "a" * 32 + s = Settings(webhook_api_key=real_key, _env_file=None) # type: ignore[call-arg] + assert s.webhook_api_key.get_secret_value() == real_key + + +def test_settings_webhook_api_key_empty_still_accepted() -> None: + """Leerer Key (Phase 1-Default) bleibt erlaubt fuer Bootstrap ohne Webhook-Auth.""" + s = Settings(webhook_api_key="", _env_file=None) # type: ignore[call-arg] + assert s.webhook_api_key.get_secret_value() == "" + + +@pytest.mark.parametrize( + "padded_key", + [ + " " * 31 + "a", # Gemini-Finding: 32 Zeichen formal, 1 Zeichen echt + " " * 32 + "abc", # langer Whitespace-Padding + "\t\tshort\n", # mit Newline/Tab + ], +) +def test_settings_webhook_api_key_rejects_short_effective_length(padded_key: str) -> None: + """Stripped-Laenge <32 muss abgelehnt werden, auch wenn Rohlaenge >=32. + + Gemini-Review #116: ``" " * 31 + "a"`` hatte mit der alten Validation + durchgegangen — 32 Zeichen formal, aber 1 Zeichen Auth-Material. Das + war das gleiche Sicherheitsrisiko wie ein zu kurzer Key. + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="at least 32 non-whitespace"): + Settings(webhook_api_key=padded_key, _env_file=None) # type: ignore[call-arg] + + +def test_settings_webhook_api_key_strips_leading_trailing_whitespace() -> None: + """Gueltige Keys mit aussen-Whitespace werden getrimmt und gespeichert. + + Vermeidet Inkonsistenzen wenn der Key im .env mit Trailing-Space landet. + """ + real_key = "x" * 40 + padded = " " + real_key + " \n" + s = Settings(webhook_api_key=padded, _env_file=None) # type: ignore[call-arg] + assert s.webhook_api_key.get_secret_value() == real_key