"""TDD spec for Phase 2 — /intel/stable list page route (issue #12). Route: GET /{owner}/{repo_slug}/intel/stable ?since_start=true — filter to eternal-only symbols ?top=N — limit rows (25 / 50 / 100) Returns 200 with stat row (eternal, veteran, total, oldest) and a days_stable-DESC ranked list. Empty state when no data exists. Seven test tiers ---------------- Unit P2_01 Route registered Integration P2_02 – P2_08 HTTP responses, filters, stat counts, HTML content E2E P2_09 – P2_11 Full seed → HTML round-trip Stress P2_12 – P2_13 200-row render, top=100 limit Data Integrity P2_14 – P2_15 Repo isolation, sort order Performance P2_16 – P2_17 Response time, ordering Security P2_18 – P2_20 XSS, path traversal, IDOR """ from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id, long_id from musehub.db.musehub_intel_models import MusehubIntelStable from musehub.db.musehub_repo_models import MusehubRepo from tests.factories import create_repo def _uid() -> str: return fake_id(secrets.token_hex(16)) _OWNER = "testuser" _SLUG = "stableroute" _REF = long_id("c" * 64) async def _seed_stable( session: AsyncSession, repo_id: str, *, address: str, days_stable: int = 180, since_start: bool = False, last_changed_commit: str | None = None, symbol_kind: str | None = None, ) -> None: """Insert a ``musehub_intel_stable`` row for test fixtures. Parameters ---------- session: Active async session. repo_id: Target repository ID. address: Symbol address (``file.py::fn``). days_stable: Days since last modification. since_start: True when symbol has never been modified. last_changed_commit: Commit ID of last modification. symbol_kind: Symbol kind (function, method, class, async_method). """ stmt = ( pg_insert(MusehubIntelStable) .values( repo_id=repo_id, address=address, days_stable=days_stable, since_start=since_start, last_changed_commit=last_changed_commit, symbol_kind=symbol_kind, ref=_REF, ) .on_conflict_do_update( index_elements=["repo_id", "address"], set_={"days_stable": days_stable, "since_start": since_start, "symbol_kind": symbol_kind}, ) ) await session.execute(stmt) await session.flush() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def route_repo(db_session: AsyncSession) -> MusehubRepo: """Bare repo — no stable rows.""" return await create_repo(db_session, owner=_OWNER, slug=_SLUG) @pytest_asyncio.fixture async def route_repo_with_symbols(db_session: AsyncSession, route_repo: MusehubRepo) -> MusehubRepo: """Repo with a varied set of stable symbols.""" repo_id = route_repo.repo_id await db_session.commit() await _seed_stable(db_session, repo_id, address="pkg/core.py::parse", days_stable=400, since_start=False, last_changed_commit=_REF, symbol_kind="function") await _seed_stable(db_session, repo_id, address="pkg/codec.py::pack", days_stable=900, since_start=True, last_changed_commit=None, symbol_kind="function") await _seed_stable(db_session, repo_id, address="pkg/utils.py::sha256", days_stable=200, since_start=False, last_changed_commit=_REF, symbol_kind="method") await db_session.commit() return route_repo # --------------------------------------------------------------------------- # Tier 1 — Unit # --------------------------------------------------------------------------- class TestStableRouteUnit: """Unit test — route registration.""" def test_P2_01_route_registered(self) -> None: """intel/stable route must be registered in the ui_intel router.""" from musehub.api.routes.musehub.ui_intel import router paths = [r.path for r in router.routes] assert any("intel/stable" in p for p in paths) # --------------------------------------------------------------------------- # Tier 2 — Integration # --------------------------------------------------------------------------- class TestStableRouteIntegration: """Integration tests — HTTP responses against the real DB.""" @pytest.mark.asyncio async def test_P2_02_empty_state_returns_200( self, client: AsyncClient, route_repo: MusehubRepo ) -> None: """Empty stable table → 200 with empty-state message.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert resp.status_code == 200 @pytest.mark.asyncio async def test_P2_03_populated_returns_200( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Populated repo → 200.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert resp.status_code == 200 @pytest.mark.asyncio async def test_P2_04_since_start_filter( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """?since_start=true shows only eternal symbols.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"since_start": "true"}) assert resp.status_code == 200 # Only pkg/codec.py::pack has since_start=True assert "pack" in resp.text assert "parse" not in resp.text @pytest.mark.asyncio async def test_P2_05_top_param_limits_rows( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """?top=2 returns at most 2 symbol rows.""" repo_id = route_repo.repo_id await db_session.commit() for i in range(5): await _seed_stable(db_session, repo_id, address=f"pkg/top{i}.py::fn", days_stable=100 + i) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 25}) assert resp.status_code == 200 @pytest.mark.asyncio async def test_P2_06_stat_eternal_count_correct( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Eternal count stat reflects since_start=True rows only.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") # 1 eternal symbol (pkg/codec.py::pack) assert "1" in resp.text @pytest.mark.asyncio async def test_P2_07_address_in_html( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Symbol address fragments appear in the rendered HTML.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert "parse" in resp.text assert "pack" in resp.text @pytest.mark.asyncio async def test_P2_08_days_stable_value_in_html( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """days_stable value (400d) appears in the HTML output.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert "400" in resp.text @pytest.mark.asyncio async def test_P2_08b_kind_filter_restricts_rows( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """?kind=method shows only method symbols (sha256), not function symbols.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"kind": "method"}) assert resp.status_code == 200 # sha256 is a method; parse and pack are functions — their file paths must not appear assert "sha256" in resp.text assert "pkg/core.py" not in resp.text # parse is function assert "pkg/codec.py" not in resp.text # pack is function @pytest.mark.asyncio async def test_P2_08c_invalid_kind_returns_all( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """?kind=bogus (invalid) is silently ignored — all rows returned.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"kind": "bogus"}) assert resp.status_code == 200 assert "parse" in resp.text assert "pkg/codec.py" in resp.text # codec.py::pack is unique @pytest.mark.asyncio async def test_P2_08d_symbol_kind_appears_in_html( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """symbol_kind values (function, method) appear in the rendered HTML.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert "function" in resp.text assert "method" in resp.text # --------------------------------------------------------------------------- # Tier 3 — E2E # --------------------------------------------------------------------------- class TestStableRouteE2E: """End-to-end tests — full seed-to-HTML round-trip.""" @pytest.mark.asyncio async def test_P2_09_full_round_trip( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Seeded symbols appear in HTML with days and address.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") text = resp.text assert "pack" in text assert "900" in text @pytest.mark.asyncio async def test_P2_10_stat_row_rendered( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Stat row keywords (Eternal, Veteran, Total, Oldest) present.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") text = resp.text assert "Eternal" in text assert "Total" in text @pytest.mark.asyncio async def test_P2_11_filter_url_contains_top( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Filter bar links contain ?top= parameter.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert "top=" in resp.text # --------------------------------------------------------------------------- # Tier 4 — Stress # --------------------------------------------------------------------------- class TestStableRouteStress: """Stress tests — large row counts render without error.""" @pytest.mark.asyncio async def test_P2_12_200_rows_render_without_error( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """200 stable rows render to a 200 response without error.""" repo_id = route_repo.repo_id await db_session.commit() for i in range(200): await _seed_stable(db_session, repo_id, address=f"pkg/stress{i}.py::fn", days_stable=100 + i) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 100}) assert resp.status_code == 200 @pytest.mark.asyncio async def test_P2_13_top_100_returns_correct_count( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """?top=100 with 150 rows renders exactly 100 symbol addresses.""" repo_id = route_repo.repo_id await db_session.commit() for i in range(150): await _seed_stable(db_session, repo_id, address=f"pkg/count{i}.py::fn", days_stable=50 + i) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 100}) # 100 rows × 2 occurrences each (title attr + symbol href) assert resp.text.count("::fn") == 200 # --------------------------------------------------------------------------- # Tier 5 — Data Integrity # --------------------------------------------------------------------------- class TestStableRouteDataIntegrity: """Data integrity tests — repo isolation and sort order.""" @pytest.mark.asyncio async def test_P2_14_only_this_repo_rows_returned( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """Rows from a different repo are not shown on this repo's page.""" repo_b = await create_repo(db_session, owner=_OWNER, slug="stableroute_b") repo_id_b = repo_b.repo_id await db_session.commit() await _seed_stable(db_session, repo_id_b, address="pkg/other.py::secret_fn", days_stable=500) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert "secret_fn" not in resp.text @pytest.mark.asyncio async def test_P2_15_sort_order_days_stable_desc( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Rows are ordered by days_stable DESC — highest first.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") text = resp.text # pack (900d) must appear before parse (400d) in the HTML assert text.index("pack") < text.index("parse") # --------------------------------------------------------------------------- # Tier 6 — Performance # --------------------------------------------------------------------------- class TestStableRoutePerformance: """Performance tests — response time and sort correctness at scale.""" @pytest.mark.asyncio async def test_P2_16_response_under_500ms( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Response time for 3 rows < 500ms.""" import time start = time.monotonic() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") elapsed = time.monotonic() - start assert resp.status_code == 200 assert elapsed < 0.5 @pytest.mark.asyncio async def test_P2_17_invalid_top_falls_back_to_default( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """?top=999 (invalid) silently falls back to default=50, returns 200.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable", params={"top": 999}) assert resp.status_code == 200 # --------------------------------------------------------------------------- # Tier 7 — Security # --------------------------------------------------------------------------- class TestStableRouteSecurity: """Security tests — XSS, path traversal, IDOR.""" @pytest.mark.asyncio async def test_P2_18_xss_in_address_escaped( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """XSS payload in address is HTML-escaped, not executed.""" repo_id = route_repo.repo_id xss = "" await db_session.commit() await _seed_stable(db_session, repo_id, address=xss, days_stable=100) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert resp.status_code == 200 assert "" not in resp.text @pytest.mark.asyncio async def test_P2_19_unknown_repo_returns_404( self, client: AsyncClient, route_repo: MusehubRepo ) -> None: """Non-existent repo slug → 404.""" resp = await client.get(f"/{_OWNER}/does_not_exist_xyz/intel/stable") assert resp.status_code == 404 @pytest.mark.asyncio async def test_P2_20_idor_repo_b_rows_not_on_repo_a_page( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """Rows belonging to repo B are not visible on repo A's stable page.""" repo_b = await create_repo(db_session, owner=_OWNER, slug="stableidor_b") repo_id_b = repo_b.repo_id await db_session.commit() await _seed_stable(db_session, repo_id_b, address="pkg/idor.py::private_fn", days_stable=300) await db_session.commit() resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable") assert "private_fn" not in resp.text # --------------------------------------------------------------------------- # Tier 8 — Detail page # --------------------------------------------------------------------------- class TestStableDetailPage: """Integration tests for the /intel/stable/detail route.""" @pytest.mark.asyncio async def test_P4_01_detail_returns_200_for_known_address( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Known address → 200 with symbol data rendered.""" resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/stable/detail", params={"address": "pkg/codec.py::pack"}, ) assert resp.status_code == 200 assert "pack" in resp.text @pytest.mark.asyncio async def test_P4_02_detail_shows_days_stable( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Detail page shows days_stable value (400 for parse, not eternal).""" resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/stable/detail", params={"address": "pkg/core.py::parse"}, ) assert "400" in resp.text @pytest.mark.asyncio async def test_P4_03_detail_shows_empty_state_for_unknown_address( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """Unknown address → 200 with empty-state message (no symbol data).""" resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/stable/detail", params={"address": "pkg/does_not_exist.py::missing"}, ) assert resp.status_code == 200 assert "No stable data" in resp.text @pytest.mark.asyncio async def test_P4_04_detail_no_address_shows_empty_state( self, client: AsyncClient, route_repo_with_symbols: MusehubRepo ) -> None: """No address query param → 200 with empty-state message.""" resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/stable/detail") assert resp.status_code == 200 assert "No stable data" in resp.text @pytest.mark.asyncio async def test_P4_05_detail_xss_in_address_escaped( self, client: AsyncClient, db_session: AsyncSession, route_repo: MusehubRepo ) -> None: """XSS payload in address query param is HTML-escaped.""" xss = "" resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/stable/detail", params={"address": xss}, ) assert resp.status_code == 200 assert "