"""Voll-Backup: Export, Prüfansicht und Wiederherstellung.""" import json from datetime import date from conftest import login_ready, new_user def _datensatz(c): """Legt ein Konto mit zwei Buchungen an und gibt das Konto zurück.""" acc = c.post("/api/accounts", json={"name": "Restore-Konto", "currency": "EUR"}).json() for betrag, wer in ((1234, "Laden A"), (5678, "Laden B")): c.post("/api/transactions", json={ "type": "expense", "date": str(date.today()), "account_id": acc["id"], "amount_cents": betrag, "payee": wer, }) return acc def _backup(c) -> dict: r = c.get("/api/export/backup.json") assert r.status_code == 200, r.text return json.loads(r.content) def _hochladen(c, daten: dict, pfad: str, mode: str | None = None): dateien = {"file": ("backup.json", json.dumps(daten).encode("utf-8"), "application/json")} if mode is None: return c.post(pfad, files=dateien) return c.post(pfad, files=dateien, data={"mode": mode}) def test_backup_hat_umschlag_und_alle_tabellen(admin): _datensatz(admin) daten = _backup(admin) assert daten["format"] == "certain-finance-backup" assert daten["schema_version"] == 2 assert daten["scope"] == "user" assert daten["username"] == "admin" # Beziehungen laufen über lokale IDs, nicht über Namen. konto = daten["data"]["accounts"][0] assert "local_id" in konto buchung = daten["data"]["transactions"][0] assert buchung["account_id"] in {a["local_id"] for a in daten["data"]["accounts"]} # Felder, die der alte Ansichts-Export verlor. assert "import_hash" in buchung assert "fx_rate" in buchung assert "budget_treatment" in buchung def test_pruefansicht_schreibt_nichts(admin): c, _ = login_ready(*new_user(admin)) _datensatz(c) daten = _backup(c) vorher = len(c.get("/api/accounts").json()) r = _hochladen(c, daten, "/api/backup/restore/preview") assert r.status_code == 200, r.text body = r.json() assert body["scope"] == "user" tabellen = {z["key"]: z["count"] for z in body["tables"]} assert tabellen["accounts"] >= 1 assert tabellen["transactions"] == 2 assert len(c.get("/api/accounts").json()) == vorher def test_replace_stellt_geloeschte_daten_wieder_her(admin): c, _ = login_ready(*new_user(admin)) acc = _datensatz(c) daten = _backup(c) konten_vorher = c.get("/api/accounts").json() buchungen_vorher = c.get("/api/transactions").json() # Alles wegwerfen … assert c.delete(f"/api/accounts/{acc['id']}").status_code in (200, 204) assert c.get("/api/accounts").json() == [] # … und aus dem Backup zurückholen. r = _hochladen(c, daten, "/api/backup/restore", "replace") assert r.status_code == 200, r.text assert r.json()["mode"] == "replace" konten = c.get("/api/accounts").json() buchungen = c.get("/api/transactions").json() assert len(konten) == len(konten_vorher) assert konten[0]["name"] == "Restore-Konto" assert len(buchungen) == len(buchungen_vorher) assert {b["payee"] for b in buchungen} == {"Laden A", "Laden B"} # Die Buchungen hängen wieder am wiederhergestellten Konto. assert {b["account_id"] for b in buchungen} == {konten[0]["id"]} def test_replace_ersetzt_statt_zu_ergaenzen(admin): c, _ = login_ready(*new_user(admin)) _datensatz(c) daten = _backup(c) # Zwischendurch etwas anlegen, das im Backup nicht vorkommt. c.post("/api/accounts", json={"name": "Danach angelegt", "currency": "EUR"}) assert len(c.get("/api/accounts").json()) == 2 assert _hochladen(c, daten, "/api/backup/restore", "replace").status_code == 200 namen = [a["name"] for a in c.get("/api/accounts").json()] assert namen == ["Restore-Konto"] def test_merge_erzeugt_keine_duplikate(admin): c, _ = login_ready(*new_user(admin)) _datensatz(c) daten = _backup(c) assert _hochladen(c, daten, "/api/backup/restore", "merge").status_code == 200 assert len(c.get("/api/accounts").json()) == 1 assert len(c.get("/api/transactions").json()) == 2 # Zweiter Durchlauf ändert weiterhin nichts. assert _hochladen(c, daten, "/api/backup/restore", "merge").status_code == 200 assert len(c.get("/api/accounts").json()) == 1 assert len(c.get("/api/transactions").json()) == 2 def test_merge_ergaenzt_fehlendes(admin): geber, _ = login_ready(*new_user(admin)) _datensatz(geber) daten = _backup(geber) nehmer, _ = login_ready(*new_user(admin)) nehmer.post("/api/accounts", json={"name": "Eigenes Konto", "currency": "EUR"}) assert _hochladen(nehmer, daten, "/api/backup/restore", "merge").status_code == 200 namen = sorted(a["name"] for a in nehmer.get("/api/accounts").json()) assert namen == ["Eigenes Konto", "Restore-Konto"] assert len(nehmer.get("/api/transactions").json()) == 2 def test_fremde_version_wird_abgelehnt(admin): daten = _backup(admin) daten["schema_version"] = 99 r = _hochladen(admin, daten, "/api/backup/restore/preview") assert r.status_code == 400 assert "99" in r.json()["detail"] def test_ansichts_export_wird_erkannt(admin): _datensatz(admin) alt = json.loads(admin.get("/api/export/all.json").content) r = _hochladen(admin, alt, "/api/backup/restore/preview") assert r.status_code == 400 assert "all.json" in r.json()["detail"] def test_fremde_datei_wird_abgelehnt(admin): r = _hochladen(admin, {"irgendwas": 1}, "/api/backup/restore/preview") assert r.status_code == 400 def test_systembackup_nur_fuer_admin(admin): assert admin.get("/api/export/system-backup.json").status_code == 200 other, _ = login_ready(*new_user(admin)) assert other.get("/api/export/system-backup.json").status_code == 403 def test_systembackup_enthaelt_alle_benutzer(admin): daten = json.loads(admin.get("/api/export/system-backup.json").content) assert daten["scope"] == "system" namen = {u["username"] for u in daten["users"]} assert "admin" in namen admin_satz = next(u for u in daten["users"] if u["username"] == "admin") # Für einen echten Wiederaufbau muss der Hash mit – deshalb der Hinweis # in der Oberfläche, die Datei wie ein Passwort zu behandeln. assert admin_satz["password_hash"].startswith("$argon2") assert "data" in admin_satz def test_systembackup_darf_kein_normaler_nutzer_einspielen(admin): daten = json.loads(admin.get("/api/export/system-backup.json").content) other, _ = login_ready(*new_user(admin)) r = _hochladen(other, daten, "/api/backup/restore/preview") assert r.status_code == 403 def test_systembackup_ohne_admin_wird_abgelehnt(admin): daten = json.loads(admin.get("/api/export/system-backup.json").content) daten["users"] = [u for u in daten["users"] if u["role"] != "admin"] r = _hochladen(admin, daten, "/api/backup/restore", "replace") assert r.status_code == 400 assert "Administrator" in r.json()["detail"] # Nichts halb eingespielt: der Admin ist noch da. assert admin.get("/api/auth/me").status_code == 200