Files
provenance/backend/tests/test_public_view.py
T
justin 9820a77d25 Visibility phase 3: redaction-safe public read API + leak test
Adds the anonymous read surface (/api/v1/public) — the privacy-critical core.

- CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK).
- public_view_service: every projection passes through privacy.person_visibility.
  persons redacted (living → "Living person", hidden dropped); relationships
  only when both endpoints non-hidden; events only for FULL-visibility persons
  (partnership events only when both partners full); names only for FULL
  persons. Not-viewable trees raise 404 (not 403) so the surface can't probe
  for private trees. Media deferred (higher-sensitivity; own pass later).
- public router: read-only directory + tree + persons/relationships/events +
  person detail/names/events. Directory lists `public` to all and adds
  `site_members` for authenticated callers; never lists unlisted/private.
- PublicTreeRead omits owner_id.

Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test
asserting a living person's real name, alias, and birth year appear in NO public
response while the deceased person's data does; plus private=404, unlisted
viewable-by-link-but-unlisted, site_members requires login, and directory
visibility. Full suite: 70 passed. Regenerated openapi.json + TS client.

Note: the AUTHED list endpoints still leak per-person for non-members
(pre-existing) — fixed next, separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 09:17:41 -04:00

181 lines
7.1 KiB
Python

"""The public viewing surface (/api/v1/public).
The central guarantee: an ANONYMOUS viewer of a public tree never receives a
possibly-living person's real name, dates, or alternate names — while deceased
people are shown in full. Plus the access matrix for each visibility level.
See docs/design/tree-visibility.md.
"""
from tests.conftest import auth, register
# Distinctive strings so we can assert they never leak anywhere anonymously.
LIVING_GIVEN = "Younglivingsecret"
LIVING_SURNAME = "Hiddensurname"
LIVING_ALIAS = "Secretmaidenalias"
LIVING_BIRTH_YEAR = "2002"
async def _person(client, tid, headers, given, surname, is_living):
r = await client.post(
f"/api/v1/trees/{tid}/persons",
json={"given": given, "surname": surname, "is_living": is_living},
headers=headers,
)
assert r.status_code == 201, r.text
return r.json()["id"]
async def _build_public_tree(client):
owner = auth(await register(client, "pv-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Heritage", "visibility": "public"}, headers=owner
)
).json()["id"]
old = await _person(client, tid, owner, "Olda", "Ancestor", False)
young = await _person(client, tid, owner, LIVING_GIVEN, LIVING_SURNAME, True)
# Birth events for each.
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": old, "date_value": "1850"},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": young, "date_value": LIVING_BIRTH_YEAR},
headers=owner,
)
# Alternate names for each.
await client.post(
f"/api/v1/trees/{tid}/persons/{old}/names",
json={"name_type": "alias", "given": "Oldnickname"},
headers=owner,
)
await client.post(
f"/api/v1/trees/{tid}/persons/{young}/names",
json={"name_type": "alias", "given": LIVING_ALIAS},
headers=owner,
)
# old --parent--> young
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={
"type": "parent_child",
"person_from_id": old,
"person_to_id": young,
"qualifier": "biological",
},
headers=owner,
)
return tid, old, young
async def test_anonymous_public_view_never_leaks_living_pii(client):
tid, old, young = await _build_public_tree(client)
# --- persons: deceased full, living redacted ---
persons = (await client.get(f"/api/v1/public/trees/{tid}/persons")).json()
by_id = {p["id"]: p for p in persons}
assert by_id[old]["primary_name"] == "Olda Ancestor"
assert by_id[young]["primary_name"] == "Living person"
assert by_id[young]["gender"] is None
# --- the living person's real name/alias/birth year must appear NOWHERE ---
for path in (
f"/api/v1/public/trees/{tid}/persons",
f"/api/v1/public/trees/{tid}/events",
f"/api/v1/public/trees/{tid}/relationships",
f"/api/v1/public/trees/{tid}/persons/{young}",
f"/api/v1/public/trees/{tid}/persons/{young}/names",
f"/api/v1/public/trees/{tid}/persons/{young}/events",
):
body = (await client.get(path)).text
assert LIVING_GIVEN not in body, path
assert LIVING_SURNAME not in body, path
assert LIVING_ALIAS not in body, path
assert LIVING_BIRTH_YEAR not in body, path
# --- events: deceased's date present, living's dropped entirely ---
events = (await client.get(f"/api/v1/public/trees/{tid}/events")).json()
assert any(e["person_id"] == old for e in events)
assert not any(e["person_id"] == young for e in events)
# --- per-person endpoints for the living person are emptied/redacted ---
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/names")).json() == []
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/events")).json() == []
assert (
await client.get(f"/api/v1/public/trees/{tid}/persons/{young}")
).json()["primary_name"] == "Living person"
# --- deceased person's names/events ARE exposed ---
old_names = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/names")).json()
assert any(n.get("given") == "Oldnickname" for n in old_names)
old_events = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/events")).json()
assert any(e["date_value"] == "1850" for e in old_events)
# --- relationship kept (links to the redacted person by id, no PII) ---
rels = (await client.get(f"/api/v1/public/trees/{tid}/relationships")).json()
assert any(r2["person_from_id"] == old and r2["person_to_id"] == young for r2 in rels)
async def test_private_tree_is_404_anonymously(client):
owner = auth(await register(client, "priv-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "Secret", "visibility": "private"}, headers=owner
)
).json()["id"]
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404
assert (await client.get(f"/api/v1/public/trees/{tid}/persons")).status_code == 404
async def test_unlisted_viewable_by_link_but_not_in_directory(client):
owner = auth(await register(client, "unl-owner@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "ByLinkOnly", "visibility": "unlisted"}, headers=owner
)
).json()["id"]
# Direct link works anonymously...
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 200
# ...but it is never listed in the directory.
directory = (await client.get("/api/v1/public/trees")).json()
assert all(t["id"] != tid for t in directory)
async def test_site_members_requires_login(client):
owner = auth(await register(client, "sm2-owner@ex.com"))
stranger = auth(await register(client, "sm2-stranger@ex.com"))
tid = (
await client.post(
"/api/v1/trees", json={"name": "MembersOnly", "visibility": "site_members"}, headers=owner
)
).json()["id"]
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 # anonymous
assert (await client.get(f"/api/v1/public/trees/{tid}", headers=stranger)).status_code == 200
async def test_directory_visibility(client):
owner = auth(await register(client, "dir-owner@ex.com"))
stranger = auth(await register(client, "dir-stranger@ex.com"))
ids = {}
for vis in ("public", "site_members", "unlisted", "private"):
ids[vis] = (
await client.post(
"/api/v1/trees", json={"name": f"dir-{vis}", "visibility": vis}, headers=owner
)
).json()["id"]
anon = {t["id"] for t in (await client.get("/api/v1/public/trees")).json()}
assert ids["public"] in anon
for vis in ("site_members", "unlisted", "private"):
assert ids[vis] not in anon
logged_in = {t["id"] for t in (await client.get("/api/v1/public/trees", headers=stranger)).json()}
assert ids["public"] in logged_in
assert ids["site_members"] in logged_in
assert ids["unlisted"] not in logged_in
assert ids["private"] not in logged_in