gabriel / musehub public
test_phase4_blast_risk_detail.py python
373 lines 12.4 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """TDD spec for Phase 4 — /intel/blast-risk/detail per-symbol page (issue #11).
2
3 Route:
4 GET /{owner}/{repo_slug}/intel/blast-risk/detail?address=<symbol_address>
5
6 Shows composite score, four sub-score bars with raw input labels, blast_top
7 dependents from musehub_symbol_intel. Unknown or missing address → 200 with
8 empty-state (not 404). Joins blast_risk with symbol_intel for raw values.
9
10 Pure helpers in ui_intel.py:
11 _risk_color_class(score: float) -> str
12 0.75+ → "br-score-fill--critical"
13 0.50+ → "br-score-fill--high"
14 0.25+ → "br-score-fill--medium"
15 else → "br-score-fill--low"
16
17 _score_bar_pct(score: float) -> int
18 clamp(round(score * 100), 0, 100)
19
20 Layers:
21 Route: P4_01 – P4_04
22 Content: P4_05 – P4_10
23 Helpers: P4_11 – P4_18
24 E2E: P4_19 – P4_20
25 Security: P4_21 – P4_22
26 """
27 from __future__ import annotations
28
29 import secrets
30
31 import pytest
32 import pytest_asyncio
33 from httpx import AsyncClient
34 from sqlalchemy.dialects.postgresql import insert as pg_insert
35 from sqlalchemy.ext.asyncio import AsyncSession
36
37 from muse.core.types import fake_id, long_id
38 from musehub.db import musehub_models as db
39 from tests.factories import create_repo
40
41
42 def _uid() -> str:
43 return fake_id(secrets.token_hex(16))
44
45
46 _OWNER = "testuser"
47 _SLUG = "brdetailrepo"
48 _REF = long_id("d" * 64)
49
50
51 async def _seed_risk(
52 session: AsyncSession,
53 repo_id: str,
54 *,
55 address: str,
56 kind: str = "function",
57 risk: str = "critical",
58 risk_score: int = 90,
59 impact_score: float = 0.8,
60 churn_score: float = 0.7,
61 test_gap_score: float = 1.0,
62 coupling_score: float = 0.6,
63 ) -> None:
64 stmt = (
65 pg_insert(db.MusehubIntelBlastRisk)
66 .values(
67 repo_id=repo_id,
68 address=address,
69 kind=kind,
70 risk=risk,
71 risk_score=risk_score,
72 impact_score=impact_score,
73 churn_score=churn_score,
74 test_gap_score=test_gap_score,
75 coupling_score=coupling_score,
76 ref=_REF,
77 )
78 .on_conflict_do_update(
79 index_elements=["repo_id", "address"],
80 set_={"risk": risk, "risk_score": risk_score},
81 )
82 )
83 await session.execute(stmt)
84 await session.flush()
85
86
87 async def _seed_symbol(
88 session: AsyncSession,
89 repo_id: str,
90 *,
91 address: str,
92 blast: int = 20,
93 churn_30d: int = 10,
94 blast_cross: int = 5,
95 blast_top: list[str] | None = None,
96 kind: str = "function",
97 ) -> None:
98 stmt = (
99 pg_insert(db.MusehubSymbolIntel)
100 .values(
101 repo_id=repo_id,
102 address=address,
103 symbol_kind=kind,
104 blast=blast,
105 blast_direct=blast,
106 blast_cross=blast_cross,
107 churn=churn_30d,
108 churn_30d=churn_30d,
109 churn_90d=churn_30d,
110 author_count=1,
111 gravity=0.0,
112 weekly=[0] * 12,
113 blast_top=blast_top or [],
114 )
115 .on_conflict_do_update(
116 index_elements=["repo_id", "address"],
117 set_={"blast": blast, "churn_30d": churn_30d, "blast_top": blast_top or []},
118 )
119 )
120 await session.execute(stmt)
121 await session.flush()
122
123
124 # ---------------------------------------------------------------------------
125 # Fixtures
126 # ---------------------------------------------------------------------------
127
128 @pytest_asyncio.fixture
129 async def detail_repo(db_session: AsyncSession):
130 return await create_repo(db_session, owner=_OWNER, slug=_SLUG)
131
132
133 @pytest_asyncio.fixture
134 async def detail_repo_with_symbol(db_session: AsyncSession, detail_repo):
135 repo_id = detail_repo.repo_id
136 await db_session.commit()
137 await _seed_risk(db_session, repo_id, address="pkg/auth.py::validate_token",
138 risk="critical", risk_score=92,
139 impact_score=0.85, churn_score=0.70,
140 test_gap_score=1.0, coupling_score=0.60)
141 await _seed_symbol(db_session, repo_id, address="pkg/auth.py::validate_token",
142 blast=42, churn_30d=14, blast_cross=6,
143 blast_top=["pkg/api.py::login", "pkg/api.py::logout"])
144 await db_session.commit()
145 return detail_repo
146
147
148 # ---------------------------------------------------------------------------
149 # Layer 1 — Route registration
150 # ---------------------------------------------------------------------------
151
152 class TestDetailRouteRegistration:
153
154 def test_P4_01_detail_route_registered(self) -> None:
155 from musehub.api.routes.musehub.ui_intel import router
156 paths = [r.path for r in router.routes]
157 assert any("blast-risk/detail" in p for p in paths)
158
159
160 # ---------------------------------------------------------------------------
161 # Layer 2 — HTTP responses
162 # ---------------------------------------------------------------------------
163
164 class TestDetailHttpResponses:
165
166 @pytest.mark.asyncio
167 async def test_P4_02_known_address_returns_200(
168 self, client: AsyncClient, detail_repo_with_symbol
169 ) -> None:
170 resp = await client.get(
171 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
172 params={"address": "pkg/auth.py::validate_token"},
173 )
174 assert resp.status_code == 200
175
176 @pytest.mark.asyncio
177 async def test_P4_03_unknown_address_returns_200_with_empty_state(
178 self, client: AsyncClient, detail_repo
179 ) -> None:
180 resp = await client.get(
181 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
182 params={"address": "no/such.py::ghost_fn"},
183 )
184 assert resp.status_code == 200
185
186 @pytest.mark.asyncio
187 async def test_P4_04_missing_address_param_returns_200_with_empty_state(
188 self, client: AsyncClient, detail_repo
189 ) -> None:
190 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail")
191 assert resp.status_code == 200
192
193
194 # ---------------------------------------------------------------------------
195 # Layer 3 — Content rendered
196 # ---------------------------------------------------------------------------
197
198 class TestDetailContent:
199
200 @pytest.mark.asyncio
201 async def test_P4_05_symbol_address_in_html(
202 self, client: AsyncClient, detail_repo_with_symbol
203 ) -> None:
204 resp = await client.get(
205 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
206 params={"address": "pkg/auth.py::validate_token"},
207 )
208 assert "validate_token" in resp.text
209
210 @pytest.mark.asyncio
211 async def test_P4_06_risk_score_in_html(
212 self, client: AsyncClient, detail_repo_with_symbol
213 ) -> None:
214 resp = await client.get(
215 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
216 params={"address": "pkg/auth.py::validate_token"},
217 )
218 assert "92" in resp.text
219
220 @pytest.mark.asyncio
221 async def test_P4_07_all_four_sub_scores_rendered(
222 self, client: AsyncClient, detail_repo_with_symbol
223 ) -> None:
224 resp = await client.get(
225 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
226 params={"address": "pkg/auth.py::validate_token"},
227 )
228 text = resp.text
229 # Each sub-score label must appear
230 assert "impact" in text.lower()
231 assert "churn" in text.lower()
232 assert "test" in text.lower()
233 assert "coupling" in text.lower()
234
235 @pytest.mark.asyncio
236 async def test_P4_08_risk_tier_badge_rendered(
237 self, client: AsyncClient, detail_repo_with_symbol
238 ) -> None:
239 resp = await client.get(
240 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
241 params={"address": "pkg/auth.py::validate_token"},
242 )
243 assert "critical" in resp.text
244
245 @pytest.mark.asyncio
246 async def test_P4_09_blast_top_dependents_listed(
247 self, client: AsyncClient, detail_repo_with_symbol
248 ) -> None:
249 resp = await client.get(
250 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
251 params={"address": "pkg/auth.py::validate_token"},
252 )
253 assert "login" in resp.text
254 assert "logout" in resp.text
255
256 @pytest.mark.asyncio
257 async def test_P4_10_back_link_to_blast_risk_list(
258 self, client: AsyncClient, detail_repo_with_symbol
259 ) -> None:
260 resp = await client.get(
261 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
262 params={"address": "pkg/auth.py::validate_token"},
263 )
264 assert "blast-risk" in resp.text
265
266
267 # ---------------------------------------------------------------------------
268 # Layer 4 — Pure helpers (no DB)
269 # ---------------------------------------------------------------------------
270
271 class TestDetailHelpers:
272
273 def test_P4_11_risk_color_class_critical(self) -> None:
274 from musehub.api.routes.musehub.ui_intel import _risk_color_class
275 assert _risk_color_class(0.8) == "br-score-fill--critical"
276
277 def test_P4_12_risk_color_class_high(self) -> None:
278 from musehub.api.routes.musehub.ui_intel import _risk_color_class
279 assert _risk_color_class(0.6) == "br-score-fill--high"
280
281 def test_P4_13_risk_color_class_medium(self) -> None:
282 from musehub.api.routes.musehub.ui_intel import _risk_color_class
283 assert _risk_color_class(0.3) == "br-score-fill--medium"
284
285 def test_P4_14_risk_color_class_low(self) -> None:
286 from musehub.api.routes.musehub.ui_intel import _risk_color_class
287 assert _risk_color_class(0.1) == "br-score-fill--low"
288
289 def test_P4_15_score_bar_pct_zero(self) -> None:
290 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
291 assert _score_bar_pct(0.0) == 0
292
293 def test_P4_16_score_bar_pct_one(self) -> None:
294 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
295 assert _score_bar_pct(1.0) == 100
296
297 def test_P4_17_score_bar_pct_half(self) -> None:
298 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
299 assert _score_bar_pct(0.5) == 50
300
301 def test_P4_18_score_bar_pct_overflow_clamped(self) -> None:
302 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
303 assert _score_bar_pct(1.5) == 100
304
305
306 # ---------------------------------------------------------------------------
307 # Layer 5 — End-to-end
308 # ---------------------------------------------------------------------------
309
310 class TestDetailE2E:
311
312 @pytest.mark.asyncio
313 async def test_P4_19_full_seed_to_html_round_trip(
314 self, client: AsyncClient, detail_repo_with_symbol
315 ) -> None:
316 resp = await client.get(
317 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
318 params={"address": "pkg/auth.py::validate_token"},
319 )
320 text = resp.text
321 assert "validate_token" in text
322 assert "92" in text
323 assert "login" in text
324
325 @pytest.mark.asyncio
326 async def test_P4_20_empty_blast_top_renders_without_error(
327 self, client: AsyncClient, db_session: AsyncSession, detail_repo
328 ) -> None:
329 repo_id = detail_repo.repo_id
330 await db_session.commit()
331 await _seed_risk(db_session, repo_id, address="pkg/solo.py::solo_fn",
332 risk="low", risk_score=15,
333 impact_score=0.1, churn_score=0.1,
334 test_gap_score=0.5, coupling_score=0.0)
335 await _seed_symbol(db_session, repo_id, address="pkg/solo.py::solo_fn",
336 blast=5, churn_30d=1, blast_top=[])
337 await db_session.commit()
338
339 resp = await client.get(
340 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
341 params={"address": "pkg/solo.py::solo_fn"},
342 )
343 assert resp.status_code == 200
344 assert "solo_fn" in resp.text
345
346
347 # ---------------------------------------------------------------------------
348 # Layer 6 — Security
349 # ---------------------------------------------------------------------------
350
351 class TestDetailSecurity:
352
353 @pytest.mark.asyncio
354 async def test_P4_21_xss_in_address_param_escaped(
355 self, client: AsyncClient, detail_repo
356 ) -> None:
357 xss = "<script>alert(1)</script>"
358 resp = await client.get(
359 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
360 params={"address": xss},
361 )
362 assert resp.status_code == 200
363 assert "<script>alert(1)</script>" not in resp.text
364
365 @pytest.mark.asyncio
366 async def test_P4_22_path_traversal_in_address_safe(
367 self, client: AsyncClient, detail_repo
368 ) -> None:
369 resp = await client.get(
370 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
371 params={"address": "../../etc/passwd"},
372 )
373 assert resp.status_code == 200
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago