diff --git a/backend/app/services/privacy.py b/backend/app/services/privacy.py index 74df825..10c1c52 100644 --- a/backend/app/services/privacy.py +++ b/backend/app/services/privacy.py @@ -45,8 +45,17 @@ async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre if tree.deleted_at is not None: return False 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 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: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 846bf0f..adee59a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -67,16 +67,21 @@ def mailer() -> CapturingMailer: @pytest_asyncio.fixture -async def client(): +async def engine(): if not TEST_DATABASE_URL: pytest.skip("TEST_DATABASE_URL not set") - engine = create_async_engine(TEST_DATABASE_URL) - async with engine.begin() as conn: + eng = create_async_engine(TEST_DATABASE_URL) + async with eng.begin() as conn: 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.create_all) + yield eng + await eng.dispose() + +@pytest_asyncio.fixture +async def client(engine): sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) async def _override_session(): @@ -95,7 +100,14 @@ async def client(): yield http_client 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: diff --git a/backend/tests/test_privacy_visibility.py b/backend/tests/test_privacy_visibility.py new file mode 100644 index 0000000..d519727 --- /dev/null +++ b/backend/tests/test_privacy_visibility.py @@ -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