Plugin neuheiten: Bearer-Token-Pflicht der BGG-XML-API2 (Fix für HTTP 401)

BoardGameGeek verlangt für die XML API2 jetzt Bearer-Authentifizierung
(Thread 3602374); ohne Token antwortet die API mit 401.

- BggClient: neuer Parameter token, sendet Authorization: Bearer … auf
  allen Requests; Token kommt aus SPIELE_BGG_TOKEN (vor jedem Sync gelesen)
- HTTP 401 → klarer BggAuthFehler ('BGG hat die Anfrage abgelehnt (401) —
  bitte gültigen API-Token in SPIELE_BGG_TOKEN hinterlegen.') statt
  generischer Meldung, kein Retry
- Ohne konfigurierten Token wird der Sync übersprungen ('Kein BGG-API-Token
  konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen.'), statt Requests zu
  feuern; Meldung erscheint im Sync-Ergebnis, UI-Banner und Protokoll
- SyncService: BggAuthFehler bricht den Lauf ab (weitere Suchbegriffe
  scheitern zwangsläufig gleich), Teilergebnisse bleiben erhalten
- Tests: Bearer-Header, 401-Fall (Client/Sync/UI), Skip ohne Token;
  bestehende Sync-UI-Tests setzen Test-Token
- README: neue Env-Variable SPIELE_BGG_TOKEN + Hinweis auf Token-Pflicht
This commit is contained in:
Flo Hartmann
2026-08-21 23:14:58 +00:00
parent 55f6f32f73
commit 60c4b6e818
8 changed files with 259 additions and 9 deletions

View File

@@ -28,6 +28,8 @@ models = _neuheiten.models
BggClient = bgg.BggClient
BggFehler = bgg.BggFehler
BggAuthFehler = bgg.BggAuthFehler
MELDUNG_401 = bgg.MELDUNG_401
SuchTreffer = bgg.SuchTreffer
BggSpiel = bgg.BggSpiel
parse_suche = bgg.parse_suche

View File

@@ -8,7 +8,14 @@ from __future__ import annotations
import httpx
import pytest
from tests._neuheiten import BggClient, BggFehler, parse_suche, parse_things
from tests._neuheiten import (
BggAuthFehler,
BggClient,
BggFehler,
MELDUNG_401,
parse_suche,
parse_things,
)
SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?>
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
@@ -61,9 +68,11 @@ class AufzeichnenderTransport(httpx.BaseTransport):
self.antworten = list(antworten)
self.uhr = uhr
self.anfragen: list[tuple[str, float]] = []
self.auth_header: list[str | None] = [] # Authorization je Request
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.anfragen.append((str(request.url), self.uhr()))
self.auth_header.append(request.headers.get("Authorization"))
element = self.antworten.pop(0)
if isinstance(element, Exception):
raise element
@@ -157,6 +166,39 @@ def test_suche_sendet_type_boardgame():
assert "query=catan" in url
def test_token_wird_als_bearer_header_gesendet():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(SUCHE_XML), _antwort(THING_XML)], uhr)
client = BggClient(
transport=transport,
token="geheim-123",
schlaf=lambda s: None,
uhr=uhr,
)
client.suche("catan")
client.details([13])
# Authorization-Header auf allen Requests (Suche und Thing):
assert transport.auth_header == ["Bearer geheim-123", "Bearer geheim-123"]
def test_leerer_token_gilt_als_nicht_konfiguriert():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
client = BggClient(
transport=transport,
token=" ",
schlaf=lambda s: None,
uhr=uhr,
)
assert client.token is None
client.suche("catan")
assert transport.auth_header == [None]
def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
@@ -266,6 +308,34 @@ def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
assert wartezeiten == []
def test_401_fuehrt_zu_klarer_auth_fehlermeldung_ohne_retry():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[_antwort("unauthorized", status_code=401)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
with pytest.raises(BggAuthFehler) as aufgetreten:
client.suche("catan")
assert str(aufgetreten.value) == MELDUNG_401
assert MELDUNG_401.startswith("BGG hat die Anfrage abgelehnt (401)")
assert len(transport.anfragen) == 1 # kein Retry bei dauerhaftem 401
assert wartezeiten == []
def test_401_beim_thing_request_gleiche_meldung():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("nope", status_code=401)], uhr)
client = _client(transport, uhr, lambda s: None)
with pytest.raises(BggAuthFehler) as aufgetreten:
client.details([13])
assert str(aufgetreten.value) == MELDUNG_401
def test_max_versuche_erschoepft_mit_fehler():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)

