Merge pull request 'Visibility phase 2: privacy-engine branches on viewer auth state' (#43) from visibility-phase2-privacy into main
build-backend / build (push) Successful in 29s
build-backend / build (push) Successful in 29s
This commit was merged in pull request #43.
This commit is contained in:
@@ -45,8 +45,17 @@ async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
|
|||||||
if tree.deleted_at is not None:
|
if tree.deleted_at is not None:
|
||||||
return False
|
return False
|
||||||
if await get_membership_role(session, user_id, tree.id) is not None:
|
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||||
|
return True # members always (any role)
|
||||||
|
# Non-members. Branch on the viewer's auth state:
|
||||||
|
# public / unlisted → anyone, including anonymous (unlisted is gated only
|
||||||
|
# by knowing the link, so the API must never *list* it).
|
||||||
|
# site_members → any authenticated account on this instance.
|
||||||
|
# private → no one.
|
||||||
|
if tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted):
|
||||||
return True
|
return True
|
||||||
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted)
|
if tree.visibility == TreeVisibility.site_members:
|
||||||
|
return user_id is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||||
|
|||||||
@@ -67,16 +67,21 @@ def mailer() -> CapturingMailer:
|
|||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client():
|
async def engine():
|
||||||
if not TEST_DATABASE_URL:
|
if not TEST_DATABASE_URL:
|
||||||
pytest.skip("TEST_DATABASE_URL not set")
|
pytest.skip("TEST_DATABASE_URL not set")
|
||||||
|
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
eng = create_async_engine(TEST_DATABASE_URL)
|
||||||
async with engine.begin() as conn:
|
async with eng.begin() as conn:
|
||||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield eng
|
||||||
|
await eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(engine):
|
||||||
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
async def _override_session():
|
async def _override_session():
|
||||||
@@ -95,7 +100,14 @@ async def client():
|
|||||||
yield http_client
|
yield http_client
|
||||||
|
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(engine):
|
||||||
|
"""A raw AsyncSession on the test DB, for unit-testing services directly."""
|
||||||
|
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def token_from_link(link: str) -> str:
|
def token_from_link(link: str) -> str:
|
||||||
|
|||||||
@@ -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