a6179037c2
Two changes. 1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only on can_view_tree, so a non-member on a public/unlisted/site_members tree could enumerate citations and sources tied to a redacted living person, leaking that the person exists and has sourced facts (and possibly their name via a source title). #46 closed this for events/media/names/relationships but not citations/sources. Now citation_service.list_citations and source_service.{list_sources,get_source} delegate non-member reads to public_view_service, mirroring the #46 pattern: - citations: shown only when the cited fact resolves to FULL-visibility person(s) — covers the person_id, name_id, event_id (person or both-partner), and relationship_id (both-partner) target paths. - sources: shown only when they back at least one visible citation; a withheld source 404s (don't reveal it exists). Tests cover all four citation target types + source withholding + member-sees-all. 2. On-demand tree purge — owners can permanently delete a soft-deleted tree now instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge (owner-only): the tree must already be in the trash, and the caller retypes its name to confirm. Media objects are deleted from storage, then a single DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE; the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever" button to the Recently-deleted list. No migration. Suite: 102 passing. Signed-off-by: Justin Paul <justin@jpaul.me>
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
"""On-demand purge of a soft-deleted tree: permanent, owner-only, name-confirmed,
|
|
and cascades to all tree data."""
|
|
|
|
import uuid
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from app.models.person import Person
|
|
from app.models.tree import Tree
|
|
from tests.conftest import auth, register
|
|
|
|
|
|
async def _tree_with_person(client, owner):
|
|
tid = (await client.post("/api/v1/trees", json={"name": "Purge Me"}, headers=owner)).json()["id"]
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/persons", json={"given": "Doomed", "surname": "Soul"}, headers=owner
|
|
)
|
|
return tid
|
|
|
|
|
|
async def test_purge_requires_soft_delete_first(client):
|
|
owner = auth(await register(client, "purge-a@ex.com"))
|
|
tid = await _tree_with_person(client, owner)
|
|
# A live tree can't be purged — it must be trashed first.
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
|
|
async def test_purge_name_must_match(client):
|
|
owner = auth(await register(client, "purge-b@ex.com"))
|
|
tid = await _tree_with_person(client, owner)
|
|
await client.delete(f"/api/v1/trees/{tid}", headers=owner) # soft-delete
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "WRONG"}, headers=owner
|
|
)
|
|
assert r.status_code == 403
|
|
# Still in the trash — nothing destroyed.
|
|
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
|
|
assert any(t["id"] == tid for t in deleted)
|
|
|
|
|
|
async def test_purge_owner_only(client):
|
|
owner = auth(await register(client, "purge-c@ex.com"))
|
|
other = auth(await register(client, "purge-c2@ex.com"))
|
|
tid = await _tree_with_person(client, owner)
|
|
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=other
|
|
)
|
|
assert r.status_code in (403, 404)
|
|
|
|
|
|
async def test_purge_removes_tree_and_cascades(client, db_session):
|
|
owner = auth(await register(client, "purge-d@ex.com"))
|
|
tid = await _tree_with_person(client, owner)
|
|
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
|
|
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
|
|
)
|
|
assert r.status_code == 204
|
|
|
|
# Gone from the trash...
|
|
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
|
|
assert not any(t["id"] == tid for t in deleted)
|
|
|
|
# ...and cascaded: no tree row, no person rows.
|
|
tuuid = uuid.UUID(tid)
|
|
assert (
|
|
await db_session.execute(select(func.count()).select_from(Tree).where(Tree.id == tuuid))
|
|
).scalar() == 0
|
|
assert (
|
|
await db_session.execute(
|
|
select(func.count()).select_from(Person).where(Person.tree_id == tuuid)
|
|
)
|
|
).scalar() == 0
|