8b91326481
A logged-in NON-member of a public/unlisted tree could read living people's dates, real alternate names, and media (incl. downloading photos) through the family-view endpoints — only the person LIST was redacted; list_events, list_relationships, list_names, list_media gated on can_view_tree alone. For non-members, these now delegate to the same visibility-filtered reads the public surface uses (person_visibility-driven): living-person events/names dropped, relationships touching a hidden person dropped, media limited to full-visibility persons, and media download (get_media → media_content) 404s for a redacted/unlinked person's media. Members are unchanged. Adds list_public_relationships_for_person / list_public_media / can_view_media to public_view_service. Test: an authed non-member sees no living-person PII across events/names/relationships/media and can't download a living person's file, while the owner still sees everything. Full suite: 72 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
122 lines
4.3 KiB
Python
122 lines
4.3 KiB
Python
"""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 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
|