From a9ea0432d89402922fcb4adc1475a8529b374ae5 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 19 Oct 2026 07:16:03 +0200 Subject: [PATCH] fix: Eine Sicherung mit Transfer-Schlagwort laesst sich wieder einspielen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das feste Zielkonto eines Transfer-Schlagworts behielt beim Wiederherstellen die alte Konto-Nummer. Die Nummern werden dabei aber neu vergeben, also lief es ins Leere – und zwar so, dass die GANZE Wiederherstellung mit einem Fremdschluessel-Fehler abbrach, nicht nur diese eine Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung nicht mehr heran. Co-Authored-By: Claude Opus 5 --- backend/app/services/backup_data.py | 20 +++++++++++++- backend/tests/test_backup.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index 2d6ea1a..6e2b62a 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -55,7 +55,9 @@ from ..models import ( LinkIgnored, PlannedIncomeLine, RecoveryCode, + Tag, Transaction, + TransactionTag, TransferLink, User, UserSetting, @@ -135,8 +137,14 @@ USER_TABLES: list[TableSpec] = [ {"budget_period_id": "budget_periods", "category_id": "categories"}, label="Budget-Monatswerte"), TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"), + # target_account_id gehört dazu: Ein Transfer-Schlagwort mit festem + # Zielkonto trug sonst die ALTE Konto-Nummer in die neue Datenbank, und die + # Wiederherstellung brach mit einem Fremdschlüssel-Fehler ab – nicht nur + # diese Zeile, die ganze Sicherung liess sich nicht mehr einspielen. + # accounts steht weiter oben, die Übersetzung greift also. TableSpec("categorization_rules", CategorizationRule, "owner", - {"category_id": "categories"}, label="Schlagwortregeln"), + {"category_id": "categories", "target_account_id": "accounts"}, + label="Schlagwortregeln"), TableSpec("user_settings", UserSetting, "user", has_id=False, label="Persönliche Einstellungen"), # Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen @@ -154,6 +162,12 @@ USER_TABLES: list[TableSpec] = [ TableSpec("link_ignored", LinkIgnored, "owner", {"a_txn_id": "transactions", "b_txn_id": "transactions"}, label="Abgelehnte Übertrags-Vorschläge"), + # Erst die Tags, dann die Zuordnung – ein refs-Verweis darf nur auf eine + # schon eingefügte Tabelle zeigen. + TableSpec("tags", Tag, "owner", label="Tags"), + TableSpec("transaction_tags", TransactionTag, "owner", + {"transaction_id": "transactions", "tag_id": "tags"}, + label="Tag-Zuordnungen"), # Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem # Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur # der Hash – der Klartext existiert nirgends. @@ -412,6 +426,10 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = { # Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit # die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag. "subscription_logos": lambda r: ("logo", r.get("subscription_id")), + # Ohne die beiden Einträge scheitert ein zweiter Ergänzen-Lauf an + # (owner_id, name) bzw. (transaction_id, tag_id). + "tags": lambda r: ("name", (r.get("name") or "").strip().lower()), + "transaction_tags": lambda r: ("zuordnung", r.get("transaction_id"), r.get("tag_id")), "transactions": lambda r: ( ("uid", r["source_uid"]) if r.get("source_uid") else ("hash", r["import_hash"]) if r.get("import_hash") diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py index 3b530f2..546907e 100644 --- a/backend/tests/test_backup.py +++ b/backend/tests/test_backup.py @@ -87,3 +87,44 @@ def test_jede_nutzertabelle_ist_im_backup_beruecksichtigt(): f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: " f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen." ) + + +def test_transfer_schlagwort_behaelt_sein_zielkonto(admin): + """Ein Transfer-Schlagwort mit festem Zielkonto trug beim Wiedereinspielen + die ALTE Konto-Nummer in die neue Datenbank. Beim Einspielen brach dann die + ganze Sicherung mit einem Fremdschluessel-Fehler ab - nicht nur diese eine + Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung also gar + nicht mehr heran.""" + import secrets + + def _konto(name): + return admin.post("/api/accounts", json={ + "name": name, "type": "sparkonto", "currency": "CHF", + "opening_balance_cents": 0}).json() + + # Erst Luecken in die Konto-Nummern reissen, dann das Zielkonto anlegen. + # Ohne Luecken vergibt SQLite beim Einspielen zufaellig dieselben Nummern + # und der Fehler bliebe unsichtbar - genau deshalb ist er so lange + # unbemerkt geblieben. + weg = [_konto(f"Weg {secrets.token_hex(3)}") for _ in range(5)] + ziel = _konto(f"Sparen {secrets.token_hex(3)}") + for k in weg: + assert admin.delete(f"/api/accounts/{k['id']}").status_code in (200, 204) + + wort = f"um chf sparen {secrets.token_hex(5)}" + r = admin.post("/api/categories/transfer-keywords", + json={"word": wort, "target_account_id": ziel["id"]}) + assert r.status_code == 201, r.text + + daten = json.loads(admin.get("/api/export/backup.json").content) + r = admin.post("/api/backup/restore", + files={"file": ("backup.json", json.dumps(daten).encode("utf-8"), + "application/json")}, + data={"mode": "replace"}) + assert r.status_code == 200, r.text + + konten = {a["name"]: a["id"] for a in admin.get("/api/accounts").json()} + regel = next(w for w in admin.get("/api/categories/transfer-keywords").json() + if w["word"] == wort) + assert regel["target_account_id"] == konten[ziel["name"]], \ + "das Zielkonto muss auf die NEUE Nummer zeigen"