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:
parent
1448782564
commit
545b57c723
2 changed files with 125 additions and 15 deletions
|
|
@ -149,25 +149,74 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# DELETE /profile/account — Konto unwiderruflich löschen
|
# 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')
|
@router.delete('/account')
|
||||||
async def delete_account(user=Depends(get_current_user)):
|
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']
|
uid = user['id']
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
# Alle Hunde-IDs des Users
|
# FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal.
|
||||||
dog_ids = [r['id'] for r in conn.execute(
|
conn.execute("PRAGMA defer_foreign_keys=ON")
|
||||||
"SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()]
|
|
||||||
for did in dog_ids:
|
tables = [r['name'] for r in conn.execute(
|
||||||
conn.execute("DELETE FROM diary WHERE dog_id=?", (did,))
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
conn.execute("DELETE FROM health WHERE dog_id=?", (did,))
|
).fetchall()]
|
||||||
conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,))
|
|
||||||
conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,))
|
# Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde
|
||||||
conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,))
|
# (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst.
|
||||||
conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,))
|
handled_fk_cols: set[tuple] = set()
|
||||||
conn.execute("DELETE FROM upgrade_requests WHERE user_id=?", (uid,))
|
|
||||||
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
|
# --- 1) Formale FKs auf users(id) ---
|
||||||
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
|
for tbl in tables:
|
||||||
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
|
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,))
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
|
||||||
61
tests/test_account_deletion.py
Normal file
61
tests/test_account_deletion.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue