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>
154 lines
5.9 KiB
Python
154 lines
5.9 KiB
Python
"""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
|