Files
certain/backend/tests/test_app_marke.py
T
ScarriffleandClaude Opus 5 8a7545bf5b 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>
2026-10-20 16:06:41 +02:00

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