From 7d8a54a3fc367d4429b6af6bc039558accf4e7b9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 12:54:43 +0200 Subject: [PATCH] Mealie anbinden: Passwort einmal, danach nur noch ein Dauertoken Unter Einstellungen gibt es jetzt eine Karte "Mealie (Rezepte)": Adresse, Benutzername, Passwort. Vorrania meldet sich damit EINMAL an (POST /api/auth/token), laesst sich von Mealie ueber POST /api/users/api-tokens ein Dauertoken ausstellen und verwirft das Passwort sofort - es steht in keiner Zeile der Datenbank, in keiner Antwort und in keinem Fehlertext. Wer sein Passwort gar nicht erst eintippen will, haekelt "Ich gebe lieber selbst ein Token ein" an. Das Token liegt NICHT in den Einstellungen: GET /settings liefert jedem angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort waere beim naechsten Geheimnis vergessen, deshalb eine eigene Tabelle ohne jeden Leseweg - setzen, loeschen, und die Auskunft "ist gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen. Ein Test prueft genau das. Der Dienst folgt services/oidc.py, nicht off.py: Bei Open Food Facts ist "kein Treffer" der Normalfall und wird still zu None, hier ist "Mealie antwortet nicht" ein Fehler, den der Nutzer sehen muss. Die Kennungen trennen auch, was verschieden ist - ein falsches Passwort (400) ist etwas anderes als ein weggetretener Server (502). Mealie 1.x legt die Haushalts-Routen unter /api/groups/, 2.x unter /api/households/. Erkannt wird das zur Laufzeit mit einer Anfrage und dann eine Stunde gepuffert; ueber die Versionsnummer waere es unzuverlaessig, die melden Installationen uneinheitlich. Rezeptliste und -abgleich kommen als Naechstes; dies ist der Teil, mit dem sich der Server ueberhaupt erst verbinden laesst. Co-Authored-By: Claude Opus 5 --- backend/app/config.py | 3 + backend/app/main.py | 2 + backend/app/models.py | 25 +++ backend/app/routers/mealie.py | 149 ++++++++++++++++ backend/app/schemas.py | 34 ++++ backend/app/services/mealie.py | 281 ++++++++++++++++++++++++++++++ backend/tests/test_mealie.py | 303 +++++++++++++++++++++++++++++++++ web/src/api.js | 7 + web/src/pages/Settings.jsx | 138 +++++++++++++++ 9 files changed, 942 insertions(+) create mode 100644 backend/app/routers/mealie.py create mode 100644 backend/app/services/mealie.py create mode 100644 backend/tests/test_mealie.py diff --git a/backend/app/config.py b/backend/app/config.py index 8c36368..16f6baa 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,9 @@ class Settings(BaseSettings): # Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung # "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist. open_days_default: int = 3 + # Mealie: Adresse und Token stehen in der Datenbank (Admin traegt sie in der + # Oberflaeche ein), hier nur die Geduld fuer eine Antwort. + mealie_timeout_seconds: float = 10.0 # CORS (comma separated); "*" allows all cors_origins: str = "*" diff --git a/backend/app/main.py b/backend/app/main.py index 8ec72b6..c54a7de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from .routers import ( loans, locations, maintenance, + mealie, measures, oidc, package_types, @@ -325,6 +326,7 @@ app.include_router(branding.router) app.include_router(categories.router) app.include_router(maintenance.router) app.include_router(measures.router) +app.include_router(mealie.router) app.include_router(dashboard.router) app.include_router(shops.router) app.include_router(field_definitions.router) diff --git a/backend/app/models.py b/backend/app/models.py index 12c9cf7..39aa91a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -790,6 +790,31 @@ class Setting(Base): value: Mapped[str] = mapped_column(String(255), nullable=False) +class IntegrationSecret(Base): + """Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken). + + Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem + angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre + beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt + keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist + gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen. + + Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir + legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt + damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater, + solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin + beim Setzen, nie in einer Antwort. + """ + + __tablename__ = "integration_secrets" + + service: Mapped[str] = mapped_column(String(32), primary_key=True) + secret: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now + ) + + class Shop(Base): """Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei"). diff --git a/backend/app/routers/mealie.py b/backend/app/routers/mealie.py new file mode 100644 index 0000000..2cab95b --- /dev/null +++ b/backend/app/routers/mealie.py @@ -0,0 +1,149 @@ +"""Mealie einrichten und prüfen. + +Die Zugangsdaten nimmt dieser Router entgegen, gibt sie aber nie zurück. Die +Oberfläche erfährt nur „eingerichtet ja/nein" — dasselbe Muster wie bei +``GET /auth/oidc/providers``, wo Client-Secrets ebenfalls nie in der Antwort +stehen. + +Bewusst eigene Routen mit JSON-Rumpf statt ``PUT /settings/{key}?value=``: +Query-Parameter landen in Zugriffsprotokollen, und ein Passwort hat dort +nichts zu suchen. +""" + +# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py. + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import Setting, User +from ..schemas import ( + MealieAvailable, + MealieConfigIn, + MealieStatus, + MealieTokenIn, +) +from ..services import mealie + +router = APIRouter(prefix="/mealie", tags=["mealie"]) + + +def _setze_adresse(db: Session, base_url: str) -> str: + sauber = base_url.strip().rstrip("/") + row = db.get(Setting, mealie.BASE_URL_KEY) + if row is None: + db.add(Setting(key=mealie.BASE_URL_KEY, value=sauber)) + else: + row.value = sauber + db.commit() + return sauber + + +def _status(db: Session, pruefen: bool) -> MealieStatus: + adresse = mealie.basis_adresse(db) + hat_token = bool(mealie.token(db)) + status_out = MealieStatus( + configured=bool(adresse) and hat_token, + base_url=adresse, + token_set=hat_token, + ) + if not pruefen or not status_out.configured: + return status_out + try: + status_out.version = mealie.version(db) + # Eine echte Abfrage – „erreichbar" soll heissen, dass auch das Token gilt. + mealie.rezepte(db, pro_seite=1) + status_out.reachable = True + except mealie.MealieFehler as fehler: + status_out.reachable = False + status_out.error = str(fehler) + return status_out + + +@router.get("/status", response_model=MealieStatus) +def mealie_status( + pruefen: bool = False, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> MealieStatus: + """Was eingerichtet ist – ohne das Token. ``pruefen=true`` fragt Mealie an.""" + return _status(db, pruefen) + + +@router.get("/available", response_model=MealieAvailable) +def mealie_available( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> MealieAvailable: + """Ob die Rezept-Seite etwas anzeigen kann. + + Für alle angemeldeten Benutzer, damit die Oberfläche den Menüpunkt nur dann + zeigt. Anders als bei den Anmeldeanbietern **mit** Anmeldung: Es gibt keinen + Grund, Unbekannten zu verraten, dass hier eine Mealie-Instanz hängt. + """ + return MealieAvailable(available=mealie.eingerichtet(db)) + + +@router.put("/config", response_model=MealieStatus) +def mealie_konfigurieren( + payload: MealieConfigIn, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> MealieStatus: + """Mit Benutzername und Passwort anmelden und ein Dauertoken holen. + + Das Passwort wird nur für diese eine Anmeldung benutzt und danach verworfen + – es steht in keiner Zeile der Datenbank und in keiner Antwort. + """ + try: + dauertoken = mealie.hole_dauertoken( + payload.base_url, payload.username, payload.password + ) + except mealie.MealieFehler as fehler: + raise HTTPException(_http_code(fehler), str(fehler)) from fehler + _setze_adresse(db, payload.base_url) + mealie.speichere_token(db, dauertoken) + return _status(db, pruefen=True) + + +@router.put("/token", response_model=MealieStatus) +def mealie_token_setzen( + payload: MealieTokenIn, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> MealieStatus: + """Ein in Mealie selbst erzeugtes Dauertoken einsetzen. + + Für alle, die ihr Passwort nicht eintippen wollen: In Mealie unter Profil ▸ + API-Tokens eines anlegen und hier einfügen. + """ + _setze_adresse(db, payload.base_url) + mealie.speichere_token(db, payload.token.strip()) + return _status(db, pruefen=True) + + +@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT) +def mealie_trennen( + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + """Verbindung lösen: Adresse und Token hier entfernen. + + Das Token selbst bleibt in Mealie bestehen – wer es dort endgültig los sein + will, löscht es unter Profil ▸ API-Tokens. + """ + mealie.loesche_zugang(db) + + +def _http_code(fehler: mealie.MealieFehler) -> int: + """Fehlerkennung auf einen Statuscode abbilden. + + Getrennt, weil „Mealie ist weg" (502) etwas anderes ist als „dein Passwort + stimmt nicht" (400) – und die Oberfläche beides verschieden erklären soll. + """ + if fehler.kennung in ("anmeldung_fehlgeschlagen",): + return status.HTTP_400_BAD_REQUEST + if fehler.kennung == "nicht_eingerichtet": + return status.HTTP_503_SERVICE_UNAVAILABLE + return status.HTTP_502_BAD_GATEWAY diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 954d5ab..eb622d0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -694,6 +694,40 @@ class LotConsumeOut(BaseModel): opened: bool = False +# ---- Mealie ---- +class MealieConfigIn(BaseModel): + """Zugangsdaten für die einmalige Anmeldung bei Mealie. + + Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal + an, lässt sich ein Dauertoken ausstellen und vergisst es. + """ + base_url: str = Field(min_length=1, max_length=255) + username: str = Field(min_length=1, max_length=120) + password: str = Field(min_length=1) + + +class MealieTokenIn(BaseModel): + """Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort).""" + base_url: str = Field(min_length=1, max_length=255) + token: str = Field(min_length=1) + + +class MealieStatus(BaseModel): + """Auskunft über die Verbindung – **nie** das Token selbst.""" + configured: bool = False + base_url: str = "" + token_set: bool = False + # Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen". + reachable: bool | None = None + version: str | None = None + error: str | None = None + + +class MealieAvailable(BaseModel): + """Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer).""" + available: bool = False + + # ---- Hausmaße ---- class MeasureIn(BaseModel): """Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel.""" diff --git a/backend/app/services/mealie.py b/backend/app/services/mealie.py new file mode 100644 index 0000000..92d57e6 --- /dev/null +++ b/backend/app/services/mealie.py @@ -0,0 +1,281 @@ +"""Anbindung an eine selbst gehostete Mealie-Instanz (Rezepte). + +Vorbild ist ``services/oidc.py``, nicht ``off.py``. Der Unterschied ist +Absicht: Bei Open Food Facts ist „kein Treffer" der Normalfall und wird still +zu ``None``. Hier ist „Mealie antwortet nicht" ein Fehler, den der Nutzer sehen +muss — sonst stünde eine leere Rezeptliste da, und niemand wüsste, ob es keine +Rezepte gibt oder der Server weg ist. + +Zugangsdaten: Der Admin tippt Adresse, Benutzername und Passwort **einmal**. +Vorrania meldet sich damit an, lässt sich von Mealie ein Dauertoken ausstellen +und verwirft das Passwort sofort. Gespeichert wird nur das Token — widerrufbar +in Mealie unter Profil ▸ API-Tokens. +""" + +from __future__ import annotations + +import time + +import httpx +from sqlalchemy.orm import Session + +from ..config import get_settings +from ..models import IntegrationSecret, Setting + +# Schlüssel in den Einstellungen bzw. in der Geheimnis-Tabelle. +BASE_URL_KEY = "mealie_base_url" +SECRET_SERVICE = "mealie" + +# Name des Tokens, wie er in Mealie erscheint – damit erkennbar ist, wer da +# zugreift, und man es gezielt widerrufen kann. +TOKEN_NAME = "Vorrania" + +CACHE_SEKUNDEN = 3600.0 # erkannte Pfadvariante +REZEPTE_CACHE_SEKUNDEN = 60.0 # kurz: ein neues Rezept soll bald auftauchen + +# Pfadvariante je Adresse. Mealie 2.x legt die Haushalts-Routen unter +# /api/households/, 1.x unter /api/groups/. Die Rezeptpfade sind in beiden +# gleich; der Unterschied zählt erst für die Einkaufslisten. +_variante_cache: dict[str, tuple[float, str]] = {} +_rezepte_cache: dict[str, tuple[float, dict]] = {} + +# Fuer Tests austauschbar – ohne das muesste jeder Test einen Server starten. +_transport: httpx.BaseTransport | None = None + + +class MealieFehler(Exception): + """Mealie hat nicht geantwortet oder abgelehnt. + + ``kennung`` ist die kurze Kennung für die Oberfläche, ``args[0]`` der + Klartext fürs Protokoll – wie ``OidcFehler``. **Das Passwort steht in + keinem der beiden.** + """ + + def __init__(self, kennung: str, text: str) -> None: + super().__init__(text) + self.kennung = kennung + + +def cache_leeren() -> None: + """Nur für Tests und zum erzwungenen Neuladen.""" + _variante_cache.clear() + _rezepte_cache.clear() + + +def setze_transport(transport: httpx.BaseTransport | None) -> None: + """Nur für Tests: HTTP-Antworten vorgeben, ohne einen Server zu starten.""" + global _transport + _transport = transport + + +def _client() -> httpx.Client: + return httpx.Client( + timeout=get_settings().mealie_timeout_seconds, + headers={"User-Agent": "Vorrania-Selfhosted/1.0"}, + follow_redirects=True, + transport=_transport, + ) + + +# -------------------------------------------------------------------------- +# Konfiguration +# -------------------------------------------------------------------------- +def basis_adresse(db: Session) -> str: + row = db.get(Setting, BASE_URL_KEY) + return (row.value if row else "").strip().rstrip("/") + + +def token(db: Session) -> str: + row = db.get(IntegrationSecret, SECRET_SERVICE) + return row.secret if row else "" + + +def eingerichtet(db: Session) -> bool: + return bool(basis_adresse(db)) and bool(token(db)) + + +def _zugang(db: Session) -> tuple[str, str]: + basis, tok = basis_adresse(db), token(db) + if not basis or not tok: + raise MealieFehler( + "nicht_eingerichtet", + "Für Mealie sind noch keine Zugangsdaten hinterlegt.", + ) + return basis, tok + + +def speichere_token(db: Session, wert: str) -> None: + row = db.get(IntegrationSecret, SECRET_SERVICE) + if row is None: + db.add(IntegrationSecret(service=SECRET_SERVICE, secret=wert)) + else: + row.secret = wert + db.commit() + cache_leeren() + + +def loesche_zugang(db: Session) -> None: + row = db.get(IntegrationSecret, SECRET_SERVICE) + if row is not None: + db.delete(row) + adresse = db.get(Setting, BASE_URL_KEY) + if adresse is not None: + db.delete(adresse) + db.commit() + cache_leeren() + + +# -------------------------------------------------------------------------- +# Einrichten: Passwort einmal, danach nur noch das Dauertoken +# -------------------------------------------------------------------------- +def hole_dauertoken(base_url: str, username: str, password: str) -> str: + """Einmal anmelden, Dauertoken ausstellen lassen, zurückgeben. + + 1. ``POST /api/auth/token`` mit Benutzername und Passwort als Formularfelder + liefert ein kurzlebiges Sitzungstoken. + 2. Damit ``POST /api/users/api-tokens`` → ein Dauertoken (rund fünf Jahre), + das genau einmal im Klartext in der Antwort steht. + + Das Passwort verlässt diese Funktion nicht: Es steht in keinem Rückgabewert, + in keiner Fehlermeldung und in keiner Protokollzeile. + """ + basis = base_url.strip().rstrip("/") + if not basis: + raise MealieFehler("nicht_eingerichtet", "Bitte die Adresse von Mealie angeben.") + + with _client() as client: + try: + antwort = client.post( + f"{basis}/api/auth/token", + data={"username": username, "password": password}, + ) + except httpx.HTTPError as fehler: + raise MealieFehler( + "nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}" + ) from fehler + if antwort.status_code in (401, 403): + raise MealieFehler( + "anmeldung_fehlgeschlagen", + "Benutzername oder Passwort stimmen bei Mealie nicht.", + ) + if antwort.status_code >= 400: + raise MealieFehler( + "abgelehnt", + f"Mealie hat die Anmeldung abgelehnt (HTTP {antwort.status_code}).", + ) + try: + sitzung = antwort.json().get("access_token") + except ValueError: + sitzung = None + if not sitzung: + raise MealieFehler( + "abgelehnt", "Mealie hat kein Sitzungstoken zurückgegeben." + ) + + try: + erstellt = client.post( + f"{basis}/api/users/api-tokens", + json={"name": TOKEN_NAME, "integration_id": "vorrania"}, + headers={"Authorization": f"Bearer {sitzung}"}, + ) + except httpx.HTTPError as fehler: + raise MealieFehler( + "nicht_erreichbar", f"Mealie nicht erreichbar: {fehler}" + ) from fehler + if erstellt.status_code >= 400: + raise MealieFehler( + "kein_dauertoken", + f"Mealie wollte kein Dauertoken ausstellen (HTTP {erstellt.status_code}).", + ) + try: + dauertoken = erstellt.json().get("token") + except ValueError: + dauertoken = None + if not dauertoken: + raise MealieFehler( + "kein_dauertoken", "Mealie hat kein Dauertoken zurückgegeben." + ) + return dauertoken + + +# -------------------------------------------------------------------------- +# Abfragen +# -------------------------------------------------------------------------- +def _hole(db: Session, pfad: str) -> dict: + basis, tok = _zugang(db) + with _client() as client: + try: + antwort = client.get( + f"{basis}{pfad}", headers={"Authorization": f"Bearer {tok}"} + ) + except httpx.HTTPError as fehler: + raise MealieFehler( + "nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}" + ) from fehler + if antwort.status_code in (401, 403): + raise MealieFehler( + "token_fehlt", + "Mealie weist das hinterlegte Token zurück. Bitte die Verbindung erneuern.", + ) + if antwort.status_code == 404: + raise MealieFehler("nicht_gefunden", f"Mealie kennt {pfad} nicht.") + if antwort.status_code >= 400: + raise MealieFehler( + "abgelehnt", f"Mealie antwortete mit HTTP {antwort.status_code} auf {pfad}." + ) + try: + return antwort.json() + except ValueError as fehler: + raise MealieFehler("abgelehnt", "Mealie antwortete nicht mit JSON.") from fehler + + +def pfadvariante(db: Session) -> str: + """„households" (Mealie 2.x) oder „groups" (1.x), gepuffert je Adresse. + + Die Rezeptpfade sind in beiden Fassungen gleich; der Unterschied zählt erst + für die Einkaufslisten. Geprüft wird mit einer Anfrage: 404 heißt „falscher + Pfad", alles andere „richtiger". Bewusst nicht über die Versionsnummer — + die melden Installationen uneinheitlich. + """ + basis = basis_adresse(db) + gepuffert = _variante_cache.get(basis) + if gepuffert and gepuffert[0] > time.monotonic(): + return gepuffert[1] + variante = "households" + try: + _hole(db, "/api/households/shopping/lists?perPage=1") + except MealieFehler as fehler: + if fehler.kennung == "nicht_gefunden": + variante = "groups" + elif fehler.kennung != "abgelehnt": + raise + _variante_cache[basis] = (time.monotonic() + CACHE_SEKUNDEN, variante) + return variante + + +def version(db: Session) -> str | None: + """Versionsangabe der Instanz, nur zur Anzeige. Fehlt sie, ist das kein Fehler.""" + try: + return str(_hole(db, "/api/app/about").get("version") or "") or None + except MealieFehler: + return None + + +def rezepte(db: Session, *, seite: int = 1, pro_seite: int = 50, suche: str | None = None) -> dict: + """Eine Seite der Rezeptliste. Kurz gepuffert, damit Blättern nicht hakt.""" + pfad = f"/api/recipes?page={seite}&perPage={pro_seite}" + if suche: + pfad += f"&search={httpx.QueryParams({'s': suche})['s']}" + schluessel = f"{basis_adresse(db)}|{pfad}" + gepuffert = _rezepte_cache.get(schluessel) + if gepuffert and gepuffert[0] > time.monotonic(): + return gepuffert[1] + daten = _hole(db, pfad) + _rezepte_cache[schluessel] = (time.monotonic() + REZEPTE_CACHE_SEKUNDEN, daten) + return daten + + +def rezept(db: Session, slug: str) -> dict: + """Ein einzelnes Rezept samt Zutaten. Bewusst **nicht** gepuffert – + es wird gezielt geöffnet und soll dann aktuell sein.""" + return _hole(db, f"/api/recipes/{slug}") diff --git a/backend/tests/test_mealie.py b/backend/tests/test_mealie.py new file mode 100644 index 0000000..ed77a44 --- /dev/null +++ b/backend/tests/test_mealie.py @@ -0,0 +1,303 @@ +"""Mealie-Anbindung: Anmelden, Dauertoken holen, und das Passwort vergessen. + +Ohne Netz: ``httpx.MockTransport`` liefert die Antworten, die eine echte +Mealie-Instanz geben würde. +""" + +import json + +import httpx +import pytest +from fastapi import HTTPException + +from app.models import IntegrationSecret, Role, Setting, User +from app.routers.mealie import ( + mealie_available, + mealie_konfigurieren, + mealie_status, + mealie_token_setzen, + mealie_trennen, +) +from app.routers.settings import list_settings +from app.schemas import MealieConfigIn, MealieTokenIn +from app.services import mealie + + +@pytest.fixture() +def admin(db): + person = User(username="admin", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@pytest.fixture(autouse=True) +def sauber(): + """Caches und Transport zwischen den Tests zuruecksetzen.""" + mealie.cache_leeren() + yield + mealie.setze_transport(None) + mealie.cache_leeren() + + +def _mealie_server(*, passwort_ok=True, token_ok=True, erreichbar=True): + """Eine Mealie-Instanz nachstellen. Merkt sich, was sie gesehen hat.""" + gesehen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + gesehen.append(request) + if not erreichbar: + raise httpx.ConnectError("keine Verbindung", request=request) + pfad = request.url.path + if pfad == "/api/auth/token": + if not passwort_ok: + return httpx.Response(401, json={"detail": "Unauthorized"}) + return httpx.Response(200, json={ + "access_token": "kurzlebig", "token_type": "bearer", "expires_in": 3600, + }) + if pfad == "/api/users/api-tokens": + if not token_ok: + return httpx.Response(403, json={"detail": "nope"}) + return httpx.Response(201, json={"token": "dauertoken-xyz", "name": "Vorrania"}) + if pfad == "/api/app/about": + return httpx.Response(200, json={"version": "2.4.1"}) + if pfad == "/api/recipes": + return httpx.Response(200, json={"items": [], "total": 0, "page": 1}) + if pfad == "/api/households/shopping/lists": + return httpx.Response(200, json={"items": []}) + return httpx.Response(404, json={"detail": "Not found"}) + + mealie.setze_transport(httpx.MockTransport(handler)) + return gesehen + + +# -------------------------------------------------------------------------- +# Einrichten +# -------------------------------------------------------------------------- +def test_einrichten_holt_ein_dauertoken(db, admin): + _mealie_server() + + status = mealie_konfigurieren( + MealieConfigIn(base_url="https://mealie.example.com", + username="guido", password="geheim"), + db=db, _=admin, + ) + + assert status.configured is True + assert status.token_set is True + assert status.base_url == "https://mealie.example.com" + assert status.reachable is True + assert status.version == "2.4.1" + assert db.get(IntegrationSecret, "mealie").secret == "dauertoken-xyz" + + +def test_passwort_wird_nirgends_gespeichert(db, admin): + """Der Kern der Zusage: Das Passwort sieht nur die Anmeldung.""" + _mealie_server() + + mealie_konfigurieren( + MealieConfigIn(base_url="https://mealie.example.com", + username="guido", password="streng-geheim"), + db=db, _=admin, + ) + + alle_werte = [s.value for s in db.query(Setting).all()] + alle_werte += [s.secret for s in db.query(IntegrationSecret).all()] + assert all("streng-geheim" not in wert for wert in alle_werte) + + +def test_passwort_steht_nicht_im_fehlertext(db, admin): + _mealie_server(passwort_ok=False) + + with pytest.raises(HTTPException) as fehler: + mealie_konfigurieren( + MealieConfigIn(base_url="https://mealie.example.com", + username="guido", password="streng-geheim"), + db=db, _=admin, + ) + + assert fehler.value.status_code == 400 + assert "streng-geheim" not in str(fehler.value.detail) + + +def test_adresse_wird_ohne_schraegstrich_abgelegt(db, admin): + _mealie_server() + + mealie_konfigurieren( + MealieConfigIn(base_url="https://mealie.example.com/", + username="g", password="p"), + db=db, _=admin, + ) + + assert db.get(Setting, "mealie_base_url").value == "https://mealie.example.com" + + +def test_falsches_passwort_wird_als_solches_gemeldet(db, admin): + _mealie_server(passwort_ok=False) + + with pytest.raises(HTTPException) as fehler: + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + assert fehler.value.status_code == 400 + assert "Passwort" in str(fehler.value.detail) + assert db.get(IntegrationSecret, "mealie") is None + + +def test_server_weg_ist_kein_passwortfehler(db, admin): + """Unterscheidung, die der Nutzer braucht: 502 statt 400.""" + _mealie_server(erreichbar=False) + + with pytest.raises(HTTPException) as fehler: + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + assert fehler.value.status_code == 502 + + +def test_kein_dauertoken_wird_gemeldet(db, admin): + _mealie_server(token_ok=False) + + with pytest.raises(HTTPException) as fehler: + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + assert fehler.value.status_code == 502 + assert db.get(IntegrationSecret, "mealie") is None + + +def test_eigenes_token_einsetzen(db, admin): + _mealie_server() + + status = mealie_token_setzen( + MealieTokenIn(base_url="https://m.example.com", token="selbst-erzeugt"), + db=db, _=admin, + ) + + assert status.configured is True + assert db.get(IntegrationSecret, "mealie").secret == "selbst-erzeugt" + + +# -------------------------------------------------------------------------- +# Auskunft – und was sie NICHT verrät +# -------------------------------------------------------------------------- +def test_status_gibt_das_token_nie_zurueck(db, admin): + _mealie_server() + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + status = mealie_status(db=db, _=admin) + + assert status.token_set is True + assert "dauertoken-xyz" not in json.dumps(status.model_dump(mode="json")) + + +def test_einstellungen_enthalten_das_token_nicht(db, admin): + """GET /settings liest jeder angemeldete Nutzer – deshalb eigene Tabelle.""" + _mealie_server() + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + eintraege = list_settings(db=db, _=admin) + + assert all("dauertoken-xyz" not in e.value for e in eintraege) + # Die Adresse darf dort stehen, sie ist kein Geheimnis. + assert any(e.key == "mealie_base_url" for e in eintraege) + + +def test_ohne_einrichtung_ist_nichts_verfuegbar(db, admin): + assert mealie_available(db=db, _=admin).available is False + status = mealie_status(db=db, _=admin) + assert status.configured is False + assert status.reachable is None # gar nicht erst nachgefragt + + +def test_nach_einrichtung_ist_es_verfuegbar(db, admin): + _mealie_server() + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + assert mealie_available(db=db, _=admin).available is True + + +def test_trennen_entfernt_adresse_und_token(db, admin): + _mealie_server() + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + mealie_trennen(db=db, _=admin) + + assert db.get(IntegrationSecret, "mealie") is None + assert db.get(Setting, "mealie_base_url") is None + assert mealie_available(db=db, _=admin).available is False + + +def test_abgelaufenes_token_meldet_sich_deutlich(db, admin): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"detail": "expired"}) + + db.add(Setting(key="mealie_base_url", value="https://m.example.com")) + db.add(IntegrationSecret(service="mealie", secret="alt")) + db.commit() + mealie.setze_transport(httpx.MockTransport(handler)) + + status = mealie_status(pruefen=True, db=db, _=admin) + + assert status.reachable is False + assert "Token" in (status.error or "") + + +# -------------------------------------------------------------------------- +# Fassungsunterschied 1.x / 2.x +# -------------------------------------------------------------------------- +def test_erkennt_die_haushalts_pfade(db, admin): + _mealie_server() + mealie_konfigurieren( + MealieConfigIn(base_url="https://m.example.com", username="g", password="p"), + db=db, _=admin, + ) + + assert mealie.pfadvariante(db) == "households" + + +def test_faellt_auf_die_gruppen_pfade_zurueck(db, admin): + """Mealie 1.x kennt /api/households/ nicht.""" + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.startswith("/api/households/"): + return httpx.Response(404, json={"detail": "Not Found"}) + return httpx.Response(200, json={"items": []}) + + db.add(Setting(key="mealie_base_url", value="https://alt.example.com")) + db.add(IntegrationSecret(service="mealie", secret="t")) + db.commit() + mealie.setze_transport(httpx.MockTransport(handler)) + + assert mealie.pfadvariante(db) == "groups" + + +def test_pfadvariante_wird_gepuffert(db, admin): + gesehen = _mealie_server() + db.add(Setting(key="mealie_base_url", value="https://m.example.com")) + db.add(IntegrationSecret(service="mealie", secret="t")) + db.commit() + + mealie.pfadvariante(db) + anzahl = len(gesehen) + mealie.pfadvariante(db) + + assert len(gesehen) == anzahl # keine zweite Anfrage diff --git a/web/src/api.js b/web/src/api.js index b3e9d44..ace51a5 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -444,6 +444,13 @@ export const api = { resetAll: () => request("/maintenance/reset/all", { method: "POST" }), // Einstellungen + // Mealie: Adresse und Token nimmt der Server entgegen, gibt sie aber nie + // zurueck – die Oberflaeche erfaehrt nur „eingerichtet ja/nein". + mealieStatus: (pruefen = false) => request(`/mealie/status${pruefen ? "?pruefen=true" : ""}`), + mealieVerfuegbar: () => request("/mealie/available"), + mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }), + mealieToken: (body) => request("/mealie/token", { method: "PUT", body }), + mealieTrennen: () => request("/mealie/config", { method: "DELETE" }), listSettings: () => request("/settings"), setSetting: (key, value) => request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 15a6726..f487fc4 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -171,6 +171,7 @@ export default function Settings() { + @@ -178,6 +179,143 @@ export default function Settings() { } /** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */ +/** + * Verbindung zu einer selbst gehosteten Mealie-Instanz. + * + * Das Passwort wird nicht gespeichert: Vorrania meldet sich damit einmal an, + * laesst sich von Mealie ein Dauertoken ausstellen und vergisst es. Deshalb + * wird das Feld nach dem Speichern geleert und nie wieder gefuellt – es gibt + * nichts, was man zurueckladen koennte. + */ +function MealieCard() { + const toast = useToast(); + const confirm = useConfirm(); + const [status, setStatus] = useState(null); + const [url, setUrl] = useState(""); + const [benutzer, setBenutzer] = useState(""); + const [passwort, setPasswort] = useState(""); + const [token, setToken] = useState(""); + const [mitToken, setMitToken] = useState(false); + const [busy, setBusy] = useState(false); + const [fehler, setFehler] = useState(null); + + async function laden(pruefen = false) { + try { + const s = await api.mealieStatus(pruefen); + setStatus(s); + if (s.base_url) setUrl(s.base_url); + } catch (err) { setFehler(err.message); } + } + useEffect(() => { laden(); }, []); + + async function verbinden(e) { + e.preventDefault(); + setBusy(true); + setFehler(null); + try { + const s = mitToken + ? await api.mealieToken({ base_url: url, token }) + : await api.mealieKonfigurieren({ base_url: url, username: benutzer, password: passwort }); + setStatus(s); + setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert + setToken(""); + toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht."); + } catch (err) { setFehler(err.message); } finally { setBusy(false); } + } + + async function trennen() { + const ok = await confirm({ + title: "Verbindung zu Mealie lösen?", + message: "Adresse und Token werden hier entfernt. In Mealie selbst bleibt das " + + "Token bestehen – dort unter Profil ▸ API-Tokens löschbar.", + confirmLabel: "Trennen", danger: true, + }); + if (!ok) return; + try { + await api.mealieTrennen(); + setStatus({ configured: false, base_url: "", token_set: false }); + setUrl(""); setBenutzer(""); setPasswort(""); setToken(""); + toast("Verbindung gelöst."); + } catch (err) { setFehler(err.message); } + } + + return ( +
+

Mealie (Rezepte)

+

+ Verbindet Vorrania mit einer selbst gehosteten Mealie-Instanz, um Rezepte + gegen den Bestand zu prüfen. Dein Passwort wird nicht{" "} + gespeichert: Vorrania meldet sich damit einmal an, lässt sich ein + Dauertoken ausstellen und vergisst es. +

+ + {status?.configured && ( +
+ + + {status.base_url} + {status.version ? ` · Mealie ${status.version}` : ""} + {" · "} + {status.reachable === false + ? (status.error || "antwortet nicht") + : status.reachable === true ? "erreichbar" : "Token gesetzt"} + +
+ )} + {fehler &&
{fehler}
} + + + + {!mitToken ? ( +
+ + +
+ ) : ( + + )} + + + +
+ + {status?.configured && ( + <> + + + + )} +
+
+ ); +} + + function ApiTokensCard() { const confirm = useConfirm(); const [tokens, setTokens] = useState([]);