Visibility phase 2: privacy-engine branches on viewer auth state
can_view_tree() now distinguishes anonymous vs authenticated non-members so the
four-level model is enforceable:
- public / unlisted → anyone, including anonymous (unlisted gated only by the
link, so the API must never *list* it)
- site_members → any authenticated account (denies anonymous)
- private → members only
Members (any role) always view; soft-deleted trees stay hidden from everyone.
person_visibility (living-person redaction) is unchanged.
Tests: a full can_view_tree matrix across {anonymous, logged-in non-member,
member} × {public, unlisted, site_members, private}, plus deleted-tree-hidden
and the site_members anon-vs-logged-in case. Adds `engine`/`db_session` fixtures
(refactored out of `client`) so the engine can be unit-tested directly,
including the anonymous path that has no HTTP endpoint yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""Tree-visibility access matrix for the privacy engine.
|
||||
|
||||
`can_view_tree` is the gate every read path consults. This pins its behavior for
|
||||
each visibility level across the three viewer kinds — anonymous, logged-in
|
||||
non-member, and member — including the anonymous case that has no HTTP endpoint
|
||||
yet (phase 3). See docs/design/tree-visibility.md.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import privacy
|
||||
from tests.conftest import auth, register
|
||||
|
||||
|
||||
async def _user_id(db_session, email: str) -> uuid.UUID:
|
||||
return (await db_session.execute(select(User).where(User.email == email))).scalar_one().id
|
||||
|
||||
|
||||
async def _make_tree(client, owner_token: str, visibility: str) -> uuid.UUID:
|
||||
r = await client.post(
|
||||
"/api/v1/trees",
|
||||
json={"name": f"t-{visibility}", "visibility": visibility},
|
||||
headers=auth(owner_token),
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
assert r.json()["visibility"] == visibility
|
||||
return uuid.UUID(r.json()["id"])
|
||||
|
||||
|
||||
async def _load_tree(db_session, tid: uuid.UUID) -> Tree:
|
||||
return (await db_session.execute(select(Tree).where(Tree.id == tid))).scalar_one()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"visibility,anon,nonmember,member",
|
||||
[
|
||||
("public", True, True, True),
|
||||
("unlisted", True, True, True),
|
||||
("site_members", False, True, True),
|
||||
("private", False, False, True),
|
||||
],
|
||||
)
|
||||
async def test_can_view_tree_matrix(client, db_session, visibility, anon, nonmember, member):
|
||||
owner_email = f"owner-{visibility}@ex.com"
|
||||
other_email = f"other-{visibility}@ex.com"
|
||||
owner = await register(client, owner_email)
|
||||
await register(client, other_email)
|
||||
owner_id = await _user_id(db_session, owner_email)
|
||||
other_id = await _user_id(db_session, other_email)
|
||||
tree = await _load_tree(db_session, await _make_tree(client, owner, visibility))
|
||||
|
||||
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is anon
|
||||
assert await privacy.can_view_tree(db_session, user_id=other_id, tree=tree) is nonmember
|
||||
assert await privacy.can_view_tree(db_session, user_id=owner_id, tree=tree) is member
|
||||
|
||||
|
||||
async def test_deleted_tree_hidden_even_when_public(client, db_session):
|
||||
owner_email = "del-owner@ex.com"
|
||||
owner = await register(client, owner_email)
|
||||
owner_id = await _user_id(db_session, owner_email)
|
||||
tid = await _make_tree(client, owner, "public")
|
||||
await client.delete(f"/api/v1/trees/{tid}", headers=auth(owner))
|
||||
|
||||
tree = await _load_tree(db_session, tid)
|
||||
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is False
|
||||
assert await privacy.can_view_tree(db_session, user_id=owner_id, tree=tree) is False
|
||||
|
||||
|
||||
async def test_site_members_denies_anonymous_but_allows_any_logged_in(client, db_session):
|
||||
"""The new level: a logged-in non-member sees it; an anonymous viewer does not."""
|
||||
owner_email = "sm-owner@ex.com"
|
||||
stranger_email = "sm-stranger@ex.com"
|
||||
owner = await register(client, owner_email)
|
||||
await register(client, stranger_email)
|
||||
stranger_id = await _user_id(db_session, stranger_email)
|
||||
tree = await _load_tree(db_session, await _make_tree(client, owner, "site_members"))
|
||||
|
||||
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is False
|
||||
assert await privacy.can_view_tree(db_session, user_id=stranger_id, tree=tree) is True
|
||||
Reference in New Issue
Block a user