View File

@@ -8,8 +8,10 @@ from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
from tests._neuheiten import (
BggAuthFehler,
BggFehler,
BggSpiel,
MELDUNG_401,
Neuheit,
SuchTreffer,
SyncService,
@@ -217,6 +219,31 @@ def test_prototyp_heuristik():
# ---------- Robustheit ----------
def test_401_bricht_sync_ab_mit_klarer_meldung(session_factory):
"""BGG-401: Lauf stoppt, Meldung landet im Ergebnis, Teilergebnisse bleiben."""
class AuthKaputterClient(FakeBggClient):
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
self.suche_aufrufe.append(suchbegriff)
if suchbegriff == "abgelehnt":
raise BggAuthFehler(MELDUNG_401)
return list(self.suchergebnisse.get(suchbegriff, []))
client = AuthKaputterClient(
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()},
)
service = SyncService(session_factory, client)
ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"])
assert ergebnis.abbruch == MELDUNG_401
assert ergebnis.als_text() == MELDUNG_401 # klare Meldung statt Fehlerzähler
assert ergebnis.neu == 1 # Teilergebnis vor dem 401 bleibt erhalten
assert client.suche_aufrufe == ["gut", "abgelehnt"] # kein weiterer Begriff
assert len(_alle_eintraege(session_factory)) == 1 # Commit trotz Abbruch
def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
class HalbKaputterClient(FakeBggClient):
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):

View File

@@ -8,7 +8,13 @@ from fastapi.testclient import TestClient
from redaktionskern.app import create_app
from tests._neuheiten import BggSpiel, Neuheit, SuchTreffer
from tests._neuheiten import (
BggAuthFehler,
BggSpiel,
MELDUNG_401,
Neuheit,
SuchTreffer,
)
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
@@ -33,6 +39,18 @@ class FakeBggClient:
pass
class FehlerwerfenderFake(FakeBggClient):
"""Wirft bei der Suche einen Fehler (z. B. BggAuthFehler bei HTTP 401)."""
def __init__(self, fehler: Exception):
super().__init__()
self.fehler = fehler
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
self.suche_aufrufe.append(suchbegriff)
raise self.fehler
def _client_mit_fakes(monkeypatch, client: TestClient, fake: FakeBggClient) -> None:
plugin = client.app.state.registry.get("neuheiten")
# Instanz-Methode ersetzen; monkeypatch stellt nach dem Test wieder her
@@ -165,6 +183,7 @@ def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
],
)
monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token")
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
@@ -193,6 +212,7 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc
from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token")
fake = FakeBggClient()
with TestClient(create_app(settings)) as client:
melde_an(client)
@@ -201,6 +221,53 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc
assert fake.suche_aufrufe == ["essen", "familien"]
def test_sync_ohne_token_wird_uebersprungen(monkeypatch, settings):
from fastapi.testclient import TestClient
monkeypatch.delenv("SPIELE_BGG_TOKEN", raising=False)
fake = FakeBggClient(treffer=[SuchTreffer(13, "Catan", 1995)])
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
antwort = client.post(
"/neuheiten/sync",
data={"suchbegriff": "catan"},
follow_redirects=False,
)
assert antwort.status_code == 303
folge = client.get(antwort.headers["location"])
assert (
"Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — "
"Sync übersprungen." in folge.text
)
assert fake.suche_aufrufe == [] # kein Request an die BGG-API gefeuert
def test_sync_401_zeigt_klare_fehlermeldung(monkeypatch, settings):
from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_TOKEN", "falscher-token")
fake = FehlerwerfenderFake(BggAuthFehler(MELDUNG_401))
with TestClient(create_app(settings)) as client:
melde_an(client)
_client_mit_fakes(monkeypatch, client, fake)
antwort = client.post(
"/neuheiten/sync",
data={"suchbegriff": "catan"},
follow_redirects=False,
)
assert antwort.status_code == 303
folge = client.get(antwort.headers["location"])
assert MELDUNG_401 in folge.text
assert "Unerwartete HTTP-Antwort" not in folge.text
def test_htmx_request_liefert_nur_teilfragment(app, client):
melde_an(client)
antwort = client.get("/neuheiten", headers={"HX-Request": "true"})