feat: Eine Geraetemarke kann jetzt auch als Anmeldung fuer eine App gelten
Die API kannte zwei Zustaende: angemeldet per Sitzungs-Cookie, oder eine enge Kurzbefehl-Marke, die nur erfassen darf. Eine native App passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das Passwort nicht auf dem Geraet halten. Neu ist die Reichweite "app": Sie gilt im Header X-Certain-Token wie eine Anmeldung, laeuft nicht nach 30 Tagen ab und laesst sich einzeln widerrufen. Die enge Marke bleibt eng - das ist der Punkt, an dem die Tests am genauesten hinsehen. Und eine App-Marke darf keine weitere ausstellen: Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen liefe ins Leere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
15b863ff5e
commit
8a7545bf5b
@@ -0,0 +1,153 @@
|
||||
"""Die Geraetemarke der nativen App.
|
||||
|
||||
Die API kannte bisher nur zwei Zustaende: angemeldet per Sitzungs-Cookie, oder
|
||||
eine enge Kurzbefehl-Marke, die ausschliesslich erfassen darf. Eine native App
|
||||
passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das
|
||||
Passwort nicht auf dem Geraet halten.
|
||||
|
||||
Deshalb die Reichweite ``app``: gilt wie eine Anmeldung, liegt im
|
||||
Schluesselbund, ist einzeln widerrufbar. Die enge Marke bleibt eng - das ist
|
||||
der Punkt, an dem diese Datei am genauesten hinsieht.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
|
||||
def _marke(client, name=None, scope=None):
|
||||
koerper = {"name": name or f"Geraet {secrets.token_hex(3)}"}
|
||||
if scope is not None:
|
||||
koerper["scope"] = scope
|
||||
r = client.post("/api/inbox/tokens", json=koerper)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _mit(token):
|
||||
return {"X-Certain-Token": token}
|
||||
|
||||
|
||||
def test_app_marke_darf_lesen_und_schreiben(admin):
|
||||
"""Der ganze Zweck: Mit der Marke allein, ohne Cookie, muss die App
|
||||
arbeiten koennen."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
assert marke["scope"] == "app"
|
||||
|
||||
konto = admin.post("/api/accounts", json={
|
||||
"name": f"Marke {secrets.token_hex(3)}", "type": "girokonto",
|
||||
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||
|
||||
# Ein frischer Client OHNE Cookie - nur der Header.
|
||||
roh = TestClient(fastapi_app)
|
||||
|
||||
r = roh.get("/api/accounts", headers=_mit(marke["token"]))
|
||||
assert r.status_code == 200, r.text
|
||||
assert konto["id"] in {a["id"] for a in r.json()}
|
||||
|
||||
# Lesen reicht nicht, schreiben muss auch gehen.
|
||||
r = roh.post("/api/transactions", headers=_mit(marke["token"]), json={
|
||||
"type": "expense", "date": "2026-07-14", "account_id": konto["id"],
|
||||
"amount_cents": 1234, "payee": "Mit Marke gebucht"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# Und die Budgetansicht, der eigentliche Grund fuer die App.
|
||||
assert roh.get("/api/budget/current", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
|
||||
def test_kurzbefehl_marke_bleibt_eng(admin):
|
||||
"""Der wichtigste Test hier. Die alte Marke darf durch die neue Pruefung
|
||||
NICHT still zu einem Vollzugang geworden sein."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin) # ohne scope -> quick_add
|
||||
assert marke["scope"] == "quick_add"
|
||||
roh = TestClient(fastapi_app)
|
||||
|
||||
# Ihr eigener Endpunkt geht weiterhin.
|
||||
assert roh.get("/api/quick/accounts", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
# Alles andere nicht.
|
||||
for pfad in ("/api/accounts", "/api/transactions", "/api/budget/current",
|
||||
"/api/subscriptions", "/api/categories"):
|
||||
r = roh.get(pfad, headers=_mit(marke["token"]))
|
||||
assert r.status_code == 401, f"{pfad} war mit einer quick_add-Marke erreichbar"
|
||||
|
||||
|
||||
def test_ohne_marke_und_mit_falscher_marke_kommt_niemand_herein(admin):
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
roh = TestClient(fastapi_app)
|
||||
assert roh.get("/api/accounts").status_code == 401
|
||||
assert roh.get("/api/accounts", headers=_mit("gibtesnicht")).status_code == 401
|
||||
assert roh.get("/api/accounts", headers=_mit("")).status_code == 401
|
||||
|
||||
|
||||
def test_fremde_marke_sieht_nur_ihre_eigenen_daten(admin):
|
||||
"""Mandanten-Trennung gilt auch auf dem Marken-Weg."""
|
||||
from conftest import login_ready, new_user
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
meins = admin.post("/api/accounts", json={
|
||||
"name": f"Admin-Konto {secrets.token_hex(3)}", "type": "girokonto",
|
||||
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||
|
||||
anderer, _ = login_ready(*new_user(admin))
|
||||
fremde_marke = _marke(anderer, scope="app")
|
||||
|
||||
roh = TestClient(fastapi_app)
|
||||
r = roh.get("/api/accounts", headers=_mit(fremde_marke["token"]))
|
||||
assert r.status_code == 200
|
||||
assert meins["id"] not in {a["id"] for a in r.json()}
|
||||
assert roh.get(f"/api/accounts/{meins['id']}",
|
||||
headers=_mit(fremde_marke["token"])).status_code == 404
|
||||
|
||||
|
||||
def test_eine_app_marke_stellt_keine_weitere_aus(admin):
|
||||
"""Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen der
|
||||
gestohlenen Marke liefe ins Leere."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
roh = TestClient(fastapi_app)
|
||||
|
||||
r = roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
|
||||
json={"name": "Ersatzschluessel", "scope": "app"})
|
||||
assert r.status_code == 403, r.text
|
||||
# Auch keine enge Marke.
|
||||
assert roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
|
||||
json={"name": "Auch nicht"}).status_code == 403
|
||||
# Lesen der eigenen Markenliste bleibt erlaubt.
|
||||
assert roh.get("/api/inbox/tokens", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
|
||||
def test_widerrufen_sperrt_sofort(admin):
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
roh = TestClient(fastapi_app)
|
||||
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
|
||||
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 401
|
||||
|
||||
|
||||
def test_marke_merkt_sich_wann_sie_zuletzt_benutzt_wurde(admin):
|
||||
"""Damit man im Nachhinein sieht, welches Geraet noch laeuft - und welches
|
||||
man bedenkenlos widerrufen kann."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
assert marke["last_used_at"] is None
|
||||
|
||||
TestClient(fastapi_app).get("/api/accounts", headers=_mit(marke["token"]))
|
||||
|
||||
danach = next(t for t in admin.get("/api/inbox/tokens").json() if t["id"] == marke["id"])
|
||||
assert danach["last_used_at"] is not None
|
||||
Reference in New Issue
Block a user