diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 3762413..f840ec8 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -149,25 +149,74 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)): # ---------------------------------------------------------- # DELETE /profile/account — Konto unwiderruflich löschen # ---------------------------------------------------------- +# Spalten, die eine HANDLUNG referenzieren (Moderator/Admin/Ersteller), +# nicht Eigentum des Users. Beim Löschen auf NULL setzen statt die fremde +# Zeile (z. B. einen Partner-Code oder eine moderierte Einreichung) mitzureißen. +_ACTOR_COLUMNS = { + ("wiki_foto_submissions", "reviewed_by"), + ("osm_poi_edits", "mod_id"), + ("partner_codes", "created_by"), + ("outreach_log", "sent_by"), + ("upgrade_requests", "fulfilled_by"), +} + + @router.delete('/account') async def delete_account(user=Depends(get_current_user)): - """Löscht das Konto und alle zugehörigen Daten unwiderruflich.""" + """Löscht das Konto und ALLE zugehörigen Daten unwiderruflich (DSGVO + App-Store-Gl. 4). + + FK-sicher und schema-robust: ermittelt per Introspektion alle Tabellen, die + auf users(id) verweisen. CASCADE-Tabellen werden beim users-DELETE automatisch + geleert; NO-ACTION/RESTRICT-Eigentumstabellen löschen wir explizit; Aktions- + Spalten (Moderator/Admin) setzen wir auf NULL. `defer_foreign_keys` macht die + Reihenfolge irrelevant — geprüft wird erst beim Commit. + """ uid = user['id'] with db() as conn: - # Alle Hunde-IDs des Users - dog_ids = [r['id'] for r in conn.execute( - "SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()] - for did in dog_ids: - conn.execute("DELETE FROM diary WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM health WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM upgrade_requests WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) + # FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal. + conn.execute("PRAGMA defer_foreign_keys=ON") + + tables = [r['name'] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall()] + + # Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde + # (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst. + handled_fk_cols: set[tuple] = set() + + # --- 1) Formale FKs auf users(id) --- + for tbl in tables: + try: + fks = conn.execute(f"PRAGMA foreign_key_list({tbl})").fetchall() + except Exception: + continue + for fk in fks: + if fk['table'] != 'users': + continue + col = fk['from'] + handled_fk_cols.add((tbl, col)) + on_delete = (fk['on_delete'] or '').upper() + if on_delete == 'CASCADE': + continue # wird durch den finalen users-DELETE mitgelöscht + if on_delete == 'SET NULL' or (tbl, col) in _ACTOR_COLUMNS: + conn.execute(f"UPDATE {tbl} SET {col}=NULL WHERE {col}=?", (uid,)) + else: + # NO ACTION / RESTRICT auf einer Eigentums-Spalte → Zeilen löschen. + conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + + # --- 2) Eigentums-Spalten OHNE formale FK (z. B. events.user_id) --- + # Manche Tabellen tragen user_id/owner_id ohne REFERENCES-Klausel. Die fängt + # die FK-Introspektion nicht — für ein echtes „alle Daten löschen" hier nach. + for tbl in tables: + try: + cols = {r['name'] for r in conn.execute(f"PRAGMA table_info({tbl})").fetchall()} + except Exception: + continue + for col in ('user_id', 'owner_id'): + if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS: + conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + + # Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab. conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} diff --git a/tests/test_account_deletion.py b/tests/test_account_deletion.py new file mode 100644 index 0000000..e016966 --- /dev/null +++ b/tests/test_account_deletion.py @@ -0,0 +1,61 @@ +""" +Account-Löschung (DSGVO + App-Store-Gl. 4): muss FK-sicher ALLE Daten entfernen, +auch wenn der User Zeilen in Tabellen ohne ON DELETE CASCADE hat (routes, places, +walks, events, forum_threads, …). Regressionstest gegen den alten, FK-unvollständigen +Delete, der am finalen `DELETE FROM users` scheiterte, sobald solche Zeilen existierten. +""" +import secrets + + +def _make_user(client): + from database import db + email = f"del-{secrets.token_hex(4)}@example.com" + pw, name = "TestPass123!", f"deltest{secrets.token_hex(3)}" + r = client.post("/api/auth/register", json={"email": email, "password": pw, "name": name}) + assert r.status_code == 200, r.text + with db() as conn: + conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,)) + uid = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()["id"] + token = client.post("/api/auth/login", json={"email": email, "password": pw}).json()["token"] + return uid, {"Authorization": f"Bearer {token}"} + + +def test_delete_account_with_noncascade_data(client): + from database import db + uid, headers = _make_user(client) + dog_id = client.post("/api/dogs", headers=headers, + json={"name": "Rex", "rasse": "Mix", "is_public": False}).json()["id"] + + # Direkt Zeilen in den Tabellen anlegen, die users(id) OHNE Cascade referenzieren — + # genau die, die den alten Delete blockiert haben. + with db() as conn: + conn.execute("INSERT INTO routes (user_id, name, gps_track) VALUES (?,?,?)", + (uid, "Testrunde", "[]")) + conn.execute("INSERT INTO places (user_id, name, typ, lat, lon) VALUES (?,?,?,?,?)", + (uid, "Hundewiese", "freilauf", 52.5, 13.4)) + conn.execute("INSERT INTO walks (user_id, titel, datum, uhrzeit, lat, lon) VALUES (?,?,?,?,?,?)", + (uid, "Gassi-Treff", "2026-07-01", "18:00", 52.5, 13.4)) + conn.execute("INSERT INTO events (user_id, titel, datum) VALUES (?,?,?)", + (uid, "Hundewanderung", "2026-07-02")) + conn.execute("INSERT INTO forum_threads (user_id, titel) VALUES (?,?)", + (uid, "Hallo Forum")) + + resp = client.delete("/api/profile/account", headers=headers) + assert resp.status_code == 200, f"Delete failed: {resp.status_code} {resp.text}" + assert resp.json()["status"] == "deleted" + + with db() as conn: + assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None + for tbl in ("routes", "places", "walks", "events", "forum_threads", "dogs"): + cnt = conn.execute(f"SELECT COUNT(*) c FROM {tbl} WHERE user_id=?", (uid,)).fetchone()["c"] + assert cnt == 0, f"{tbl} hat noch {cnt} Zeile(n) nach Account-Löschung" + + +def test_delete_account_minimal_user(client): + """Auch ein User ganz ohne Zusatzdaten lässt sich löschen.""" + from database import db + uid, headers = _make_user(client) + resp = client.delete("/api/profile/account", headers=headers) + assert resp.status_code == 200, resp.text + with db() as conn: + assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None