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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
37ab1ba47d
commit
7d8a54a3fc
@@ -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
|
||||
Reference in New Issue
Block a user