"""SSR visibility enforcement — AV_19 through AV_26. Verifies that the visibility gate added to _resolve_repo / _resolve_repo_full in _ui_helpers.py correctly enforces private repo access, and that the owner-only gate on settings returns the right status codes. Test matrix: - AV_19: Anonymous GET on private repo home page → 404 - AV_20: Anonymous GET on private repo commits page → 404 - AV_21: Anonymous GET on private repo issues page → 404 - AV_22: Anonymous GET on public repo home page → 200 - AV_23: Authenticated owner GET on private repo → 200 - AV_24: Unauthenticated GET on /{owner}/{slug}/settings → 401 - AV_25: Authenticated non-owner GET on /{owner}/{slug}/settings → 403 - AV_26: Authenticated owner GET on /{owner}/{slug}/settings → 200 """ from __future__ import annotations from datetime import datetime, timezone from typing import Generator import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id, compute_repo_id from musehub.db.musehub_identity_models import MusehubIdentity from musehub.db.musehub_repo_models import MusehubRepo # Matches the handle injected by the auth_headers fixture from conftest.py. _OWNER = "testuser" async def _make_repo( db: AsyncSession, owner: str, slug: str, visibility: str = "private", ) -> MusehubRepo: owner_id = compute_identity_id(owner.encode()) created_at = datetime.now(tz=timezone.utc) repo = MusehubRepo( repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), name=slug, owner=owner, slug=slug, visibility=visibility, owner_user_id=owner_id, created_at=created_at, updated_at=created_at, ) db.add(repo) await db.commit() await db.refresh(repo) return repo # --------------------------------------------------------------------------- # AV_19 — Anonymous GET on private repo home → 404 # --------------------------------------------------------------------------- async def test_av_19_anon_private_repo_home_returns_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """AV_19: Anonymous GET on a private repo's home page returns 404.""" repo = await _make_repo(db_session, owner="av19owner", slug="av19-private-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}") assert resp.status_code == 404 # --------------------------------------------------------------------------- # AV_20 — Anonymous GET on private repo commits → 404 # --------------------------------------------------------------------------- async def test_av_20_anon_private_repo_commits_returns_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """AV_20: Anonymous GET on a private repo's commits page returns 404.""" repo = await _make_repo(db_session, owner="av20owner", slug="av20-private-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/commits") assert resp.status_code == 404 # --------------------------------------------------------------------------- # AV_21 — Anonymous GET on private repo issues → 404 # --------------------------------------------------------------------------- async def test_av_21_anon_private_repo_issues_returns_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """AV_21: Anonymous GET on a private repo's issues page returns 404.""" repo = await _make_repo(db_session, owner="av21owner", slug="av21-private-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}/issues") assert resp.status_code == 404 # --------------------------------------------------------------------------- # AV_22 — Anonymous GET on public repo home → 200 # --------------------------------------------------------------------------- async def test_av_22_anon_public_repo_home_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """AV_22: Anonymous GET on a public repo's home page returns 200.""" repo = await _make_repo( db_session, owner="av22owner", slug="av22-public-repo", visibility="public" ) resp = await client.get(f"/{repo.owner}/{repo.slug}") assert resp.status_code == 200 # --------------------------------------------------------------------------- # AV_23 — Authenticated owner GET on private repo → 200 # --------------------------------------------------------------------------- async def test_av_23_authed_owner_private_repo_returns_200( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """AV_23: Authenticated GET (valid token, owner handle) on a private repo returns 200. auth_headers overrides optional_signed_request to return a MSignContext with handle='testuser', so the visibility gate passes for a repo owned by 'testuser'. """ repo = await _make_repo(db_session, owner=_OWNER, slug="av23-private-repo") resp = await client.get(f"/{repo.owner}/{repo.slug}") assert resp.status_code == 200 # --------------------------------------------------------------------------- # AV_24 — Unauthenticated GET on settings → 401 # --------------------------------------------------------------------------- async def test_av_24_anon_settings_returns_401( client: AsyncClient, db_session: AsyncSession, ) -> None: """AV_24: Unauthenticated GET on /{owner}/{slug}/settings returns 401. The settings page uses require_valid_token (not optional_token), so the absence of a valid MSign token must return 401 before any repo lookup. """ repo = await _make_repo( db_session, owner="av24owner", slug="av24-repo", visibility="public" ) resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 401 # --------------------------------------------------------------------------- # AV_25 — Authenticated non-owner GET on settings → 403 # --------------------------------------------------------------------------- async def test_av_25_authed_nonowner_settings_returns_403( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """AV_25: Authenticated non-owner GET on /{owner}/{slug}/settings returns 403. auth_headers injects handle='testuser'. The repo owner is a different handle, so the owner check (claims.handle != owner) fires 403. """ repo = await _make_repo( db_session, owner="av25-different-owner", slug="av25-repo", visibility="public" ) resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 403 # --------------------------------------------------------------------------- # AV_26 — Authenticated owner GET on settings → 200 # --------------------------------------------------------------------------- async def test_av_26_authed_owner_settings_returns_200( client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, auth_headers: dict[str, str], ) -> None: """AV_26: Authenticated owner GET on /{owner}/{slug}/settings returns 200.""" repo = await _make_repo( db_session, owner=_OWNER, slug="av26-repo", visibility="public" ) resp = await client.get(f"/{repo.owner}/{repo.slug}/settings") assert resp.status_code == 200