Fix: Account-Löschung FK-sicher über alle Tabellen (defer_foreign_keys + Introspektion)

Der alte delete_account löschte nur ~6 Tabellen und scheiterte am finalen
DELETE FROM users, sobald der User Zeilen in Non-Cascade-Tabellen hatte
(routes, places, walks, events, forum_threads, invoices …). Jetzt:
PRAGMA defer_foreign_keys + foreign_key_list-Introspektion (CASCADE automatisch,
NO-ACTION-Eigentum löschen, Actor/SET-NULL-Spalten nullen) plus user_id/owner_id-
Scan für Tabellen ohne formale FK. Regressionstest test_account_deletion.py.

Relevant für App-Store-Gl. 4 (In-App-Konto-Löschung muss zuverlässig funktionieren).
This commit is contained in:
rene 2026-06-04 19:21:18 +02:00
parent 1448782564
commit 545b57c723
2 changed files with 125 additions and 15 deletions

View file

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