e9b2436ce0
New account_service + endpoints under /users/me: - GET /me/export — zip of every owned tree (account.json + media blobs). - POST /me/import — restore a backup into NEW trees (ids remapped, media re-uploaded); non-destructive, never touches existing data. - DELETE /me — soft-delete the user, their owned trees, and revoke sessions; guarded by retyping the account email. Settings page wires all three (export download, restore upload, delete with typed-email confirmation). No migration — uses existing tables + soft-delete. 52 backend tests pass (export→restore round-trip + delete guards); frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
"""Account export -> restore round-trip, and account deletion."""
|
|
|
|
from tests.conftest import auth, register
|
|
|
|
|
|
async def _seed(client, h):
|
|
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=h)).json()["id"]
|
|
p1 = (
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Lovelace"}, headers=h
|
|
)
|
|
).json()["id"]
|
|
p2 = (
|
|
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Kid"}, headers=h)
|
|
).json()["id"]
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/relationships",
|
|
json={"type": "parent_child", "person_from_id": p1, "person_to_id": p2},
|
|
headers=h,
|
|
)
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/events",
|
|
json={"event_type": "birth", "person_id": p1, "date_value": "1815"},
|
|
headers=h,
|
|
)
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/media",
|
|
files={"file": ("scan.txt", b"hello", "text/plain")},
|
|
data={"title": "Scan", "person_id": p1},
|
|
headers=h,
|
|
)
|
|
return tid
|
|
|
|
|
|
async def test_export_then_restore_roundtrip(client):
|
|
h = auth(await register(client, "exp@example.com"))
|
|
await _seed(client, h)
|
|
|
|
export = await client.get("/api/v1/users/me/export", headers=h)
|
|
assert export.status_code == 200
|
|
assert export.headers["content-type"] == "application/zip"
|
|
blob = export.content
|
|
assert blob[:2] == b"PK" # zip magic
|
|
|
|
# Restore into new trees (non-destructive: the original stays).
|
|
r = await client.post(
|
|
"/api/v1/users/me/import",
|
|
files={"file": ("provenance-export.zip", blob, "application/zip")},
|
|
headers=h,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
counts = r.json()
|
|
assert counts["trees"] == 1 and counts["persons"] == 2
|
|
assert counts["events"] == 1 and counts["media"] == 1
|
|
|
|
trees = (await client.get("/api/v1/trees", headers=h)).json()
|
|
assert len(trees) == 2 # original + restored
|
|
|
|
# The restored tree has the people, with a working relationship and media.
|
|
restored = [t for t in trees if t["name"] == "Fam"][1]["id"]
|
|
ppl = (await client.get(f"/api/v1/trees/{restored}/persons", headers=h)).json()
|
|
assert {p["primary_name"] for p in ppl} == {"Ada Lovelace", "Kid"}
|
|
rels = (await client.get(f"/api/v1/trees/{restored}/relationships", headers=h)).json()
|
|
assert len(rels) == 1
|
|
med = (await client.get(f"/api/v1/trees/{restored}/media", headers=h)).json()
|
|
assert len(med) == 1 and med[0]["title"] == "Scan"
|
|
|
|
|
|
async def test_delete_account_requires_email_then_revokes(client):
|
|
token = await register(client, "del@example.com")
|
|
h = auth(token)
|
|
await _seed(client, h)
|
|
|
|
# Wrong email is rejected.
|
|
bad = await client.request(
|
|
"DELETE", "/api/v1/users/me", data={"confirm_email": "nope@example.com"}, headers=h
|
|
)
|
|
assert bad.status_code == 403
|
|
|
|
ok = await client.request(
|
|
"DELETE", "/api/v1/users/me", data={"confirm_email": "del@example.com"}, headers=h
|
|
)
|
|
assert ok.status_code == 204
|
|
|
|
# Session is revoked — the token no longer works.
|
|
assert (await client.get("/api/v1/users/me", headers=h)).status_code == 401
|