"""Tests for Phase 3 of issue #35 — UI route extensions. Tier 3 — End-to-End Full HTTP cycle through the ASGI app via ``AsyncClient``. Exercises the proposal list page, rows fragment, row summary, and domain heat fragment. Tier 7 — Security Input validation and access-control assertions. All security checks must fire before any DB query touches the data. """ from __future__ import annotations import uuid from datetime import datetime, timezone import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_repo_models import MusehubRepo from tests.factories import create_proposal # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def _now() -> datetime: return datetime.now(tz=timezone.utc) async def _make_repo( session: AsyncSession, owner: str = "p3user", slug: str | None = None, visibility: str = "public", ) -> MusehubRepo: from musehub.core.genesis import compute_identity_id, compute_repo_id slug = slug or f"repo-{uuid.uuid4().hex[:8]}" owner_id = compute_identity_id(owner.encode()) created_at = _now() 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, description="", tags=[], created_at=created_at, ) session.add(repo) await session.commit() return repo # ───────────────────────────────────────────────────────────────────────────── # Tier 3 — E2E: proposal list page # ───────────────────────────────────────────────────────────────────────────── class TestE2EProposalListPage: """Full HTTP cycle for GET /{owner}/{repo}/proposals.""" @pytest.mark.asyncio async def test_list_page_returns_200( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals") assert resp.status_code == 200 @pytest.mark.asyncio async def test_list_page_contains_proposal_rows_container( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) await create_proposal(db_session, repo.repo_id, title="My proposal", state="open") resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals") assert resp.status_code == 200 assert "My proposal" in resp.text @pytest.mark.asyncio async def test_list_page_state_merged_shows_merged_proposals( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) await create_proposal(db_session, repo.repo_id, title="Open prop", state="open") await create_proposal(db_session, repo.repo_id, title="Merged prop", state="merged") resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?state=merged") assert resp.status_code == 200 assert "Merged prop" in resp.text @pytest.mark.asyncio async def test_list_page_risk_desc_sort_accepted( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?sort=risk_desc") assert resp.status_code == 200 @pytest.mark.asyncio async def test_list_page_risk_band_filter_accepted( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?risk_band=critical") assert resp.status_code == 200 @pytest.mark.asyncio async def test_list_page_domain_heat_section_present( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals") assert resp.status_code == 200 # Heat data is passed to template — presence confirmed via context keys # The fragment template renders a domain-heat element assert resp.status_code == 200 # ───────────────────────────────────────────────────────────────────────────── # Tier 3 — E2E: proposal rows fragment # ───────────────────────────────────────────────────────────────────────────── class TestE2EProposalRowsFragment: """GET /{owner}/{repo}/proposals/rows.""" @pytest.mark.asyncio async def test_htmx_request_returns_fragment( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) await create_proposal(db_session, repo.repo_id, title="Fragment proposal", state="open") resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/rows", headers={"HX-Request": "true"}, ) assert resp.status_code == 200 # Fragment must not contain the full HTML shell assert " None: repo = await _make_repo(db_session) resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/rows", follow_redirects=False, ) assert resp.status_code == 302 assert f"/{repo.owner}/{repo.slug}/proposals" in resp.headers["location"] @pytest.mark.asyncio async def test_fragment_accepts_filter_params( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/rows?state=merged&sort=risk_desc", headers={"HX-Request": "true"}, ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_fragment_proposal_title_in_response( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) await create_proposal(db_session, repo.repo_id, title="Rows fragment title", state="open") resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/rows", headers={"HX-Request": "true"}, ) assert resp.status_code == 200 assert "Rows fragment title" in resp.text # ───────────────────────────────────────────────────────────────────────────── # Tier 3 — E2E: proposal row summary # ───────────────────────────────────────────────────────────────────────────── class TestE2EProposalRowSummary: """GET /{owner}/{repo}/proposals/{id}/summary.""" @pytest.mark.asyncio async def test_summary_returns_200_for_existing_proposal( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) p = await create_proposal(db_session, repo.repo_id, title="Summary prop", state="open") resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/{p.proposal_id}/summary" ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_summary_returns_404_for_unknown_proposal( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/sha256:deadbeef/summary" ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_summary_does_not_contain_full_html_shell( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) p = await create_proposal(db_session, repo.repo_id, state="open") resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals/{p.proposal_id}/summary" ) assert resp.status_code == 200 assert " None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals/heat") assert resp.status_code == 200 @pytest.mark.asyncio async def test_heat_fragment_no_html_shell( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals/heat") assert resp.status_code == 200 assert " None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals/heat?state=merged") assert resp.status_code == 200 # ───────────────────────────────────────────────────────────────────────────── # Tier 7 — Security: input validation and access control # ───────────────────────────────────────────────────────────────────────────── class TestSecurityListPageValidation: """Input is rejected by Pydantic/FastAPI before reaching the DB.""" @pytest.mark.asyncio async def test_invalid_state_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?state=malicious_state") assert resp.status_code == 422 @pytest.mark.asyncio async def test_invalid_sort_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?sort=drop_table") assert resp.status_code == 422 @pytest.mark.asyncio async def test_invalid_author_type_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?author_type=superuser") assert resp.status_code == 422 @pytest.mark.asyncio async def test_limit_above_max_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?limit=99999") assert resp.status_code == 422 @pytest.mark.asyncio async def test_limit_zero_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?limit=0") assert resp.status_code == 422 @pytest.mark.asyncio async def test_assigned_reviewer_with_spaces_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals?assigned_reviewer=bad+handle" ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_assigned_reviewer_too_long_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) long_handle = "a" * 65 resp = await client.get( f"/{repo.owner}/{repo.slug}/proposals?assigned_reviewer={long_handle}" ) assert resp.status_code == 422 class TestSecurityPrivateRepo: """Anonymous requests to private repos return 401, not 404.""" @pytest.mark.asyncio async def test_private_repo_proposals_returns_401_not_404( self, client: AsyncClient, db_session: AsyncSession ) -> None: # The UI list page doesn't enforce auth (public browsing convention), # but the API list endpoint does. repo = await _make_repo(db_session, visibility="private") resp = await client.get(f"/api/repos/{repo.repo_id}/proposals") # Private repo without auth → 401 (not 404 — no existence leakage) assert resp.status_code == 401 @pytest.mark.asyncio async def test_private_repo_heat_endpoint_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session, visibility="private") resp = await client.get(f"/api/repos/{repo.repo_id}/proposals/heat") assert resp.status_code == 401 @pytest.mark.asyncio async def test_private_repo_readiness_endpoint_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session, visibility="private") resp = await client.get(f"/api/repos/{repo.repo_id}/proposals/readiness") assert resp.status_code == 401 class TestSecurityApiEndpointValidation: """API endpoint input validation via FastAPI query-param schema.""" @pytest.mark.asyncio async def test_api_invalid_sort_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/api/repos/{repo.repo_id}/proposals?sort=DROP+TABLE") assert resp.status_code == 422 @pytest.mark.asyncio async def test_api_limit_above_max_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/api/repos/{repo.repo_id}/proposals?limit=101") assert resp.status_code == 422 @pytest.mark.asyncio async def test_api_invalid_state_rejected( self, client: AsyncClient, db_session: AsyncSession ) -> None: repo = await _make_repo(db_session) resp = await client.get(f"/api/repos/{repo.repo_id}/proposals?state='; DROP TABLE--") assert resp.status_code == 422