"""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