Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.
admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
88 lines
4.3 KiB
Python
88 lines
4.3 KiB
Python
"""
|
|
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
|
|
|
|
|
|
def test_delete_account_purges_note_media(client):
|
|
"""Account-Löschung entfernt Notiz-Medien — DB-Zeilen UND Dateien auf Disk."""
|
|
import io, os
|
|
from database import db
|
|
from PIL import Image
|
|
|
|
uid, headers = _make_user(client)
|
|
nid = client.post("/api/notes/diary/1", headers=headers,
|
|
json={"text": "Mit Foto", "parent_label": "X"}).json()["id"]
|
|
|
|
buf = io.BytesIO(); Image.new("RGB", (10, 10), (1, 2, 3)).save(buf, format="JPEG")
|
|
up = client.post(f"/api/notes/{nid}/media", headers=headers,
|
|
files={"file": ("f.jpg", buf.getvalue(), "image/jpeg")})
|
|
assert up.status_code == 200, up.text
|
|
url = up.json()["url"]
|
|
fpath = os.path.join(os.getenv("MEDIA_DIR", "/data/media"), url[len("/media/"):])
|
|
assert os.path.exists(fpath)
|
|
|
|
resp = client.delete("/api/profile/account", headers=headers)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
with db() as conn:
|
|
assert conn.execute("SELECT COUNT(*) c FROM note_media WHERE note_id=?", (nid,)).fetchone()["c"] == 0
|
|
assert conn.execute("SELECT COUNT(*) c FROM notes WHERE user_id=?", (uid,)).fetchone()["c"] == 0
|
|
assert not os.path.exists(fpath), "note_media-Datei blieb als Leiche auf Disk"
|