"""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