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([]);