"""Authed non-member reads must redact PER-PERSON, not just gate on the tree. A logged-in user who is NOT a member of a public tree previously saw living people's dates, real alternate names, and media through the family-view endpoints — only the person *list* was redacted. These tests assert that leak is closed while members still see everything. """ from tests.conftest import auth, register LSURNAME = "Authleaksurname" LALIAS = "Authleakalias" LYEAR = "2003" async def _setup(client): owner = auth(await register(client, "anm-owner@ex.com")) tid = ( await client.post( "/api/v1/trees", json={"name": "Pub", "visibility": "public"}, headers=owner ) ).json()["id"] old = ( await client.post( f"/api/v1/trees/{tid}/persons", json={"given": "Olde", "surname": "Gone", "is_living": False}, headers=owner, ) ).json()["id"] young = ( await client.post( f"/api/v1/trees/{tid}/persons", json={"given": "Youngauth", "surname": LSURNAME, "is_living": True}, headers=owner, ) ).json()["id"] for pid, year in ((old, "1855"), (young, LYEAR)): await client.post( f"/api/v1/trees/{tid}/events", json={"event_type": "birth", "person_id": pid, "date_value": year}, headers=owner, ) await client.post( f"/api/v1/trees/{tid}/persons/{young}/names", json={"name_type": "alias", "given": LALIAS}, headers=owner, ) om = ( await client.post( f"/api/v1/trees/{tid}/media", files={"file": ("o.txt", b"old-photo", "text/plain")}, data={"person_id": old}, headers=owner, ) ).json()["id"] ym = ( await client.post( f"/api/v1/trees/{tid}/media", files={"file": ("y.txt", b"young-photo", "text/plain")}, data={"person_id": young}, headers=owner, ) ).json()["id"] return owner, tid, old, young, om, ym async def test_authed_nonmember_does_not_see_living_pii(client): owner, tid, old, young, om, ym = await _setup(client) stranger = auth(await register(client, "anm-stranger@ex.com")) # Living person's events dropped; deceased kept. events = (await client.get(f"/api/v1/trees/{tid}/events", headers=stranger)).json() assert any(e["person_id"] == old for e in events) assert not any(e["person_id"] == young for e in events) # Per-person living: names + events empty. assert ( await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=stranger) ).json() == [] assert ( await client.get(f"/api/v1/trees/{tid}/persons/{young}/events", headers=stranger) ).json() == [] # The living surname/alias/birth-year must not appear in any of these. for path in ( f"/api/v1/trees/{tid}/events", f"/api/v1/trees/{tid}/relationships", f"/api/v1/trees/{tid}/persons/{young}/names", f"/api/v1/trees/{tid}/media", ): body = (await client.get(path, headers=stranger)).text assert LSURNAME not in body, path assert LALIAS not in body, path assert LYEAR not in body, path # Media: living person's media hidden from the list and undownloadable; # deceased person's media is fine. media_ids = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=stranger)).json()} assert om in media_ids assert ym not in media_ids assert ( await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=stranger) ).status_code == 404 assert ( await client.get(f"/api/v1/trees/{tid}/media/{om}/content", headers=stranger) ).status_code == 200 async def _setup_sources(client): owner = auth(await register(client, "anmcs-owner@ex.com")) tid = ( await client.post( "/api/v1/trees", json={"name": "PubCS", "visibility": "public"}, headers=owner ) ).json()["id"] old = ( await client.post( f"/api/v1/trees/{tid}/persons", json={"given": "Oldcs", "surname": "Gonecs", "is_living": False}, headers=owner, ) ).json()["id"] young = ( await client.post( f"/api/v1/trees/{tid}/persons", json={"given": "Youngcs", "surname": "Csleaksurname", "is_living": True}, headers=owner, ) ).json()["id"] for pid, year in ((old, "1851"), (young, "2004")): await client.post( f"/api/v1/trees/{tid}/events", json={"event_type": "birth", "person_id": pid, "date_value": year}, headers=owner, ) s_old = ( await client.post( f"/api/v1/trees/{tid}/sources", json={"title": "Oldsource record"}, headers=owner ) ).json()["id"] s_young = ( await client.post( f"/api/v1/trees/{tid}/sources", json={"title": "Youngsource Csleaktitle"}, # title names the living person headers=owner, ) ).json()["id"] await client.post( f"/api/v1/trees/{tid}/citations", json={"source_id": s_old, "person_id": old, "page": "p.1"}, headers=owner, ) await client.post( f"/api/v1/trees/{tid}/citations", json={"source_id": s_young, "person_id": young, "page": "p.2"}, headers=owner, ) return owner, tid, old, young, s_old, s_young async def test_authed_nonmember_citation_source_redaction(client): """A non-member must not see citations on a redacted living person's facts, nor sources used only for them.""" owner, tid, old, young, s_old, s_young = await _setup_sources(client) stranger = auth(await register(client, "anmcs-stranger@ex.com")) cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json() cited = {c.get("person_id") for c in cites} assert old in cited assert young not in cited # living person's citation dropped srcs = (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)) src_ids = {s["id"] for s in srcs.json()} assert s_old in src_ids assert s_young not in src_ids # source used only for the living person withheld assert "Csleaktitle" not in srcs.text # its title (which names them) must not leak # The withheld source 404s — don't reveal it exists; the visible one is fine. assert ( await client.get(f"/api/v1/trees/{tid}/sources/{s_young}", headers=stranger) ).status_code == 404 assert ( await client.get(f"/api/v1/trees/{tid}/sources/{s_old}", headers=stranger) ).status_code == 200 # Members still see everything. mc = {c.get("person_id") for c in (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()} assert {old, young} <= mc ms = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=owner)).json()} assert {s_old, s_young} <= ms async def test_citation_redaction_via_indirect_targets(client): """Citations targeting a living person *indirectly* (via their event or name, not person_id) must also be dropped for non-members.""" owner = auth(await register(client, "anmind-owner@ex.com")) tid = ( await client.post( "/api/v1/trees", json={"name": "PubInd", "visibility": "public"}, headers=owner ) ).json()["id"] young = ( await client.post( f"/api/v1/trees/{tid}/persons", json={"given": "Youngind", "surname": "Indsurname", "is_living": True}, headers=owner, ) ).json()["id"] ev = ( await client.post( f"/api/v1/trees/{tid}/events", json={"event_type": "birth", "person_id": young, "date_value": "2005"}, headers=owner, ) ).json()["id"] nm = ( await client.post( f"/api/v1/trees/{tid}/persons/{young}/names", json={"name_type": "alias", "given": "Indalias"}, headers=owner, ) ).json()["id"] s_ev = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "EvSrc"}, headers=owner)).json()["id"] s_nm = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "NmSrc"}, headers=owner)).json()["id"] await client.post( f"/api/v1/trees/{tid}/citations", json={"source_id": s_ev, "event_id": ev}, headers=owner ) await client.post( f"/api/v1/trees/{tid}/citations", json={"source_id": s_nm, "name_id": nm}, headers=owner ) stranger = auth(await register(client, "anmind-stranger@ex.com")) cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json() # Neither the event-citation nor the name-citation may surface. assert not any(c.get("event_id") == ev for c in cites) assert not any(c.get("name_id") == nm for c in cites) src_ids = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)).json()} assert s_ev not in src_ids and s_nm not in src_ids # Owner (member) sees both citations and both sources. mc = (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json() assert any(c.get("event_id") == ev for c in mc) and any(c.get("name_id") == nm for c in mc) async def test_member_still_sees_everything(client): owner, tid, old, young, om, ym = await _setup(client) events = (await client.get(f"/api/v1/trees/{tid}/events", headers=owner)).json() assert any(e["person_id"] == young for e in events) assert ( await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=owner) ).json() != [] member_media = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=owner)).json()} assert ym in member_media assert ( await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=owner) ).status_code == 200