Files
Vorrania/backend/tests/test_mealie.py
T
ScarriffleandClaude Opus 5 7d8a54a3fc 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 <noreply@anthropic.com>
2026-10-02 12:54:43 +02:00

304 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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